Files
lmxopcua/docs/research/drivers/sql-poll.md
T
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
2026-07-15 16:40:36 -04:00

28 KiB

SQL Poll driver — design research

Status: research / not-yet-approved. Author: driver research sweep, 2026-07-15. Scope: a new SqlPoll Equipment-kind driver that reads values from arbitrary SQL databases (MES/ERP staging tables, historians, custom app DBs) and surfaces them as OPC UA variable nodes — same shape as the existing Modbus / S7 / OpcUaClient drivers. Grounding: CLAUDE.md, IDriver.cs + capability neighbours, Driver.Modbus/ (polling template), Commons/Browsing/ (browse seam), docs/plans/2026-05-28-driver-browsers-design.md.


0. TL;DR verdicts

Question Verdict
Browseable? YES. SQL is self-describing via INFORMATION_SCHEMA (databases/schemas → tables/views → columns + types). A full IDriverBrowser / IBrowseSession is natural.
Provider ship-first? SQL Server (Microsoft.Data.SqlClient — already in the dependency graph at 6.1.1), behind an ADO.NET DbProviderFactory abstraction. PostgreSQL (Npgsql) second, ODBC (System.Data.Odbc) as the universal fallback, MySQL/Oracle later.
Write in v1? NO — read-only v1. Writing back to MES/ERP staging is operationally risky; ship an optional, opt-in, per-tag parameterized-UPSERT write mode as a later phase (design sketched in §2.4).
Top risks (1) SQL injection — every identifier and value must be parameterized / allow-listed, never string-concatenated; (2) secret handling — connection-string credentials must come from env/secret store, never committed (mirror ServerHistorian__ApiKey).

1. Design summary + provider strategy

1.1 Shape

SqlPoll is a standard Equipment-kind driver: a SqlPollDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe living in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll/, with a .Contracts project for the options + equipment-tag parser, and a .Browser project for the AdminUI address picker. It has no native push model — subscriptions overlay a polling loop via the shared PollGroupEngine (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs), exactly as Modbus/S7/AB-CIP do. IWritable and IAlarmSource/IHistoryProvider are out of scope for v1 (writes deferred; alarms/history aren't expressible from a poll query).

Points are ordinary equipment Tags bound to the driver via TagConfig.FullName; reads/subscribes resolve FullName to a tag definition through the shared EquipmentTagRefResolver<TDef> (Core.Abstractions/EquipmentTagRefResolver.cs) — same two-model bridge Modbus uses (an authored tag-table entry by name or an equipment tag whose reference is its raw TagConfig JSON, parsed once and cached).

1.2 Provider abstraction

Use ADO.NET's provider-agnostic base types (System.Data.Common: DbConnection, DbCommand, DbParameter, DbDataReader) obtained through a DbProviderFactory. In .NET (Core/5+) there is no machine.config/GAC provider registry, so factories are registered in code via DbProviderFactories.RegisterFactory(invariantName, factory.Instance) and each provider exposes a static Instance singleton (SqlClientFactory.Instance, NpgsqlFactory.Instance, OdbcFactory.Instance, OracleClientFactory.Instance, MySqlConnectorFactory.Instance) (MS Learn: Obtain a SqlClientFactory, MS Learn: Obtaining a DbProviderFactory).

Recommended approach — a small provider enum mapped to a factory, not open-ended invariant strings, because two provider-specific concerns leak past the ADO.NET base types and must be dispatched per-provider:

  1. Identifier quoting for the browse layer and any generated SQL — [SqlServer] / "Postgres" / `MySql` / "Oracle".
  2. Parameter marker syntax — SQL Server & MySQL use @p, Npgsql uses @p or :p, Oracle uses :p, ODBC uses positional ?. A SqlDialect abstraction owns quoting + parameter naming + the metadata-catalog query (see §4).
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }

internal interface ISqlDialect
{
    DbProviderFactory Factory { get; }
    string QuoteIdentifier(string ident);          // [x] / "x" / `x`
    string ParameterMarker(int ordinal);           // @p0 / :p0 / ?
    string ListColumnsSql(string? catalog);        // metadata catalog query (browse)
    string ListTablesSql(string? catalog);
}

