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
28 KiB
SQL Poll driver — design research
Status: research / not-yet-approved. Author: driver research sweep, 2026-07-15. Scope: a new
SqlPollEquipment-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 existingModbus/S7/OpcUaClientdrivers. 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:
- Identifier quoting for the browse layer and any generated SQL —
[SqlServer]/"Postgres"/`MySql`/"Oracle". - Parameter marker syntax — SQL Server & MySQL use
@p, Npgsql uses@por:p, Oracle uses:p, ODBC uses positional?. ASqlDialectabstraction 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
SqlPollDriverOptionsfromDriverConfigJSON (via the factory extensions, mirroringModbusDriverFactoryExtensions.CreateInstance). Resolve the connection string (§6 — from env/secret, never the committed JSON). Select theISqlDialectfromoptions.Provider. - Validate by opening one
DbConnectionand running a cheap liveness query (SELECT 1— portable across SQL Server/Postgres/MySQL; Oracle needsSELECT 1 FROM DUAL, so the liveness probe is a dialect method). SetDriverHealth=Healthyon success,Faultedon failure (same state machine asModbusDriver.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 Sizeetc. 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:
-
Key-value / EAV table (primary — ship first). A table of
(tagname, value, timestamp)rows; a tag names akeyColumn+keyValue, avalueColumn, and an optionaltimestampColumn. Group key = the table. OneSELECT 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. (ValuesIN (@params)— never interpolated.) -
Wide row (ship first, cheap). One "latest status" row, many columns, each column a tag. A tag names a
table, acolumnName, and a row selector (WHERE id=@id, or "top 1 by timestamp"). Group key = (table, row-selector). OneSELECT col_a, col_b, … FROM table WHERE …per poll maps each selected column to its tag. -
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 withConfigEditorrights 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)
{
"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:
- Oracle uses
ALL_TABLES/ALL_TAB_COLUMNS(andUSER_/DBA_variants) instead (Oracle: ALL_TAB_COLUMNS, dataedo: what supports INFORMATION_SCHEMA). - SQLite uses
sqlite_schema/sqlite_master+PRAGMA table_info(<table>)(SQLite: the schema table).
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-rowLatestStatustable in-process. Covers read/subscribe mapping, type mapping, group-batch slicing, the poll engine wiring, and the browser overPRAGMA 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 perCLAUDE.md) — the integration fixture. A seededSqlPollFixturedatabase with the same two sample tables validates the realMicrosoft.Data.SqlClientpath,INFORMATION_SCHEMAbrowse,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 stackproject=lmxopcua. - Postgres/ODBC fixtures land with their respective phases (a
postgres:16container on the docker host for Npgsql; an ODBC DSN against the same SQL Server for theSystem.Data.Odbcpath). - Blackhole / timeout live-gate (mirrors R2-01 S7):
docker pausethe SQL container mid-poll and assert the read surfacesBadTimeout+ 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
DriverConfigJSON stores aconnectionStringRef(a logical name), not the connection string. The Host resolves it from configuration/env atInitializeAsync(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
connectionStringis 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
.Browserpath 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_lastConfigJsonfield anywhere.
7. Effort, risk, phasing
7.1 Key risks
- SQL injection (critical). Every value is a bound
DbParameter; identifiers (table/column/ schema) that must appear in SQL text come only fromINFORMATION_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. - Secret handling (critical). Per §6 — env/secret-store only, redact in UI/logs, never commit.
- Frozen-peer / timeout wedge (high). A slow/blocked DB must time out per-query and back off
(the R2-01 lesson) —
CommandTimeout+ linked-CTSOperationTimeouton every command; assert it in the blackhole live-gate. - Connection pooling / exhaustion (medium). Open-use-dispose per poll; never leak a
DbConnectionorDbDataReader(alwaysawait 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). - Provider portability (medium).
INFORMATION_SCHEMAcovers SQL Server/Postgres/MySQL but not Oracle/SQLite (§4.2); parameter markers and identifier quoting differ. TheISqlDialectseam contains all of it — do not let dialect assumptions leak into the driver core. - Type / precision (low-medium).
decimal→Float64precision loss;NULLsemantics; timezone handling ondatetimevsdatetimeoffset. Documented + per-tagtypeoverride.
7.2 Suggested phasing
- Phase 1 — SQL Server read-only, key-value + wide-row.
.Contracts(options + equipment-tag parser),SqlPollDriver(IDriver/ITagDiscovery/IReadable/ISubscribableviaPollGroupEngine),SqlServerDialect, factory registration,SqlPollTagConfigEditor+ validator, SQLite unit fixture, SQL-Server integration fixture (env-gated) + blackhole live-gate. No new NuGet deps. - Phase 2 — Browse.
SqlPoll.Browserproject (IDriverBrowser/IBrowseSessionoverINFORMATION_SCHEMA), AdminUI picker body + registry wire-up, column→TagConfig commit. - Phase 3 — Named-query model + PostgreSQL + ODBC.
PostgresDialect/OdbcDialect, addNpgsqlSystem.Data.OdbcPackageVersions, Postgres container fixture.
- Phase 4 (optional) — Write mode.
IWritablewith per-tag parameterized UPSERT/StoredProc, triple-gated (WriteOperaterole +AllowWrites+TagConfig.writable),WriteIdempotentretry policy, write-failure revert (already provided by the runtime). - Phase 5 (demand-driven) — MySQL/MariaDB (
MySqlConnector) + native Oracle (ALL_TAB_COLUMNSdialect).
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) - MS Learn — Obtaining a DbProviderFactory (
DbProviderFactories, providerInstancepattern) - Oracle — ALL_TAB_COLUMNS (Oracle has no INFORMATION_SCHEMA)
- dataedo — What is INFORMATION_SCHEMA and which databases support it
- SQLite — The Schema Table (
sqlite_schema/PRAGMA table_info) - 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).