Files
lmxopcua/docs/plans/2026-07-24-sql-poll-driver.md
T
Joseph Doherty 76e55fc112 docs(drivers): Wave 0-2 tracking doc + Wave 1/2 implementation plans
Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.

Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:

- Modbus RTU        11 tasks  (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll          22 tasks  (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent   23 tasks  (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B  27 tasks  (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)

Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:17:44 -04:00

48 KiB
Raw Blame History

SQL Poll Driver Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Ship a read-only Sql Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.

Architecture: Three new projects mirroring the Modbus split — Driver.Sql.Contracts (options/tag DTOs + parser, zero transport deps), Driver.Sql (the IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe runtime + ISqlDialect seam + SqlServerDialect, owning Microsoft.Data.SqlClient), and Driver.Sql.Browser (schema-walk IBrowseSession). The PollGroupEngine, EquipmentTagRefResolver<TDef>, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a DbParameter; identifiers are dialect-quoted from catalog-validated names only.

Tech Stack: Microsoft.Data.SqlClient (6.1.1, already in Directory.Packages.props) via System.Data.Common base types behind a DbProviderFactory; ISqlDialect + SqlProvider enum seam (only SqlServer implemented in v1); Microsoft.Data.Sqlite (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. Zero new NuGet dependencies.

Source design: docs/plans/2026-07-15-sql-poll-driver-design.md Program design (shared contract): docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7

Conventions for every task below: all projects net10.0, Nullable/ImplicitUsings enabled, TreatWarningsAsErrors on product projects. Every commit message ends with the repo trailer Claude-Session: <session-url> (omitted from the git commit examples for brevity). Run dotnet build ZB.MOM.WW.OtOpcUa.slnx green before each commit. New test projects use xunit.v3 + Shouldly (see tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj).


Task 0: Scaffold Driver.Sql.Contracts project + register in slnx

Classification: small Estimated implement time: ~4 min Parallelizable with: none (root of the tree) Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj
  • Modify: ZB.MOM.WW.OtOpcUa.slnx (add the project under /src/Drivers/)

Steps:

  1. Create the csproj — refs Core.Abstractions ONLY (no transport deps, so AdminUI + browser can reference it without dragging Microsoft.Data.SqlClient). InternalsVisibleTo the Contracts is not needed. RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);CS1591</NoWarn>
    <RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
  </ItemGroup>
</Project>
  1. Add to ZB.MOM.WW.OtOpcUa.slnx under the /src/Drivers/ folder, beside the Driver.Modbus.Contracts line.
  2. Run dotnet build ZB.MOM.WW.OtOpcUa.slnx — expect PASS (empty project compiles).
  3. Commit: git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"

Task 1: SqlProvider + SqlTagModel enums + SqlDriverConfigDto (string-enum round-trip)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (blocks most downstream) Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/* is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. Instead, put this task's test in a throwaway Contracts-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. This task ships DTOs only; its guard is Task 9.

Note: Tasks 12 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, reorder locally: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The blockedBy graph permits this. The concrete failing-test-first discipline begins at Task 2.

Steps:

  1. SqlProvider.cs:
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }

/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel { KeyValue, WideRow, Query }
  1. SqlDriverConfigDto.cs — the driver-config blob shape (§5.1). Enum-typed fields (Provider) so the string converter round-trips names; timeouts as TimeSpan?; ConnectionStringRef (NOT the connection string). Fields: Provider, ConnectionStringRef, DefaultPollInterval, OperationTimeout, CommandTimeout, MaxConcurrentGroups, NullIsBad, AllowWrites, Probe (SqlProbeDto{Enabled, Interval}). [JsonStringEnumConverter] is applied at the factory (Task 9), not on the DTO.
  2. Build — expect PASS.
  3. Commit: git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"

Task 2: SqlTagDefinition + SqlEquipmentTagParser.TryParse (strict enum reads, malicious-input safe)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs (Tests project scaffolded in Task 6 — pull the csproj creation forward from Task 6 for this task, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)

TDD:

  1. Failing test — parse a KeyValue blob and a malicious keyValue; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd model enum rejects:
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

public class SqlEquipmentTagParserTests
{
    [Fact]
    public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
    {
        var json = """
        {"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
         "keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
        """;
        SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
        def.Model.ShouldBe(SqlTagModel.KeyValue);
        def.Table.ShouldBe("dbo.TagValues");
        def.KeyValue.ShouldBe("'; DROP TABLE x --");   // captured as-is; the reader BINDS it, never concatenates
        def.DeclaredType.ShouldBe(DriverDataType.Float64);
        def.Name.ShouldBe("Plant/Sql/dev1/Speed");     // Name == RawPath (forward-router key)
    }

    [Fact]
    public void TryParse_invalidModelEnum_rejectsTag()
        => SqlEquipmentTagParser.TryParse(
            """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
            "raw", out _).ShouldBeFalse();
}
  1. Run dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests — expect FAIL (types don't exist).
  2. Minimal implSqlTagDefinition record (Name = RawPath, Model, Table, KeyColumn, KeyValue, ValueColumn, TimestampColumn, ColumnName, RowSelectorColumn, RowSelectorValue, RowSelectorTopByTimestamp, DeclaredType = DriverDataType?). SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def) — mirror ModbusTagDefinitionFactory.FromTagConfig: recognize by leading {, read the model discriminator via TagConfigJson.TryReadEnumStrict (a present-but-invalid enum → false → the driver surfaces BadNodeIdUnknown, per R2-11), read model-specific fields, read the optional type override strictly, set Name = rawPath. Wrap in try/catch(JsonException/FormatException) → false. No SQL is built here — the parser only captures strings.
  3. Run test — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"

Task 3: Scaffold Driver.Sql runtime project + register in slnx

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
  • Modify: ZB.MOM.WW.OtOpcUa.slnx

Steps:

  1. Create the csproj per design §2.1 — refs Core.Abstractions + Core (for DriverFactoryRegistry in ZB.MOM.WW.OtOpcUa.Core.Hosting) + Driver.Sql.Contracts; packages Microsoft.Data.SqlClient + Microsoft.Extensions.Logging.Abstractions; InternalsVisibleTo the .Tests.
  2. Add to ZB.MOM.WW.OtOpcUa.slnx under /src/Drivers/.
  3. Build — expect PASS.
  4. Commit: git commit -m "feat(sql): scaffold Driver.Sql runtime project"

Task 4: ISqlDialect seam + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries

Classification: high-risk (identifier quoting is the injection boundary) Estimated implement time: ~5 min Parallelizable with: Task 2 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs

TDD:

  1. Failing test — golden catalog SQL, QuoteIdentifier escape + reject, MapColumnType table:
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
    var d = new SqlServerDialect();
    d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
    d.QuoteIdentifier("a]b").ShouldBe("[a]]b]");            // ] doubled per T-SQL rules
}

[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
    => Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));

[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
    => new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);

[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
    var d = new SqlServerDialect();
    d.LivenessSql.ShouldBe("SELECT 1");
    d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
    d.ListTablesSql.ShouldContain("@schema");               // never string-interpolated
    d.ListColumnsSql.ShouldContain("@table");
}
  1. Run — expect FAIL.
  2. Minimal implISqlDialect per design §2.2 (Provider, Factory = DbProviderFactory, QuoteIdentifier, LivenessSql, ListSchemasSql, ListTablesSql, ListColumnsSql, MapColumnType). SqlServerDialect: Factory => SqlClientFactory.Instance; QuoteIdentifier doubles ], rejects embedded NUL/control chars via ArgumentException; catalog SQL uses INFORMATION_SCHEMA with @schema/@table markers; MapColumnType folds SQL type families to DriverDataType per design §3.7 (decimal/numeric/moneyFloat64 with the documented precision caveat as an XML remark).
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"

Task 5: SqlQueryPlan grouping + parameter binding (pure, no DB)

Classification: high-risk (the query-mapping core; GroupKey correctness governs correctness of every read) Estimated implement time: ~5 min Parallelizable with: Task 10 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs

TDD:

  1. Failing test — KeyValue tags on the same (table,keyColumn,valueColumn,timestampColumn) fold into ONE plan whose SQL is ... WHERE [tag_name] IN (@k0,@k1) with two bound params; WideRow tags on the same (table,rowSelector) fold into one SELECT <cols> ... WHERE [station_id]=@w; different tables → different plans:
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
    var dialect = new SqlServerDialect();
    var tags = new[] {
        KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
        KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
    };
    var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
    plans.Count.ShouldBe(1);
    plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
    plans[0].SqlText.ShouldContain("[tag_name]");
    plans[0].Members.Count.ShouldBe(2);
    plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" });   // values are params, not text
}
  1. Run — expect FAIL.
  2. Minimal implSqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> } (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). SqlGroupPlanner.Plan(tags, dialect): KeyValue → GroupKey (table,keyColumn,valueColumn,timestampColumn), emit SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN) with distinct-key params; WideRow → GroupKey (table, rowSelector), emit SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w (or ORDER BY <col> DESC + dialect TOP-1 for topByTimestamp). Identifiers via dialect.QuoteIdentifier; <table> split on . and each part quoted. Every value is a param; zero interpolation.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"

Task 6: Scaffold Driver.Sql.Tests + SqliteDialect (test) + SqlitePollFixture

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10, Task 12 Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs
  • Modify: ZB.MOM.WW.OtOpcUa.slnx

If Task 2 already created the csproj, this task adds the SqliteDialect/fixture and the slnx entry only.

Steps:

  1. csproj — xunit.v3 + Shouldly + Microsoft.NET.Test.Sdk + xunit.runner.visualstudio; PackageReference Microsoft.Data.Sqlite (10.0.7, test-only — no product dep on SQLite); project-refs Driver.Sql + Driver.Sql.Contracts. Add to slnx under /tests/Drivers/.
  2. SqliteDialect : ISqlDialectProvider = SqlServer shim is wrong; add a test-only Provider value is not needed — implement ISqlDialect directly with Factory => SqliteFactory.Instance, QuoteIdentifier doubling ", LivenessSql = "SELECT 1", catalog SQL over sqlite_schema + PRAGMA table_info(...), MapColumnType mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via <Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />.
  3. SqlitePollFixture — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT) and a wide-row LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT) with a few rows. Exposes the open SqliteConnection + a DbProviderFactory shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
  4. Build + run (no tests yet) — expect PASS/empty.
  5. Commit: git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"

Task 7: SqlPollReader core — one query per group, bounded deadline, slice back in input order

Classification: high-risk (connection/timeout/result-slicing core) Estimated implement time: ~5 min Parallelizable with: none Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs

TDD:

  1. Failing test (against SqlitePollFixture) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → BadNoData, NULL cell → Uncertain (default nullIsBad=false), source timestamp from timestampColumn:
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
    await using var fx = await SqlitePollFixture.CreateAsync();     // seeds Line1.Speed=42.0, Line1.Temp=NULL
    var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
        commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
        maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
    var refs = new[] { "Speed", "Missing", "Temp" };
    var snaps = await reader.ReadAsync(refs, CancellationToken.None);
    snaps.Count.ShouldBe(3);
    snaps[0].Value.ShouldBe(42.0);
    snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);          // key deleted/absent
    snaps[2].Value.ShouldBeNull();
    StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue();       // NULL cell, nullIsBad=false
}
  1. Run — expect FAIL.
  2. Minimal implSqlPollReader.ReadAsync(refs, ct): resolve each ref → SqlTagDefinition (miss → BadNodeIdUnknown); SqlGroupPlanner.Plan(...); per group under a SemaphoreSlim(maxConcurrentGroups): await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt); build DbCommand with CommandTimeout = (int)commandTimeout.TotalSeconds and bound params; using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token); index rows by key/selector; map each member's cell → DataValueSnapshot (type per DeclaredType ?? dialect.MapColumnType-inferred; NULL → nullIsBad ? Bad : Uncertain; absent key → BadNoData; source timestamp from timestampColumn else read wall-clock). Reassemble in input order (DataValueSnapshot[refs.Count]). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). await using both connection and reader — never leak. Add SqlStatusCodes static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
  3. Run — expect PASS. Add a NULL-cell test with nullIsBad:trueBad.
  4. Commit: git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"

Task 8: SqlDriver shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe

Classification: high-risk (lifecycle + poll-engine wiring + connection validation) Estimated implement time: ~5 min Parallelizable with: none Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs

TDD:

  1. Failing test — construct SqlDriver with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored RawTagEntry list; InitializeAsyncHealthy; DiscoverAsync streams one Variable per authored tag with SecurityClass=ViewOnly + RediscoverPolicy=Once + SupportsOnlineDiscovery=false; ReadAsync delegates to the reader; a subscribe fires an initial change:
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
    await using var fx = await SqlitePollFixture.CreateAsync();
    var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
    await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
    driver.GetHealth().State.ShouldBe(DriverState.Healthy);
    ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
    ((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
    var cap = new CapturingBuilder();
    await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
    cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
}
  1. Run — expect FAIL.
  2. Minimal implSqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable. Mirror ModbusDriver: fields grouped up top; _resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...); _poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s). InitializeAsync: build _tagsByRawPath from _options.RawTags via SqlEquipmentTagParser.TryParse (miss = logged skip, never throw); open ONE connection, run dialect.LivenessSql under CommandTimeout + linked CTS, dispose; success → Healthy, failure → Faulted + LastError. ReadAsync_reader.ReadAsync. SubscribeAsync_poll.Subscribe; UnsubscribeAsync_poll.Unsubscribe. IHostConnectivityProbe: single logical host (server host from the connection string) with Running/Stopped from the last poll/liveness outcome + OnHostStatusChanged on transitions. GetMemoryFootprint => 0; FlushOptionalCachesAsync => Task.CompletedTask; ReinitializeAsync = teardown+init. DriverType => DriverTypeNames.Sql. Add a ForTest(...) internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"

Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum-serialization guard

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs

TDD:

  1. Failing testCreateInstance with a config carrying "provider":"SqlServer" builds a driver; a missing connectionStringRef throws a clean error; the connection-string ref resolves from env Sql__ConnectionStrings__<ref>; enum-serialization guard — a config author serializing provider as a NUMBER still parses (JsonStringEnumConverter accepts ordinals) AND the round-trip writes the NAME:
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
    => Should.Throw<InvalidOperationException>(() =>
        SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
        .Message.ShouldContain("connectionStringRef");

[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
    Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
    SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
}

[Fact]
public void Provider_serializesAsNameNotNumber()
{
    var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
        SqlDriverFactoryExtensions.JsonOptionsForTest);
    json.ShouldContain("\"SqlServer\"");
    json.ShouldNotContain("\"provider\":0");
}
  1. Run — expect FAIL.
  2. Minimal implSqlConnectionStringResolver.Resolve(string @ref)Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}") (direct env read, deliberate — the factory closure has no IConfiguration, per design §8.2), throws actionable if absent. SqlDriverFactoryExtensions: const DriverTypeName = DriverTypeNames.Sql; Register(registry, loggerFactory)registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory)); CreateInstance deserializes SqlDriverConfigDto with JsonOptions carrying JsonStringEnumConverter + UnmappedMemberHandling.Skip, validates ConnectionStringRef present, builds SqlServerDialect from Provider (P1: only SqlServer constructible; others throw "provider not available in this build"), resolves the connection string, constructs the SqlPollReader + SqlDriver. Never log the resolved connection string (log provider + server host + database only). Expose JsonOptionsForTest internal.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"

Task 10: SqlDriverProbe (SELECT 1 liveness, bounded timeout)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 5, Task 9, Task 12 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs

TDD:

  1. Failing test — against the SQLite fixture (inject dialect+factory), ProbeAsync returns green with latency; a bad connection string returns a red result with the error message (never throws):
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
    await using var fx = await SqlitePollFixture.CreateAsync();
    var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
    var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
    r.Success.ShouldBeTrue();
}
  1. Run — expect FAIL.
  2. Minimal implSqlDriverProbe : IDriverProbe, DriverType => DriverTypeNames.Sql. Parse config via the SAME factory DTO shape + JsonStringEnumConverter (R2-11 factory parity, mirroring ModbusDriverProbe), resolve the connection string ref, open a connection under a linked CTS bounded by timeout, run dialect.LivenessSql (SELECT 1), return DriverProbeResult(true, "SQL SELECT 1 OK", latency); catch → red result naming the failure. Never throws. ForTest injects factory+dialect for the SQLite path.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"

Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test

Classification: small Estimated implement time: ~4 min Parallelizable with: none Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj (project-ref Driver.Sql if not transitively present)
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs is EXISTING — it asserts bidirectional parity between DriverTypeNames constants and the factory-registered set; this task makes it pass by adding both sides.

TDD:

  1. Failing step — add public const string Sql = "Sql"; to DriverTypeNames + include in All, but DON'T yet register the factory. Run DriverTypeNamesGuardTests → expect FAIL (constant with no registered factory).
  2. Impl — in DriverFactoryBootstrap.Register(...) add Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);; in AddOtOpcUaDriverProbes add services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>()); with a using SqlProbe = Driver.Sql.SqlDriverProbe; alias; add the Driver.Sql project-ref to the Host csproj. Default tier DriverTier.A (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so ShouldStub() needs no change).
  3. Run guard test + dotnet build ZB.MOM.WW.OtOpcUa.slnx — expect PASS.
  4. Commit: git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"

Task 12: Scaffold Driver.Sql.Browser project + register in slnx

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 5, Task 6, Task 9, Task 10 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
  • Modify: ZB.MOM.WW.OtOpcUa.slnx

Steps:

  1. csproj refs Commons (ZB.MOM.WW.OtOpcUa.CommonsIDriverBrowser/IBrowseSession/BrowseNode) + Driver.Sql.Contracts + Driver.Sql (for ISqlDialect/SqlServerDialect — the dialect's catalog SQL is the browse engine; Microsoft.Data.SqlClient comes transitively). This runtime-project ref is the deliberate deviation from OpcUaClient.Browser recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. InternalsVisibleTo the .Browser.Tests.
  2. Add to slnx under /src/Drivers/.
  3. Build — expect PASS.
  4. Commit: git commit -m "feat(sql): scaffold Driver.Sql.Browser project"

Task 13: SqlBrowseSession — schema walk over the dialect catalog

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj (+ slnx entry, + link SqliteDialect.cs from Sql.Tests)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs

TDD:

  1. Failing test — over a seeded SQLite DB + SqliteDialect: RootAsync yields schema folder(s); ExpandAsync(schema) yields the seeded tables; ExpandAsync(table) yields columns as leaves; AttributesAsync(column) yields the mapped DriverDataType:
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
    await using var db = await SqliteBrowseFixture.CreateAsync();   // TagValues(tag_name TEXT, num_value REAL)
    var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
    var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
    var cols = await session.ExpandAsync(tableNode.NodeId, default);
    cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
    var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
    attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
  1. Run — expect FAIL.
  2. Minimal implSqlBrowseSession : IBrowseSession (match the Commons.Browsing interface shape used by OpcUaClientBrowseSession): RootAsync/ExpandAsync(nodeId)/AttributesAsync(nodeId) run the dialect catalog queries with bound @schema/@table params; encode NodeId as schema / schema.table / schema.table|column; a SemaphoreSlim _gate serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf AttributesAsync returns AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly") (mirrors Galaxy two-stage pick).
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"

Task 14: SqlDriverBrowser — IDriverBrowser open (env ref OR pasted literal, transient connection)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs

TDD:

  1. Failing testOpenAsync with a form JSON whose connectionStringRef resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
    var browser = new SqlDriverBrowser();
    var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
        browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
    ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
  1. Run — expect FAIL.
  2. Minimal implSqlDriverBrowser : IDriverBrowser, DriverType => DriverTypeNames.Sql. OpenAsync(configJson, ct): deserialize with the same JsonStringEnumConverter options; resolve connectionStringRef via a direct env read (Sql__ConnectionStrings__<ref>) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal connectionString for ad-hoc browse (session-only, never persisted, never logged, no _lastConfigJson cached field); select SqlServerDialect (P1); open ONE transient DbConnection, return a SqlBrowseSession. Reuses the AdminUI BrowseSessionRegistry + reaper + idle TTL unchanged.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"

Task 15: AdminUI browser DI + SqlAddressPickerBody.razor (picker body)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17 Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs (add services.AddSingleton<IDriverBrowser, SqlDriverBrowser>(); beside the OpcUaClient/Galaxy lines ~:75)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj (project-ref Driver.Sql.Browser)
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor (reuse DriverBrowseTree.razor for the tree + an attribute side-panel + model/selector fields; gated by the existing DriverOperator policy; manual entry retained)

Steps (no unit test — Razor is live-verified in Task 21):

  1. Register the bespoke IDriverBrowser (the universal-browser DI line is NOT added for Sql — its CanBrowse is false and bespoke wins by construction).
  2. SqlAddressPickerBody.razor — thin body: DriverBrowseTree in picker mode + attribute side-panel; on column pick, compose the TagConfig blob (DisplayName default table.column, prefill type from AttributesAsync, operator picks model + fills key/row selector; SecurityClass=ViewOnly).
  3. dotnet build — expect PASS.
  4. Commit: git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"

Task 16: Env-gated central-SQL integration fixture (Driver.Sql.IntegrationTests)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14, Task 15, Task 17 Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj (+ slnx entry)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs

TDD:

  1. Failing/skipping test — against the always-on central SQL Server (10.100.0.35,14330), seed a SqlPollFixture database with the two sample tables (KeyValue + wide-row), then validate the real Microsoft.Data.SqlClient path (read round-trip, INFORMATION_SCHEMA browse, CommandTimeout/cancellation, pooling). Env-gated via SQL_TEST_ENDPOINT / a full Sql__ConnectionStrings__Fixture — absent ⇒ skip cleanly (use Assert.Skip(...) / xunit.v3 [Fact(Skip=...)] dynamic skip), like every other *.IntegrationTests:
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
    var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
    Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
    await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
    var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
    await driver.InitializeAsync(fx.ConfigJson, default);
    var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
    snaps[0].StatusCode.ShouldBe(0u);
}
  1. Run offline — expect SKIP (green).
  2. Impl — the fixture (create-if-missing DB + tables + seed rows via Microsoft.Data.SqlClient) and the read tests. The seed DB is SqlPollFixture, NOT ConfigDb — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
  3. Commit: git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"

Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 13, Task 14, Task 15, Task 16 Files:

  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs

TDD:

  1. Failing test (offline, SQLite fixture) — a malicious keyValue ('; DROP TABLE TagValues; --) reads harmlessly as a bound param and the seed table still exists afterward; a malicious table/column identifier that is not catalog-valid is REJECTED (tag → BadNodeIdUnknown), never executed:
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
    await using var fx = await SqlitePollFixture.CreateAsync();
    var reader = fx.NewReader();
    var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
    snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);   // no such key — bound, not executed
    (await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0);   // table intact
}
  1. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
  2. Impl — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
  3. Commit: git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"

Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container

Classification: high-risk (frozen-peer resilience — the single highest-value integration test) Estimated implement time: ~5 min Parallelizable with: none Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml (a dedicated disposable mssql stack)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs

CRITICAL: this gate docker pauses a dedicated mcr.microsoft.com/mssql/server container it owns — NEVER the shared central SQL Server on 10.100.0.35,14330, which hosts ConfigDb for the whole rig (design §9). The compose stack is deployed via lmxopcua-fix sync; lmxopcua-fix applies the project=lmxopcua label host-side.

TDD:

  1. Failing/skipping test — mirror the R2-01 S7 blackhole (S7_1500ConnectTimeoutOutageTests): start reading against the dedicated mssql, then docker pause it mid-poll; assert the next read surfaces BadTimeout within operationTimeout + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; docker unpause recovers. Env-gated (SQL_BLACKHOLE_ENDPOINT) — offline skip:
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
    var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
    Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
    // ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
}
  1. Run offline — expect SKIP.
  2. Impl — dedicated docker-compose.yml (mssql service, own container name, restart: "no"), seed.sql (the two sample tables), and the test that shells docker pause/docker unpause against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): CommandTimeout (server-side backstop) + ExecuteReaderAsync(linkedCt) bounded by operationTimeout (client-side real cancellation) both fire.
  3. Commit: git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"

Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 16, Task 17, Task 18 Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs (add [DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor))
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs (add [DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate())
  • Test: tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs (locate the existing AdminUI tag-editor test project; mirror ModbusTagConfigModel tests)

TDD:

  1. Failing testFromJsonToJson round-trips the model, preserves unknown keys, writes enums (model/type) as NAME strings not numbers; Validate() rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
    var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
    var json = m.ToJson();
    json.ShouldContain("\"KeyValue\"");
    json.ShouldContain("\"Float64\"");
    json.ShouldContain("customX");            // unknown key survives load→save
    json.ShouldNotContain("\"model\":0");
}
  1. Run — expect FAIL.
  2. Minimal implSqlTagConfigModel (thin, over TagConfigJson.ParseOrNew, preserving the _bag): Model, Table, KeyColumn/KeyValue/ValueColumn/TimestampColumn, ColumnName + RowSelector, Type (DriverDataType?), FromJson/ToJson (enums as name strings via TagConfigJson.Set) /Validate (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in TagConfigEditorMap + TagConfigValidator. Enum-serialization trap (systemic): model/type MUST round-trip as strings on both sides — the editor ToJson and the factory SqlEquipmentTagParser both use string enums; add the assertion above.
  3. Run — expect PASS.
  4. Commit: git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"

Task 20: SqlTagConfigEditor.razor (thin shell over the model)

Classification: small Estimated implement time: ~5 min Parallelizable with: none Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor

Steps (Razor — live-verified in Task 21):

  1. Copy the Modbus editor template; bind over SqlTagConfigModel: a Model dropdown (KeyValue/WideRow) that shows the matching field group, Table, the key/value/timestamp fields (KeyValue) OR ColumnName + row-selector fields (WideRow), and a Type override dropdown. @bind string component params need the @ prefix (the Global-UNS gotcha).
  2. dotnet build — expect PASS.
  3. Commit: git commit -m "feat(sql): SqlTagConfigEditor razor shell"

Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)

Classification: trivial (procedure — no product code; may surface fix-forward tasks) Estimated implement time: ~5 min (verification, excluding any bugs found) Parallelizable with: none (last) Files: none (verification only; any defect becomes a new fix-forward task)

Procedure (docker-dev rig, AdminUI auto-authenticated at http://localhost:9200 — login disabled, so drive it yourself, don't defer to the user):

  1. Set Sql__ConnectionStrings__DevSql on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (:9200 round-robins).
  2. In /raw, add a Sql driver + device (paste a literal connection string or use the ref); Test-Connect → green (SELECT 1).
  3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
  4. Reference the raw tags into an equipment on /uns; deploy via POST :9200/api/deployments (X-Api-Key) or the deploy button; confirm the deployment seals green.
  5. Read via Client.CLI (read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>") — confirm the live SQL value + quality + source timestamp.
  6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).

Deferred / out of scope (pointers to the design, NOT executable tasks here)

These are recorded so nobody re-derives them; each is a later phase in docs/plans/2026-07-15-sql-poll-driver-design.md. The ISqlDialect seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.

  • Named-query model (design §5.4, §3.6(c) / design-P3) — the arbitrary-SELECT escape hatch (queries: {...} + a tag referencing a query by name + projection). Not built in v1; the SqlTagModel.Query enum member exists but the reader/planner path is deferred.
  • PostgreSQL + ODBC dialects (design §2.2, §8.5 / design-P3) — PostgresDialect (Npgsql) + OdbcDialect (System.Data.Odbc), each a NEW gated PackageVersion. v1 constructs only SqlServerDialect; the other SqlProvider members throw "provider not available in this build."
  • MySQL/MariaDB + native Oracle (design §10 / design-P5) — MySqlConnector + an ALL_TAB_COLUMNS Oracle dialect. Demand-driven.
  • Write mode (IWritable) (design §3.4 / design-P4) — opt-in, triple-gated (WriteOperate role + driver allowWrites master switch + TagConfig.writable), parameterized UPSERT/StoredProc, WriteIdempotent retry policy. v1 is read-only; SecurityClass=ViewOnly everywhere and allowWrites defaults false.
  • Split-role env-parity operational note (design §4.4) — admin nodes must carry the same Sql__ConnectionStrings__<ref> env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.