Ship-first recommendation: SQL Server only. Microsoft.Data.SqlClient is already a repo dependency (Directory.Packages.props line 53, 6.1.1), the shared central test SQL Server is already on the docker host (§5), and SQL Server is the dominant MES/historian staging backend in this AVEVA/Wonderware estate (the Galaxy Repository and the HistorianGateway both sit on SQL Server). Phase 2 adds PostgreSQL (Npgsql) and ODBC (System.Data.Odbc, the universal fallback that reaches any DSN — DB2, Oracle via the Oracle ODBC driver, Access, etc.). MySQL/MariaDB (MySqlConnector, whose MySqlConnectorFactory is fully async-correct, unlike Oracle's official MySql.Data) and native Oracle (Oracle.ManagedDataProvider) are later, demand-driven additions.

Npgsql, System.Data.Odbc, and Oracle.ManagedDataAccess.Core are not yet in Directory.Packages.props — each is a new PackageVersion entry gated behind its phase, so the ship-first SQL-Server slice adds zero new NuGet dependencies.


2. Capability mapping

2.1 Connect — InitializeAsync

  • Deserialize SqlPollDriverOptions from DriverConfig JSON (via the factory extensions, mirroring ModbusDriverFactoryExtensions.CreateInstance). Resolve the connection string (§6 — from env/secret, never the committed JSON). Select the ISqlDialect from options.Provider.
  • Validate by opening one DbConnection and running a cheap liveness query (SELECT 1 — portable across SQL Server/Postgres/MySQL; Oracle needs SELECT 1 FROM DUAL, so the liveness probe is a dialect method). Set DriverHealth = Healthy on success, Faulted on failure (same state machine as ModbusDriver.InitializeAsync).
  • Pooling: rely on ADO.NET's built-in connection pooling (keyed by exact connection-string text). Do not hold one long-lived connection; open-use-dispose per poll pass so the pool manages lifetime and a dropped server recovers transparently. Expose Max Pool Size etc. only via the connection string.
  • ReinitializeAsync = teardown + init (Modbus pattern). No live sockets to preserve.

2.2 Read / Subscribe — poll queries, one query per table/group

The whole efficiency story is batching by query-group, not per-tag. A naive "one SELECT per tag" hammers the DB; instead the driver coalesces tags that share a source query and issues one round-trip per group per poll, then slices the result back to per-tag snapshots — structurally the same idea as ModbusDriver.ReadCoalescedAsync (group → single PDU → slice back).

IReadable.ReadAsync(fullReferences) groups the referenced tags by their query group key (model-dependent, see §2.5), executes each group's query once, and maps rows/columns back to the DataValueSnapshot[] in input order. ISubscribable delegates to PollGroupEngine with the same ReadAsync as the reader delegate — the engine owns the loop, the 100 ms interval floor, capped- exponential failure backoff, and change-diffing. Route poll-loop failures to DriverHealth via the engine's onError sink + PollBackoffCap (Modbus/S7 precedent, 05/STAB-8/9).

Poll interval is driver-level (DefaultPollInterval, default e.g. 5 s) with an optional per-tag or per-group override; the OPC UA subscription's requested publishing interval is clamped up to the group's configured floor by PollGroupEngine.

Per-call deadline (mandatory). Every DbCommand must set CommandTimeout and run under a linked CancellationTokenSource bounded by an OperationTimeout option. This is the SQL analogue of the R2-01 S7 blackhole finding: a frozen DB server (network partition, lock wait, slow query) must surface BadTimeout/Degraded and let the poll loop back off — not wedge the poll thread forever. Prefer DbCommand.ExecuteReaderAsync(ct) with the linked token so cancellation is real, and still set CommandTimeout as a server-side backstop.

2.3 Discover — offline / config-driven

Like Modbus, SQL has no runtime discovery obligation: DiscoverAsync materializes exactly the authored tags (the Tags table in options, plus equipment tags contributed at deploy time). It does not enumerate the DB schema at discovery — schema enumeration is the browse concern (§4), an interactive AdminUI operation, not a deploy-time one. RediscoverPolicy = Once.

2.4 Write — verdict: read-only v1, optional write mode later

v1 does not implement IWritable. Rationale: MES/ERP/historian staging tables are frequently downstream-of-record systems where an unexpected UPDATE/INSERT can corrupt a batch record, double-post a transaction, or trip a trigger with side effects. A read-only integration is the safe, common case and removes an entire class of risk from the first ship.

Optional Phase-N write mode (opt-in per driver and per tag): a tag may carry a writeCommand describing a parameterized statement — never generated by string concatenation:

"write": {
  "mode": "Update",                                  // Update | Insert | Upsert | StoredProc
  "sql": "UPDATE dbo.Setpoints SET value=@value, ts=@ts WHERE tag_name=@key",
  "keyParam": "@key",   "keyValue": "Line1.Speed",
  "valueParam": "@value"
}

Writes route through IWritable.WriteAsync → the tag's parameterized command with the OPC UA write value bound to @value (typed via the tag's declared type). Gate at three layers: (a) the OPC UA node's WriteOperate role (standard NodeWriteRouter), (b) a driver-level AllowWrites master switch (default false), (c) TagConfig.writable. Writes are not auto-retried unless the tag is flagged WriteIdempotent (an UPDATE ... WHERE key= set-value is idempotent; an INSERT / counter UPDATE x=x+1 is not — mirror DriverAttributeInfo.WriteIdempotent). Because a failed inbound device write reverts the node to its prior value (the write-outcome self-correction path, master 1d797c1c), SQL writes — unlike Galaxy's fire-and-forget — can surface a genuine failure, which is the correct, honest behaviour.

