# 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 `Tag`s bound to the driver via `TagConfig.FullName`; reads/subscribes resolve `FullName` to a tag definition through the shared `EquipmentTagRefResolver` (`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](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory), [MS Learn: Obtaining a DbProviderFactory](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/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). ```csharp 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: ```jsonc "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`/`decimal`→`double` | `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). `NULL` → `DataValueSnapshot` 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) ```jsonc { "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** ```jsonc { "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** ```jsonc { "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, , … FROM dbo.LatestStatus WHERE station_id=@id`. ### 3.4 Per-tag TagConfig — **named-query model** ```jsonc { "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`:** - **Oracle** uses `ALL_TABLES` / `ALL_TAB_COLUMNS` (and `USER_`/`DBA_` variants) instead ([Oracle: ALL_TAB_COLUMNS](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html), [dataedo: what supports INFORMATION_SCHEMA](https://dataedo.com/kb/databases/all/information_schema)). - **SQLite** uses `sqlite_schema`/`sqlite_master` + `PRAGMA table_info()` ([SQLite: the schema table](https://sqlite.org/schematab.html)). 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();` (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__`), 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).** `decimal`→`Float64` 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` `PackageVersion`s, 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 - [MS Learn — Obtain a SqlClientFactory (RegisterFactory in .NET Core, `SqlClientFactory.Instance`)](https://learn.microsoft.com/en-us/sql/connect/ado-net/obtain-sqlclientfactory) - [MS Learn — Obtaining a DbProviderFactory (`DbProviderFactories`, provider `Instance` pattern)](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/obtaining-a-dbproviderfactory) - [Oracle — ALL_TAB_COLUMNS (Oracle has no INFORMATION_SCHEMA)](https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/ALL_TAB_COLUMNS.html) - [dataedo — What is INFORMATION_SCHEMA and which databases support it](https://dataedo.com/kb/databases/all/information_schema) - [SQLite — The Schema Table (`sqlite_schema`/`PRAGMA table_info`)](https://sqlite.org/schematab.html) - In-repo grounding: `IDriver.cs`, `ISubscribable.cs`, `PollGroupEngine.cs`, `DriverDataType.cs`, `Driver.Modbus/ModbusDriver.cs` + `.Contracts`, `Commons/Browsing/*`, `Driver.OpcUaClient.Browser/*`, `AdminUI/EndpointRouteBuilderExtensions.cs`, `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `docs/plans/2026-05-28-driver-browsers-design.md`, `Directory.Packages.props` (Microsoft.Data.SqlClient 6.1.1, Microsoft.Data.Sqlite 10.0.7).