2.5 Tag→value mapping models

Support two models in v1 (both cover the vast majority of MES/ERP staging shapes), plus an escape hatch:

  1. Key-value / EAV table (primary — ship first). A table of (tagname, value, timestamp) rows; a tag names a keyColumn+keyValue, a valueColumn, and an optional timestampColumn. Group key = the table. One SELECT keyColumn, valueColumn, timestampColumn FROM table WHERE keyColumn IN (@k0,@k1,…) per poll returns every subscribed tag on that table in one round-trip; the driver indexes rows by key and slices back. (Values IN (@params) — never interpolated.)

  2. Wide row (ship first, cheap). One "latest status" row, many columns, each column a tag. A tag names a table, a columnName, and a row selector (WHERE id=@id, or "top 1 by timestamp"). Group key = (table, row-selector). One SELECT col_a, col_b, … FROM table WHERE … per poll maps each selected column to its tag.

  3. Arbitrary parameterized SELECT (escape hatch, per named query group). Named query definitions at the driver level (queries: { "q1": { sql, params } }); a tag references a query by name + a projection (which output column / which row-key). Group key = the named query. This covers joins, views, and computed projections the two structured models can't express. The SQL is authored by an operator with ConfigEditor rights and stored server-side — it is config, not user input — but it still binds only parameters, never interpolated tag values.

2.6 Type mapping (SQL → OPC UA) + timestamps

Map the column's provider metadata (DbColumn.DataType from DbDataReader.GetColumnSchema(), or the INFORMATION_SCHEMA DATA_TYPE) to the driver-agnostic DriverDataType (Core.Abstractions/DriverDataType.cs), which the address-space layer maps to OPC UA built-ins:

SQL type (family) CLR (DbDataReader) DriverDataType
bit / boolean bool Boolean
tinyint/smallint byte/short Int16
int/integer int Int32
bigint long Int64
real/float(24) float Float32
float/double/decimal/numeric/money double/decimaldouble Float64
char/varchar/nvarchar/text string String
date/datetime/datetime2/timestamptz DateTime/DateTimeOffset DateTime
uniqueidentifier/uuid Guid String

decimal/numeric collapse to Float64 (v1) with a documented precision-loss caveat; a tag may override the inferred type explicitly ("type": "Int32") for cases where the operator knows better than the column metadata (e.g. a varchar column holding a number). NULLDataValueSnapshot with Value = null and a Good/Uncertain status per an option (NullIsBad, default treat as GoodNoData-ish Uncertain).

Source timestamp: if a tag declares a timestampColumn, that column's value becomes the DataValueSnapshot.SourceTimestamp; otherwise the poll-read wall-clock is used for both source and server timestamps (the Modbus behaviour). A source timestamp is what makes SQL-poll data honest about staleness — strongly recommend authoring one wherever the staging table has it.


3. TagConfig + driver-config JSON shape

3.1 Driver config (the DriverConfig blob)

{
  "provider": "SqlServer",
  // Connection string is resolved at Initialize from an env/secret ref — NOT stored here.
  // Option A (recommended): a named ref the Host resolves from env:
  "connectionStringRef": "SqlPoll_MesStaging",     // => env SqlPoll__ConnectionStrings__MesStaging
  "defaultPollInterval": "00:00:05",
  "operationTimeout": "00:00:15",                  // per-query wall-clock deadline (BadTimeout)
  "commandTimeout": "00:00:10",                    // server-side CommandTimeout backstop
  "allowWrites": false,                            // master write kill-switch (v1: always false)
  "probe": { "enabled": true, "interval": "00:00:10" },

  // Optional pre-declared tag table (analogous to Modbus "Tags"); equipment tags may also
  // contribute their address as raw TagConfig JSON at deploy time.
  "tags": [ /* see 3.2 */ ],

  // Optional named queries for the arbitrary-SELECT model.
  "queries": {
    "activeBatch": {
      "sql": "SELECT line, speed, temp, ts FROM dbo.LineStatus WHERE active=@active",
      "params": { "@active": true }
    }
  }
}

3.2 Per-tag TagConfig — key-value model

{
  "driver": "SqlPoll",
  "model": "KeyValue",
  "table": "dbo.TagValues",
  "keyColumn": "tag_name",
  "keyValue": "Line1.Speed",
  "valueColumn": "num_value",
  "timestampColumn": "sample_ts",
  "type": "Float64",                 // optional explicit override of inferred type
  "writable": false
}

Group key for batching = ("dbo.TagValues", keyColumn, valueColumn, timestampColumn); all such tags fold into one … WHERE tag_name IN (@k0,@k1,…) per poll.

3.3 Per-tag TagConfig — wide-row model

{
  "driver": "SqlPoll",
  "model": "WideRow",
  "table": "dbo.LatestStatus",
  "columnName": "oven_temp",
  "rowSelector": { "whereColumn": "station_id", "whereValue": 7 },
  // or "rowSelector": { "topByTimestamp": "sample_ts" }  // newest row
  "type": "Float64",
  "writable": false
}

Group key = ("dbo.LatestStatus", rowSelector); every wide-row tag on the same row folds into one SELECT oven_temp, <other cols>, … FROM dbo.LatestStatus WHERE station_id=@id.

3.4 Per-tag TagConfig — named-query model

{
  "driver": "SqlPoll",
  "model": "Query",
  "query": "activeBatch",
  "rowKey": { "column": "line", "value": "Line1" },  // which row this tag reads
  "column": "speed",                                  // which projected column is the value
  "timestampColumn": "ts"
}

The equipment-tag parser (SqlPollEquipmentTagParser, in .Contracts) recognizes the blob by a leading { + a "driver":"SqlPoll" (or a "model" discriminator) and maps it to a transient SqlPollTagDefinition whose Name == the reference string — exactly the ModbusEquipmentTagParser pattern, so the value the driver publishes keys the forward router correctly.


4. Browseability verdict + browse design

4.1 Verdict: YES — SQL is browseable via its metadata catalog.

Relational databases are self-describing. The picker walks the catalog three levels deep:

  • RootAsync → databases / schemas (Folder nodes). SQL Server: databases (sys.databases) then schemas; Postgres: databases then schemas; MySQL: schemas (== databases). For SQLite: the single main database (Folder).
  • ExpandAsync(schemaNodeId) → tables + views in that schema (Folder nodes).
  • ExpandAsync(tableNodeId) → columns (Leaf nodes).
  • AttributesAsync(columnNodeId) → column data type + nullability + (for key-value tables) a hint panel. This mirrors Galaxy's two-stage attribute side-panel: pick a table (Folder), then pick the column/key that becomes the tag.

4.2 Portability caveat — the metadata query is dialect-specific

INFORMATION_SCHEMA is the SQL-92 standard catalog and is supported by SQL Server, PostgreSQL, and MySQL/MariaDB — so SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES and … COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t are portable across those three. But two important backends do not implement INFORMATION_SCHEMA:

So the catalog query belongs on ISqlDialect (ListTablesSql / ListColumnsSql), not hardcoded. The ship-first SQL-Server dialect uses INFORMATION_SCHEMA; Postgres/MySQL reuse it; the SQLite fixture dialect (§5) uses PRAGMA; Oracle (later) uses ALL_TAB_COLUMNS. A cleaner but slower alternative that avoids catalog SQL entirely for the column level is DbDataReader.GetColumnSchema() on a SELECT * … WHERE 1=0 (SchemaOnly) command — but it still needs catalog SQL for the table list, so the dialect method stays.

4.3 Picked-column → TagConfig

On column pick, the browser emits a BrowseNode.NodeId that encodes provider|schema.table|column, and the picker body composes the TagConfig blob (§3.2/3.3): DisplayName defaults to table.column, the inferred DriverDataType from AttributesAsync prefills type, and the operator chooses the model (key-value vs wide-row) + fills the key/row selector. AttributeInfo.DriverDataType carries the SQL type; SecurityClass = ViewOnly in read-only v1.

4.4 New .Browser project layout

Follow the exact OpcUaClient.Browser template (docs/plans/2026-05-28-driver-browsers-design.md):

Path Purpose
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.SqlPoll.Browser/SqlPollDriverBrowser.cs IDriverBrowser, DriverType => "SqlPoll"; opens a DbConnection from form JSON (transient)
src/Drivers/…SqlPoll.Browser/SqlPollBrowseSession.cs IBrowseSession; RootAsync/ExpandAsync/AttributesAsync over the dialect's catalog queries; SemaphoreSlim gate (ADO.NET connection not concurrent)
register in AdminUI/EndpointRouteBuilderExtensions.cs services.AddSingleton<IDriverBrowser, SqlPollDriverBrowser>(); (next to the OpcUaClient/Galaxy lines, :49-50)
AdminUI/Uns/TagEditors/TagConfigEditorMap.cs add ["SqlPoll"] = typeof(…SqlPollTagConfigEditor) + TagConfigValidator entry (typed editor; else it falls to the raw-JSON textarea like Galaxy)

The browser reuses the AdminUI BrowseSessionRegistry + reaper + 2-min idle TTL + per-call timeout unchanged. Form JSON contains the connection secret (same trust boundary as TestDriverConnect and the OpcUaClient browser) — deserialize → build connection → release, never cache the JSON.


5. Test-fixture strategy

  • SQLite in-memory / file — the unit-test fixture. Microsoft.Data.Sqlite (already in-repo at 10.0.7) needs no container; seed a (tag_name, num_value, sample_ts) key-value table and a wide-row LatestStatus table in-process. Covers read/subscribe mapping, type mapping, group-batch slicing, the poll engine wiring, and the browser over PRAGMA table_info. Fast, offline, CI-safe on macOS — the primary suite.
  • Central SQL Server on the docker host (10.100.0.35,14330, always-on per CLAUDE.md) — the integration fixture. A seeded SqlPollFixture database with the same two sample tables validates the real Microsoft.Data.SqlClient path, INFORMATION_SCHEMA browse, CommandTimeout/cancellation, and connection pooling. Env-gated (SQLPOLL_TEST_ENDPOINT) so it skips cleanly offline, like the other *.IntegrationTests. Deploy the seed via the docker rig; label the stack project=lmxopcua.
  • Postgres/ODBC fixtures land with their respective phases (a postgres:16 container on the docker host for Npgsql; an ODBC DSN against the same SQL Server for the System.Data.Odbc path).
  • Blackhole / timeout live-gate (mirrors R2-01 S7): docker pause the SQL container mid-poll and assert the read surfaces BadTimeout + the driver degrades + the poll loop backs off, not a wedged thread. This is the single most important integration test given the frozen-peer risk class.

6. Security — secret handling

Connection-string credentials are the crown jewels and must never be committed. Mirror the repo's existing pattern for ServerHistorian:ApiKey (supplied via env ServerHistorian__ApiKey, appsettings carries only a _comment reminder) and the ConfigDb connection string (env-overridable):

  • The DriverConfig JSON stores a connectionStringRef (a logical name), not the connection string. The Host resolves it from configuration/env at InitializeAsync (SqlPoll__ConnectionStrings__<ref>), so the secret lives in the process environment / secret store, never in the config DB row or any committed file.
  • If an inline connectionString is permitted at all (dev convenience), the AdminUI must redact it in display/logging and it must be flagged as dev-only. Never log the resolved connection string (Serilog); log only the provider + server host + database name.
  • Prefer integrated/managed auth where the estate supports it (Integrated Security=true / Azure AD / Kerberos) so no password transits config at all.
  • The .Browser path receives the secret in form JSON over the authenticated Blazor circuit (same boundary as every other driver browser + TestDriverConnect); it is used to build one transient connection and then released — no _lastConfigJson field anywhere.

7. Effort, risk, phasing

7.1 Key risks

  1. SQL injection (critical). Every value is a bound DbParameter; identifiers (table/column/ schema) that must appear in SQL text come only from INFORMATION_SCHEMA-validated, dialect-QuoteIdentifier'd names or an allow-list derived from browse — never from free-form tag input concatenated into a query. The named-query model stores operator-authored SQL as config (ConfigEditor-gated), and even there only parameters bind runtime values. Treat any code path that builds SQL by string concatenation of a tag field as a defect.
  2. Secret handling (critical). Per §6 — env/secret-store only, redact in UI/logs, never commit.
  3. Frozen-peer / timeout wedge (high). A slow/blocked DB must time out per-query and back off (the R2-01 lesson) — CommandTimeout + linked-CTS OperationTimeout on every command; assert it in the blackhole live-gate.
  4. Connection pooling / exhaustion (medium). Open-use-dispose per poll; never leak a DbConnection or DbDataReader (always await using); a group query that fans out to many tables must not open an unbounded number of concurrent connections — cap group-parallelism (Modbus/S7 precedent).
  5. Provider portability (medium). INFORMATION_SCHEMA covers SQL Server/Postgres/MySQL but not Oracle/SQLite (§4.2); parameter markers and identifier quoting differ. The ISqlDialect seam contains all of it — do not let dialect assumptions leak into the driver core.
  6. Type / precision (low-medium). decimalFloat64 precision loss; NULL semantics; timezone handling on datetime vs datetimeoffset. Documented + per-tag type override.

7.2 Suggested phasing

  1. Phase 1 — SQL Server read-only, key-value + wide-row. .Contracts (options + equipment-tag parser), SqlPollDriver (IDriver/ITagDiscovery/IReadable/ISubscribable via PollGroupEngine), SqlServerDialect, factory registration, SqlPollTagConfigEditor + validator, SQLite unit fixture, SQL-Server integration fixture (env-gated) + blackhole live-gate. No new NuGet deps.
  2. Phase 2 — Browse. SqlPoll.Browser project (IDriverBrowser/IBrowseSession over INFORMATION_SCHEMA), AdminUI picker body + registry wire-up, column→TagConfig commit.
  3. Phase 3 — Named-query model + PostgreSQL + ODBC. PostgresDialect/OdbcDialect, add Npgsql
    • System.Data.Odbc PackageVersions, Postgres container fixture.
  4. Phase 4 (optional) — Write mode. IWritable with per-tag parameterized UPSERT/StoredProc, triple-gated (WriteOperate role + AllowWrites + TagConfig.writable), WriteIdempotent retry policy, write-failure revert (already provided by the runtime).
  5. Phase 5 (demand-driven) — MySQL/MariaDB (MySqlConnector) + native Oracle (ALL_TAB_COLUMNS dialect).

7.3 Effort estimate

Phase 1 ≈ the Modbus driver minus the wire-protocol codec complexity (no byte-order/register math) but plus the dialect seam and result-set slicing — medium, most of it reusable from Modbus (poll engine, resolver, health state machine, factory shape are all shared). Phase 2 (browse) is small — a near- mechanical clone of OpcUaClient.Browser with a different one-level expansion. The provider/dialect work (Phase 3+) is where the long tail of portability effort lives; keeping it behind ISqlDialect from day one is what makes the ship-first SQL-Server slice cheap and the rest additive.


Sources