From b13ff0be4607aafa6e879c7bd82c82a967a2602d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:29:25 -0400 Subject: [PATCH 01/33] feat(sql): scaffold Driver.Sql.Contracts project Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index b6647bd2..9e2122bd 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -29,6 +29,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj new file mode 100644 index 00000000..1c81c26e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts + + + + + From 227cf8ee2baf73b435c65d89031ea1a8a2a69aa2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:30:11 -0400 Subject: [PATCH 02/33] feat(sql): scaffold Driver.Sql runtime project Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 9e2122bd..ed13d1f3 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -29,6 +29,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj new file mode 100644 index 00000000..04426389 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql + + + + + + + + + + + + + + From 3b75e1ac6979cfa99d1952087dbfc0a4af03ea4c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:31:09 -0400 Subject: [PATCH 03/33] feat(sql): scaffold Driver.Sql.Browser project Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + ...B.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index ed13d1f3..81e15e0c 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -30,6 +30,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj new file mode 100644 index 00000000..daa01255 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser + + + + + + + + + + + From 5b7fe9336e99f4fc9a7be32dc5e62651cd5877c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:34:50 -0400 Subject: [PATCH 04/33] feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlDriverConfigDto.cs | 59 +++++++++++++++++++ .../SqlProvider.cs | 39 ++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs new file mode 100644 index 00000000..fac5ee4c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs @@ -0,0 +1,59 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// The DriverConfig blob shape for a Sql driver instance (design §5.1). Deserialised by the +/// driver factory, which applies the documented defaults for every absent (null) field and maps the result +/// onto the driver's typed options. +/// No connection string lives here. Only — a name resolved at +/// Initialize from the environment / secret store (e.g. Sql__ConnectionStrings__MesStaging) — so a +/// deployed artifact never carries database credentials. +/// Enum-valued fields are enum-typed (not strings) so a JsonStringEnumConverter round-trips +/// their names rather than their ordinals. The converter is applied by the factory's +/// JsonSerializerOptions (Task 9), not by an attribute on this DTO — mirroring the driver +/// enum-serialization trap that bit the AdminUI driver pages. +/// +public sealed class SqlDriverConfigDto +{ + /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs. + public SqlProvider Provider { get; init; } = SqlProvider.SqlServer; + + /// + /// Name of the connection string to resolve from the environment / secret store at Initialize — + /// never the connection string itself. + /// + public string? ConnectionStringRef { get; init; } + + /// Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default. + public TimeSpan? DefaultPollInterval { get; init; } + + /// + /// Per-query wall-clock deadline enforced client-side (a breach surfaces BadTimeout). Must be + /// greater than , which is only the server-side backstop (design §8.3). + /// + public TimeSpan? OperationTimeout { get; init; } + + /// ADO.NET CommandTimeout backstop (seconds granularity server-side). + public TimeSpan? CommandTimeout { get; init; } + + /// Cap on concurrently executing group queries — the connection-pool guard. + public int? MaxConcurrentGroups { get; init; } + + /// When , a NULL cell publishes Bad rather than the default Uncertain. + public bool? NullIsBad { get; init; } + + /// Master write kill-switch. v1 is read-only — writes stay disabled regardless. + public bool? AllowWrites { get; init; } + + /// Background connectivity-probe settings. Absent ⇒ the factory default. + public SqlProbeDto? Probe { get; init; } +} + +/// Background connectivity-probe settings inside a (design §5.1). +public sealed class SqlProbeDto +{ + /// Whether the driver runs its background connectivity probe. + public bool? Enabled { get; init; } + + /// Interval between probe attempts. + public TimeSpan? Interval { get; init; } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs new file mode 100644 index 00000000..67879307 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer. +public enum SqlProvider +{ + /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs. + SqlServer, + + /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1. + Postgres, + + /// MySQL / MariaDB — reserved; not constructed in v1. + MySql, + + /// Generic ODBC — reserved for P2; not constructed in v1. + Odbc, + + /// Oracle — reserved; not constructed in v1. + Oracle, +} + +/// Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3). +public enum SqlTagModel +{ + /// + /// Key-value (EAV) table: one row per tag, selected by keyColumn = keyValue, value read from + /// valueColumn. + /// + KeyValue, + + /// + /// Wide row: one row holds many signals as columns; the tag names its columnName and the row is + /// picked by a row selector (a whereColumn/whereValue pair, or newest-by-timestamp). + /// + WideRow, + + /// Named arbitrary-SELECT query (design §5.4). Deferred to P3 — not accepted by v1's parser. + Query, +} From 53ff041229aece8d9d879b388d7c8564ce79de95 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:36:51 -0400 Subject: [PATCH 05/33] feat(sql): SqlTagDefinition + strict-enum equipment-tag parser Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../SqlEquipmentTagParser.cs | 131 ++++++++++++++++++ .../SqlTagDefinition.cs | 47 +++++++ .../SqlEquipmentTagParserTests.cs | 30 ++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj | 27 ++++ 5 files changed, 236 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 81e15e0c..1bff1974 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -93,6 +93,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs new file mode 100644 index 00000000..006089f8 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a +/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is +/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator; +/// the model and type enums are read with +/// , so a present-but-invalid (typo'd) value rejects +/// the whole tag — the driver then surfaces BadNodeIdUnknown instead of silently defaulting to a +/// different model and publishing a misleading Good (R2-11). +/// No SQL is built here. The parser only captures strings; the reader binds value fields as +/// DbParameters and validates + quotes identifier fields. Tag input is never concatenated into a +/// command text, so a hostile keyValue is stored — and must be stored — verbatim. +/// +public static class SqlEquipmentTagParser +{ + /// + /// Maps an authored TagConfig blob to a typed definition keyed by . + /// Returns — never throws — for anything the driver must not serve: a + /// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or + /// type enum, a missing model-required field, or the P3-deferred + /// model. + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid Sql tag blob. + public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(reference)) return false; + if (reference.TrimStart().FirstOrDefault() != '{') return false; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + + // Recognition: either the explicit driver marker or the model discriminator must be present, + // so another driver's TagConfig blob is not mistaken for a Sql one. + var driver = ReadString(root, "driver"); + var hasModel = root.TryGetProperty("model", out _); + if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false; + + // Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag. + if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false; + if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false; + DriverDataType? declaredType = + root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String + ? type + : null; + + var table = ReadString(root, "table"); + if (string.IsNullOrWhiteSpace(table)) return false; + var timestampColumn = ReadString(root, "timestampColumn"); + + switch (model) + { + case SqlTagModel.KeyValue: + { + var keyColumn = ReadString(root, "keyColumn"); + var keyValue = ReadString(root, "keyValue"); + var valueColumn = ReadString(root, "valueColumn"); + if (string.IsNullOrWhiteSpace(keyColumn) + || string.IsNullOrWhiteSpace(valueColumn) + || keyValue is null) + return false; + def = new SqlTagDefinition( + Name: rawPath, Model: model, Table: table!, + KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn, + TimestampColumn: timestampColumn, DeclaredType: declaredType); + return true; + } + + case SqlTagModel.WideRow: + { + var columnName = ReadString(root, "columnName"); + if (string.IsNullOrWhiteSpace(columnName)) return false; + string? selectorColumn = null, selectorValue = null, topByTimestamp = null; + if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object) + { + selectorColumn = ReadString(sel, "whereColumn"); + selectorValue = ReadScalar(sel, "whereValue"); + topByTimestamp = ReadString(sel, "topByTimestamp"); + } + // A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp. + var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null; + if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false; + def = new SqlTagDefinition( + Name: rawPath, Model: model, Table: table!, + TimestampColumn: timestampColumn, ColumnName: columnName, + RowSelectorColumn: hasWherePair ? selectorColumn : null, + RowSelectorValue: hasWherePair ? selectorValue : null, + RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp, + DeclaredType: declaredType); + return true; + } + + default: + // SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model + // the runtime cannot read. + return false; + } + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + } + + /// Reads a string-valued property; null when absent or not a JSON string. + private static string? ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + /// + /// Reads a scalar property as its textual form — a JSON string yields its contents, a number or + /// boolean yields its raw token — so an authored whereValue of either shape is captured + /// verbatim for later parameter binding. Null when absent or not a scalar. + /// + private static string? ReadScalar(JsonElement o, string name) + { + if (!o.TryGetProperty(name, out var e)) return null; + return e.ValueKind switch + { + JsonValueKind.String => e.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(), + _ => null, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs new file mode 100644 index 00000000..755e2b9d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's TagConfig blob +/// (design §5.2 / §5.3), produced by . +/// Every string here is captured verbatim from the authored blob and is NEVER concatenated +/// into SQL. Value-bearing fields (, ) are bound +/// as DbParameters by the reader; identifier-bearing fields (, +/// , , , +/// , , +/// ) must be validated against INFORMATION_SCHEMA and +/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt +/// legitimate values, so the parser deliberately does not. +/// +/// +/// The tag's identity — its RawPath, exactly as handed to the parser. The driver's forward +/// router keys published values on this, so it must never be derived from the blob's contents. +/// +/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only. +/// The table or view to read (e.g. dbo.TagValues). +/// : the column matched against . +/// : this tag's key — bound, never interpolated. +/// : the column the value is read from. +/// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. +/// : the column this tag reads from the selected row. +/// : the whereColumn that picks the row. +/// : the whereValuebound, never interpolated. +/// : newest-row selector — the timestamp column to order by, when no where-pair is authored. +/// +/// Optional explicit type override from the blob's type field. ⇒ the +/// driver infers the type from the result set's column metadata. +/// +public sealed record SqlTagDefinition( + string Name, + SqlTagModel Model, + string Table, + string? KeyColumn = null, + string? KeyValue = null, + string? ValueColumn = null, + string? TimestampColumn = null, + string? ColumnName = null, + string? RowSelectorColumn = null, + string? RowSelectorValue = null, + string? RowSelectorTopByTimestamp = null, + DriverDataType? DeclaredType = null); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs new file mode 100644 index 00000000..18177ec4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs @@ -0,0 +1,30 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +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(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj new file mode 100644 index 00000000..74a7aae6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + From 531a110ffcaffde57b83067bcc7bdcd43c543131 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:37:52 -0400 Subject: [PATCH 06/33] chore(sql): align Driver.Sql csproj with sibling driver LangVersion convention Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj index 04426389..66ddb720 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj @@ -3,6 +3,7 @@ net10.0 enable enable + latest true true $(NoWarn);CS1591 From 6bb0108d30477f8a88d17a3ba0c6914b134dcacc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:43:53 -0400 Subject: [PATCH 07/33] feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialect owns the two things ADO.NET's System.Data.Common base types cannot abstract: identifier quoting and the metadata-catalog SQL. It is the driver's SQL-injection boundary — values always bind as DbParameters, identifiers cannot, so the few that reach a command text are catalog- sourced and pass through QuoteIdentifier. QuoteIdentifier brackets one identifier part and doubles embedded ] (the only metacharacter that can terminate a bracketed identifier early); rejects null/empty/whitespace, >128 chars (T-SQL sysname ceiling), and any control character (incl. NUL) as ArgumentException, without echoing the untrusted value into the message. MapColumnType folds the design §3.7 families case-insensitively and never throws — an unrecognised family falls back to String so a browse over an exotic column still renders; timestamp/rowversion and time are deliberately NOT mapped to DateTime. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ISqlDialect.cs | 68 ++++++++++ .../SqlServerDialect.cs | 127 ++++++++++++++++++ .../SqlServerDialectTests.cs | 107 +++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs new file mode 100644 index 00000000..076bd3b4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -0,0 +1,68 @@ +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The provider seam (design §2.2). ADO.NET's base types abstract +/// connections, commands, parameters and readers; they do not abstract the two things that differ +/// per backend — identifier quoting and the metadata-catalog SQL. Those live here, so no +/// dialect assumption leaks into the reader, the poll planner, or the schema browser. +/// This interface is the driver's SQL-injection boundary. Every runtime value is +/// bound as a and never reaches a command text. Identifiers cannot be +/// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated +/// (INFORMATION_SCHEMA) names and emitted through . Any code path that +/// builds SQL by concatenating an authored tag field without going through +/// is a defect (design §8.1). +/// Public because Driver.Sql.Browser consumes it — the catalog SQL is the browse +/// engine, so it is shared rather than duplicated. Implementations own their provider package; +/// no provider-specific type appears in this signature ( is the abstract +/// ). +/// +public interface ISqlDialect +{ + /// Which backend this dialect speaks. v1 constructs only. + SqlProvider Provider { get; } + + /// + /// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the + /// abstract base type so consumers never bind to a concrete provider package. + /// + DbProviderFactory Factory { get; } + + /// + /// Quotes one identifier part so it can be safely embedded in a command text, escaping the + /// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather + /// than emitting it. + /// One part only. A multi-part name such as dbo.TagValues must be split by the + /// caller and each part quoted separately; passing the dotted form yields a single (nonexistent) + /// identifier — safe, but not what the caller meant. + /// + /// The bare, unquoted identifier — as returned by the catalog, not pre-quoted. + /// The quoted identifier, ready to concatenate into a command text. + /// The value cannot be a valid catalog identifier. + string QuoteIdentifier(string ident); + + /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL). + string LivenessSql { get; } + + /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. + string ListSchemasSql { get; } + + /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema. + string ListTablesSql { get; } + + /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table. + string ListColumnsSql { get; } + + /// + /// Folds a catalog data-type family name (as INFORMATION_SCHEMA.COLUMNS.DATA_TYPE reports it — + /// e.g. nvarchar, never nvarchar(50)) onto a . + /// Must never throw: a browse over a table holding one exotic column must still render. + /// An unrecognised family falls back to . + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + DriverDataType MapColumnType(string sqlDataType); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs new file mode 100644 index 00000000..bd7eebbd --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -0,0 +1,127 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting, +/// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map. +/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and +/// the AdminUI browse session. +/// +public sealed class SqlServerDialect : ISqlDialect +{ + /// + /// T-SQL's regular-identifier ceiling (sysname = nvarchar(128)). Enforced because a + /// longer string cannot name a real catalog object — it is either a config mistake or padding, and + /// rejecting it keeps the quoted output bounded. + /// + internal const int MaxIdentifierLength = 128; + + /// + public SqlProvider Provider => SqlProvider.SqlServer; + + /// + public DbProviderFactory Factory => SqlClientFactory.Instance; + + /// + public string LivenessSql => "SELECT 1"; + + /// + public string ListSchemasSql => + "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; + + /// + public string ListTablesSql => + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME"; + + /// + public string ListColumnsSql => + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION"; + + /// + /// Brackets one identifier part, doubling any embedded ] — the T-SQL escape rule, and the only + /// metacharacter that could terminate a bracketed identifier early. [, quotes, semicolons and + /// comment markers are inert inside brackets and are therefore passed through unchanged. + /// + /// + /// Rejects (all as ): null / empty / all-whitespace; longer + /// than ; any control character (which covers embedded NUL, and the + /// C0/C1 ranges that can truncate or corrupt a logged/audited statement). + /// The rejection messages deliberately do not echo the offending value — it is untrusted + /// input and this exception's text reaches the driver log. + /// Rejection is a backstop, not the primary defence: an identifier only reaches here after being + /// validated against the live catalog (design §8.1). + /// + /// The bare, unquoted identifier part. + /// The bracket-quoted identifier. + /// The value cannot be a valid SQL Server identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + if (ident.Length > MaxIdentifierLength) + throw new ArgumentException( + $"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + } + + return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]"); + } + + /// + /// Folds an INFORMATION_SCHEMA.COLUMNS.DATA_TYPE family name onto a + /// per design §3.7. + /// + /// + /// Precision caveat: decimal / numeric / money / smallmoney + /// collapse to in v1. A .NET decimal carries more + /// significant digits than a double, so a high-precision column (e.g. decimal(38,10)) + /// loses low-order digits. Authoring an explicit "type": "String" on the tag preserves the exact + /// textual value when that matters. + /// Fallback: an unrecognised family maps to and never + /// throws — a schema browse must render a table containing an exotic column (xml, + /// geography, hierarchyid, sql_variant, a CLR UDT, varbinary) rather than + /// crash on it. Null/blank input takes the same fallback. + /// Deliberate non-mappings: SQL Server's timestamp/rowversion is an opaque + /// 8-byte row version, not a date — it falls back to + /// rather than being mistaken for a . time is a + /// time-of-day span, likewise not a point in time, and takes the same fallback. + /// tinyint widens to (it is unsigned 0–255 in T-SQL, so + /// a signed 8-bit type would not hold it, and the driver type set has no Byte). + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "bit" or "boolean" => DriverDataType.Boolean, + "tinyint" or "smallint" => DriverDataType.Int16, + "int" or "integer" => DriverDataType.Int32, + "bigint" => DriverDataType.Int64, + "real" => DriverDataType.Float32, + "float" or "double" or "double precision" + or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64, + "char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext" + or "uniqueidentifier" => DriverDataType.String, + "date" or "datetime" or "datetime2" or "smalldatetime" + or "datetimeoffset" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs new file mode 100644 index 00000000..9fecd3ba --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Golden tests for the driver's SQL-injection boundary. Values are always bound as +/// DbParameters; identifiers cannot be, so is the +/// only thing standing between a catalog-sourced name and a command text. These tests pin the escape +/// rule, the rejection rules, and the exact catalog SQL. +/// +public class SqlServerDialectTests +{ + [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(() => 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"); + } + + // ---- extra guards on the injection boundary (beyond the plan's golden set) ---- + + [Fact] + public void Dialect_identifiesItselfAsSqlServer_andExposesTheAbstractFactory() + { + var d = new SqlServerDialect(); + d.Provider.ShouldBe(SqlProvider.SqlServer); + d.Factory.ShouldNotBeNull(); + // The seam must not leak Microsoft.Data.SqlClient into its signature. + typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))! + .PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident!)); + + [Fact] + public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButABracket() + => new SqlServerDialect().QuoteIdentifier("]").ShouldBe("[]]]"); // T-SQL for the 1-char name "]" + + [Fact] + public void QuoteIdentifier_acceptsExactly128Chars_andRejects129() + { + var d = new SqlServerDialect(); + d.QuoteIdentifier(new string('a', 128)).ShouldBe("[" + new string('a', 128) + "]"); + Should.Throw(() => d.QuoteIdentifier(new string('a', 129))); + } + + [Fact] + public void QuoteIdentifier_neutralisesAClassicInjectionPayload() + { + // A hostile "column name" cannot escape the brackets: the only metacharacter that could is ], + // and it is doubled. The result is a single (nonexistent) identifier, never executable SQL. + new SqlServerDialect().QuoteIdentifier("x] ; DROP TABLE Users --") + .ShouldBe("[x]] ; DROP TABLE Users --]"); + } + + [Theory] + [InlineData("NVARCHAR", DriverDataType.String)] + [InlineData("BiT", DriverDataType.Boolean)] + public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected) + => new SqlServerDialect().MapColumnType(sql).ShouldBe(expected); + + [Theory] + [InlineData("geography")] + [InlineData("sql_variant")] + [InlineData("timestamp")] // SQL Server rowversion — binary, deliberately NOT a DateTime + [InlineData("")] + [InlineData(null)] + public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql) + => new SqlServerDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String); +} From 984cc875e8e6792408e353e286dd72958220be52 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:52:27 -0400 Subject: [PATCH 08/33] feat(sql): SqlGroupPlanner folds tags into one parameterized query per group Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlGroupPlanner.cs | 253 ++++++++++++ .../SqlQueryPlan.cs | 75 ++++ .../SqlGroupPlannerTests.cs | 386 ++++++++++++++++++ 3 files changed, 714 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs new file mode 100644 index 00000000..54f92d1d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -0,0 +1,253 @@ +using System.Globalization; +using System.Text; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Folds a set of s into the minimum number of parameterized queries +/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock. +/// The GroupKey is the correctness contract. Two tags share a query only when every +/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell; +/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by +/// SqlGroupPlannerTests. +/// Injection boundary (design §8.1). Identifiers reach the command text through +/// and nothing else; every authored value becomes a +/// @k0/@w marker with the value carried in . There is no +/// interpolation of tag content into SQL anywhere in this type. +/// +public static class SqlGroupPlanner +{ + /// The parameter-marker prefix for a key-value model's IN list. + private const string KeyMarkerPrefix = "@k"; + + /// The single parameter marker for a wide-row model's row selector. + private const string RowSelectorMarker = "@w"; + + /// + /// Groups by source and emits one per group. + /// Deterministic: groups appear in the input order of their first member, members keep their input + /// order within a group, and parameters keep the first-appearance order of their distinct values — so a + /// given tag list always yields byte-identical SQL. + /// + /// The tags to plan. An empty sequence yields no plans. + /// Supplies identifier quoting and the provider's row-limit syntax. + /// One plan per distinct group key. + /// or is null. + /// + /// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an + /// identifier cannot be quoted (an empty table-name part, a control character — see + /// ). rejects all of these + /// up front, so reaching one here means a caller built a definition by hand with a broken invariant. + /// + /// + /// A tag carries (design §5.4, deferred to P3), or a + /// topByTimestamp selector is planned for a provider whose row-limit syntax is not yet modelled. + /// Deliberately loud rather than skipped: silently dropping a tag would leave an authored node + /// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice. + /// + public static IReadOnlyList Plan(IEnumerable tags, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(tags); + ArgumentNullException.ThrowIfNull(dialect); + + // Ordered grouping: the dictionary decides membership, the list decides emission order. + var order = new List(); + var groups = new Dictionary>(); + + foreach (var tag in tags) + { + ArgumentNullException.ThrowIfNull(tag); + var key = BuildGroupKey(tag); + if (!groups.TryGetValue(key, out var members)) + { + members = []; + groups.Add(key, members); + order.Add(key); + } + + members.Add(tag); + } + + var plans = new List(order.Count); + foreach (var key in order) + { + var members = groups[key]; + plans.Add(members[0].Model switch + { + SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect), + SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect), + _ => throw UnsupportedModel(members[0]), + }); + } + + return plans; + } + + /// + /// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose + /// first element is the model, so a key-value key can never compare equal to a wide-row key. + /// + /// + /// (model, table, keyColumn, valueColumn, timestampColumn). + /// Every one of those shapes the emitted SELECT: sharing a plan across two valueColumns + /// would make one tag publish the other's column. + /// + /// + /// (model, table, whereColumn, whereValue, topByTimestamp) + /// — i.e. the table plus the whole row selector, exactly design §5.3's + /// (table, rowSelector). The members' own columnName/timestampColumn are + /// deliberately not in the key: differing there is the point of the model (many columns, + /// one row), and they are added to the SELECT list instead. + /// + /// + /// String comparison is ordinal. SQL Server object names are usually case-insensitive, so + /// two tags authored dbo.T and DBO.T plan as two groups. That costs one extra round-trip + /// and is never wrong; guessing the server's collation here could fold two genuinely different sources. + /// + private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch + { + SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn), + SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue, + tag.RowSelectorTopByTimestamp), + _ => throw UnsupportedModel(tag), + }; + + /// + /// Emits SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN). + /// The key column is selected because the reader indexes rows by it to slice values back per member. + /// Keys are de-duplicated (ordinal) before binding: two tags authored against the same + /// keyValue bind one parameter but stay two members, so both nodes are fed from the one row. + /// + private static SqlQueryPlan BuildKeyValuePlan( + object groupKey, List members, ISqlDialect dialect) + { + var first = members[0]; + var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first); + var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first); + var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn; + + var selected = new List(3); + AddDistinct(selected, keyColumn); + AddDistinct(selected, valueColumn); + if (timestampColumn is not null) AddDistinct(selected, timestampColumn); + + // A keyValue may legitimately be the empty string, so only null is a broken invariant. + var names = new List(members.Count); + var values = new List(members.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + var keyValue = member.KeyValue + ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member); + if (!seen.Add(keyValue)) continue; + names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture)); + values.Add(keyValue); + } + + var sql = new StringBuilder("SELECT ") + .Append(QuoteList(selected, dialect)) + .Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect)) + .Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn)) + .Append(" IN (").AppendJoin(", ", names).Append(')') + .ToString(); + + return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected); + } + + /// + /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w, or — for a + /// topByTimestamp selector — SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC. + /// The SELECT list is each member's columnName (distinct, in first-appearance order), + /// followed by each distinct member timestampColumn — so members of one row may carry different + /// timestamp columns without splitting the group. The where-column itself is not selected: every + /// row in the result already matches the bound value, so reading it back adds nothing. + /// + private static SqlQueryPlan BuildWideRowPlan( + object groupKey, List members, ISqlDialect dialect) + { + var first = members[0]; + + var selected = new List(members.Count + 1); + foreach (var member in members) + AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member)); + foreach (var member in members) + if (!Blank(member.TimestampColumn)) + AddDistinct(selected, member.TimestampColumn!); + + var table = QuoteQualifiedName(first.Table, dialect); + var columns = QuoteList(selected, dialect); + + // A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated. + if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null) + { + var sql = string.Concat( + "SELECT ", columns, " FROM ", table, + " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker); + return new SqlQueryPlan( + SqlTagModel.WideRow, groupKey, sql, + [RowSelectorMarker], [first.RowSelectorValue], members, selected); + } + + if (!Blank(first.RowSelectorTopByTimestamp)) + { + // "TOP 1" is T-SQL. Other providers spell the row limit differently (LIMIT 1, FETCH FIRST, + // ROWNUM), and ISqlDialect does not model it yet because v1 constructs SqlServer only — so refuse + // loudly rather than emit a statement that would not parse. Promote a row-limit member onto + // ISqlDialect when P2 adds Postgres/ODBC. + if (dialect.Provider != SqlProvider.SqlServer) + throw new NotSupportedException( + $"The wide-row 'topByTimestamp' row selector needs a provider-specific row limit; " + + $"{dialect.Provider} is not modelled yet (v1 constructs SqlServer only)."); + + var sql = string.Concat( + "SELECT TOP 1 ", columns, " FROM ", table, + " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC"); + return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected); + } + + throw new ArgumentException( + $"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" + + "whereValue pair or a rowSelector.topByTimestamp column.", nameof(members)); + } + + /// + /// Quotes a possibly multi-part object name by splitting on . and quoting each part — + /// dbo.TagValues becomes [dbo].[TagValues]. + /// quotes exactly one part; handing it the dotted + /// form would yield [dbo.TagValues], which is safe but names an object that does not exist. An + /// empty part (dbo..T) is rejected by the dialect rather than emitted. As a consequence an object + /// whose real name contains a literal . cannot be authored — an accepted v1 limitation. + /// + private static string QuoteQualifiedName(string name, ISqlDialect dialect) + { + if (Blank(name)) + throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name)); + + return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier)); + } + + /// Quotes each already-de-duplicated column name and joins them into a SELECT list. + private static string QuoteList(IReadOnlyList columns, ISqlDialect dialect) + => string.Join(", ", columns.Select(dialect.QuoteIdentifier)); + + /// Appends unless an ordinal-equal entry is already present. + private static void AddDistinct(List target, string value) + { + if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value); + } + + /// True when the string is null, empty, or all whitespace — i.e. cannot be an identifier. + private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value); + + /// Returns a required identifier field, or throws naming both the tag and the field. + private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag) + => Blank(value) ? throw MissingField(field, tag) : value!; + + private static ArgumentException MissingField(string field, SqlTagDefinition tag) + => new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'."); + + private static NotSupportedException UnsupportedModel(SqlTagDefinition tag) + => new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " + + "v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4)."); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs new file mode 100644 index 00000000..0d933d07 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -0,0 +1,75 @@ +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that +/// shares a reads from the same result set, so a poll pass executes one plan +/// per group rather than one query per tag. +/// Injection boundary. is built from dialect-quoted identifiers only; +/// every authored value lives in and is bound as a +/// named by the positionally-aligned . There is no +/// path by which a tag's keyValue or whereValue reaches the command text. +/// Produced only by ; consumed by the poll reader, which executes +/// once and slices the rows back to per-member snapshots. +/// +/// +/// The tag→value mapping model every member shares. Determines how the reader slices the result set: +/// indexes rows by and matches each member's +/// ; takes the single row and +/// reads each member's . +/// +/// +/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly +/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing. +/// +/// The single parameterized statement to execute for this group. +/// +/// The parameter markers appearing in , in the order they appear, aligned index-for-index +/// with . Carried explicitly so the reader never has to re-derive the naming +/// convention. +/// +/// +/// The values to bind, aligned with . Captured verbatim from the authored tags +/// — a hostile value is inert here because it is data, not text. +/// +/// +/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags +/// may legitimately read the same cell, and both must receive a value. +/// +/// +/// The bare (unquoted) column names in 's SELECT list, in order and distinct — the +/// reader's map from result-set ordinal to source column. +/// +public sealed record SqlQueryPlan( + SqlTagModel Model, + object GroupKey, + string SqlText, + IReadOnlyList ParameterNames, + IReadOnlyList Parameters, + IReadOnlyList Members, + IReadOnlyList SelectedColumns) +{ + /// + /// The key column all members are matched on — for any model but + /// . Uniform across by the group-key invariant, + /// so Members[0] is authoritative. + /// + public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null; + + /// + /// The column every member's value is read from — for any model but + /// . Uniform across by the group-key invariant. + /// + public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null; + + /// + /// The shared source-timestamp column, or when the group has none (the reader + /// then stamps the poll time). only — it is part of that model's + /// group key, so it is uniform. A plan deliberately allows members to + /// carry different timestamp columns (all of them are selected), so the reader must read + /// per member there. + /// + public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs new file mode 100644 index 00000000..85fe8f34 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs @@ -0,0 +1,386 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Pins the query-mapping core: which tags share a query (the GroupKey), the exact SQL emitted for each +/// group, and — the thing this type exists to guarantee — that every authored value is a bound +/// parameter and never text. +/// GroupKey correctness governs read correctness: too coarse and tags silently read each other's +/// values; too fine and the batching collapses to one query per tag. Both failure modes are asserted +/// here. +/// +public class SqlGroupPlannerTests +{ + private static SqlTagDefinition KvTag( + string name, string table, string keyColumn, string keyValue, string valueColumn, string? timestampColumn) + => new(name, SqlTagModel.KeyValue, table, + KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn, + TimestampColumn: timestampColumn); + + private static SqlTagDefinition WideTag( + string name, string table, string columnName, + string? selectorColumn = null, string? selectorValue = null, + string? topByTimestamp = null, string? timestampColumn = null) + => new(name, SqlTagModel.WideRow, table, + TimestampColumn: timestampColumn, ColumnName: columnName, + RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + // ---- the plan's golden case ---- + + [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 + } + + [Fact] + public void KeyValuePlan_emitsTheWholeStatement_keyValueTimestampFromTheQuotedTable() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts")], + new SqlServerDialect()); + + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)"); + plans[0].ParameterNames.ShouldBe(new[] { "@k0" }); + plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" }); + plans[0].Model.ShouldBe(SqlTagModel.KeyValue); + plans[0].KeyColumn.ShouldBe("tag_name"); + plans[0].ValueColumn.ShouldBe("num_value"); + plans[0].TimestampColumn.ShouldBe("sample_ts"); + } + + [Fact] + public void KeyValuePlan_withoutATimestampColumn_omitsItFromTheSelectList() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)], + new SqlServerDialect()); + + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)"); + plans[0].TimestampColumn.ShouldBeNull(); + } + + // ---- GroupKey: what must NOT fold together ---- + + [Fact] + public void KeyValueTags_onDifferentTables_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.OtherValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].GroupKey.ShouldNotBe(plans[1].GroupKey); + plans[0].SqlText.ShouldContain("[dbo].[TagValues]"); + plans[1].SqlText.ShouldContain("[dbo].[OtherValues]"); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentValueColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "str_value", "sample_ts"), + ], new SqlServerDialect()).ToList(); + + // Folding these would make B read A's column — the silent cross-read this GroupKey prevents. + plans.Count.ShouldBe(2); + plans[0].SqlText.ShouldContain("[num_value]"); + plans[1].SqlText.ShouldContain("[str_value]"); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentTimestampColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "recorded_at"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentKeyColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("B", "dbo.TagValues", "alt_name", "Line1.Temp", "num_value", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + [Fact] + public void TagsOfDifferentModels_neverShareAPlan_evenOnTheSameTable() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.Latest", "tag_name", "Line1.Speed", "num_value", null), + WideTag("B", "dbo.Latest", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].Model.ShouldBe(SqlTagModel.KeyValue); + plans[1].Model.ShouldBe(SqlTagModel.WideRow); + } + + // ---- distinct parameter binding ---- + + [Fact] + public void KeyValueTags_sharingAKeyValue_bindTheKeyOnce_butKeepBothMembers() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("C", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); + plans[0].ParameterNames.ShouldBe(new[] { "@k0", "@k1" }); + plans[0].SqlText.ShouldContain("IN (@k0, @k1)"); + // Both tags stay members: two OPC UA nodes are fed from the one row. + plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" }); + } + + [Fact] + public void ParameterNamesAndParameters_arePositionallyAligned_andMatchTheMarkersInTheSql() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null), + KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null), + KvTag("C", "dbo.TagValues", "tag_name", "k2", "num_value", null), + ], new SqlServerDialect()).ToList(); + + var plan = plans[0]; + plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count); + plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); + plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" }); + } + + // ---- wide row ---- + + [Fact] + public void WideRowTags_onTheSameRowSelector_foldIntoOnePlan_listingEachColumnOnce() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + WideTag("B", "dbo.LatestStatus", "pressure", selectorColumn: "station_id", selectorValue: "7"), + WideTag("C", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SqlText.ShouldBe( + "SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w"); + plans[0].ParameterNames.ShouldBe(new[] { "@w" }); + plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined + plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" }); + plans[0].Members.Count.ShouldBe(3); + } + + [Fact] + public void WideRowTags_onADifferentRowSelectorValue_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + WideTag("B", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "8"), + ], new SqlServerDialect()).ToList(); + + // Folding these would publish station 7's temperature on station 8's node. + plans.Count.ShouldBe(2); + plans[0].Parameters.ShouldBe(new object[] { "7" }); + plans[1].Parameters.ShouldBe(new object[] { "8" }); + } + + [Fact] + public void WideRowPlan_appendsEachDistinctMemberTimestampColumnAfterTheValueColumns() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", + selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"), + WideTag("B", "dbo.LatestStatus", "pressure", + selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" }); + plans[0].SqlText.ShouldBe( + "SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w"); + } + + [Fact] + public void WideRowTags_topByTimestamp_emitTheTop1OrderByDescForm_withNoParameters() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"), + WideTag("B", "dbo.Readings", "pressure", topByTimestamp: "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SqlText.ShouldBe( + "SELECT TOP 1 [oven_temp], [pressure] FROM [dbo].[Readings] ORDER BY [sample_ts] DESC"); + plans[0].Parameters.ShouldBeEmpty(); + plans[0].ParameterNames.ShouldBeEmpty(); + } + + [Fact] + public void WideRowTags_topByTimestamp_andAWherePair_onTheSameTable_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"), + WideTag("B", "dbo.Readings", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + // ---- identifier quoting ---- + + [Fact] + public void DottedTableName_isQuotedPerPart_notAsOneIdentifier() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("[dbo].[TagValues]"); + plans[0].SqlText.ShouldNotContain("[dbo.TagValues]"); // would name a nonexistent object + } + + [Fact] + public void UndottedTableName_isQuotedAsASinglePart() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("FROM [TagValues] "); + } + + [Fact] + public void ThreePartTableName_isQuotedPerPart() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "Plant.dbo.TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("[Plant].[dbo].[TagValues]"); + } + + [Fact] + public void AnEmptyTableNamePart_isRejected_ratherThanEmitted() + => Should.Throw(() => SqlGroupPlanner.Plan( + [KvTag("A", "dbo..TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect())); + + // ---- the injection boundary ---- + + [Fact] + public void AHostileKeyValue_isBoundAsAParameter_andNeverReachesTheSqlText() + { + const string payload = "'; DROP TABLE x --"; + + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", payload, "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].Parameters.ShouldBe(new object[] { payload }); + plans[0].SqlText.ShouldNotContain("DROP"); + plans[0].SqlText.ShouldNotContain("--"); + plans[0].SqlText.ShouldNotContain("'"); + } + + [Fact] + public void AHostileWideRowSelectorValue_isBoundAsAParameter_andNeverReachesTheSqlText() + { + const string payload = "7'); DROP TABLE x --"; + + var plans = SqlGroupPlanner.Plan( + [WideTag("A", "dbo.LatestStatus", "oven_temp", + selectorColumn: "station_id", selectorValue: payload)], + new SqlServerDialect()).ToList(); + + plans[0].Parameters.ShouldBe(new object[] { payload }); + plans[0].SqlText.ShouldNotContain("DROP"); + } + + [Fact] + public void AHostileColumnName_goesThroughQuoteIdentifier_ratherThanBeingConcatenatedRaw() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.T", "tag_name", "k", "v] ; DROP TABLE Users --", null)], + new SqlServerDialect()).ToList(); + + // A column name is an identifier — it cannot be parameterized, so the guarantee is that it is + // bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL. + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)"); + } + + // ---- contract edges ---- + + [Fact] + public void NoTags_yieldNoPlans() + => SqlGroupPlanner.Plan([], new SqlServerDialect()).ShouldBeEmpty(); + + [Fact] + public void PlanOrder_followsTheInputOrderOfTheFirstMemberOfEachGroup() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.Third", "k", "1", "v", null), + KvTag("B", "dbo.First", "k", "2", "v", null), + KvTag("C", "dbo.Third", "k", "3", "v", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "C" }); + plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "B" }); + } + + [Fact] + public void TheDeferredQueryModel_throws_ratherThanBeingSilentlyDropped() + { + var tag = new SqlTagDefinition("A", SqlTagModel.Query, "dbo.T"); + Should.Throw(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect())); + } + + [Fact] + public void AWideRowTagWithNoRowSelectorAtAll_throws() + { + var tag = WideTag("A", "dbo.T", "oven_temp"); + Should.Throw(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect())); + } +} From 3d7e1226d2bd729164d3a3d84a023d404ea1088a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:57:25 -0400 Subject: [PATCH 09/33] refactor(sql): promote the single-row limit onto ISqlDialect Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ISqlDialect.cs | 41 +++++++++++++++++-- .../SqlGroupPlanner.cs | 25 +++++------ .../SqlServerDialect.cs | 10 +++++ .../SqlServerDialectTests.cs | 13 ++++++ 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs index 076bd3b4..f6b37df0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// /// The provider seam (design §2.2). ADO.NET's base types abstract -/// connections, commands, parameters and readers; they do not abstract the two things that differ -/// per backend — identifier quoting and the metadata-catalog SQL. Those live here, so no -/// dialect assumption leaks into the reader, the poll planner, or the schema browser. +/// connections, commands, parameters and readers; they do not abstract the three things that differ +/// per backend — identifier quoting, the metadata-catalog SQL, and row-limit syntax. +/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser. /// This interface is the driver's SQL-injection boundary. Every runtime value is /// bound as a and never reaches a command text. Identifiers cannot be /// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated @@ -47,6 +47,41 @@ public interface ISqlDialect /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL). string LivenessSql { get; } + /// + /// The fragment that goes immediately after SELECT to limit a statement to one row — + /// T-SQL's "TOP 1 ". Empty for a dialect that spells the limit at the end of the statement (see + /// ). + /// Include the trailing space when non-empty: the planner concatenates + /// "SELECT " + SingleRowLimitPrefix + columns with no separator of its own, so an empty fragment + /// must leave the statement byte-identical to an unlimited one. + /// + /// + /// Why a prefix/suffix pair rather than one ApplyRowLimit(sql, n) method. The two + /// spellings sit at opposite ends of the statement — SQL Server writes + /// SELECT TOP 1 … ORDER BY … DESC, while SQLite/Postgres/MySQL write + /// SELECT … ORDER BY … DESC LIMIT 1. A single method taking the SELECT list could only serve the + /// prefix position; a single method taking the whole statement would move statement assembly out of the + /// planner and into every dialect. Two fragments keep the planner the sole author of statement shape + /// and let each dialect fill in the end it uses. + /// Why "single row" rather than a row count. The only limit v1 emits is the wide-row + /// topByTimestamp selector's newest-row pick. Modelling an arbitrary n would be untested + /// surface, and FETCH FIRST/ROWNUM dialects need more than a substituted number anyway. + /// Widen this to a method when a feature actually needs n > 1. + /// + string SingleRowLimitPrefix { get; } + + /// + /// The fragment appended to the very end of a statement to limit it to one row — + /// " LIMIT 1" for SQLite/Postgres/MySQL. Empty for a dialect that limits after SELECT + /// (see ). + /// Include the leading space when non-empty, for the same reason the prefix carries a + /// trailing one: the planner appends it directly to the finished statement. + /// Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner + /// emits both unconditionally — a dialect needing both ends (or neither) is expressible without + /// touching the call site. + /// + string SingleRowLimitSuffix { get; } + /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. string ListSchemasSql { get; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs index 54f92d1d..73510d1d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -41,8 +41,7 @@ public static class SqlGroupPlanner /// up front, so reaching one here means a caller built a definition by hand with a broken invariant. /// /// - /// A tag carries (design §5.4, deferred to P3), or a - /// topByTimestamp selector is planned for a provider whose row-limit syntax is not yet modelled. + /// A tag carries (design §5.4, deferred to P3). /// Deliberately loud rather than skipped: silently dropping a tag would leave an authored node /// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice. /// @@ -157,7 +156,10 @@ public static class SqlGroupPlanner /// /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w, or — for a - /// topByTimestamp selector — SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC. + /// topByTimestamp selector — the single-row form + /// SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit>, whose + /// row limit comes from / + /// (T-SQL fills the prefix: SELECT TOP 1 …). /// The SELECT list is each member's columnName (distinct, in first-appearance order), /// followed by each distinct member timestampColumn — so members of one row may carry different /// timestamp columns without splitting the group. The where-column itself is not selected: every @@ -191,18 +193,13 @@ public static class SqlGroupPlanner if (!Blank(first.RowSelectorTopByTimestamp)) { - // "TOP 1" is T-SQL. Other providers spell the row limit differently (LIMIT 1, FETCH FIRST, - // ROWNUM), and ISqlDialect does not model it yet because v1 constructs SqlServer only — so refuse - // loudly rather than emit a statement that would not parse. Promote a row-limit member onto - // ISqlDialect when P2 adds Postgres/ODBC. - if (dialect.Provider != SqlProvider.SqlServer) - throw new NotSupportedException( - $"The wide-row 'topByTimestamp' row selector needs a provider-specific row limit; " + - $"{dialect.Provider} is not modelled yet (v1 constructs SqlServer only)."); - + // The row limit is dialect syntax and sits at opposite ends of the statement per provider + // (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted + // unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix. var sql = string.Concat( - "SELECT TOP 1 ", columns, " FROM ", table, - " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC"); + "SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table, + " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC", + dialect.SingleRowLimitSuffix); return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs index bd7eebbd..2e76821b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -29,6 +29,16 @@ public sealed class SqlServerDialect : ISqlDialect /// public string LivenessSql => "SELECT 1"; + /// + /// T-SQL limits after the SELECT keyword, so the whole limit lives in the prefix — + /// "TOP 1 ", trailing space included so SELECT TOP 1 [col] composes with no extra + /// separator at the call site. + /// + public string SingleRowLimitPrefix => "TOP 1 "; + + /// Empty — T-SQL has no trailing row-limit clause (OFFSET/FETCH is a paging construct, not this). + public string SingleRowLimitSuffix => string.Empty; + /// public string ListSchemasSql => "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs index 9fecd3ba..af0e4c0f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -48,6 +48,19 @@ public class SqlServerDialectTests d.ListColumnsSql.ShouldContain("@table"); } + [Fact] + public void SingleRowLimit_isTSqlsTop1_inThePrefixPosition_withItsOwnTrailingSpace() + { + var d = new SqlServerDialect(); + + // The planner concatenates "SELECT " + prefix + columns with no separator of its own, so the + // trailing space belongs to the fragment. T-SQL has no end-of-statement limit clause. + d.SingleRowLimitPrefix.ShouldBe("TOP 1 "); + d.SingleRowLimitSuffix.ShouldBe(""); + string.Concat("SELECT ", d.SingleRowLimitPrefix, "[a]", d.SingleRowLimitSuffix) + .ShouldBe("SELECT TOP 1 [a]"); + } + // ---- extra guards on the injection boundary (beyond the plan's golden set) ---- [Fact] From 6de782e0fd6ab8fbaf649b4ff5e2158262cdbeed Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:59:01 -0400 Subject: [PATCH 10/33] test(sql): cover the WideRow + rejection branches of the equipment-tag parser Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlEquipmentTagParserTests.cs | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs index 18177ec4..bec219f7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs @@ -5,6 +5,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; +/// +/// Pins against its documented contract: what it accepts, what +/// it captures verbatim, and — the larger half — everything it must reject. Rejection is the whole +/// safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for +/// a typo'd enum) a node fed from the wrong model while publishing a confident Good (R2-11). +/// public class SqlEquipmentTagParserTests { [Fact] @@ -27,4 +33,167 @@ public class SqlEquipmentTagParserTests => SqlEquipmentTagParser.TryParse( """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", "raw", out _).ShouldBeFalse(); + + // ---- WideRow: the where-pair selector ---- + + [Fact] + public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp", + "timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}} + """; + + SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.Table.ShouldBe("dbo.LatestStatus"); + def.ColumnName.ShouldBe("oven_temp"); + def.TimestampColumn.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); + // Wide-row carries none of the key-value fields. + def.KeyColumn.ShouldBeNull(); + def.KeyValue.ShouldBeNull(); + def.ValueColumn.ShouldBeNull(); + def.DeclaredType.ShouldBeNull(); // no "type" authored ⇒ infer from column metadata + } + + [Theory] + [InlineData("7", "7")] // JSON number → its raw token, so the reader binds one string either way + [InlineData("\"7\"", "7")] // JSON string → its contents + [InlineData("true", "true")] // JSON bool → its raw token (lower-case, as written) + public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar( + string authored, string expected) + { + var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":""" + + authored + "}}"; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorValue.ShouldBe(expected); + } + + [Fact] + public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values. + def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --"); + } + + // ---- WideRow: the topByTimestamp selector ---- + + [Fact] + public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp", + "rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorTopByTimestamp.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + def.DeclaredType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair + // first and would otherwise silently drop the topByTimestamp ordering from a group's key. + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + // ---- WideRow rejections ---- + + [Theory] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":" ","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Theory] + // No rowSelector object at all. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")] + // Present but empty. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")] + // Half a where-pair is not a selector: a whereColumn with no value… + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")] + // …and a whereValue with no column. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")] + // rowSelector authored as the wrong JSON kind is ignored, leaving no selector. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")] + public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json) + { + // Accepting one would author a node whose query cannot say WHICH row it reads. + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + // ---- recognition + shared rejections ---- + + [Theory] + [InlineData("""["not","an","object"]""")] + [InlineData(""""just a string"""")] + [InlineData("42")] + [InlineData("null")] + [InlineData("not json at all")] + [InlineData("")] + [InlineData(" ")] + public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference) + => SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag() + { + // Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag. + const string json = + """{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Fact] + public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", + "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_theDeferredQueryModel_rejectsTag() + { + // Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read. + const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Theory] + [InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":" ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_withoutATable_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_invalidTypeEnum_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""", + "raw", out _).ShouldBeFalse(); } From a05cff794cc52acc95bfb65eadc342f0d268fec0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:04:06 -0400 Subject: [PATCH 11/33] test(sql): SqliteDialect + poll fixture Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqliteDialect.cs | 154 ++++++++++ .../SqlitePollFixture.cs | 212 ++++++++++++++ .../SqlitePollFixtureTests.cs | 263 ++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj | 10 + 4 files changed, 639 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs new file mode 100644 index 00000000..8a7a7527 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -0,0 +1,154 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// A test-only over SQLite, so the poll reader and the schema browser can +/// be exercised against a real — real parameter binding, real type coercion, +/// real NULLs — with no SQL Server and no network. No product project references SQLite. +/// It is also the seam's falsifiability control: it is the only non-SQL-Server +/// in the tree, so it is what proves the planner emits dialect SQL +/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's +/// TOP 1 does not parse, and the fixture tests fail loudly if that regresses. +/// Kept public and self-contained on purpose: Driver.Sql.Browser.Tests links this file +/// (<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />) +/// rather than referencing this project, so it must not depend on anything else defined here. +/// +public sealed class SqliteDialect : ISqlDialect +{ + /// + /// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the + /// catalog queries accept this one value so the browser's schema level has something real to expand. + /// + public const string MainSchema = "main"; + + /// + /// Reports . + /// Why not a Sqlite member: is a shipped product + /// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain + /// a case that can never occur in production. + /// Why of the existing members: it is the enum's generic, + /// not-yet-constructed member, so nothing branches on it today. Crucially it is not + /// — any future T-SQL-only code path that keys off + /// stays switched off against this dialect and fails visibly here rather than + /// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp + /// for exactly the assumptions it exists to catch. + /// + public SqlProvider Provider => SqlProvider.Odbc; + + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// SQLite spells the row limit at the end of the statement, so the after-SELECT + /// position is empty — the mirror image of SqlServerDialect, which is the point of having both + /// ends on the seam. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// " LIMIT 1", leading space included so it appends straight onto the finished statement + /// (… ORDER BY "sample_ts" DESC LIMIT 1). + /// + public string SingleRowLimitSuffix => " LIMIT 1"; + + /// + /// SQLite has no schema namespace, so the schema level is the single literal + /// — enough to give the browser a real root to expand. + /// + public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; + + /// + /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the + /// internal sqlite_* objects filtered out and the type folded onto the + /// INFORMATION_SCHEMA.TABLES vocabulary the browser already speaks. @schema is bound and + /// honoured — anything but legitimately lists nothing. + /// + public string ListTablesSql => + "SELECT name AS TABLE_NAME, " + + "CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " + + "FROM sqlite_schema " + + "WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " + + $"AND @schema = '{MainSchema}' ORDER BY name"; + + /// + /// Columns via the pragma_table_info table-valued function rather than the bare + /// PRAGMA table_info(x) statement, because only the function form lets the table name be a + /// bound parameter — a bare PRAGMA takes its argument as text, which would put an authored name + /// back into a command string and reopen the injection boundary this seam exists to close. + /// DATA_TYPE is the column's declared type (SQLite stores affinity, not a type). + /// + public string ListColumnsSql => + "SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " + + "CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " + + "FROM pragma_table_info(@table) " + + $"WHERE @schema = '{MainSchema}' ORDER BY cid"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — SQLite's escape rule, and the + /// only metacharacter that could close a quoted identifier early. + /// + /// + /// Mirrors SqlServerDialect.QuoteIdentifier's rejections (null / empty / all-whitespace / + /// control characters) so a test written against one dialect fails the same way against the other. + /// SQLite imposes no identifier length ceiling, so there is no length rule here. + /// + /// The bare, unquoted identifier part. + /// The double-quote-quoted identifier. + /// The value cannot be a valid SQLite identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + } + + return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + } + + /// + /// Folds a SQLite declared column type onto a . SQLite is + /// dynamically typed and a column's declared type is advisory, which is exactly why the fixture + /// declares every seeded column explicitly. + /// + /// + /// Never throws, mirroring SqlServerDialect: an unrecognised (or blank, or + /// parameterised like VARCHAR(50)) declaration falls back to + /// so a browse over an oddly-declared table still renders. + /// Deliberate divergence from SQL Server: REAL maps to + /// here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's + /// real is 4-byte — mapping it to Float32 would narrow every value the fixture returns. + /// + /// The declared type as pragma_table_info reports it; case-insensitive. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "boolean" or "bool" or "bit" => DriverDataType.Boolean, + "tinyint" or "smallint" or "int2" => DriverDataType.Int16, + "int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32, + "bigint" or "int8" => DriverDataType.Int64, + // SQLite's REAL is a double — deliberately NOT Float32 (see remarks). + "real" or "double" or "double precision" or "float" + or "numeric" or "decimal" => DriverDataType.Float64, + "text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String, + "date" or "datetime" or "timestamp" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs new file mode 100644 index 00000000..604b5521 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs @@ -0,0 +1,212 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate +/// for every reader test. It exists so the poll path is exercised against a genuine +/// : real parameter binding, real provider type coercion, real +/// , real "no such row". +/// Backed by a temporary FILE, not :memory:. The reader opens a fresh +/// connection per poll (Factory.CreateConnection() → set → +/// OpenAsync) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment +/// its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate +/// between polls and every test would fail for a reason that has nothing to do with the code under test. +/// The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an +/// invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to +/// exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no +/// keep-alive, and its connection string is an ordinary path the code under test takes verbatim. +/// The fixture is a contract, not scaffolding. The seeded shapes below are what reader tests +/// assert against, so the constants are public and the seed deliberately covers the three cases that +/// behave differently: a present value, a present row whose value cell is NULL, and a key that is +/// absent entirely. Change a constant and you are changing what the reader is proven to do. +/// +public sealed class SqlitePollFixture : IDisposable +{ + // ---- key-value (EAV) source ---- + + /// The key-value table: TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT). + public const string KeyValueTable = "TagValues"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by both seeded tables. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for . + public const double SecondPresentValue = 3.5; + + /// + /// A key whose row exists but whose value cell is NULL — the case that must not be + /// confused with : the row was read, the value is simply not there. + /// + public const string NullValueKey = "Line1.Temp"; + + /// + /// A key with no row at all. Distinct from on purpose: a missing row + /// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell. + /// + public const string AbsentKey = "Line1.Missing"; + + // ---- wide-row source ---- + + /// + /// The wide-row table: + /// LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT). + /// + public const string WideRowTable = "LatestStatus"; + + /// The wide-row table's row-selector column. + public const string WideRowSelectorColumn = "station_id"; + + /// A station whose row exists, with both value columns populated. + public const string PresentStation = "7"; + + /// 's oven_temp. + public const double PresentStationOvenTemp = 180.5; + + /// 's pressure. + public const double PresentStationPressure = 1.2; + + /// + /// The station holding the newest sample_ts — what a topByTimestamp row selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the + /// ORDER BY … DESC (or the row limit) picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — the value a topByTimestamp plan yields. + public const double NewestStationOvenTemp = 210.25; + + /// A station whose row exists but whose oven_temp cell is NULL. + public const string NullOvenTempStation = "9"; + + /// A station with no row at all. + public const string AbsentStation = "404"; + + private readonly string _databasePath; + + /// Creates the temporary database, applies the schema, and seeds it. + public SqlitePollFixture() + { + _databasePath = Path.Combine( + Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db"); + ConnectionString = new SqliteConnectionStringBuilder + { + DataSource = _databasePath, + }.ToString(); + + Connection = new SqliteConnection(ConnectionString); + Connection.Open(); + Seed(Connection); + } + + /// + /// The connection string to hand the code under test. An ordinary file path — safe to open and close + /// any number of times, from any number of connections, in any order. + /// + public string ConnectionString { get; } + + /// + /// The provider factory to hand the code under test, matching the reader's + /// (DbProviderFactory factory, string connectionString, …) seam. + /// This is the real , not a shim that hands back a + /// pre-opened connection: because the database is a file, the reader's genuine + /// create → open → query → close cycle works unmodified, so what the tests exercise is the production + /// connection lifecycle rather than a test-only shortcut around it. + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + /// A long-lived open connection over the same database, for arranging extra state or inspecting + /// results directly. Independent of the connections the code under test opens — closing one has no + /// effect on the others, and none of them destroy the data. + /// + public SqliteConnection Connection { get; } + + /// Opens a brand-new connection, exactly as the reader does on each poll. + /// An open connection the caller owns and must dispose. + public SqliteConnection OpenNewConnection() + { + var connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + /// Runs one non-query statement against , for test-local arrangement. + /// The statement to execute. + public void Execute(string sql) + { + using var command = Connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Closes the fixture's connection, clears the pool, and deletes the temporary database file. + public void Dispose() + { + Connection.Close(); + Connection.Dispose(); + // Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be + // held open and the delete silently fails, leaking a file per test class into the temp directory. + SqliteConnection.ClearAllPools(); + try + { + if (File.Exists(_databasePath)) File.Delete(_databasePath); + } + catch (IOException) + { + // A leaked temp file must never fail a test run. + } + } + + /// + /// Applies the schema and rows described by this type's constants. + /// Every column is explicitly declared. SQLite is dynamically typed and would happily + /// store a string in an undeclared column, which would make the reader's type-coercion tests prove + /// nothing; declared types give pragma_table_info (and therefore + /// ) something real to report. + /// + private static void Seed(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = string.Create(CultureInfo.InvariantCulture, $""" + CREATE TABLE {KeyValueTable} ( + {KeyColumn} TEXT NOT NULL, + {ValueColumn} REAL NULL, + {TimestampColumn} TEXT NOT NULL + ); + INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES + ('{PresentKey}', {PresentValue}, '2026-07-24T10:00:00Z'), + ('{NullValueKey}', NULL, '2026-07-24T10:00:01Z'), + ('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z'); + + CREATE TABLE {WideRowTable} ( + {WideRowSelectorColumn} INTEGER NOT NULL, + oven_temp REAL NULL, + pressure REAL NULL, + {TimestampColumn} TEXT NOT NULL + ); + INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES + ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'), + ({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01Z'), + ({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02Z'); + """); + command.ExecuteNonQuery(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs new file mode 100644 index 00000000..390a1c08 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs @@ -0,0 +1,263 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves the offline substrate the reader tests will stand on: that +/// survives the reader's real per-poll connection lifecycle, and that a +/// -produced plan executes — parses, binds, and returns the seeded +/// rows — rather than merely looking right as a string. +/// The planner's own tests assert emitted SQL text. Nothing there can catch a statement that is +/// well-formed but unexecutable, or a parameter marker the provider will not bind. These tests can, and +/// they are the reason the next task starts from a known-good query path. +/// +public sealed class SqlitePollFixtureTests : IClassFixture +{ + private readonly SqlitePollFixture _fixture; + + public SqlitePollFixtureTests(SqlitePollFixture fixture) => _fixture = fixture; + + // ---- the fixture itself ---- + + [Fact] + public void AConnectionFromTheFactory_roundTripsASeededRow() + { + // Exactly the reader's seam: create from the abstract factory, assign the connection string, open. + var connection = _fixture.Factory.CreateConnection(); + connection.ShouldNotBeNull(); + connection.ConnectionString = _fixture.ConnectionString; + connection.Open(); + using var owned = connection; + + using var command = owned.CreateCommand(); + command.CommandText = + $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " + + $"WHERE {SqlitePollFixture.KeyColumn} = @k"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@k"; + parameter.Value = SqlitePollFixture.PresentKey; + command.Parameters.Add(parameter); + + Convert.ToDouble(command.ExecuteScalar()).ShouldBe(SqlitePollFixture.PresentValue); + } + + [Fact] + public void TheDatabase_survivesTheReadersOpenAndCloseCyclePerPoll() + { + // The whole reason the fixture is file-backed: an in-memory database would be gone by "poll" 2. + for (var poll = 0; poll < 3; poll++) + { + using var connection = _fixture.OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {SqlitePollFixture.KeyValueTable}"; + Convert.ToInt64(command.ExecuteScalar()).ShouldBe(3L); + } + } + + [Fact] + public void TheSeed_distinguishesAPresentValue_aNullCell_andAnAbsentKey() + { + using var connection = _fixture.OpenNewConnection(); + + ReadValue(connection, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue); + ReadValue(connection, SqlitePollFixture.NullValueKey).ShouldBe(DBNull.Value); // row present, cell NULL + ReadValue(connection, SqlitePollFixture.AbsentKey).ShouldBeNull(); // no row at all + + static object? ReadValue(SqliteConnection connection, string key) + { + using var command = connection.CreateCommand(); + command.CommandText = + $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " + + $"WHERE {SqlitePollFixture.KeyColumn} = @k"; + command.Parameters.AddWithValue("@k", key); + return command.ExecuteScalar(); + } + } + + // ---- planner-produced plans, actually executed ---- + + [Fact] + public void AKeyValuePlan_executesAgainstTheFixture_andSlicesBackPerMember() + { + var plan = SqlGroupPlanner.Plan( + [ + KvTag("A", SqlitePollFixture.PresentKey), + KvTag("B", SqlitePollFixture.NullValueKey), + KvTag("C", SqlitePollFixture.AbsentKey), + ], new SqliteDialect()).ShouldHaveSingleItem(); + + var rows = ExecutePlan(plan); + + // The result set is indexed by the key column, exactly as the reader will slice it. + var byKey = rows.ToDictionary( + r => (string)r[SqlitePollFixture.KeyColumn]!, StringComparer.Ordinal); + + byKey.Count.ShouldBe(2); // the absent key contributes no row — it is not an error, just no data + byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.ValueColumn] + .ShouldBe(SqlitePollFixture.PresentValue); + byKey[SqlitePollFixture.NullValueKey][SqlitePollFixture.ValueColumn] + .ShouldBeNull(); // present row, NULL cell — distinct from the absent key above + byKey.ShouldNotContainKey(SqlitePollFixture.AbsentKey); + byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.TimestampColumn] + .ShouldBe("2026-07-24T10:00:00Z"); + } + + [Fact] + public void AWideRowWherePairPlan_executesAgainstTheFixture_andReturnsTheSelectedRow() + { + var plan = SqlGroupPlanner.Plan( + [ + WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("B", "pressure", selectorValue: SqlitePollFixture.PresentStation), + ], new SqliteDialect()).ShouldHaveSingleItem(); + + // The station id is bound, not inlined — and SQLite coerces the bound string against an INTEGER column. + plan.Parameters.ShouldBe(new object[] { SqlitePollFixture.PresentStation }); + + var row = ExecutePlan(plan).ShouldHaveSingleItem(); + row["oven_temp"].ShouldBe(SqlitePollFixture.PresentStationOvenTemp); + row["pressure"].ShouldBe(SqlitePollFixture.PresentStationPressure); + } + + [Fact] + public void AWideRowWherePairPlan_forAnAbsentSelectorValue_returnsNoRows() + { + var plan = SqlGroupPlanner.Plan( + [WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation)], + new SqliteDialect()).ShouldHaveSingleItem(); + + ExecutePlan(plan).ShouldBeEmpty(); + } + + [Fact] + public void ATopByTimestampPlan_emitsSqlitesLimitForm_andExecutesToTheNewestRow() + { + var plan = SqlGroupPlanner.Plan( + [WideTag("A", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)], + new SqliteDialect()).ShouldHaveSingleItem(); + + // The payoff of putting the row limit on ISqlDialect: T-SQL's "TOP 1" does not parse in SQLite, so + // this statement would throw at ExecuteReader if the planner had kept emitting it inline. + plan.SqlText.ShouldBe( + "SELECT \"oven_temp\" FROM \"LatestStatus\" ORDER BY \"sample_ts\" DESC LIMIT 1"); + plan.SqlText.ShouldNotContain("TOP 1"); + + var row = ExecutePlan(plan).ShouldHaveSingleItem(); + row["oven_temp"].ShouldBe(SqlitePollFixture.NewestStationOvenTemp); // station 8, not the first row + } + + // ---- the dialect ---- + + [Fact] + public void TheDialect_quotesWithDoubleQuotes_andDoublesAnEmbeddedOne() + { + var dialect = new SqliteDialect(); + dialect.QuoteIdentifier("oven_temp").ShouldBe("\"oven_temp\""); + dialect.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); + Should.Throw(() => dialect.QuoteIdentifier("x\0y")); + Should.Throw(() => dialect.QuoteIdentifier(" ")); + } + + [Fact] + public void TheDialectsCatalogSql_answersOverTheRealSeededDatabase() + { + var dialect = new SqliteDialect(); + using var connection = _fixture.OpenNewConnection(); + + Query(dialect.ListSchemasSql).ShouldBe(new[] { SqliteDialect.MainSchema }); + + var tables = Query(dialect.ListTablesSql, ("@schema", SqliteDialect.MainSchema)); + tables.ShouldContain(SqlitePollFixture.KeyValueTable); + tables.ShouldContain(SqlitePollFixture.WideRowTable); + tables.ShouldAllBe(t => !t.StartsWith("sqlite_", StringComparison.Ordinal)); + + var columns = Query( + dialect.ListColumnsSql, + ("@schema", SqliteDialect.MainSchema), ("@table", SqlitePollFixture.WideRowTable)); + columns.ShouldBe(new[] + { + SqlitePollFixture.WideRowSelectorColumn, "oven_temp", "pressure", SqlitePollFixture.TimestampColumn, + }); + + List Query(string sql, params (string Name, string Value)[] parameters) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value); + using var reader = command.ExecuteReader(); + var results = new List(); + while (reader.Read()) results.Add(reader.GetString(0)); + return results; + } + } + + [Fact] + public void TheDialect_mapsTheSeededDeclaredTypes_andNeverThrowsOnAnUnknownAffinity() + { + var dialect = new SqliteDialect(); + dialect.MapColumnType("TEXT").ShouldBe(DriverDataType.String); + dialect.MapColumnType("INTEGER").ShouldBe(DriverDataType.Int32); + // SQLite's REAL is a double — NOT Float32, which is what the same word means in T-SQL. + dialect.MapColumnType("REAL").ShouldBe(DriverDataType.Float64); + dialect.MapColumnType("real").ShouldBe(DriverDataType.Float64); + dialect.MapColumnType("VARCHAR(50)").ShouldBe(DriverDataType.String); // unparsed ⇒ fallback + dialect.MapColumnType("some_udt").ShouldBe(DriverDataType.String); + dialect.MapColumnType("").ShouldBe(DriverDataType.String); + dialect.MapColumnType(null!).ShouldBe(DriverDataType.String); + } + + [Fact] + public void TheDialect_doesNotClaimToBeSqlServer() + // A test dialect that reported SqlServer would rubber-stamp the T-SQL assumptions it exists to catch. + => new SqliteDialect().Provider.ShouldNotBe(SqlProvider.SqlServer); + + // ---- helpers ---- + + private static SqlTagDefinition KvTag(string name, string keyValue) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn); + + private static SqlTagDefinition WideTag( + string name, string columnName, string? selectorValue = null, string? topByTimestamp = null) + => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable, + ColumnName: columnName, + RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn, + RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + /// + /// Executes a plan the way the reader will: one fresh connection, the plan's SQL verbatim, its + /// parameters bound positionally by name, and the rows projected by + /// . + /// + private List> ExecutePlan(SqlQueryPlan plan) + { + using var connection = _fixture.OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = plan.SqlText; + for (var i = 0; i < plan.ParameterNames.Count; i++) + command.Parameters.AddWithValue(plan.ParameterNames[i], plan.Parameters[i] ?? DBNull.Value); + + using var reader = command.ExecuteReader(); + var rows = new List>(); + while (reader.Read()) + { + var row = new Dictionary(StringComparer.Ordinal); + foreach (var column in plan.SelectedColumns) + { + var ordinal = reader.GetOrdinal(column); + row[column] = reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal); + } + + rows.Add(row); + } + + return rows; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj index 74a7aae6..47c8bf1f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -12,6 +12,16 @@ + + + all From 50f88b938fc058606a638cec6babeb247edd037d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:07:54 -0400 Subject: [PATCH 12/33] fix(sql): reject Unicode format chars in identifiers; stop overclaiming catalog validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the code review of the dialect seam: - QuoteIdentifier used char.IsControl, which only covers Unicode category Cc. Bidi overrides, zero-width spaces, soft hyphen and BOM are category Cf and passed through untouched. They cannot escape the brackets, so this is not an injection bypass — but the method rejects control characters precisely to protect the integrity of logged/audited statements, and Cf characters spoof rendered text while comparing byte-different from the real catalog name (Trojan Source, CVE-2021-42574). Same rule, same rationale, wider category. - The XML docs on both files asserted that identifiers 'are sourced only from catalog-validated (INFORMATION_SCHEMA) names' and that rejection is 'a backstop, not the primary defence'. Neither is true yet: design §8.1 does specify that gate, but nothing implements it and no task in the plan schedules one, so an authored TagConfig table/column reaches QuoteIdentifier unfiltered. The docs now say so, because under-scrutinising this method on the strength of a filter that does not exist is exactly the failure mode. - The control-character test only pinned NUL, so a regression to a Contains('\0') check would still have passed. Pinned the whole Cc category plus the new Cf rule; verified load-bearing by deleting the guard and observing exactly the 5 new cases go red. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ISqlDialect.cs | 12 ++++++---- .../SqlServerDialect.cs | 20 +++++++++++++--- .../SqlServerDialectTests.cs | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs index f6b37df0..9b15d22e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -11,10 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser. /// This interface is the driver's SQL-injection boundary. Every runtime value is /// bound as a and never reaches a command text. Identifiers cannot be -/// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated -/// (INFORMATION_SCHEMA) names and emitted through . Any code path that -/// builds SQL by concatenating an authored tag field without going through -/// is a defect (design §8.1). +/// parameterized in SQL, so the few that must appear as text are emitted through +/// . Any code path that builds SQL by concatenating an authored tag field +/// without going through is a defect (design §8.1). +/// Not yet implemented: design §8.1 also specifies that an authored table/column is +/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier +/// rejects the tag. No such gate exists yet — an identifier reaches +/// straight from the authored TagConfig blob, so quoting is currently the only defence, +/// not a backstop behind an upstream filter. Scrutinise it accordingly. /// Public because Driver.Sql.Browser consumes it — the catalog SQL is the browse /// engine, so it is shared rather than duplicated. Implementations own their provider package; /// no provider-specific type appears in this signature ( is the abstract diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs index 2e76821b..e666856f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using System.Globalization; using Microsoft.Data.SqlClient; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; @@ -61,11 +62,17 @@ public sealed class SqlServerDialect : ISqlDialect /// /// Rejects (all as ): null / empty / all-whitespace; longer /// than ; any control character (which covers embedded NUL, and the - /// C0/C1 ranges that can truncate or corrupt a logged/audited statement). + /// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode + /// character — bidi overrides, zero-width + /// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the rendered + /// text of a log line or an AdminUI label while comparing byte-different from the real catalog name + /// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the + /// control-character rule. /// The rejection messages deliberately do not echo the offending value — it is untrusted /// input and this exception's text reaches the driver log. - /// Rejection is a backstop, not the primary defence: an identifier only reaches here after being - /// validated against the live catalog (design §8.1). + /// This is currently the only defence, not a backstop. Design §8.1 specifies that an + /// identifier is validated against the live catalog before reaching here; that gate is not implemented + /// yet, so an authored TagConfig table/column arrives unfiltered. /// /// The bare, unquoted identifier part. /// The bracket-quoted identifier. @@ -86,6 +93,13 @@ public sealed class SqlServerDialect : ISqlDialect if (char.IsControl(ch)) throw new ArgumentException( "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + + // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and + // zero-width character. They are inert inside brackets but spoof rendered log/UI text. + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format) + throw new ArgumentException( + "A SQL identifier may not contain Unicode format characters " + + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident)); } return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs index af0e4c0f..e10995bc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -25,6 +25,29 @@ public class SqlServerDialectTests public void QuoteIdentifier_rejectsControlCharsAndNul() => Should.Throw(() => new SqlServerDialect().QuoteIdentifier("x\0y")); + // NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. + [Theory] + [InlineData("x\u0001y")] // C0 SOH + [InlineData("x\u001By")] // C0 ESC + [InlineData("x\u007Fy")] // DEL + [InlineData("x\u0085y")] // C1 NEL + [InlineData("x\ty")] + [InlineData("x\ny")] + public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident)); + + // Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all + // of these. They cannot escape the brackets, but they spoof rendered log/AdminUI text while comparing + // byte-different from the real catalog name (Trojan Source, CVE-2021-42574). + [Theory] + [InlineData("Sp\u200Beed")] // zero-width space + [InlineData("\u202Ediop.selbat")] // right-to-left override + [InlineData("x\u2066y\u2069")] // bidi isolates + [InlineData("x\uFEFFy")] // BOM / zero-width no-break space + [InlineData("x\u00ADy")] // soft hyphen + public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident)); + [Theory] [InlineData("bit", DriverDataType.Boolean)] [InlineData("int", DriverDataType.Int32)] From 967e5140f1236cc92b36c55ea71e77031f855a44 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:26:29 -0400 Subject: [PATCH 13/33] =?UTF-8?q?feat(sql):=20SqlPollReader=20=E2=80=94=20?= =?UTF-8?q?grouped=20read,=20bounded=20deadline,=20ordered=20slice-back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes a poll pass: resolve refs -> SqlGroupPlanner.Plan -> one command per group -> slice the result set back to per-tag DataValueSnapshots, positionally aligned with the caller's reference list (design §3.2). The frozen-peer contract (design §8.3, the R2-01 S7 lesson) is the core of it. CommandTimeout is only the server-side backstop; the client-side bound is a linked CancelAfter PLUS Task.WaitAsync, because a linked token bounds only a provider that honours it and ADO.NET providers vary. The whole per-group operation — waiting for a concurrency slot, OpenAsync, and the query — runs off one operationTimeout budget, so ReadAsync returns on time regardless of what the database is doing. A breach Bad-codes that group (BadTimeout); caller cancellation propagates instead, since nobody consumes a torn-down poll. Also: absent row (BadNoData) stays distinct from a NULL cell (Uncertain, or Bad under nullIsBad); duplicate plan members are all fed (two tags on one keyValue bind one parameter but stay two members); the concurrency slot is released by the work, not the waiter, so a frozen database can never hold more than maxConcurrentGroups connections; the whole call throws only when the connection itself cannot open, which is what earns PollGroupEngine's backoff. SqlStatusCodes is driver-local, matching every sibling driver's own table — there is no shared helper in Core.Abstractions and drivers do not reference the OPC UA SDK. Values were read off Opc.Ua.StatusCodes rather than copied from a sibling, because two of those tables carry transcription errors. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlPollReader.cs | 770 ++++++++++++++++++ .../SqlPollReaderTests.cs | 480 +++++++++++ 2 files changed, 1250 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs new file mode 100644 index 00000000..2c4155ea --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -0,0 +1,770 @@ +using System.Data.Common; +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Executes one poll pass: resolves each requested reference to a , folds +/// the definitions into the minimum number of queries via , runs one +/// command per group under a bounded deadline, and slices each result set back to per-tag +/// s — positionally aligned with the caller's reference list +/// (design §3.2). Structurally the SQL analogue of ModbusDriver.ReadCoalescedAsync: group → one +/// round-trip → slice back. +/// N in, N out, in order. The returned list always has exactly one entry per requested +/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never +/// exceptions (the contract) — see for which code +/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags, +/// so a slicing defect does not fail loudly — it publishes one tag's value onto another tag's +/// node. +/// The whole call throws only when the database is unreachable — that is, when opening a +/// connection fails. That surfaces to as a poll failure and earns the +/// capped-exponential backoff; the driver's IHostConnectivityProbe is what scopes Bad quality +/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for +/// an outage. A query that fails after the connection opened is the group's problem, not the +/// server's, and Bad-codes that group only. +/// Deadlines (design §8.3, the R2-01 frozen-peer lesson). Two independent mechanisms, both +/// required. CommandTimeout is the server-side backstop and bounds nothing on a wedged +/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see +/// . A frozen database yields +/// snapshots — it must never wedge the poll thread. +/// Not a driver. This type owns the read path only; it deliberately holds no health state, +/// no subscription state and no connection between polls, so the driver shell can wrap it in +/// and own those concerns. +/// +public sealed class SqlPollReader +{ + /// Minimum spacing between "your source violates the query contract" warnings, per reader. + private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1); + + private readonly DbProviderFactory _factory; + private readonly string _connectionString; + private readonly ISqlDialect _dialect; + private readonly TimeSpan _operationTimeout; + private readonly int _commandTimeoutSeconds; + private readonly bool _nullIsBad; + private readonly Func _resolve; + private readonly ILogger _logger; + + /// + /// Caps concurrently executing group queries — and therefore concurrently held connections + /// (design §8.4). Never disposed: a only needs disposal once its + /// AvailableWaitHandle has been touched, and not disposing it also removes the hazard of a + /// timed-out-but-still-running group releasing into a disposed semaphore. + /// + private readonly SemaphoreSlim _gate; + + private long _lastContractWarningTicks; + + /// Initializes a new instance of the class. + /// + /// Creates the provider's connections. Passed explicitly rather than taken from + /// so the driver can hand in an instrumented or pre-configured factory. + /// + /// + /// The resolved connection string (never the authored connectionStringRef — secrets are + /// resolved by the driver at Initialize, design §8.2). + /// + /// Supplies identifier quoting, row-limit syntax, and column-type mapping. + /// + /// The ADO.NET CommandTimeout backstop. Rounded up to whole seconds and floored at 1, + /// because ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value this option + /// must never silently become. + /// + /// + /// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening + /// the connection, and running the query. A breach Bad-codes that group with + /// . + /// Authoring should keep this strictly greater than + /// (design §8.3): inverted, the client-side abort always fires first and masks the server-side + /// backstop. That rule is not enforced here — it belongs to config validation, and this type + /// stays usable for the tests that must deliberately invert it. + /// + /// Maximum group queries — and connections — in flight at once. + /// + /// publishes for a NULL value cell instead + /// of the default . It governs a present row with a + /// NULL cell only — an absent row is always . + /// + /// + /// RawPath → tag definition; on a miss. Shaped to match + /// 's lookup so the driver can pass its authored table + /// straight through. + /// + /// Optional; defaults to a no-op logger. + /// A required reference argument is null. + /// is blank. + /// A timeout is non-positive, or the cap is below 1. + public SqlPollReader( + DbProviderFactory factory, + string connectionString, + ISqlDialect dialect, + TimeSpan commandTimeout, + TimeSpan operationTimeout, + int maxConcurrentGroups, + bool nullIsBad, + Func resolve, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(dialect); + ArgumentNullException.ThrowIfNull(resolve); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString)); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1); + + _factory = factory; + _connectionString = connectionString; + _dialect = dialect; + _operationTimeout = operationTimeout; + _commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds)); + _nullIsBad = nullIsBad; + _resolve = resolve; + _logger = logger ?? NullLogger.Instance; + _gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups); + } + + /// + /// Reads every reference in one poll pass. Matches 's shape so the + /// driver shell can delegate to it directly. + /// + /// The RawPaths to read. Empty yields an empty result and touches no connection. + /// + /// The caller's token. Its cancellation propagates as an + /// — the engine asked the poll to stop, and nobody is + /// waiting for the snapshots. This is deliberately asymmetric with an operationTimeout + /// breach, which is a data-quality fact clients must see and so becomes + /// snapshots. + /// + /// One snapshot per reference, at the same index. + /// is null. + /// was cancelled. + /// The database could not be reached (opening a connection failed). + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + + var results = new DataValueSnapshot[fullReferences.Count]; + if (results.Length == 0) return results; + + var readAt = DateTime.UtcNow; + + // Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner, + // whose members are the sole thing the slice-back walks. + var definitions = new List(fullReferences.Count); + var slots = new Dictionary>(); + for (var i = 0; i < fullReferences.Count; i++) + { + var definition = fullReferences[i] is { } reference ? _resolve(reference) : null; + if (definition is null) + { + results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt); + continue; + } + + definitions.Add(definition); + if (!slots.TryGetValue(definition, out var positions)) + slots[definition] = positions = []; + positions.Add(i); + } + + if (definitions.Count > 0) + await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false); + + // Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes + // blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause. + for (var i = 0; i < results.Length; i++) + results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt); + + return results; + } + + /// Plans the resolved definitions, runs every group concurrently, and slices the results back. + private async Task ReadGroupsAsync( + List definitions, + Dictionary> slots, + DataValueSnapshot[] results, + DateTime readAt, + CancellationToken cancellationToken) + { + IReadOnlyList plans; + try + { + plans = SqlGroupPlanner.Plan(definitions, _dialect); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + // A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so + // reaching here means a definition was built past the parser. It is an authoring fault, not a + // database fault: Bad-code the planned tags rather than throwing and triggering connection backoff. + _logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.", + definitions.Count); + foreach (var definition in definitions) + { + if (!slots.TryGetValue(definition, out var positions)) continue; + var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt); + foreach (var position in positions) results[position] = snapshot; + } + + return; + } + + // Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group + // carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by + // plans.Count × operationTimeout. + var groups = new Task[plans.Count]; + for (var g = 0; g < plans.Count; g++) + groups[g] = RunGroupAsync(plans[g], cancellationToken); + + // WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never + // leaves a sibling group running against a connection nobody is watching. + var outcomes = await Task.WhenAll(groups).ConfigureAwait(false); + + for (var g = 0; g < plans.Count; g++) + Slice(plans[g], outcomes[g], slots, results, readAt); + } + + /// + /// Runs one group's query under a hard wall-clock ceiling. + /// Why the ceiling is Task.WaitAsync and not just a linked + /// CancelAfter. A linked token only bounds a + /// provider that honours it. The S7 R2-01 finding was exactly a provider whose async path + /// ignored its deadline, and ADO.NET has the same shape: some providers implement + /// ExecuteReaderAsync synchronously, and even a genuinely async one can hang inside its own + /// cancellation handshake against a frozen socket. Both are used here: the linked token so a + /// well-behaved provider unwinds cleanly and releases its connection, and WaitAsync so + /// control returns at the deadline regardless. + /// The work is started on the thread pool so a provider that blocks synchronously blocks a + /// pool thread rather than the caller — without that, a synchronous provider never yields the task + /// there would be to bound. + /// The concurrency slot is released by the work, not by the waiter. A timed-out group + /// keeps its slot until it truly finishes, so a frozen database can never accumulate more than + /// maxConcurrentGroups connections however long it stays frozen. The slot wait is itself + /// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout. + /// + private async Task RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken) + { + var clock = Stopwatch.StartNew(); + + if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false)) + return GroupResult.TimedOut; + + var handedOff = false; + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task work; + try + { + var budget = Remaining(clock); + if (budget <= TimeSpan.Zero) return GroupResult.TimedOut; + deadline.CancelAfter(budget); + + work = Task.Run( + async () => + { + try + { + return await QueryAsync(plan, deadline.Token).ConfigureAwait(false); + } + finally + { + _gate.Release(); + deadline.Dispose(); + } + }, + CancellationToken.None); + handedOff = true; + } + finally + { + // Ownership of both the slot and the CTS transfers to the work task; release them here only on + // the paths where that task was never created. + if (!handedOff) + { + _gate.Release(); + deadline.Dispose(); + } + } + + try + { + return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Our wall-clock bound fired while the provider was still inside the call. + return TimedOut(plan, work); + } + catch (OperationCanceledException) + { + // Either the caller pulled the plug, or our deadline fired and the provider DID honour the + // linked token. Both leave the work running with nobody to observe its fault. + if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work); + Detach(work); + throw; + } + } + + /// How much of this group's operationTimeout budget is left; never negative. + private TimeSpan Remaining(Stopwatch clock) + { + var remaining = _operationTimeout - clock.Elapsed; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// Records the deadline breach and detaches the abandoned work. + private GroupResult TimedOut(SqlQueryPlan plan, Task work) + { + _logger.LogWarning( + "Sql poll group ({Model}, {Members} tag(s)) exceeded the {Timeout} ms operation timeout; " + + "publishing BadTimeout.", plan.Model, plan.Members.Count, (int)_operationTimeout.TotalMilliseconds); + + Detach(work); + return GroupResult.TimedOut; + } + + /// + /// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task + /// exception. The task still owns its connection and still releases the concurrency slot when it + /// finally completes — that is what keeps a frozen database from accumulating connections. + /// + private static void Detach(Task work) + => _ = work.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// + /// Opens a connection, executes the plan's single command, and materialises the result set. + /// The rows are read into memory before the reader is disposed — the slice-back needs + /// random access across members, and holding a reader (and therefore a connection) open across that + /// work is exactly how a poll loop exhausts a pool. + /// + private async Task QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken) + { + var connection = _factory.CreateConnection() + ?? throw new InvalidOperationException( + $"The {_dialect.Provider} provider factory returned no connection."); + + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = _connectionString; + + // Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which + // is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff). + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + try + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = plan.SqlText; + command.CommandTimeout = _commandTimeoutSeconds; + for (var i = 0; i < plan.ParameterNames.Count; i++) + { + var parameter = command.CreateParameter(); + // Bound by the plan's own index pairing — never by re-deriving the marker convention. + parameter.ParameterName = plan.ParameterNames[i]; + parameter.Value = plan.Parameters[i] ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // The connection opened, so the server is up — this query is what failed (an identifier that + // is not in the catalog, a lock, a permission). That is this group's problem alone. + _logger.LogWarning( + ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}", + plan.Model, plan.Members.Count, plan.SqlText); + return GroupResult.Failed; + } + } + } + + /// + /// Reads every row into memory, projected onto . Row + /// fetching stays on the async path so a result set that streams slowly still honours the deadline + /// token mid-read, rather than only at the ExecuteReader boundary. + /// + private async Task MaterialiseAsync( + SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken) + { + var selected = plan.SelectedColumns; + var ordinals = new int[selected.Count]; + var typeNames = new string[selected.Count]; + var columnIndex = new Dictionary(selected.Count, StringComparer.Ordinal); + for (var c = 0; c < selected.Count; c++) + { + // GetOrdinal rather than the position in SelectedColumns: the list is documented to be in + // ordinal order, but resolving it against the live result set is what makes a wrong slice + // impossible rather than merely unlikely. + ordinals[c] = reader.GetOrdinal(selected[c]); + typeNames[c] = SafeTypeName(reader, ordinals[c]); + columnIndex[selected[c]] = c; + } + + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var row = new object?[selected.Count]; + for (var c = 0; c < selected.Count; c++) + row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]); + rows.Add(row); + } + + return GroupResult.Ok(rows, columnIndex, typeNames); + } + + /// Reads a column's provider type name, falling back to the dialect's unknown-type default. + private static string SafeTypeName(DbDataReader reader, int ordinal) + { + try + { + return reader.GetDataTypeName(ordinal) ?? string.Empty; + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException + or IndexOutOfRangeException) + { + // A provider that declines to report a type name must not fail the poll — the tag's declared + // type (or the dialect's String fallback) covers it. + return string.Empty; + } + } + + /// Maps one group's outcome onto every slot its members occupy in the caller's list. + private void Slice( + SqlQueryPlan plan, + GroupResult outcome, + Dictionary> slots, + DataValueSnapshot[] results, + DateTime readAt) + { + // KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the + // where-column is not even in the result set (design §5.3). + var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue + ? IndexByKey(plan, outcome) + : null; + + foreach (var member in plan.Members) + { + // Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed. + if (!slots.TryGetValue(member, out var positions)) continue; + + var snapshot = outcome.Succeeded + ? MapMember(plan, member, outcome, byKey, readAt) + : new DataValueSnapshot(null, outcome.FailureStatus, null, readAt); + + foreach (var position in positions) results[position] = snapshot; + } + } + + /// + /// Builds the key → row index a key-value plan slices through. Later rows win, so a source that + /// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the + /// violation is reported through a rate-limited warning rather than silently. + /// + private Dictionary IndexByKey(SqlQueryPlan plan, GroupResult outcome) + { + var index = new Dictionary(StringComparer.Ordinal); + if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt)) + return index; + + var duplicated = 0; + foreach (var row in outcome.Rows) + { + // A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be + // matched by a tag whose keyValue is legitimately the empty string. + if (row[keyAt] is not { } keyCell) continue; + + // Stringified because the bound keyValue is authored text while the column may be any type + // (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out. + var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture); + if (key is null) continue; + if (!index.TryAdd(key, row)) + { + index[key] = row; + duplicated++; + } + } + + if (duplicated > 0 && ShouldWarnAboutContract()) + { + _logger.LogWarning( + "Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " + + "last row wins. The key-value model requires one row per key — point the tag at a " + + "current-values table or an operator-authored latest-per-key view.", + plan.Members[0].Table, duplicated); + } + + return index; + } + + /// Maps one member's cell to a snapshot: value, quality, and source timestamp. + private DataValueSnapshot MapMember( + SqlQueryPlan plan, + SqlTagDefinition member, + GroupResult outcome, + Dictionary? byKey, + DateTime readAt) + { + object?[]? row; + string? valueColumn; + string? timestampColumn; + if (plan.Model == SqlTagModel.KeyValue) + { + valueColumn = plan.ValueColumn; + timestampColumn = plan.TimestampColumn; + row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null; + } + else + { + valueColumn = member.ColumnName; + // A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are + // selected), which is why the plan-level TimestampColumn is null for this model by design. + timestampColumn = member.TimestampColumn; + row = outcome.Rows.Count > 0 ? outcome.Rows[^1] : null; + } + + // No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell. + if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt); + + // A timestamp that will not parse falls back to the poll clock rather than poisoning a good value. + var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt; + + if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt)) + { + // The planner selects every member's value column, so this is unreachable short of plan/result + // drift — loud rather than silently publishing someone else's cell. + _logger.LogError( + "Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.", + member.Name, valueColumn); + return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt); + } + + var cell = row[valueAt]; + if (cell is null) + { + return new DataValueSnapshot( + null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt); + } + + var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]); + if (!TryCoerce(cell, target, out var value)) + { + _logger.LogWarning( + "Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.", + member.Name, cell.GetType().Name, target); + return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt); + } + + return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt); + } + + /// Reads a row's source timestamp, or null when there is no column, no value, or no parse. + private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn) + { + if (string.IsNullOrWhiteSpace(timestampColumn)) return null; + if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null; + if (row[at] is not { } cell) return null; + return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null; + } + + /// + /// Coerces a provider cell to the tag's effective , so the published CLR + /// type matches the type the address space declared for the node. Never throws. + /// + private static bool TryCoerce(object cell, DriverDataType target, out object? value) + { + try + { + value = target switch + { + DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture), + DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture), + DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture), + DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture), + DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture), + // decimal/numeric collapse here, with the documented precision caveat (design §8.6). + DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture), + DriverDataType.DateTime => ToUtc(cell), + _ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty, + }; + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException + or OverflowException or ArgumentException) + { + value = null; + return false; + } + } + + /// + /// Normalises a timestamp cell to UTC. A naive datetime (no offset — the common SQL Server + /// shape) is assumed to already be UTC rather than reinterpreted through the server's local + /// zone, which would silently shift every source timestamp by the host's offset. + /// + private static DateTime ToUtc(object cell) => cell switch + { + DateTime { Kind: DateTimeKind.Utc } utc => utc, + DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(), + DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc), + DateTimeOffset offset => offset.UtcDateTime, + string text => DateTime.Parse( + text, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), + _ => DateTime.SpecifyKind( + Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc), + }; + + /// Rate-limits the source-contract warning so a misconfigured table cannot flood the log. + private bool ShouldWarnAboutContract() + { + var now = Stopwatch.GetTimestamp(); + var last = Interlocked.Read(ref _lastContractWarningTicks); + if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false; + return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last; + } + + /// One group's outcome: either a materialised result set, or the status its members inherit. + private sealed class GroupResult + { + private static readonly IReadOnlyList NoRows = []; + private static readonly IReadOnlyDictionary NoColumns = + new Dictionary(StringComparer.Ordinal); + + private GroupResult( + uint failureStatus, + IReadOnlyList rows, + IReadOnlyDictionary columnIndex, + IReadOnlyList typeNames) + { + FailureStatus = failureStatus; + Rows = rows; + ColumnIndex = columnIndex; + TypeNames = typeNames; + } + + /// The group exceeded its wall-clock deadline (design §8.3). + public static GroupResult TimedOut { get; } = + new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []); + + /// The connection was fine but the query was not. + public static GroupResult Failed { get; } = + new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []); + + /// The status every member inherits when is false. + public uint FailureStatus { get; } + + /// The materialised rows, projected onto the plan's selected columns. + public IReadOnlyList Rows { get; } + + /// Selected column name → its position within each row array. + public IReadOnlyDictionary ColumnIndex { get; } + + /// Each selected column's provider type name, for dialect type inference. + public IReadOnlyList TypeNames { get; } + + /// True when the query ran; a zero-row result set is still a success. + public bool Succeeded => FailureStatus == SqlStatusCodes.Good; + + /// A completed result set. + /// The materialised rows. + /// Selected column name → row-array position. + /// Each selected column's provider type name. + /// A successful outcome. + public static GroupResult Ok( + IReadOnlyList rows, + IReadOnlyDictionary columnIndex, + IReadOnlyList typeNames) + => new(SqlStatusCodes.Good, rows, columnIndex, typeNames); + } +} + +/// +/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read +/// them back with. +/// Why a driver-local table rather than a shared one. Every driver in this tree carries its +/// own (TwinCATStatusMapper, AbCipStatusMapper, FocasStatusMapper, +/// AbLegacyStatusMapper, Galaxy's StatusCodeMap) — there is no shared helper in +/// Core.Abstractions, because drivers deliberately do not reference the OPC UA SDK and +/// is a bare . The values below were read +/// off Opc.Ua.StatusCodes rather than copied from a sibling driver, since two of those tables +/// carry transcription errors. +/// +public static class SqlStatusCodes +{ + /// The value is good. (Good) + public const uint Good = 0x00000000u; + + /// Quality is uncertain with no more specific reason — a NULL cell under nullIsBad=false. + public const uint Uncertain = 0x40000000u; + + /// Quality is bad with no more specific reason — a NULL cell under nullIsBad=true. + public const uint Bad = 0x80000000u; + + /// + /// No row was returned for this tag — the key is absent from the result set, or the wide-row + /// selector matched nothing. Deliberately distinct from a NULL cell: the row is gone, versus + /// the row is there and the value is not. + /// + public const uint BadNoData = 0x809B0000u; + + /// The group exceeded its client-side wall-clock deadline (design §8.3). + public const uint BadTimeout = 0x800A0000u; + + /// The reference resolves to no authored tag. + public const uint BadNodeIdUnknown = 0x80340000u; + + /// The query failed after the connection opened. + public const uint BadCommunicationError = 0x80050000u; + + /// The cell could not be coerced to the tag's effective data type. + public const uint BadTypeMismatch = 0x80740000u; + + /// A tag definition the planner rejected — an authoring fault, not a database fault. + public const uint BadConfigurationError = 0x80890000u; + + /// A defect in the reader itself; never expected in the field. + public const uint BadInternalError = 0x80020000u; + + /// The quality-class mask — the top two bits of an OPC UA status code. + private const uint SeverityMask = 0xC0000000u; + + /// True when is in the Good quality class. + /// The status code to classify. + /// True when the code's severity bits are Good. + public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u; + + /// True when is in the Uncertain quality class. + /// The status code to classify. + /// True when the code's severity bits are Uncertain. + public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain; + + /// True when is in the Bad quality class. + /// The status code to classify. + /// True when the code's severity bits are Bad. + public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs new file mode 100644 index 00000000..79897f3e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs @@ -0,0 +1,480 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves against a real database (): the +/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct +/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven +/// by reading the code — that a query which refuses to return does not wedge the caller. +/// Each test owns its own fixture. The fixture is a temp file, so a fresh one per test is +/// cheap and buys total isolation — which matters here because two tests deliberately mutate the +/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into +/// every other test in the class. +/// +public sealed class SqlPollReaderTests +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15); + + // ---- the plan's headline contract: N in, N out, in input order ---- + + [Fact] + public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("Speed", SqlitePollFixture.PresentKey), + KvTag("Missing", SqlitePollFixture.AbsentKey), + KvTag("Temp", SqlitePollFixture.NullValueKey)); + + var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None); + + snapshots.Count.ShouldBe(3); + + // [0] present row, present cell — REAL infers Float64, so the published CLR type is double. + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + snapshots[0].SourceTimestampUtc.ShouldBe( + new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock + + // [1] no row at all — NOT the same thing as a NULL cell. + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshots[1].Value.ShouldBeNull(); + + // [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives). + snapshots[2].Value.ShouldBeNull(); + SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue(); + snapshots[2].SourceTimestampUtc.ShouldBe( + new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc)); + } + + [Fact] + public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, nullIsBad: true, + tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]); + + var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad); + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + // The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code. + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + } + + [Fact] + public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("Speed", SqlitePollFixture.PresentKey), + KvTag("Pressure", SqlitePollFixture.SecondPresentKey)); + + var snapshots = await reader.ReadAsync( + ["Speed", "NotAuthored", "Pressure"], CancellationToken.None); + + snapshots.Count.ShouldBe(3); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + // The hole must not shift the tail — this is the ordering contract's real failure mode. + snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue); + } + + [Fact] + public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue() + { + // SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members, + // and a reader that indexed members by key into a 1:1 map would feed only one of them. + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("SpeedA", SqlitePollFixture.PresentKey), + KvTag("SpeedB", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + [Fact] + public async Task ReadAsync_theSameRefTwice_feedsBothSlots() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None); + + snapshots.Count.ShouldBe(2); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + [Fact] + public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection() + { + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null); + + (await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty(); + + factory.Created.ShouldBe(0); + } + + // ---- wide row ---- + + [Fact] + public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation)); + + var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None); + + // One row, two tags — the wrong-slice defect would cross these two values over. + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure); + snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation), + WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation), + WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation)); + + var snapshots = await reader.ReadAsync( + ["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector + SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL + snapshots[1].Value.ShouldBeNull(); + snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected + } + + [Fact] + public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None); + + // Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here. + snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp); + snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp); + } + + // ---- type + timestamp mapping ---- + + [Fact] + public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32), + KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String), + KvTag("Inferred", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins + snapshots[1].Value.ShouldBe("42"); // string + snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL + } + + [Fact] + public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock() + { + using var fixture = new SqlitePollFixture(); + var before = DateTime.UtcNow; + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[0].SourceTimestampUtc.ShouldNotBeNull(); + snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1)); + snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc); + } + + [Fact] + public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically() + { + using var fixture = new SqlitePollFixture(); + fixture.Execute( + $"INSERT INTO {SqlitePollFixture.KeyValueTable} " + + $"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " + + $"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')"); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + // Design §3.6: the contract is one row per key; when a source breaks it the reader is at least + // deterministic — last occurrence in reader order — rather than silently arbitrary. + snapshots[0].Value.ShouldBe(99.0); + } + + // ---- the frozen-peer contract ---- + + [Fact] + public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline() + { + using var fixture = new SqlitePollFixture(); + + // A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and + // Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a + // cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async + // API that ignores its deadline), so only a real wall-clock bound can end this call early. + using var locker = fixture.OpenNewConnection(); + using (var begin = locker.CreateCommand()) + { + begin.CommandText = "BEGIN EXCLUSIVE"; + begin.ExecuteNonQuery(); + } + + try + { + // Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the + // point is to prove the CLIENT-side bound fires while the server-side backstop would still wait. + var reader = new SqlPollReader( + fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500), + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + var clock = Stopwatch.StartNew(); + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + clock.Stop(); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + snapshots[0].Value.ShouldBeNull(); + // Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s. + clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + finally + { + using var rollback = locker.CreateCommand(); + rollback.CommandText = "ROLLBACK"; + rollback.ExecuteNonQuery(); + } + } + + [Fact] + public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation() + { + // Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so + // clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must + // propagate rather than be laundered into a snapshot nobody will consume. + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + await Should.ThrowAsync( + async () => await reader.ReadAsync(["Speed"], cancelled.Token)); + } + + [Fact] + public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff() + { + // IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws. + using var fixture = new SqlitePollFixture(); + var unreachable = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine( + Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"), + }.ToString(); + var reader = new SqlPollReader( + fixture.Factory, unreachable, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + await Should.ThrowAsync( + async () => await reader.ReadAsync(["Speed"], CancellationToken.None)); + } + + // ---- connection lifecycle ---- + + [Theory] + [InlineData(1)] + [InlineData(3)] + public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups) + { + using var fixture = new SqlitePollFixture(); + // Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp. + var tags = new[] + { + KvTag("Speed", SqlitePollFixture.PresentKey), + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn), + }; + // The delay makes the groups genuinely overlap; without it a SQLite query is too fast for + // concurrency to be observable at all and the cap assertion below would pass vacuously. + var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150)); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30), + maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags)); + + var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None); + + snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good); + factory.Created.ShouldBe(3); + factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real + factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop + } + + [Fact] + public async Task ReadAsync_repeatedPolls_leakNoConnections() + { + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + for (var poll = 0; poll < 5; poll++) + { + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + factory.Created.ShouldBe(5); + factory.Live.ShouldBe(0); + } + + // ---- argument guards ---- + + [Fact] + public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap() + { + var dialect = new SqliteDialect(); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null)); + } + + [Fact] + public async Task ReadAsync_rejectsANullReferenceList() + => await Should.ThrowAsync(async () => + { + using var fixture = new SqlitePollFixture(); + await Reader(fixture).ReadAsync(null!, CancellationToken.None); + }); + + // ---- helpers ---- + + private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags) + => Reader(fixture, nullIsBad: false, tags: tags); + + private static SqlPollReader Reader( + SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags) + => new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags)); + + /// The driver's RawPath→definition table, standing in for EquipmentTagRefResolver. + private static Func Resolver(params SqlTagDefinition[] tags) + { + var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal); + return rawPath => table.GetValueOrDefault(rawPath); + } + + private static SqlTagDefinition KvTag( + string name, + string keyValue, + DriverDataType? declaredType = null, + string? timestampColumn = SqlitePollFixture.TimestampColumn) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: timestampColumn, + DeclaredType: declaredType); + + private static SqlTagDefinition WideTag( + string name, + string columnName, + string? selectorValue = null, + string? topByTimestamp = null, + string? timestampColumn = SqlitePollFixture.TimestampColumn) + => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable, + ColumnName: columnName, + TimestampColumn: timestampColumn, + RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn, + RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + /// + /// A over SQLite that counts how many connections the reader has + /// created and how many are alive at once — the only way to observe the connection lifecycle from + /// outside, since deliberately owns its connections end to end. + /// The optional createDelay widens the window each connection is alive so that + /// concurrent group execution is actually observable against a database whose queries would + /// otherwise finish in microseconds. + /// + private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory + { + private readonly object _sync = new(); + private int _live; + private int _peak; + private int _created; + + /// How many connections the reader has asked the factory for. + public int Created { get { lock (_sync) { return _created; } } } + + /// How many created connections have not yet closed. + public int Live { get { lock (_sync) { return _live; } } } + + /// The high-water mark of . + public int Peak { get { lock (_sync) { return _peak; } } } + + public override DbConnection CreateConnection() + { + var connection = SqliteFactory.Instance.CreateConnection()!; + connection.StateChange += OnStateChange; + lock (_sync) + { + _created++; + _live++; + if (_live > _peak) _peak = _live; + } + + if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay); + return connection; + } + + private void OnStateChange(object sender, StateChangeEventArgs e) + { + if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return; + lock (_sync) { _live--; } + } + } +} From 0efee7413adc096a6a690dc76405e48bf2af9968 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:31:35 -0400 Subject: [PATCH 14/33] fix(sql): SqlQueryPlan snapshots its collections instead of aliasing the planner's lists Code-review Minor: the record typed ParameterNames/Parameters/Members/ SelectedColumns as IReadOnlyList but was handed the planner's live List instances. SqlQueryPlan documents itself as safe to hold for plan caching, so an aliased list is a real hazard once the reader starts reusing plans across polls: IReadOnlyList is downcastable, and one mutation would corrupt every subsequent poll served by that plan. Copying at the record boundary fixes it for every call site at once rather than relying on each planner branch remembering to call AsReadOnly. Pinned by a test that mutates the caller's lists after construction; verified load-bearing (reverting the record turns it red). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlQueryPlan.cs | 15 ++++++++ .../SqlQueryPlanImmutabilityTests.cs | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs index 0d933d07..f586bbf6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -51,6 +51,21 @@ public sealed record SqlQueryPlan( IReadOnlyList Members, IReadOnlyList SelectedColumns) { + // A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the + // planner's mutable working lists — an IReadOnlyList holding a List is downcastable, and a + // mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary. + /// The parameter markers in , aligned with . + public IReadOnlyList ParameterNames { get; } = [.. ParameterNames]; + + /// The values to bind, aligned with . + public IReadOnlyList Parameters { get; } = [.. Parameters]; + + /// The tags this plan feeds, in input order; duplicates preserved. + public IReadOnlyList Members { get; } = [.. Members]; + + /// The bare SELECT-list column names, in result-set ordinal order. + public IReadOnlyList SelectedColumns { get; } = [.. SelectedColumns]; + /// /// The key column all members are matched on — for any model but /// . Uniform across by the group-key invariant, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs new file mode 100644 index 00000000..b76ddd8a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs @@ -0,0 +1,34 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +public class SqlQueryPlanImmutabilityTests +{ + // A plan is documented as cacheable across polls. If it stayed aliased to the planner's working + // List, a consumer downcast could corrupt every later poll that reused it. + [Fact] + public void Plan_doesNotAliasTheCallersLists() + { + var names = new List { "@k0" }; + var values = new List { "v" }; + var members = new List + { + new("raw", SqlTagModel.KeyValue, "t") { KeyColumn = "k", KeyValue = "v", ValueColumn = "c" }, + }; + var selected = new List { "k", "c" }; + + var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected); + + names.Add("@k1"); + values.Add("other"); + members.Add(members[0]); + selected.Add("extra"); + + plan.ParameterNames.Count.ShouldBe(1); + plan.Parameters.Count.ShouldBe(1); + plan.Members.Count.ShouldBe(1); + plan.SelectedColumns.Count.ShouldBe(2); + } +} From 9b30bdeb7adc7f669ade7891126f3b05fc81e450 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:48:14 -0400 Subject: [PATCH 15/33] feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlDriver implements IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe / IAsyncDisposable over the landed SqlPollReader, mirroring ModbusDriver. IWritable is deliberately absent: v1 is read-only structurally, and every discovered variable is SecurityClass=ViewOnly. The shell classifies poll outcomes because nothing below it does. The reader honours IReadable literally — it throws only when the database is unreachable and Bad-codes everything else — so a frozen database returns all-BadTimeout snapshots through a perfectly successful PollGroupEngine tick. Without ObservePollOutcome the driver would report Healthy while every value was Bad. Connection-class codes (BadTimeout / BadCommunicationError) degrade health and report the host Stopped; authoring-class codes (unresolvable RawPath, absent row, type mismatch) change nothing, so a tag typo never reports the database down. It deliberately does NOT synthesise an exception to earn engine backoff: the engine's exception path publishes nothing, which would cost clients the Bad quality the reader went out of its way to produce. Initialize builds the authored RawPath table first (pure, cannot fail; a malformed TagConfig is logged and skipped) then verifies liveness over one open-use-dispose connection bounded by wall clock as well as by token (the R2-01 lesson) — failure records Faulted and rethrows so DriverInstanceActor retries. The connection string is never logged: a credential-free server/database Endpoint is the only rendering that reaches a log, LastError, or the host status. DriverTypeNames.Sql is NOT added here — DriverTypeNamesGuardTests asserts bidirectional parity with registered factories, so the constant must land with the factory (Task 11). SqlDriver.DriverTypeName carries the string until then. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 694 ++++++++++++++++++ .../SqlDriverOptions.cs | 56 ++ .../SqlDriverTests.cs | 490 +++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj | 7 + 4 files changed, 1247 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs new file mode 100644 index 00000000..c52e07e2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -0,0 +1,694 @@ +using System.Data.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The Sql driver instance: a read-only Equipment-kind driver that polls SQL tables/views +/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of +/// ModbusDriver — this type owns lifecycle, the authored RawPath table, health, host +/// connectivity, and the subscription overlay, while +/// owns the read path. +/// Read-only is structural, not configured. is deliberately not +/// implemented, so no amount of config (and no WriteOperate role) can produce a write; every +/// discovered variable is . A future write feature is a +/// new capability, not a flag flip. +/// Health is classified here, because nothing below does it. The reader honours +/// literally: it throws only when the database cannot be reached, and turns +/// every other failure — including a frozen database — into Bad-coded snapshots. Those return +/// successfully, so sees a clean tick, does not back off, and does +/// not call . Without below, a driver whose +/// every value is would keep reporting +/// . See that method for the exact rule and for why it does not +/// manufacture an exception to force backoff. +/// +public sealed class SqlDriver + : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable +{ + /// + /// The driver-type string this driver registers under. + /// Deliberately a local constant, not DriverTypeNames.Sql. + /// DriverTypeNamesGuardTests asserts bidirectional parity between the constants and the + /// driver factories actually registered in the process, so the constant may only be added in the + /// same change that wires the factory (this task ships no factory — see the plan's Task 11, which + /// adds DriverTypeNames.Sql and repoints this constant at it). + /// + public const string DriverTypeName = "Sql"; + + /// Shown for a connection string that names no recognisable server. + private const string UnknownEndpoint = "(unknown sql endpoint)"; + + /// Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8). + private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); + + /// Connection-string keys that name the server, in the order they are consulted. + private static readonly string[] ServerKeys = + ["server", "data source", "datasource", "host", "address", "addr", "network address"]; + + /// Connection-string keys that name the database. + private static readonly string[] DatabaseKeys = ["database", "initial catalog"]; + + // ---- instance fields (grouped at top for auditability) ---- + + private readonly SqlDriverOptions _options; + private readonly string _driverInstanceId; + private readonly ISqlDialect _dialect; + private readonly DbProviderFactory _factory; + + /// + /// The resolved connection string — a secret. It is passed to the provider and to nothing + /// else: every log line, health message and host status carries instead. + /// + private readonly string _connectionString; + + private readonly ILogger _logger; + + /// + /// The authored RawPath → definition table, held as an immutable snapshot swapped atomically + /// rather than as a mutated dictionary (the shape ModbusDriver uses). + /// rebuilds this table while poll loops are reading it; clearing and + /// refilling a shared under that concurrency is a torn read at + /// best and an inside a poll at worst. + /// + private IReadOnlyDictionary _tagsByRawPath = + new Dictionary(StringComparer.Ordinal); + + /// Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss. + private readonly EquipmentTagRefResolver _resolver; + + /// + /// The read path. Built once, in the constructor, and not injected: its resolve + /// delegate must read this driver's live authored table, which does not exist before the driver does. + /// + private readonly SqlPollReader _reader; + + /// Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop. + private readonly PollGroupEngine _poll; + + // Single logical host: one driver instance dials one connection string. HostName is the endpoint + // description (server[/database]) so the Admin UI shows operators where to look. + private readonly object _probeLock = new(); + private HostState _hostState = HostState.Unknown; + private DateTime _hostStateChangedUtc = DateTime.UtcNow; + + private DriverHealth _health = new(DriverState.Unknown, null, null); + + /// Occurs when a subscribed tag's value or quality changes. + public event EventHandler? OnDataChange; + + /// Occurs when the database endpoint transitions between reachable and unreachable. + public event EventHandler? OnHostStatusChanged; + + // ---- ctor + identity ---- + + /// Initializes a new Sql driver instance. + /// The driver's typed configuration (the factory applies the documented defaults). + /// The central config DB's identity for this driver instance. + /// + /// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping. + /// + /// + /// The already-resolved connection string. The driver never resolves a + /// connectionStringRef itself and never logs this value. + /// + /// + /// Creates the provider's connections. Defaults to 's factory; passed + /// explicitly by tests that need to observe or delay connection creation. + /// + /// Optional; defaults to a no-op logger. + /// A required reference argument is null. + /// or is blank. + public SqlDriver( + SqlDriverOptions options, + string driverInstanceId, + ISqlDialect dialect, + string connectionString, + DbProviderFactory? factory = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dialect); + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString)); + + _options = options; + _driverInstanceId = driverInstanceId; + _dialect = dialect; + _factory = factory ?? dialect.Factory; + _connectionString = connectionString; + _logger = logger ?? NullLogger.Instance; + Endpoint = DescribeEndpoint(connectionString); + + _resolver = new EquipmentTagRefResolver(Lookup); + _reader = new SqlPollReader( + _factory, + connectionString, + dialect, + commandTimeout: options.CommandTimeout, + operationTimeout: options.OperationTimeout, + maxConcurrentGroups: options.MaxConcurrentGroups, + nullIsBad: options.NullIsBad, + resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null, + logger: _logger); + _poll = new PollGroupEngine( + reader: ReadAsync, + onChange: (handle, tagRef, snapshot) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), + onError: HandlePollError, + backoffCap: PollBackoffCap); + } + + /// + public string DriverInstanceId => _driverInstanceId; + + /// + public string DriverType => DriverTypeName; + + /// + /// The credential-free description of the database this instance polls — server/database where + /// the connection string names both. This is the only rendering of the connection string that may + /// appear in a log, a health message, or the Admin UI. + /// + public string Endpoint { get; } + + /// Host identifier surfaced through — the endpoint description. + public string HostName => Endpoint; + + /// Active polled-subscription count. Diagnostics + disposal assertions. + internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount; + + /// The current authored-tag snapshot. Read through a barrier — swaps it. + private IReadOnlyDictionary Tags => Volatile.Read(ref _tagsByRawPath); + + /// The resolver's lookup: RawPath → authored definition, or null on a miss. + private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath); + + // ---- IDriver lifecycle ---- + + /// + /// Builds the authored RawPath table and proves the database is reachable. + /// is not re-parsed here: the driver serves the + /// typed it was constructed with, exactly as ModbusDriver does + /// (config parsing belongs to the factory, which builds a fresh instance). + /// The table is built first because it is pure and cannot fail; the liveness check is the only + /// I/O, and on failure this method records and rethrows — + /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its + /// retry timer running, which is exactly the recovery a database that is merely down needs. + /// + /// The driver configuration JSON (unused; see remarks). + /// Cancellation token for the operation. + /// A task that represents the asynchronous operation. + /// The database could not be reached. + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); + BuildTagTable(); + + try + { + await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The caller tore the initialization down; not a database verdict. + WriteHealth(new DriverHealth(DriverState.Unknown, null, null)); + throw; + } + catch (Exception ex) + { + // Never interpolate _connectionString — Endpoint is the credential-free rendering. + var message = + $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message} " + + $"Check the database is up, the network path is open, and the configured credentials are valid."; + _logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.", + _driverInstanceId, Endpoint); + WriteHealth(new DriverHealth(DriverState.Faulted, null, message)); + TransitionHostTo(HostState.Stopped); + throw new InvalidOperationException(message, ex); + } + + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + TransitionHostTo(HostState.Running); + _logger.LogInformation( + "Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).", + _driverInstanceId, Endpoint, Tags.Count); + } + + /// + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await ShutdownAsync(cancellationToken).ConfigureAwait(false); + await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + var lastRead = ReadHealth().LastSuccessfulRead; + await TeardownAsync().ConfigureAwait(false); + WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null)); + } + + /// + public DriverHealth GetHealth() => ReadHealth(); + + /// No caches, no symbol table, no connection between polls — the footprint is the tag table. + /// Always zero. + public long GetMemoryFootprint() => 0; + + /// Nothing optional to flush: the authored table is required for correctness. + /// Cancellation token for the operation. + /// A completed task. + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // ---- ITagDiscovery ---- + + /// + /// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery + /// could only produce the same nodes. + /// + public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; + + /// + /// False — replays authored tags rather than enumerating the backend, so + /// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is + /// a separate surface: Driver.Sql.Browser.) + /// + public bool SupportsOnlineDiscovery => false; + + /// + /// Streams the authored tags as read-only variables. + /// An undeclared tag materialises as — the same fallback + /// uses for an unrecognised column type — because a column's + /// real type is only known once a poll has returned result-set metadata, which has not happened at + /// discovery time. Author "type" on a numeric tag. + /// + /// The address-space builder to stream discovered nodes into. + /// Cancellation token for the operation. + /// A completed task — discovery is synchronous over the authored table. + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var folder = builder.Folder(DriverTypeName, DriverTypeName); + foreach (var tag in Tags.Values) + { + folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( + FullName: tag.Name, + DriverDataType: tag.DeclaredType ?? DriverDataType.String, + IsArray: false, + ArrayDim: null, + // v1 is read-only: no tag, and no configuration, can widen this. + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + + return Task.CompletedTask; + } + + // ---- IReadable ---- + + /// + /// Reads a batch, delegating to and classifying what came back + /// (). + /// + /// The RawPaths to read. + /// Cancellation token for the operation. + /// One snapshot per reference, at the same index. + /// The database could not be reached — the engine backs off on this. + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + // Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is + // unreachable" and degrade a perfectly healthy driver. + ArgumentNullException.ThrowIfNull(fullReferences); + + try + { + var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false); + ObservePollOutcome(snapshots); + return snapshots; + } + catch (OperationCanceledException) + { + // Teardown, not a database verdict — leave health and host state exactly as they were. + throw; + } + catch (Exception ex) + { + // The reader throws for one reason only: opening a connection failed (IReadable's contract). + _logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.", + _driverInstanceId, Endpoint); + Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message}"); + TransitionHostTo(HostState.Stopped); + throw; + } + } + + // ---- ISubscribable (polling overlay via the shared engine) ---- + + /// + /// Registers a polled subscription. + /// A non-positive falls back to the configured + /// ; anything faster than + /// is floored by the engine. + /// + /// The RawPaths to poll. + /// The requested publishing interval. + /// Cancellation token for the operation. + /// The subscription handle. + public Task SubscribeAsync( + IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) + { + var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval; + return Task.FromResult(_poll.Subscribe(fullReferences, interval)); + } + + /// + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) + { + _poll.Unsubscribe(handle); + return Task.CompletedTask; + } + + // ---- IHostConnectivityProbe ---- + + /// + /// The single logical host this instance dials. There is no background probe loop: the state is a + /// by-product of the Initialize-time liveness check and of every poll, so the report is always a + /// statement about traffic that actually happened rather than about a synthetic ping. + /// + /// A one-element list describing the configured database endpoint. + public IReadOnlyList GetHostStatuses() + { + lock (_probeLock) + return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)]; + } + + // ---- health + host-state classification ---- + + /// + /// Classifies one poll's snapshots into a health verdict and a host state. + /// + /// Any Good snapshot ⇒ the database answered: LastSuccessfulRead advances and the + /// host is Running. + /// Any connection-class Bad snapshot ( / + /// ) ⇒ Degraded; when nothing at all was Good, + /// the host is Stopped. + /// Bad codes that are authoring facts — an unresolvable RawPath, an absent row, a + /// type mismatch, a rejected definition — change nothing. A tag typo must never report the + /// database as down and send an operator to the wrong system. + /// + /// Why this does not throw to force poll-engine backoff. Backoff would require turning + /// an all-BadTimeout batch into an exception, and the engine's exception path publishes + /// nothing — clients would lose the very BadTimeout quality the reader went out of its way to + /// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is + /// already bounded by operationTimeout and the reader never holds more than + /// maxConcurrentGroups connections however long the database stays frozen. So a frozen + /// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll + /// cadence rather than backed off from. + /// + /// The snapshots the reader returned for one poll. + private void ObservePollOutcome(IReadOnlyList snapshots) + { + if (snapshots.Count == 0) return; + + var good = 0; + var unreachable = 0; + foreach (var snapshot in snapshots) + { + if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++; + else if (IsConnectionClass(snapshot.StatusCode)) unreachable++; + } + + if (unreachable > 0) + { + Degrade( + $"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " + + $"{Endpoint} on the last poll."); + if (good > 0) + { + // Partial: something answered, so the endpoint is up — but the driver is not healthy. + TouchLastSuccessfulRead(); + TransitionHostTo(HostState.Running); + } + else + { + TransitionHostTo(HostState.Stopped); + } + + return; + } + + if (good == 0) return; // authoring-class Bad only — not a statement about the database. + + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + TransitionHostTo(HostState.Running); + } + + /// True for the status codes that mean "the database did not answer", as opposed to "the data is not there". + private static bool IsConnectionClass(uint statusCode) + => statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError; + + /// + /// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does not + /// touch host state: the engine also reports its own contract violations here, which say nothing + /// about connectivity — the unreachable-database transition is raised by , + /// which knows the exception came from the reader. + /// + /// The exception the poll engine caught. + internal void HandlePollError(Exception ex) + { + _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}", + _driverInstanceId, Endpoint); + Degrade(ex.Message); + } + + /// + /// Degrades health, preserving LastSuccessfulRead and never downgrading + /// — a config-level verdict that only a successful read or an + /// operator reinitialize may clear. + /// + /// The operator-facing reason, which must never carry the connection string. + private void Degrade(string reason) + { + var current = ReadHealth(); + if (current.State == DriverState.Faulted) return; + WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason)); + } + + /// Advances LastSuccessfulRead without changing the current state or error. + private void TouchLastSuccessfulRead() + { + var current = ReadHealth(); + WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow }); + } + + /// Barrier-protected read of the multi-threaded health field. + private DriverHealth ReadHealth() => Volatile.Read(ref _health); + + /// Barrier-protected publish of a new health snapshot. + private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value); + + /// Records a host-state change and raises — on transitions only. + private void TransitionHostTo(HostState newState) + { + HostState old; + lock (_probeLock) + { + old = _hostState; + if (old == newState) return; + _hostState = newState; + _hostStateChangedUtc = DateTime.UtcNow; + } + + OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState)); + } + + // ---- authored tag table ---- + + /// + /// Maps every authored through + /// and publishes the result as one atomic snapshot. A blob that does not map is logged and + /// skipped, never thrown: one malformed tag must not take the whole driver — and therefore every + /// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it + /// publishes rather than someone else's row. + /// + private void BuildTagTable() + { + var table = new Dictionary(_options.RawTags.Count, StringComparer.Ordinal); + foreach (var entry in _options.RawTags) + { + if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition)) + table[entry.RawPath] = definition; + else + _logger.LogWarning( + "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + } + + Volatile.Write(ref _tagsByRawPath, table); + } + + // ---- liveness ---- + + /// + /// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is + /// held between this check and the first poll — the reader opens, uses and disposes per poll, so a + /// long-lived connection here would only be a second thing to keep alive. + /// Bounded by wall-clock, not only by the token (the R2-01 / STAB-14 lesson): some ADO.NET + /// providers implement the async path synchronously, and a wedged socket can hang inside the + /// provider's own cancellation handshake. If Initialize hung there, DriverInstanceActor's init + /// task would never complete and the driver would sit in Connecting forever with no retry. The work + /// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and + /// an abandoned attempt still owns and disposes its own connection. + /// + /// The caller's token. + /// A task that completes when the database has answered. + private async Task VerifyLivenessAsync(CancellationToken cancellationToken) + { + var budget = _options.CommandTimeout; + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task work; + try + { + deadline.CancelAfter(budget); + work = Task.Run( + async () => + { + try { await PingAsync(deadline.Token).ConfigureAwait(false); } + finally { deadline.Dispose(); } + }, + CancellationToken.None); + } + catch + { + // Ownership of the CTS transfers to the work task; release it only if that task never started. + deadline.Dispose(); + throw; + } + + try + { + await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + Detach(work); + throw new TimeoutException( + $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms."); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Our deadline fired and the provider DID honour the linked token. + Detach(work); + throw new TimeoutException( + $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms."); + } + } + + /// Opens a connection and executes the dialect's cheapest proof-of-life statement. + private async Task PingAsync(CancellationToken cancellationToken) + { + var connection = _factory.CreateConnection() + ?? throw new InvalidOperationException( + $"the {_dialect.Provider} provider factory returned no connection."); + + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = _connectionString; + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = _dialect.LivenessSql; + // ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become. + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds)); + await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved + /// task exception. The task still owns — and disposes — its own connection. + /// + private static void Detach(Task work) + => _ = work.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + // ---- endpoint description ---- + + /// + /// Renders a connection string as server/database, reading only those two keys. + /// This is the only function permitted to look at the connection string outside the + /// provider call, and it exists so that no other code is ever tempted to log the string itself: + /// credentials live in User ID / Password / Authentication, which are never + /// read here. A string that cannot be parsed yields — an unusable + /// description must not become a crash at construction. + /// + /// The resolved connection string. + /// A credential-free description safe to log and display. + private static string DescribeEndpoint(string connectionString) + { + try + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + var server = FirstValue(builder, ServerKeys); + if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint; + var database = FirstValue(builder, DatabaseKeys); + return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}"; + } + catch (ArgumentException) + { + return UnknownEndpoint; + } + } + + /// Reads the first of the builder carries; null when it carries none. + private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys) + { + foreach (var key in keys) + { + // DbConnectionStringBuilder's key comparison is case-insensitive. + if (builder.TryGetValue(key, out var value) && value is not null) + { + var text = value.ToString(); + if (!string.IsNullOrWhiteSpace(text)) return text; + } + } + + return null; + } + + // ---- teardown ---- + + /// + /// Performs the same teardown as , so a caller that only uses + /// await using does not leak the poll loops. + /// + /// A task that represents the asynchronous operation. + public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false); + + /// + /// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe + /// to call any number of times, in any order, which is what makes + /// ShutdownAsync-then-DisposeAsync (and ) safe. + /// There is no connection to close: the reader opens and disposes one per poll. + /// + private async Task TeardownAsync() + { + await _poll.DisposeAsync().ConfigureAwait(false); + Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal)); + _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs new file mode 100644 index 00000000..6157fb20 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs @@ -0,0 +1,56 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The typed form of a Sql driver instance's configuration — what +/// becomes once the factory has applied its defaults (design §5.1). +/// Mirrors ModbusDriverOptions: the driver instance is constructed with these, and its +/// serves them rather than re-parsing the config JSON. +/// No connection string here. The resolved connection string is a separate constructor +/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot. +/// +public sealed class SqlDriverOptions +{ + /// + /// The authored raw tags this driver serves. The deploy artifact hands each authored raw Tag + /// as a (RawPath identity + driver TagConfig blob); the driver maps + /// each through into its RawPath → definition table. + /// A SQL source has no tag-discovery protocol — the driver serves exactly these. + /// + public IReadOnlyList RawTags { get; init; } = []; + + /// + /// Poll interval used when a subscriber does not ask for one (design §4: defaultPollInterval, + /// default 5 s). A subscriber that names an interval gets that interval, floored by + /// . + /// + public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5); + + /// + /// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach + /// publishes for that group's tags. + /// + public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15); + + /// + /// The ADO.NET CommandTimeout server-side backstop (default 10 s), also the bound on the + /// Initialize-time liveness check. + /// Authoring must keep strictly greater than this (design §8.3); + /// inverted, the client-side abort always fires first and masks the backstop. That rule is + /// enforced by config validation in the factory, not here — the reader deliberately stays usable + /// with the order inverted so the frozen-database tests can prove the client-side bound fires. + /// + public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10); + + /// Cap on concurrently executing group queries — and therefore on concurrently open connections. + public int MaxConcurrentGroups { get; init; } = 4; + + /// + /// When , a present row whose value cell is NULL publishes + /// instead of the default . + /// It never governs an absent row, which is always . + /// + public bool NullIsBad { get; init; } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs new file mode 100644 index 00000000..eb4bee5c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -0,0 +1,490 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves the shell — the part deliberately does not +/// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery, +/// the poll-engine subscription overlay, and the health + host-connectivity surface. +/// The health assertions are the point of this class. The reader returns Bad-coded snapshots +/// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades +/// health on its own: a frozen database yields all-BadTimeout snapshots and a perfectly successful +/// tick. If the shell does not classify what came back, the driver reports +/// while every value is Bad — the failure mode these tests exist to +/// prevent, together with its inverse (a tag typo must NOT report the database down). +/// +public sealed class SqlDriverTests +{ + private const string DriverInstanceId = "sql-1"; + + // ---- discovery + initialize ---- + + [Fact] + public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.DriverType.ShouldBe("Sql"); + driver.DriverInstanceId.ShouldBe(DriverInstanceId); + ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse(); + ((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); + + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + + capture.Variables.ShouldContain(v => + v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly); + // v1 is read-only by construction, not by configuration. + driver.ShouldNotBeAssignableTo(); + } + + [Fact] + public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe() + { + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver( + fixture, + logger, + KvEntry("Speed", SqlitePollFixture.PresentKey), + new RawTagEntry("Broken", "not json at all", WriteIdempotent: false)); + + // A single malformed blob must never fail the whole driver. + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + capture.Variables.Count.ShouldBe(1); + capture.Variables[0].Info.FullName.ShouldBe("Speed"); + + logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken")); + + // …and the skipped tag resolves to nothing rather than to someone else's row. + var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + } + + [Fact] + public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + // Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in + // Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not + // seal as a silently-connected driver. + var thrown = await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Faulted); + health.LastError.ShouldNotBeNullOrWhiteSpace(); + // Actionable: it names the endpoint the operator has to go look at… + health.LastError!.ShouldContain(NoSuchDatabaseName); + thrown.Message.ShouldContain(NoSuchDatabaseName); + // …and never the credential-bearing connection string. + health.LastError.ShouldNotContain(SecretToken); + thrown.Message.ShouldNotContain(SecretToken); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); + driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken); + } + + [Fact] + public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain() + { + using var fixture = new SqlitePollFixture(); + // SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection + // string is unreachable until the directory exists, and reachable the moment it does. + var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine(directory, "late.db"), + }.ToString(); + + await using var driver = NewDriver( + fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + + try + { + Directory.CreateDirectory(directory); + + await driver.ReinitializeAsync(ConfigJson, CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); + // The authored table survived the round trip — a recovered driver still serves its tags. + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + capture.Variables.Count.ShouldBe(1); + } + finally + { + SqliteConnection.ClearAllPools(); + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + + // ---- IReadable delegation ---- + + [Fact] + public async Task ReadAsync_delegatesToTheReader_valueForValue() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, + KvEntry("Speed", SqlitePollFixture.PresentKey), + KvEntry("Temp", SqlitePollFixture.NullValueKey), + KvEntry("Missing", SqlitePollFixture.AbsentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var reference = new SqlPollReader( + fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15), + maxConcurrentGroups: 4, nullIsBad: false, + resolve: rawPath => rawPath switch + { + "Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey), + "Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey), + "Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey), + _ => null, + }); + + string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"]; + var throughDriver = await driver.ReadAsync(refs, CancellationToken.None); + var throughReader = await reference.ReadAsync(refs, CancellationToken.None); + + throughDriver.Count.ShouldBe(throughReader.Count); + for (var i = 0; i < throughDriver.Count; i++) + { + throughDriver[i].Value.ShouldBe(throughReader[i].Value); + throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode); + throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc); + } + } + + // ---- the sustained-timeout decision ---- + + [Fact] + public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, fixture.ConnectionString, CapturingLogger.Null, + // Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while + // the server-side backstop would still be waiting — the reader's frozen-peer shape. + operationTimeout: TimeSpan.FromMilliseconds(500), + commandTimeout: TimeSpan.FromSeconds(30), + rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + // A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests). + using var locker = fixture.OpenNewConnection(); + using (var begin = locker.CreateCommand()) + { + begin.CommandText = "BEGIN EXCLUSIVE"; + begin.ExecuteNonQuery(); + } + + try + { + var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None); + + // The reader does exactly what it promises — Bad-coded snapshots, no exception… + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + // …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health. + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastError.ShouldNotBeNullOrWhiteSpace(); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); + } + finally + { + using var rollback = locker.CreateCommand(); + rollback.CommandText = "ROLLBACK"; + rollback.ExecuteNonQuery(); + } + } + + [Fact] + public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand() + { + // The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when + // the Bad codes are connection-class. An authoring typo reporting "the database is down" would send + // an operator to the wrong system entirely. + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + (await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + + var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); + } + + // ---- ISubscribable (poll-engine overlay) ---- + + [Fact] + public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var first = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changes = 0; + driver.OnDataChange += (_, args) => + { + Interlocked.Increment(ref changes); + first.TrySetResult(args); + }; + + var handle = await driver.SubscribeAsync( + ["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + + var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + initial.FullReference.ShouldBe("Speed"); + initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue); + initial.SubscriptionHandle.ShouldBe(handle); + + await driver.UnsubscribeAsync(handle, CancellationToken.None); + driver.ActiveSubscriptionCount.ShouldBe(0); + + // Unsubscribe awaits the loop task, so nothing may arrive after it returns. + var afterUnsubscribe = Volatile.Read(ref changes); + await Task.Delay(400, TestContext.Current.CancellationToken); + Volatile.Read(ref changes).ShouldBe(afterUnsubscribe); + } + + // ---- disposal ---- + + [Fact] + public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent() + { + using var fixture = new SqlitePollFixture(); + var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var changes = 0; + driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes); + _ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + await Task.Delay(300, TestContext.Current.CancellationToken); + + await driver.DisposeAsync(); + driver.ActiveSubscriptionCount.ShouldBe(0); + + var afterDispose = Volatile.Read(ref changes); + await Task.Delay(400, TestContext.Current.CancellationToken); + Volatile.Read(ref changes).ShouldBe(afterDispose); + + // Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw. + await driver.ShutdownAsync(CancellationToken.None); + await Should.NotThrowAsync(async () => await driver.DisposeAsync()); + } + + // ---- host identity ---- + + [Fact] + public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials() + { + using var fixture = new SqlitePollFixture(); + const string connectionString = + "Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";"; + await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null); + + var hosts = driver.GetHostStatuses(); + + hosts.Count.ShouldBe(1); + hosts[0].HostName.ShouldContain("sqlsrv01"); + hosts[0].HostName.ShouldContain("MesStaging"); + hosts[0].HostName.ShouldNotContain(SecretToken); + hosts[0].HostName.ShouldNotContain("svc_ot"); + hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet + } + + [Fact] + public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition() + { + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + var transitions = new List(); + driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } }; + + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + // A second successful poll must not re-raise — the event is for transitions, not for ticks. + await driver.ReadAsync(["Speed"], CancellationToken.None); + + lock (transitions) + { + transitions.Count.ShouldBe(1); + transitions[0].OldState.ShouldBe(HostState.Unknown); + transitions[0].NewState.ShouldBe(HostState.Running); + } + } + + // ---- helpers ---- + + /// A distinctive password token: any assertion that finds it has found a credential leak. + private const string SecretToken = "hunter2-do-not-log"; + + /// The unreachable connection string's database file name, used as the actionable-error probe. + private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir"; + + /// + /// The driver's DriverConfig JSON. The shell serves its typed options (the factory, Task 9, + /// owns parsing) exactly as ModbusDriver does, so this is deliberately inert. + /// + private const string ConfigJson = """{"provider":"SqlServer"}"""; + + private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags) + => NewDriver(fixture, CapturingLogger.Null, rawTags); + + private static SqlDriver NewDriver( + SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags) + => NewDriver(fixture, fixture.ConnectionString, logger, rawTags); + + private static SqlDriver NewDriver( + SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags) + => NewDriver( + fixture, connectionString, logger, + operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10), + rawTags: rawTags); + + /// + /// Builds the driver through its production constructor. That constructor is the injection + /// seam — dialect, provider factory and already-resolved connection string are all parameters — so no + /// test-only factory is needed on the product type (Task 9 resolves the same three from config). + /// + private static SqlDriver NewDriver( + SqlitePollFixture fixture, + string connectionString, + CapturingLogger logger, + TimeSpan operationTimeout, + TimeSpan commandTimeout, + IReadOnlyList rawTags) + => new( + new SqlDriverOptions + { + RawTags = rawTags, + OperationTimeout = operationTimeout, + CommandTimeout = commandTimeout, + }, + DriverInstanceId, + new SqliteDialect(), + connectionString, + factory: fixture.Factory, + logger: logger); + + /// A connection string whose database cannot be opened — the directory does not exist. + private static string Unreachable() => new SqliteConnectionStringBuilder + { + DataSource = Path.Combine( + Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"), + Password = SecretToken, + }.ToString(); + + /// One authored raw tag: a key-value TagConfig blob over the fixture's EAV table. + private static RawTagEntry KvEntry(string rawPath, string keyValue) + => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" + { + "driver": "Sql", + "model": "KeyValue", + "table": "{{SqlitePollFixture.KeyValueTable}}", + "keyColumn": "{{SqlitePollFixture.KeyColumn}}", + "keyValue": "{{keyValue}}", + "valueColumn": "{{SqlitePollFixture.ValueColumn}}", + "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}" + } + """), WriteIdempotent: false); + + /// The same tag as , already typed — for the reference reader. + private static SqlTagDefinition KvDefinition(string rawPath, string keyValue) + => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn); + + /// Records everything the driver streams into the address space. + private sealed class CapturingBuilder : IAddressSpaceBuilder + { + /// The folders created, in order. + public List<(string BrowseName, string DisplayName)> Folders { get; } = []; + + /// The variables registered, in order. + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add((browseName, displayName)); + return this; + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new Handle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullReference) : IVariableHandle + { + public string FullReference => fullReference; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); + + private sealed class Sink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } + } + + /// Captures the driver's log so "skipped and logged" can be asserted rather than assumed. + private sealed class CapturingLogger : ILogger + { + /// A logger that records nothing — for the tests that do not assert on logging. + public static CapturingLogger Null { get; } = new(); + + /// Every record written, level + rendered message. + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() { } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj index 47c8bf1f..6b5c15d4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -1,5 +1,12 @@ + + + $(NoWarn);OTOPCUA0001 + + net10.0 enable From 861a1d1df04de8c5a80a58c42005470f48f7662c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:49:36 -0400 Subject: [PATCH 16/33] feat(sql): SqlBrowseSession schema-walk over dialect catalog Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with @schema/@table bound as parameters at every level. Column leaves carry the dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only v1). The NodeId encoding deliberately departs from the design sketch's literal `schema.table|column`: SQL Server permits `.` and `|` inside a quoted identifier, so that form mis-parses (main.a.b|c reads equally as schema `main`+table `a.b` and schema `main.a`+table `b`) and silently binds an operator's tag to the wrong column. SqlBrowseNodeId encodes `:[|...]` with `\`/`|` escaped and the kind prefix carrying the arity; it is public because the picker body decodes it back. The session owns the connection it is handed and closes it on dispose -- the registry-held session is the only lifetime hook, so a non-owning session would leak one pooled connection per reaped picker. Per-call work stays bounded by the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout); no second deadline is invented here. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../SqlBrowseNodeId.cs | 201 ++++++++++ .../SqlBrowseSession.cs | 304 ++++++++++++++ .../ConcurrencyProbingDbConnection.cs | 204 ++++++++++ .../SqlBrowseNodeIdTests.cs | 128 ++++++ .../SqlBrowseSessionTests.cs | 374 ++++++++++++++++++ .../SqliteBrowseFixture.cs | 190 +++++++++ ...WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj | 50 +++ 8 files changed, 1452 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 1bff1974..033ff3d3 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -94,6 +94,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs new file mode 100644 index 00000000..e55c0bf6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs @@ -0,0 +1,201 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// Which catalog level a browse NodeId addresses. +public enum SqlBrowseNodeKind +{ + /// A schema — the browse root level. + Schema, + + /// A table or view inside a schema. + Table, + + /// A column of a table or view — the terminal (leaf) level. + Column, +} + +/// A decoded browse NodeId. +/// Which catalog level this reference addresses. +/// The schema name. Always present. +/// The table/view name; null for . +/// The column name; null unless . +public readonly record struct SqlBrowseNodeRef( + SqlBrowseNodeKind Kind, + string Schema, + string? Table, + string? Column); + +/// +/// Codec for the browse NodeIds the SQL schema browser hands the picker, and the picker hands back +/// on expand / attribute fetch / column commit. +/// Why not the design's literal schema.table|column. That sketch assumes . and +/// | cannot occur inside an identifier. They can: SQL Server permits essentially any character +/// inside a quoted (bracketed) identifier, and SqlServerDialect.QuoteIdentifier deliberately passes +/// both through — only ], control characters and Unicode format characters are special to it. So +/// main.a.b|c decodes equally as schema main + table a.b and as schema main.a +/// + table b, and a table named x|y loses its second half entirely. That is not a cosmetic +/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author +/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads. +/// +/// The encoding. <kind>:<part>[|<part>…], where kind is one +/// of schema / table / column and every part is escaped so no identifier character +/// can be mistaken for structure: a literal \ becomes \\ and a literal | becomes +/// \|. . needs no escape at all — it is not structural here — so the common case stays +/// readable (column:dbo|TagValues|num_value). The kind prefix carries the arity, so decoding never +/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is +/// rejected rather than truncated or padded. +/// Public on purpose, unlike the session itself: the AdminUI picker body decodes a committed +/// column NodeId back into schema/table/column to compose the tag's TagConfig. Encode and decode +/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type +/// exists to prevent. +/// +public static class SqlBrowseNodeId +{ + private const char Separator = '|'; + private const char Escape = '\\'; + private const char KindTerminator = ':'; + + private const string SchemaKind = "schema"; + private const string TableKind = "table"; + private const string ColumnKind = "column"; + + /// Encodes a schema-level NodeId. + /// The schema name, exactly as the catalog reports it. + /// The encoded NodeId. + /// is null, empty or whitespace. + public static string ForSchema(string schema) => Encode(SchemaKind, schema); + + /// Encodes a table-level NodeId. + /// The schema name, exactly as the catalog reports it. + /// The table or view name, exactly as the catalog reports it. + /// The encoded NodeId. + /// Either name is null, empty or whitespace. + public static string ForTable(string schema, string table) => Encode(TableKind, schema, table); + + /// Encodes a column-level (leaf) NodeId. + /// The schema name, exactly as the catalog reports it. + /// The table or view name, exactly as the catalog reports it. + /// The column name, exactly as the catalog reports it. + /// The encoded NodeId. + /// Any name is null, empty or whitespace. + public static string ForColumn(string schema, string table, string column) => + Encode(ColumnKind, schema, table, column); + + /// Attempts to decode a NodeId. + /// The NodeId to decode. + /// The decoded reference on success; default otherwise. + /// true when is a well-formed SQL browse NodeId. + public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference) + { + reference = default; + if (string.IsNullOrWhiteSpace(nodeId)) return false; + + var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal); + if (terminator <= 0) return false; + + var kind = nodeId[..terminator]; + if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false; + if (parts.Exists(string.IsNullOrWhiteSpace)) return false; + + switch (kind) + { + case SchemaKind when parts.Count == 1: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null); + return true; + case TableKind when parts.Count == 2: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null); + return true; + case ColumnKind when parts.Count == 3: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]); + return true; + default: + return false; + } + } + + /// + /// Decodes a NodeId, throwing when it is malformed. + /// The message deliberately does not echo the offending value: a NodeId can carry an + /// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log. + /// + /// The NodeId to decode. + /// The decoded reference. + /// The value is not a well-formed SQL browse NodeId. + public static SqlBrowseNodeRef Parse(string? nodeId) + { + if (TryParse(nodeId, out var reference)) return reference; + throw new ArgumentException( + "Not a SQL schema-browse node. Expected 'schema:', 'table:|' or " + + "'column:|
|'. Re-open the browser and expand from the root.", + nameof(nodeId)); + } + + private static string Encode(string kind, params string[] parts) + { + var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16)); + builder.Append(kind).Append(KindTerminator); + for (var i = 0; i < parts.Length; i++) + { + if (string.IsNullOrWhiteSpace(parts[i])) + { + throw new ArgumentException( + "A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts)); + } + + if (i > 0) builder.Append(Separator); + AppendEscaped(builder, parts[i]); + } + + return builder.ToString(); + } + + private static void AppendEscaped(StringBuilder builder, string part) + { + foreach (var ch in part) + { + if (ch is Separator or Escape) builder.Append(Escape); + builder.Append(ch); + } + } + + /// + /// Splits an encoded payload on unescaped separators, unescaping as it goes. Returns + /// false on a dangling trailing escape, which cannot be produced by + /// and therefore means the value was hand-made or truncated. + /// + private static bool TrySplit(string payload, out List parts) + { + parts = []; + var current = new StringBuilder(payload.Length); + var escaped = false; + + foreach (var ch in payload) + { + if (escaped) + { + current.Append(ch); + escaped = false; + continue; + } + + switch (ch) + { + case Escape: + escaped = true; + break; + case Separator: + parts.Add(current.ToString()); + current.Clear(); + break; + default: + current.Append(ch); + break; + } + } + + if (escaped) return false; + parts.Add(current.ToString()); + return true; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs new file mode 100644 index 00000000..06c05d8f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -0,0 +1,304 @@ +using System.Data; +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// +/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the +/// column's mapped DriverDataType in the attribute side-panel (design §4.1, mirroring Galaxy's +/// two-stage object-then-attribute pick). Created by SqlDriverBrowser on picker open and owned by +/// the AdminUI's BrowseSessionRegistry, whose TTL reaper disposes idle sessions. +/// Every level is the dialect's catalog SQL ( / +/// / ) — nothing here +/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is +/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId +/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here. +/// is deliberately not used on this path — the browse +/// never needs an identifier in text. +/// Connection ownership: this session owns the connection it is handed and closes it on +/// . It does not open one, and it never re-opens a closed one. The handoff +/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until +/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook — +/// if the session did not close it, nothing would, and every reaped picker would leak a pooled +/// connection. +/// No timeout of its own. The AdminUI already bounds each root/expand/attributes call with a +/// 20-second linked CTS (BrowserSessionService.PerCallTimeout); this session's job is simply to +/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second +/// independent deadline here would only produce two competing, differently-worded failures. +/// +internal sealed class SqlBrowseSession : IBrowseSession +{ + /// Column alias every dialect's projects. + private const string SchemaColumn = "TABLE_SCHEMA"; + + /// Column alias every dialect's projects. + private const string TableNameColumn = "TABLE_NAME"; + + /// Column alias carrying BASE TABLE / VIEW. + private const string TableTypeColumn = "TABLE_TYPE"; + + /// Column alias every dialect's projects. + private const string ColumnNameColumn = "COLUMN_NAME"; + + /// Column alias carrying the SQL type family name. + private const string DataTypeColumn = "DATA_TYPE"; + + /// The TABLE_TYPE value marking a view rather than a base table. + private const string ViewTableType = "VIEW"; + + /// + /// The security class every browsed column reports. The Sql driver is read-only in v1 + /// (design §4.3), so there is nothing else a picked column could be. + /// + private const string ReadOnlySecurityClass = "ViewOnly"; + + private readonly DbConnection _connection; + private readonly ISqlDialect _dialect; + + /// + /// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and + /// the AdminUI tree happily fires several expands at once when an operator clicks quickly. + /// + private readonly SemaphoreSlim _gate = new(1, 1); + + private volatile bool _disposed; + + /// Constructs a session over an already-open connection, which it takes ownership of. + /// The open connection to browse. Closed by . + /// The dialect supplying the catalog SQL and the column-type map. + internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _dialect = dialect ?? throw new ArgumentNullException(nameof(dialect)); + } + + /// + public Guid Token { get; } = Guid.NewGuid(); + + /// + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + /// + public async Task> RootAsync(CancellationToken cancellationToken) + { + var schemas = await QueryAsync( + _dialect.ListSchemasSql, + static _ => { }, + static reader => ReadString(reader, SchemaColumn), + cancellationToken).ConfigureAwait(false); + + var nodes = new List(schemas.Count); + foreach (var schema in schemas) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForSchema(schema), + DisplayName: schema, + Kind: BrowseNodeKind.Folder, + HasChildrenHint: true)); + } + + return nodes; + } + + /// + /// is not a SQL schema-browse node. + public async Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var reference = SqlBrowseNodeId.Parse(nodeId); + switch (reference.Kind) + { + case SqlBrowseNodeKind.Schema: + return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false); + case SqlBrowseNodeKind.Table: + return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken) + .ConfigureAwait(false); + default: + // A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before + // it has re-read the node's Kind. + LastUsedUtc = DateTime.UtcNow; + return Array.Empty(); + } + } + + /// + /// is not a SQL schema-browse node. + public async Task> AttributesAsync( + string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var reference = SqlBrowseNodeId.Parse(nodeId); + if (reference.Kind != SqlBrowseNodeKind.Column) + { + // Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is + // uniform and returns empty for every node. + LastUsedUtc = DateTime.UtcNow; + return Array.Empty(); + } + + var columns = await ReadColumnsAsync( + reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false); + + // Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the + // catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation + // that legitimately carries both "Value" and "value". + foreach (var column in columns) + { + if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue; + return + [ + new AttributeInfo( + Name: column.Name, + DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), + IsArray: false, + SecurityClass: ReadOnlySecurityClass), + ]; + } + + // The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is + // the honest answer; the picker simply has nothing to prefill. + return Array.Empty(); + } + + /// + /// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper + /// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI. + /// + /// A task that represents the asynchronous operation. + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + try { await _connection.DisposeAsync().ConfigureAwait(false); } + catch { /* best-effort: the connection may already be broken or closed. */ } + + try { _gate.Dispose(); } + catch { /* best-effort: a concurrent second dispose already tore it down. */ } + } + + private async Task> ExpandSchemaAsync(string schema, CancellationToken ct) + { + var rows = await QueryAsync( + _dialect.ListTablesSql, + command => Bind(command, "@schema", schema), + static reader => ( + Name: ReadString(reader, TableNameColumn), + Type: ReadOptionalString(reader, TableTypeColumn)), + ct).ConfigureAwait(false); + + var nodes = new List(rows.Count); + foreach (var (name, type) in rows) + { + if (string.IsNullOrWhiteSpace(name)) continue; + var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase); + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForTable(schema, name), + // The label is decorated, never the NodeId — a view and a table of the same name in the same + // schema cannot exist, so the suffix is presentation only. + DisplayName: isView ? $"{name} (view)" : name, + Kind: BrowseNodeKind.Folder, + HasChildrenHint: true)); + } + + return nodes; + } + + private async Task> ExpandTableAsync( + string schema, string table, CancellationToken ct) + { + var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false); + + var nodes = new List(columns.Count); + foreach (var column in columns) + { + if (string.IsNullOrWhiteSpace(column.Name)) continue; + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name), + DisplayName: column.Name, + Kind: BrowseNodeKind.Leaf, + HasChildrenHint: false)); + } + + return nodes; + } + + /// + /// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather + /// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less + /// relation, and neither is a reason to fail an operator's click. + /// + private Task> ReadColumnsAsync( + string schema, string table, CancellationToken ct) => + QueryAsync( + _dialect.ListColumnsSql, + command => + { + Bind(command, "@schema", schema); + Bind(command, "@table", table); + }, + static reader => ( + Name: ReadString(reader, ColumnNameColumn), + DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty), + ct); + + /// + /// Runs one catalog query under the gate and projects every row. The disposed check happens + /// inside the gate wait so a dispose racing an in-flight call cannot be missed, and + /// only advances on a call that actually completed. + /// + private async Task> QueryAsync( + string sql, + Action bind, + Func project, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var results = new List(); + await using var command = _connection.CreateCommand(); + command.CommandText = sql; + bind(command); + + await using var reader = await command + .ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + results.Add(project(reader)); + + LastUsedUtc = DateTime.UtcNow; + return results; + } + finally + { + // A dispose that raced this call has already disposed the gate; releasing it then is harmless to + // ignore and must not mask the real result. + try { _gate.Release(); } + catch (ObjectDisposedException) { } + } + } + + /// Binds one catalog-query parameter. The only way a name reaches the database. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private static string ReadString(DbDataReader reader, string columnName) => + ReadOptionalString(reader, columnName) ?? string.Empty; + + private static string? ReadOptionalString(DbDataReader reader, string columnName) + { + var ordinal = reader.GetOrdinal(columnName); + return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs new file mode 100644 index 00000000..4a34528a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs @@ -0,0 +1,204 @@ +using System.Data; +using System.Data.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Records how many command executions are ever in flight at once, and how long each one is artificially +/// held open. Shared by and the commands it creates. +/// +/// +/// This exists because "the session serializes on a gate" is otherwise unfalsifiable from the outside: +/// against a real SQLite connection, overlapping calls mostly still *work*, so a test that only asserted +/// correct results would pass with the gate deleted. Measuring the overlap directly is what makes the +/// gate's removal visible. +/// +public sealed class ConcurrencyProbe +{ + private int _inFlight; + private int _peak; + + /// How long each command execution is held before it reaches the real provider. + public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120); + + /// The greatest number of executions observed in flight simultaneously. + public int Peak => Volatile.Read(ref _peak); + + /// Marks the start of one command execution. + public void Enter() + { + var current = Interlocked.Increment(ref _inFlight); + int observed; + while (current > (observed = Volatile.Read(ref _peak))) + { + if (Interlocked.CompareExchange(ref _peak, current, observed) == observed) break; + } + } + + /// Marks the end of one command execution. + public void Exit() => Interlocked.Decrement(ref _inFlight); +} + +/// +/// A pass-through that hands out commands instrumented with a +/// . Everything else delegates to the wrapped connection, so the code under +/// test runs against a real SQLite catalog. +/// +/// The real connection to delegate to. Not owned — the caller disposes it. +/// The probe that observes command overlap. +public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection +{ + /// + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString + { + get => inner.ConnectionString; + set => inner.ConnectionString = value!; + } + + /// + public override string Database => inner.Database; + + /// + public override string DataSource => inner.DataSource; + + /// + public override string ServerVersion => inner.ServerVersion; + + /// + public override ConnectionState State => inner.State; + + /// + public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); + + /// + public override void Close() => inner.Close(); + + /// + public override void Open() => inner.Open(); + + /// + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => + inner.BeginTransaction(isolationLevel); + + /// + protected override DbCommand CreateDbCommand() => + new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe); +} + +/// +/// A pass-through that reports every execution to a +/// and holds it open for the probe's delay, so overlapping executions are observable. +/// +/// The real command to delegate to. +/// The connection that created this command. +/// The probe that observes command overlap. +public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe) + : DbCommand +{ + /// + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string CommandText + { + get => inner.CommandText; + set => inner.CommandText = value!; + } + + /// + public override int CommandTimeout + { + get => inner.CommandTimeout; + set => inner.CommandTimeout = value; + } + + /// + public override CommandType CommandType + { + get => inner.CommandType; + set => inner.CommandType = value; + } + + /// + public override bool DesignTimeVisible + { + get => inner.DesignTimeVisible; + set => inner.DesignTimeVisible = value; + } + + /// + public override UpdateRowSource UpdatedRowSource + { + get => inner.UpdatedRowSource; + set => inner.UpdatedRowSource = value; + } + + /// + protected override DbConnection? DbConnection + { + get => owner; + set { /* The session never reassigns the connection; the wrapped command keeps its own. */ } + } + + /// + protected override DbParameterCollection DbParameterCollection => inner.Parameters; + + /// + protected override DbTransaction? DbTransaction + { + get => inner.Transaction; + set => inner.Transaction = value; + } + + /// + public override void Cancel() => inner.Cancel(); + + /// + public override int ExecuteNonQuery() => inner.ExecuteNonQuery(); + + /// + public override object? ExecuteScalar() => inner.ExecuteScalar(); + + /// + public override void Prepare() => inner.Prepare(); + + /// + protected override DbParameter CreateDbParameter() => inner.CreateParameter(); + + /// + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + probe.Enter(); + try + { + Thread.Sleep(probe.Delay); + return inner.ExecuteReader(behavior); + } + finally + { + probe.Exit(); + } + } + + /// + protected override async Task ExecuteDbDataReaderAsync( + CommandBehavior behavior, CancellationToken cancellationToken) + { + probe.Enter(); + try + { + await Task.Delay(probe.Delay, cancellationToken).ConfigureAwait(false); + return await inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false); + } + finally + { + probe.Exit(); + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs new file mode 100644 index 00000000..2dcc2a41 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs @@ -0,0 +1,128 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Pins the browse NodeId encoding. This codec is the load-bearing detail of the whole picker: a NodeId +/// that mis-parses sends the operator to a different column than the one they clicked, and the resulting +/// tag polls the wrong data forever with no error anywhere. SQL Server identifiers may legally contain +/// . and | (inside a quoted identifier), so the round-trip has to hold for those too. +/// +public sealed class SqlBrowseNodeIdTests +{ + [Fact] + public void ForSchema_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("dbo")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Schema); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBeNull(); + reference.Column.ShouldBeNull(); + } + + [Fact] + public void ForTable_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("dbo", "TagValues")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Table); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBe("TagValues"); + reference.Column.ShouldBeNull(); + } + + [Fact] + public void ForColumn_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn("dbo", "TagValues", "num_value")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Column); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBe("TagValues"); + reference.Column.ShouldBe("num_value"); + } + + /// + /// The exact case the plan's schema.table|column sketch cannot express: main.a.b|c reads + /// equally as schema main + table a.b and as schema main.a + table b. + /// + [Theory] + [InlineData("a.b", "c", "d")] + [InlineData("a", "b.c", "d")] + [InlineData("a", "b", "c.d")] + [InlineData("a|b", "c", "d")] + [InlineData("a", "b|c", "d")] + [InlineData("a", "b", "c|d")] + [InlineData(@"a\b", "c", "d")] + [InlineData("a", @"b\|c", "d")] + [InlineData("a", "b", @"c\\d")] + [InlineData("a.b|c", "d.e|f", "g.h|i")] + [InlineData("schema", "table", "column")] + [InlineData("x:y", "z:w", "q:r")] + public void AwkwardIdentifiers_roundTripExactly(string schema, string table, string column) + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn(schema, table, column)); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Column); + reference.Schema.ShouldBe(schema); + reference.Table.ShouldBe(table); + reference.Column.ShouldBe(column); + } + + /// + /// Two genuinely different columns must never collide on one NodeId. Under the plan's literal + /// schema.table|column sketch both of these render as a.b|c. + /// + [Fact] + public void DistinctReferences_neverCollide() + { + var nested = SqlBrowseNodeId.ForColumn("a.b", "t", "c"); + var flat = SqlBrowseNodeId.ForColumn("a", "b.t", "c"); + + nested.ShouldNotBe(flat); + SqlBrowseNodeId.Parse(nested).Schema.ShouldBe("a.b"); + SqlBrowseNodeId.Parse(flat).Schema.ShouldBe("a"); + } + + /// A table NodeId and a schema NodeId are never confusable, whatever the names contain. + [Fact] + public void KindsAreDistinguishable() + { + SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("a|b")).Kind.ShouldBe(SqlBrowseNodeKind.Schema); + SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("a", "b")).Kind.ShouldBe(SqlBrowseNodeKind.Table); + SqlBrowseNodeId.ForSchema("a|b").ShouldNotBe(SqlBrowseNodeId.ForTable("a", "b")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("main")] // no kind prefix + [InlineData("bogus:main")] // unknown kind + [InlineData("table:main")] // table kind with only one part + [InlineData("schema:main|extra")] // schema kind with two parts + [InlineData("column:a|b")] // column kind with two parts + [InlineData("column:a|b|c|d")] // column kind with four parts + [InlineData("schema:")] // empty part + [InlineData("table:main|")] // empty trailing part + [InlineData(@"schema:main\")] // dangling escape + [InlineData(":main")] // empty kind + public void MalformedNodeIds_areRejected(string? nodeId) + { + SqlBrowseNodeId.TryParse(nodeId, out _).ShouldBeFalse(); + Should.Throw(() => SqlBrowseNodeId.Parse(nodeId)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyIdentifiers_areRejectedAtEncode(string? identifier) + { + Should.Throw(() => SqlBrowseNodeId.ForSchema(identifier!)); + Should.Throw(() => SqlBrowseNodeId.ForTable("main", identifier!)); + Should.Throw(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs new file mode 100644 index 00000000..d75ec6ec --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs @@ -0,0 +1,374 @@ +using System.Data; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Walks over a real, seeded SQLite catalog through the +/// — the only non-SQL-Server dialect in the tree, and therefore the control +/// that proves the session runs the dialect's catalog SQL rather than INFORMATION_SCHEMA +/// with a quoting helper bolted on. +/// +public sealed class SqlBrowseSessionTests +{ + private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId; + + [Fact] + public async Task RootAsync_yieldsSchemaFolders() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var roots = await session.RootAsync(CancellationToken.None); + + roots.Count.ShouldBe(1); + roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema); + roots[0].NodeId.ShouldBe(SchemaNodeId); + roots[0].Kind.ShouldBe(BrowseNodeKind.Folder); + roots[0].HasChildrenHint.ShouldBeTrue(); + } + + [Fact] + public async Task ExpandSchema_yieldsTablesAndViewsAsFolders() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + + children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint); + children.ShouldContain(n => + n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable)); + // The view is listed beside the tables, and is labelled so the operator can tell them apart. + var view = children + .Where(n => n.NodeId == + SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName)) + .ShouldHaveSingleItem(); + view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName); + view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName); + } + + /// The plan's headline case: table expand yields columns as committable leaves. + [Fact] + public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --", + // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too. + var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) + .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); + + var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); + + columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn + && c.Kind == BrowseNodeKind.Leaf + && !c.HasChildrenHint); + + var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn); + var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None); + + var attribute = attributes.ShouldHaveSingleItem(); + attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn); + attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); + attribute.IsArray.ShouldBeFalse(); + attribute.SecurityClass.ShouldBe("ViewOnly"); + attribute.IsAlarm.ShouldBeFalse(); + } + + [Fact] + public async Task AttributesAsync_mapsTextColumnToString() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn); + var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); + + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); + } + + /// + /// An exotic declared type must fall back to via + /// MapColumnType rather than crash the browse — a table with one geography column must + /// still render. + /// + [Fact] + public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable); + var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None); + columns.Count.ShouldBe(2); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn); + var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); + + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); + } + + /// + /// The end-to-end awkward-identifier walk: a table named a.b|c holding a column named + /// x|y. Both characters are the ones a naive schema.table|column NodeId splits on, so + /// this is the case that proves the encoding, not just the codec unit test. + /// + [Fact] + public async Task AwkwardIdentifiers_surviveTheFullWalk() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + var awkward = tables + .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable) + .ShouldHaveSingleItem(); + + var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None); + columns.Select(c => c.DisplayName) + .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 }, + ignoreOrder: true); + + var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn); + var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId); + parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable); + parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn); + + var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None); + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); + } + + /// + /// A table whose name is an injection payload. If any catalog query concatenated the name + /// instead of binding @table, expanding it would drop TagValues. + /// + [Fact] + public async Task HostileTableName_isBound_notConcatenated() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable); + var columns = await session.ExpandAsync(nodeId, CancellationToken.None); + + // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it + // before anything about the columns, or a splice that returns no rows reds on the wrong line and the + // test stops proving the payload was inert. + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn); + + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn); + var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None); + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32)); + } + + /// A hostile schema name takes the same bound path at the table level. + [Fact] + public async Task HostileSchemaName_isBound_notConcatenated() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --"); + var tables = await session.ExpandAsync(nodeId, CancellationToken.None); + + // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing. + tables.ShouldBeEmpty(); + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + } + + /// + /// A table node whose table is gone (dropped between browse and expand) yields an empty column list. + /// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less + /// table, and an empty catalog answer is what both shapes look like from the browser's side. + /// + [Fact] + public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable); + + (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); + + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever"); + (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// A column NodeId is terminal — expanding it is a no-op, never an error. + [Fact] + public async Task ExpandColumn_yieldsEmpty() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); + + (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser. + [Fact] + public async Task AttributesAsync_onNonLeafNode_yieldsEmpty() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty(); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); + (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// + /// A garbage NodeId must surface as an carrying an operator-readable + /// message — the same contract the Galaxy and OPC UA client sessions have, and what + /// DriverBrowseTree.razor renders inline as the node's error. It must not be a + /// or an from a blind split. + /// + [Theory] + [InlineData("")] + [InlineData("not-a-node-id")] + [InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format + [InlineData("column:a|b|c|d")] + public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId) + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var expand = await Should.ThrowAsync( + () => session.ExpandAsync(nodeId, CancellationToken.None)); + expand.Message.ShouldNotBeNullOrWhiteSpace(); + + await Should.ThrowAsync( + () => session.AttributesAsync(nodeId, CancellationToken.None)); + } + + /// + /// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate. + /// Asserted by measuring overlap directly through : a results-only + /// assertion would pass with the gate deleted. + /// + [Fact] + public async Task ConcurrentCalls_areSerializedByTheGate() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var probe = new ConcurrencyProbe(); + var probing = new ConcurrencyProbingDbConnection(db.Connection, probe); + await using var session = new SqlBrowseSession(probing, new SqliteDialect()); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); + var calls = new[] + { + session.RootAsync(CancellationToken.None), + session.ExpandAsync(SchemaNodeId, CancellationToken.None), + session.ExpandAsync(tableNodeId, CancellationToken.None), + session.ExpandAsync(tableNodeId, CancellationToken.None), + }; + + var results = await Task.WhenAll(calls); + + probe.Peak.ShouldBe(1); + // TagValues has three columns — proves the serialized calls still returned real catalog rows. + results[2].Count.ShouldBe(3); + } + + /// + /// Ownership: the session takes over the connection it is handed and closes it on dispose. The + /// AdminUI's BrowseSessionRegistry is the only thing holding a session, so if the session did + /// not close the connection, nothing would — every reaped picker would leak one. + /// + [Fact] + public async Task DisposeAsync_closesTheConnectionItWasGiven() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var connection = db.OpenNewConnection(); + var session = new SqlBrowseSession(connection, new SqliteDialect()); + + connection.State.ShouldBe(ConnectionState.Open); + await session.DisposeAsync(); + + connection.State.ShouldBe(ConnectionState.Closed); + } + + [Fact] + public async Task DisposeAsync_isIdempotent() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); + + await session.DisposeAsync(); + await Should.NotThrowAsync(async () => await session.DisposeAsync()); + } + + [Fact] + public async Task CallsAfterDispose_throwObjectDisposedException() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); + await session.DisposeAsync(); + + await Should.ThrowAsync( + () => session.RootAsync(CancellationToken.None)); + await Should.ThrowAsync( + () => session.ExpandAsync(SchemaNodeId, CancellationToken.None)); + } + + /// + /// The AdminUI bounds every call with a 20 s linked CTS + /// (BrowserSessionService.PerCallTimeout) — the session invents no timeout of its own, it just + /// has to honour the token it is handed, all the way into the ADO.NET call. + /// + [Fact] + public async Task CancelledToken_isHonoured() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync(() => session.RootAsync(cts.Token)); + await Should.ThrowAsync(() => session.ExpandAsync(SchemaNodeId, cts.Token)); + } + + /// + /// feeds the registry's idle reaper — a session that never + /// refreshed it would be evicted out from under an actively browsing operator. + /// + [Fact] + public async Task LastUsedUtc_advancesOnEverySuccessfulCall() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var atStart = session.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + await session.RootAsync(CancellationToken.None); + var afterRoot = session.LastUsedUtc; + afterRoot.ShouldBeGreaterThan(atStart); + + await Task.Delay(15, TestContext.Current.CancellationToken); + await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + var afterExpand = session.LastUsedUtc; + afterExpand.ShouldBeGreaterThan(afterRoot); + + await Task.Delay(15, TestContext.Current.CancellationToken); + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); + await session.AttributesAsync(columnNodeId, CancellationToken.None); + session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs new file mode 100644 index 00000000..65c4e8e8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs @@ -0,0 +1,190 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// A real, seeded SQLite database standing in for the SQL Server the schema browser walks. It exists so +/// the browse path runs against a genuine catalog — real sqlite_schema rows, real +/// pragma_table_info output, real parameter binding — with no SQL Server and no network. +/// Backed by a temporary FILE, not :memory:, for the same reason +/// SqlitePollFixture is: an in-memory database dies with its last connection, and several tests +/// here deliberately open a second connection or dispose the session's connection mid-test. +/// The seed is a contract. Every table below exists to pin one browse behaviour, and the +/// awkward ones are the point: and carry the +/// . and | characters that a naive schema.table|column NodeId would mis-parse, and +/// is a live injection probe — if any catalog query concatenated a name +/// instead of binding it, expanding that table drops and the assertions say +/// so. +/// +public sealed class SqliteBrowseFixture : IAsyncDisposable +{ + /// The ordinary key-value table, mirroring the poll fixture's shape. + public const string KeyValueTable = "TagValues"; + + /// The column declared REAL, i.e. Float64. + public const string RealColumn = "num_value"; + + /// The column declared TEXT, i.e. String. + public const string TextColumn = "tag_name"; + + /// A view over — the browser must list views beside tables. + public const string ViewName = "TagValuesView"; + + /// + /// A table whose name contains BOTH NodeId metacharacters. A schema.table|column NodeId cannot + /// represent this unambiguously — main.a.b|c reads equally as schema main.a, table + /// b. Legal in SQL Server inside a quoted identifier, so the encoding must survive it. + /// + public const string AwkwardTable = "a.b|c"; + + /// A column name carrying the separator character. + public const string AwkwardColumn = "x|y"; + + /// A column name carrying a dot and the escape character. + public const string AwkwardColumn2 = @"d.o\t"; + + /// + /// A table named as a SQL injection payload. Nothing about it is special to the browser — that is + /// exactly the assertion: it is bound as a value, never spliced into a command text. + /// + public const string HostileTable = "'); DROP TABLE TagValues; --"; + + /// The single column of . + public const string HostileColumn = "hostile_col"; + + /// A table whose columns carry types no dialect map recognises. + public const string ExoticTable = "Exotic"; + + /// An column declared with a type outside the map's vocabulary. + public const string ExoticColumn = "shape_col"; + + /// A table name that exists in no catalog — the "vanished table" / zero-column probe. + public const string NonExistentTable = "NoSuchTable"; + + private readonly string _databasePath; + + private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection) + { + _databasePath = databasePath; + ConnectionString = connectionString; + Connection = connection; + } + + /// The connection string over the temporary database file. + public string ConnectionString { get; } + + /// + /// A long-lived open connection over the seeded database — what tests hand + /// SqlBrowseSession. Independent of any other connection over the same file. + /// + public SqliteConnection Connection { get; } + + /// + /// The NodeId of SQLite's one and only schema, encoded exactly as + /// SqlBrowseSession.RootAsync emits it. + /// + public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema); + + /// Creates the temporary database, applies the schema, and seeds it. + /// The ready fixture. + public static async Task CreateAsync() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"otopcua-sql-browse-{Guid.NewGuid():N}.db"); + var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString(); + var connection = new SqliteConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await SeedAsync(connection).ConfigureAwait(false); + return new SqliteBrowseFixture(databasePath, connectionString, connection); + } + + /// Opens a brand-new connection over the same database. + /// An open connection the caller owns. + public SqliteConnection OpenNewConnection() + { + var connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + /// Counts rows in a table, on a connection of its own so a disposed session cannot affect it. + /// The (unquoted) table name to count. + /// The row count, or -1 when the table does not exist. + public long CountRows(string table) + { + using var connection = OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + try + { + return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + catch (SqliteException) + { + return -1; + } + } + + /// Closes the fixture connection, clears the pool, and deletes the temporary file. + /// A task that represents the asynchronous operation. + public async ValueTask DisposeAsync() + { + await Connection.DisposeAsync().ConfigureAwait(false); + // Microsoft.Data.Sqlite pools per connection string; without this the file stays held open and the + // delete silently fails, leaking one file per test into the temp directory. + SqliteConnection.ClearAllPools(); + try + { + if (File.Exists(_databasePath)) File.Delete(_databasePath); + } + catch (IOException) + { + // A leaked temp file must never fail a test run. + } + } + + /// + /// Applies the schema described by this type's constants. Every column is explicitly declared — + /// SQLite stores affinity rather than a type, so an undeclared column would give + /// pragma_table_info (and therefore MapColumnType) nothing real to report. + /// + private static async Task SeedAsync(SqliteConnection connection) + { + await ExecuteAsync(connection, $""" + CREATE TABLE "{KeyValueTable}" ( + "{TextColumn}" TEXT NOT NULL, + "{RealColumn}" REAL NULL, + sample_ts TEXT NOT NULL + ); + INSERT INTO "{KeyValueTable}" ("{TextColumn}", "{RealColumn}", sample_ts) + VALUES ('Line1.Speed', 42.0, '2026-07-24T10:00:00Z'); + + CREATE VIEW "{ViewName}" AS SELECT "{TextColumn}", "{RealColumn}" FROM "{KeyValueTable}"; + + CREATE TABLE "{ExoticTable}" ( + "{ExoticColumn}" GEOGRAPHY NULL, + blob_col BLOB NULL + ); + """).ConfigureAwait(false); + + // Separate statements: these names must be quoted by the seed itself, so keep them off the + // interpolation path above where a stray quote would be hard to spot. + await ExecuteAsync(connection, + $"CREATE TABLE {Quote(AwkwardTable)} ({Quote(AwkwardColumn)} REAL NULL, {Quote(AwkwardColumn2)} TEXT NULL)") + .ConfigureAwait(false); + + await ExecuteAsync(connection, + $"CREATE TABLE {Quote(HostileTable)} ({Quote(HostileColumn)} INTEGER NULL)") + .ConfigureAwait(false); + } + + private static string Quote(string identifier) => + string.Concat("\"", identifier.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + + private static async Task ExecuteAsync(SqliteConnection connection, string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj new file mode 100644 index 00000000..b3837b17 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + From e3e3f5fb04926b038422c54269983d0e6b191ec6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:57:14 -0400 Subject: [PATCH 17/33] fix(sql): report an ambiguous wide-row selector; pin the zombie-slot bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings against SqlPollReader (967e5140). 1. WideRow's where-pair form emits no ORDER BY and no row limit, so a selector that is not actually unique made the reader publish an arbitrary, storage-order-dependent row — silently, unlike IndexByKey's duplicate-key warning. Row selection moves out of MapMember into a new SelectWideRow, which is called once per group and emits the same rate-limited contract warning (naming table, selector and row count). The row-limit half of the suggested fix was deliberately NOT taken: a TOP 1 with no ORDER BY is not deterministic either, and it would destroy the Rows.Count > 1 evidence the warning depends on, turning a loud misconfiguration into a permanently silent one. Rationale is in SelectWideRow's docs. No SQL changed, so no planner golden string moved. 2. The "a frozen database can never accumulate more than maxConcurrentGroups connections" claim was asserted in the docs and untested. ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQuery- TrulyFinishes wedges a group behind an EXCLUSIVE transaction, times it out, and proves the slot is still held (a second poll on a cap of 1 times out on the gate without ever reaching the factory) and is handed back only once the abandoned query truly finishes. Moving the release from the work to the waiter fails this test and no other. 3. Task.Run thread-pool starvation: documented, not changed. Microsoft.Data.SqlClient's async path parks no pool thread on a frozen server, so the driver cannot starve itself; LongRunning would cost a dedicated OS thread per group per poll forever to insure against a provider this driver does not ship. RunGroupAsync now records the decision and what a BadTimeout does and does not mean, for the blackhole gate. Also: the timeout warning now names the group's table, so a BadTimeout storm identifies which source is frozen. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlPollReader.cs | 81 +++++++- .../SqlPollReaderTests.cs | 187 +++++++++++++++++- 2 files changed, 254 insertions(+), 14 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs index 2c4155ea..ee1dce0f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -84,7 +84,13 @@ public sealed class SqlPollReader /// backstop. That rule is not enforced here — it belongs to config validation, and this type /// stays usable for the tests that must deliberately invert it. /// - /// Maximum group queries — and connections — in flight at once. + /// + /// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot + /// until it truly finishes, so this is a hard ceiling on connections even against a frozen database. + /// See for why a high value is safe under + /// Microsoft.Data.SqlClient but wants sizing against thread-pool headroom under a provider + /// that blocks synchronously. + /// /// /// publishes for a NULL value cell instead /// of the default . It governs a present row with a @@ -244,6 +250,23 @@ public sealed class SqlPollReader /// The work is started on the thread pool so a provider that blocks synchronously blocks a /// pool thread rather than the caller — without that, a synchronous provider never yields the task /// there would be to bound. + /// Thread-pool scheduling and what a BadTimeout therefore means. The group's clock + /// starts before Task.Run, so in principle a group could burn budget queueing for a pool + /// thread rather than waiting on the database. TaskCreationOptions.LongRunning (a dedicated + /// OS thread) was considered and rejected: it would create and tear down one thread per group + /// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost + /// paid against a hazard that cannot arise with the provider this driver actually ships. + /// Microsoft.Data.SqlClient's async path is genuinely asynchronous: the delegate reaches its + /// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks + /// zero pool threads however long it stays frozen, and the driver cannot starve itself no + /// matter how high maxConcurrentGroups goes. The queueing scenario needs a provider that + /// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose, + /// and is not a shipped configuration. Consequence for outage diagnosis (e.g. blackhole-testing a + /// paused SQL Server): under Microsoft.Data.SqlClient a + /// means the database did not answer inside + /// operationTimeout — it cannot be an artefact of this driver's own pool pressure. Under a + /// synchronously-blocking provider it may also include queueing delay, so size + /// maxConcurrentGroups below the process's readily-available worker count there. /// The concurrency slot is released by the work, not by the waiter. A timed-out group /// keeps its slot until it truly finishes, so a frozen database can never accumulate more than /// maxConcurrentGroups connections however long it stays frozen. The slot wait is itself @@ -321,9 +344,13 @@ public sealed class SqlPollReader /// Records the deadline breach and detaches the abandoned work. private GroupResult TimedOut(SqlQueryPlan plan, Task work) { + // The table is named for the same reason the contract warnings name it: under a partial outage this + // line repeats every poll, and "which source is frozen" is the only question the operator has. _logger.LogWarning( - "Sql poll group ({Model}, {Members} tag(s)) exceeded the {Timeout} ms operation timeout; " + - "publishing BadTimeout.", plan.Model, plan.Members.Count, (int)_operationTimeout.TotalMilliseconds); + "Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " + + "timeout; publishing BadTimeout.", + plan.Model, plan.Members[0].Table, plan.Members.Count, + (int)_operationTimeout.TotalMilliseconds); Detach(work); return GroupResult.TimedOut; @@ -459,10 +486,14 @@ public sealed class SqlPollReader DateTime readAt) { // KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the - // where-column is not even in the result set (design §5.3). + // where-column is not even in the result set (design §5.3). Both selections happen ONCE per group — + // they are group-wide facts, and doing them per member would report a contract violation N times. var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue ? IndexByKey(plan, outcome) : null; + var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow + ? SelectWideRow(plan, outcome) + : null; foreach (var member in plan.Members) { @@ -470,7 +501,7 @@ public sealed class SqlPollReader if (!slots.TryGetValue(member, out var positions)) continue; var snapshot = outcome.Succeeded - ? MapMember(plan, member, outcome, byKey, readAt) + ? MapMember(plan, member, outcome, byKey, wideRow, readAt) : new DataValueSnapshot(null, outcome.FailureStatus, null, readAt); foreach (var position in positions) results[position] = snapshot; @@ -518,12 +549,50 @@ public sealed class SqlPollReader return index; } + /// + /// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector. + /// Last row wins — the same tie-break applies, so both models + /// degrade the same way. It is deterministic only within one result set: the where-pair form + /// emits no ORDER BY, so across polls the "last" row is whatever the server returned last, + /// i.e. storage order. That is why more than one row is a reported contract violation and not merely + /// a tie to break — the model's premise (design §5.3) is that the selector identifies one row. + /// Why the where-pair form is deliberately NOT given the dialect's single-row limit. A + /// TOP 1 with no ORDER BY is not deterministic either — it returns whichever row the + /// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary + /// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous + /// (Rows.Count > 1), converting a loud misconfiguration into a permanently silent one — + /// precisely the failure mode this class's summary calls its top risk. The topByTimestamp + /// form keeps its row limit because there the ORDER BY makes "the first row" a defined + /// answer. The accepted cost is that an ambiguous selector materialises every matching row for one + /// poll; the warning is what gets it fixed. + /// + private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome) + { + if (outcome.Rows.Count == 0) return null; + if (outcome.Rows.Count > 1 && ShouldWarnAboutContract()) + { + var first = plan.Members[0]; + var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn) + ? string.Concat("topByTimestamp ", first.RowSelectorTopByTimestamp) + : string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue); + _logger.LogWarning( + "Sql poll source '{Table}' returned {Rows} rows for the wide-row selector '{Selector}' in " + + "one poll; the last row read wins, and the query carries no ORDER BY, so which row that is " + + "depends on storage order. The wide-row model requires a selector matching exactly one row " + + "— narrow the whereColumn/whereValue pair, or point the tag at a latest-row view.", + first.Table, outcome.Rows.Count, selector); + } + + return outcome.Rows[^1]; + } + /// Maps one member's cell to a snapshot: value, quality, and source timestamp. private DataValueSnapshot MapMember( SqlQueryPlan plan, SqlTagDefinition member, GroupResult outcome, Dictionary? byKey, + object?[]? wideRow, DateTime readAt) { object?[]? row; @@ -541,7 +610,7 @@ public sealed class SqlPollReader // A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are // selected), which is why the plan-level TimestampColumn is null for this model by design. timestampColumn = member.TimestampColumn; - row = outcome.Rows.Count > 0 ? outcome.Rows[^1] : null; + row = wideRow; } // No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs index 79897f3e..cc203d9d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs @@ -1,7 +1,9 @@ using System.Data; using System.Data.Common; using System.Diagnostics; +using System.Globalization; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -184,6 +186,56 @@ public sealed class SqlPollReaderTests snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp); } + [Fact] + public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne() + { + using var fixture = new SqlitePollFixture(); + // A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so + // which of the two the reader publishes is decided by physical storage order — an authoring mistake + // (a selector column that is not actually unique) that must not pass silently. This is the WideRow + // counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning. + AddSecondRowForPresentStation(fixture); + var logger = new RecordingLogger(); + var reader = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + + var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None); + + // Still a value — the reader degrades to "last row wins", exactly as the key-value model does. + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + + var warning = logger.Entries.ShouldHaveSingleItem(); + warning.Level.ShouldBe(LogLevel.Warning); + // Names the table AND the selector, so an operator can find the offending tag from the log alone. + warning.Message.ShouldContain(SqlitePollFixture.WideRowTable); + warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn); + warning.Message.ShouldContain(SqlitePollFixture.PresentStation); + } + + [Fact] + public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous() + { + using var fixture = new SqlitePollFixture(); + var logger = new RecordingLogger(); + + // A selector that matches exactly one row is the normal case and must log nothing at all — + // without this leg a warning that always fires would still pass the test above. + var clean = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + await clean.ReadAsync(["Oven"], CancellationToken.None); + logger.Entries.ShouldBeEmpty(); + + AddSecondRowForPresentStation(fixture); + var ambiguous = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + await ambiguous.ReadAsync(["Oven"], CancellationToken.None); + await ambiguous.ReadAsync(["Oven"], CancellationToken.None); + + // A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding + // the log. Two polls, one warning. + logger.Entries.Count.ShouldBe(1); + } + // ---- type + timestamp mapping ---- [Fact] @@ -246,11 +298,7 @@ public sealed class SqlPollReaderTests // cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async // API that ignores its deadline), so only a real wall-clock bound can end this call early. using var locker = fixture.OpenNewConnection(); - using (var begin = locker.CreateCommand()) - { - begin.CommandText = "BEGIN EXCLUSIVE"; - begin.ExecuteNonQuery(); - } + Execute(locker, "BEGIN EXCLUSIVE"); try { @@ -273,12 +321,63 @@ public sealed class SqlPollReaderTests } finally { - using var rollback = locker.CreateCommand(); - rollback.CommandText = "ROLLBACK"; - rollback.ExecuteNonQuery(); + Execute(locker, "ROLLBACK"); } } + [Fact] + public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes() + { + // The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group + // is still holding a connection, so handing its slot back at the deadline would let the next poll + // open another one — and a database that stays frozen would accumulate one connection per poll pass + // forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test + // above never times a group out, and the BadTimeout test above never counts connections. + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + // commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay + // wedged for as long as the lock is held, so nothing but the test releases the slot. + commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500), + maxConcurrentGroups: 1, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + var locker = fixture.OpenNewConnection(); + Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws + try + { + // Poll 1 wedges against the lock and times out, abandoning a still-running query. + var first = await reader.ReadAsync(["Speed"], CancellationToken.None); + first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + factory.Created.ShouldBe(1); + factory.Live.ShouldBe(1); // the zombie still owns its connection + + // (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the + // factory: it times out waiting on the gate. Created staying at 1 is the load-bearing + // assertion — release-from-the-waiter would make this 2. + var second = await reader.ReadAsync(["Speed"], CancellationToken.None); + second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + factory.Created.ShouldBe(1); + factory.Live.ShouldBe(1); + } + finally + { + Execute(locker, "ROLLBACK"); + locker.Dispose(); + } + + // (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline), + // closes its connection, and only then hands the slot back. + (await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30))) + .ShouldBeTrue("the abandoned group never released its connection"); + + var third = await reader.ReadAsync(["Speed"], CancellationToken.None); + third[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished + factory.Live.ShouldBe(0); + } + [Fact] public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation() { @@ -401,6 +500,49 @@ public sealed class SqlPollReaderTests commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags)); + private static SqlPollReader Reader( + SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags) + => new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger); + + /// + /// Adds a second row for the station the wide-row tests + /// select, breaking that model's one-row-per-selector contract. + /// + private static void AddSecondRowForPresentStation(SqlitePollFixture fixture) + => fixture.Execute(string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO {SqlitePollFixture.WideRowTable} + ({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure, + {SqlitePollFixture.TimestampColumn}) + VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z') + """)); + + /// Runs one statement on a caller-owned connection. + private static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// + /// Polls until it holds or elapses. Bounded on + /// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it + /// does not. + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan limit) + { + var clock = Stopwatch.StartNew(); + while (clock.Elapsed < limit) + { + if (condition()) return true; + await Task.Delay(25); + } + + return condition(); + } + /// The driver's RawPath→definition table, standing in for EquipmentTagRefResolver. private static Func Resolver(params SqlTagDefinition[] tags) { @@ -432,6 +574,35 @@ public sealed class SqlPollReaderTests RowSelectorValue: selectorValue, RowSelectorTopByTimestamp: topByTimestamp); + /// + /// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the + /// contract-violation warnings are the reader's only signal that a source is misauthored, so they are + /// behaviour, not diagnostics. + /// + private sealed class RecordingLogger : ILogger + { + /// Every record written, level + rendered message. + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => Scope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class Scope : IDisposable + { + public static Scope Instance { get; } = new(); + + public void Dispose() { } + } + } + /// /// A over SQLite that counts how many connections the reader has /// created and how many are alive at once — the only way to observe the connection lifecycle from From 9e7fa5d11f8fc7e94d00385f11253dff1d5dd02a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:02:17 -0400 Subject: [PATCH 18/33] feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens one transient DbConnection from a form-supplied driver-config blob and hands ownership to SqlBrowseSession, which closes it on disposal. A database is named either by connectionStringRef — resolved in the AdminUI process from Sql__ConnectionStrings__, with an unresolvable ref naming the exact missing variable — or by a pasted, session-only literal. The literal wins and the ref is then not read; it cannot arrive from a persisted blob (SqlDriverConfigDto has no such property), so its presence proves an operator typed it now. Credential hygiene is the point of the type: no cached config field, nothing persisted, and no connection text in any log line or exception. Live-probed: Microsoft.Data.SqlClient and Microsoft.Data.Sqlite keep connection-string values out of SqlException/SqliteException, but their connection-string PARSER echoes an unrecognised keyword verbatim — and an unquoted ';' inside a password splits, so the tail of the password is reported as a keyword (Password=Sup3r;SecretTail => "Keyword not supported: 'secrettail;connect timeout'."). The parser's message is therefore never surfaced; every other failure additionally passes through a substring redactor that drops the inner exception when it fires. DriverType comes from SqlDriver.DriverTypeName, the driver's interim local constant — DriverTypeNames.Sql is added by the driver-factory task. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlDriverBrowser.cs | 401 ++++++++++++++++++ .../SqlDriverBrowserTests.cs | 384 +++++++++++++++++ 2 files changed, 785 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs new file mode 100644 index 00000000..bd43f089 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -0,0 +1,401 @@ +using System.Data.Common; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// +/// Opens a transient database connection from form-supplied JSON for the AdminUI schema picker, +/// and hands it to a . The AdminUI's BrowseSessionRegistry (its idle +/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a +/// registry of its own. +/// Two ways to name the database, and only one of them is persisted. +/// connectionStringRef is the deployed form: a name resolved in the AdminUI process from +/// the environment as Sql__ConnectionStrings__<ref> (design §4.4), so a deployed artifact +/// never carries credentials. connectionString is an ad-hoc, session-only literal an +/// operator may paste to browse a database that has no ref yet; it is used for this one connection and +/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it +/// anywhere. +/// Precedence: a pasted literal wins over a ref, and the ref is then not even read. The +/// literal cannot arrive from a persisted blob — has no such property, so +/// the driver factory would drop it — which makes its presence proof that an operator typed it just now. +/// A config carrying both logs a warning (naming the shadowed ref, never any connection text) so +/// the override is visible rather than silent. +/// Credential hygiene is the point of this type. A connection string is a secret. It is +/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field, +/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their +/// own errors, every failure on the open path goes through before it leaves this +/// class. +/// +public sealed class SqlDriverBrowser : IDriverBrowser +{ + /// + /// Prefix of the environment variable a connectionStringRef resolves through — the + /// double-underscore form .NET configuration uses for the Sql:ConnectionStrings:<ref> + /// key path. + /// + public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__"; + + /// + /// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each + /// browse call at 20 s; this only stops a pathological provider-side connect from outliving the + /// picker entirely. + /// + private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30); + + /// + /// Connection-string keys whose values are redacted out of any provider error text. The + /// whole connection string is redacted unconditionally; these are the parts that could survive as a + /// fragment. Server / database names are deliberately not redacted — "server X was not found" + /// is the diagnostic the operator needs, and a hostname is not a credential. + /// + private static readonly string[] CredentialKeys = + [ + "password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token", + ]; + + /// + /// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued + /// knobs are authored as their names, so the browser parses a given DriverConfig blob + /// exactly as the runtime factory does. also accepts ordinals, + /// so a numeric config still binds. + /// + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly Func _dialectSelector; + private readonly Func _environmentReader; + private readonly ILogger _logger; + + /// Creates a browser. Every dependency has a production default, so DI may construct it bare. + /// + /// Maps an authored onto the dialect that browses it, or + /// for a provider this build does not carry. Defaults to + /// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2 + /// provider gets added without touching this class. + /// + /// + /// Reads one environment variable. Defaults to . + /// Injectable because environment variables are process-global, and a test that sets one races every + /// other test in the assembly. + /// + /// Optional; defaults to . + public SqlDriverBrowser( + Func? dialectSelector = null, + Func? environmentReader = null, + ILogger? logger = null) + { + _dialectSelector = dialectSelector ?? DefaultDialectSelector; + _environmentReader = environmentReader ?? Environment.GetEnvironmentVariable; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// + /// Sourced from , the driver's interim local constant, rather + /// than from DriverTypeNames — the shared constant set is added by the driver-factory task, + /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is + /// registered against it. Repointing that one constant repoints this too. + /// + public string DriverType => SqlDriver.DriverTypeName; + + /// The environment variable a connectionStringRef resolves through. + /// The authored reference name. + /// The full environment-variable name, e.g. Sql__ConnectionStrings__MesStaging. + public static string EnvironmentVariableFor(string connectionStringRef) => + ConnectionStringEnvironmentPrefix + connectionStringRef; + + /// + /// The providers this build can browse. v1 constructs SQL Server only; every other + /// member is a reserved name, so it resolves to and + /// the caller reports it as unavailable rather than crashing on a null dialect. + /// + /// The authored provider. + /// The dialect, or when this build does not carry the provider. + public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => + provider == SqlProvider.SqlServer ? new SqlServerDialect() : null; + + /// + /// + /// The configuration is absent / malformed, names a provider this build does not carry, names neither + /// a connectionStringRef nor a literal, resolves a ref that is not set in the environment, or + /// the connection could not be opened. + /// + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(configJson)) + { + throw new InvalidOperationException( + "The Sql browser requires a driver configuration; none was supplied."); + } + + var literal = ReadPastedConnectionString(configJson); + var config = Deserialize(configJson, literal); + + // Provider first: "this build cannot browse Postgres" is true regardless of how the database is + // named, and answering it before touching the environment keeps the two failures from masking. + var dialect = _dialectSelector(config.Provider) + ?? throw new InvalidOperationException( + $"Sql provider '{config.Provider}' is not available in this build; v1 browses " + + $"'{SqlProvider.SqlServer}' only."); + + var reference = config.ConnectionStringRef; + var connectionString = ResolveConnectionString(literal, reference); + + _logger.LogInformation( + "AdminUI Sql browse session opening (provider {Provider}, connection from {Source})", + config.Provider, + DescribeSource(literal, reference)); + + return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false); + } + + /// + /// Opens one transient connection and hands ownership to the session. The session closes the + /// connection on its own disposal, so the only disposal this method owns is the failure path — + /// disposing on success would double-close and leave the picker with a dead session. + /// + private static async Task OpenSessionAsync( + ISqlDialect dialect, string connectionString, CancellationToken cancellationToken) + { + var connection = dialect.Factory.CreateConnection() + ?? throw new InvalidOperationException( + $"The ADO.NET provider for '{dialect.Provider}' returned no connection object."); + + try + { + connection.ConnectionString = connectionString; + + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCts.CancelAfter(ConnectBudget); + await connection.OpenAsync(connectCts.Token).ConfigureAwait(false); + + return new SqlBrowseSession(connection, dialect); + } + catch (Exception ex) + { + try { await connection.DisposeAsync().ConfigureAwait(false); } + catch { /* best-effort: the original failure is the useful one. */ } + + if (ex is OperationCanceledException) throw; + throw Sanitize(ex, connectionString); + } + } + + /// + /// Picks the connection string, literal first. A blank literal is treated as absent (an untouched + /// form field), never as an empty connection string. + /// + private string ResolveConnectionString(string? literal, string? reference) + { + if (!string.IsNullOrWhiteSpace(literal)) + { + if (!string.IsNullOrWhiteSpace(reference)) + { + _logger.LogWarning( + "Sql browse: a pasted connection string was supplied alongside connectionStringRef " + + "'{Ref}'. The pasted value wins for this session only and is not persisted.", + reference); + } + + return literal; + } + + if (string.IsNullOrWhiteSpace(reference)) + { + throw new InvalidOperationException( + "The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved " + + $"from the environment as '{ConnectionStringEnvironmentPrefix}', or paste a " + + "connection string into 'connectionString' for an ad-hoc browse."); + } + + var variable = EnvironmentVariableFor(reference); + var resolved = _environmentReader(variable); + if (string.IsNullOrWhiteSpace(resolved)) + { + // Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next + // move is to set this one name on the AdminUI host. + throw new InvalidOperationException( + $"connectionStringRef '{reference}' does not resolve: environment variable " + + $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse " + + "connection strings in its own process), or paste a connection string for an ad-hoc " + + "browse."); + } + + return resolved; + } + + /// How the connection string was obtained, for the log. Carries no connection text. + private static string DescribeSource(string? literal, string? reference) => + !string.IsNullOrWhiteSpace(literal) + ? "a pasted literal (session-only, not persisted)" + : $"connectionStringRef '{reference}'"; + + /// + /// Pulls the ad-hoc connectionString literal out of the raw JSON. It is read here rather than + /// off the DTO because deliberately has no such property — the + /// persisted contract must not be able to carry a credential — and the DTO's + /// would silently drop it. + /// + /// The literal, or when absent, blank, or not a JSON string. + private static string? ReadPastedConnectionString(string configJson) + { + using var document = ParseDocument(configJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration must be a JSON object."); + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase)) + continue; + + if (property.Value.ValueKind != JsonValueKind.String) return null; + var value = property.Value.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + return null; + } + + /// + /// Parses the raw configuration. The is not attached or echoed: + /// at this point nothing has been extracted, so there is no known secret to redact against, and the + /// malformed text may itself be a pasted connection string. + /// + private static JsonDocument ParseDocument(string configJson) + { + try + { + return JsonDocument.Parse(configJson); + } + catch (JsonException) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration is not valid JSON. (The parser's message is " + + "withheld because the malformed text may carry a connection string.)"); + } + } + + /// + /// Binds the typed configuration. A bind failure (e.g. an unknown provider name) is reported + /// with the parser's own message redacted against the literal, which is known by now. + /// + private static SqlDriverConfigDto Deserialize(string configJson, string? literal) + { + try + { + return JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException( + "The Sql browser's driver configuration deserialized to null."); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration could not be bound: " + + Redact(ex.Message, CollectSecrets(literal))); + } + } + + /// + /// Turns a provider-side open failure into an exception that cannot carry the connection string. + /// Live-probed, not assumed. Microsoft.Data.SqlClient and + /// Microsoft.Data.Sqlite keep connection-string values out of their + /// SqlException/SqliteException text (a failed connect says "the server was not found", + /// never what it was handed). Their connection-string parser is the exception: it reports an + /// unrecognised keyword by quoting it, and a value carrying an unquoted ; — legal inside a + /// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a + /// keyword and echoed verbatim. Password=Sup3r;SecretTail yields + /// Keyword not supported: 'secrettail;connect timeout'. — the credential, in the message, + /// lower-cased and therefore invisible to a naive ordinal search for the value. + /// So the parser's message (an ) is never surfaced. Every + /// other failure is additionally passed through the substring redactor as a backstop, and when that + /// fires anywhere in the exception chain the inner exception is dropped too — keeping it + /// would re-expose through ToString() exactly what the message just removed. + /// + private static InvalidOperationException Sanitize(Exception ex, string connectionString) + { + if (ex is ArgumentException) + { + return new InvalidOperationException( + "The Sql browser could not open a connection: the connection string is malformed, or " + + "carries a keyword this provider does not support. (The provider's own message is " + + "withheld because it quotes connection-string text verbatim; check for an unquoted ';' " + + "or '=' inside a value such as the password.)"); + } + + var secrets = CollectSecrets(connectionString); + var leaked = ContainsAny(ex.ToString(), secrets); + var reason = Redact(ex.Message, secrets); + + var message = leaked + ? "The Sql browser could not open a connection: " + reason + + " (the provider's error echoed the connection string, so it was redacted and the inner " + + "exception withheld)" + : "The Sql browser could not open a connection: " + reason; + + return new InvalidOperationException(message, leaked ? null : ex); + } + + /// + /// The substrings that must never leave this class: the whole connection string, and the values of + /// its credential-bearing keys (which could surface on their own). + /// + private static List CollectSecrets(string? connectionString) + { + var secrets = new List(); + if (string.IsNullOrWhiteSpace(connectionString)) return secrets; + + secrets.Add(connectionString); + + try + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + foreach (var key in CredentialKeys) + { + if (!builder.TryGetValue(key, out var value)) continue; + var text = value?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text); + } + } + catch (ArgumentException) + { + // A malformed connection string has no parseable parts; the whole-string entry still stands. + } + + // Longest first, so redacting a short credential value cannot chop a longer secret into + // unredactable halves. + secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length)); + return secrets; + } + + private static bool ContainsAny(string text, List secrets) + { + foreach (var secret in secrets) + { + if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true; + } + + return false; + } + + private static string Redact(string text, List secrets) + { + foreach (var secret in secrets) + { + text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase); + } + + return text; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs new file mode 100644 index 00000000..c984c7bd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Covers 's open side: which of the two ways to name a database wins, what +/// an unresolvable reference tells the operator, which providers this build carries — and, above all, +/// that a connection string never escapes into a log line or an exception. +/// The successful-open cases run through the linked against the real +/// seeded , injected via the browser's dialectSelector +/// constructor parameter. That parameter is the production extension point for a P2 provider, not a test +/// hatch — the same conclusion the driver author reached for SqlDriver's factory +/// parameter. +/// +public sealed class SqlDriverBrowserTests +{ + /// + /// The credential every hygiene assertion hunts for. Distinctive enough that a substring match + /// anywhere in an exception or a log line is proof of a leak, not a coincidence. + /// + private const string Password = "Sup3rSecret-OtOpcUa-Probe"; + + /// + /// A host that cannot resolve, so the connect fails in DNS rather than on a timer. .invalid is + /// reserved by RFC 2606 and can never be registered. + /// + private const string DeadHost = "otopcua-no-such-host.invalid"; + + private static Func SqliteSelector => static _ => new SqliteDialect(); + + // ---- identity ---- + + [Fact] + public void DriverType_isTheSqlDriversOwnInterimConstant() + { + // Deliberately asserted against SqlDriver.DriverTypeName rather than a literal: DriverTypeNames.Sql + // does not exist yet (adding it reddens DriverTypeNamesGuardTests until a factory is registered), so + // the driver's local constant is the single definition both sides must agree on. + new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName); + } + + [Fact] + public void DefaultDialectSelector_carriesSqlServerOnly() + { + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType(); + + foreach (var provider in Enum.GetValues().Where(p => p != SqlProvider.SqlServer)) + SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); + } + + // ---- connection-string reference resolution ---- + + /// The plan's headline case: an unresolvable ref must name the exact variable to set. + [Fact] + public async Task Open_unresolvableRef_failsNamingExactEnvVar() + { + var browser = new SqlDriverBrowser(); + var ex = await Should.ThrowAsync(() => + browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default)); + ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging"); + } + + /// + /// The default (production) path: no injected reader, a real process environment variable. Uses a + /// run-unique name and restores it in a finally, so a failure cannot leave it set for the rest + /// of the assembly — environment variables are process-global and every other test shares them. + /// + [Fact] + public async Task Open_refResolvedFromTheRealEnvironment_opensSession() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var reference = "OtOpcUaBrowseTest" + Guid.NewGuid().ToString("N"); + var variable = SqlDriverBrowser.EnvironmentVariableFor(reference); + + Environment.SetEnvironmentVariable(variable, db.ConnectionString); + try + { + var browser = new SqlDriverBrowser(SqliteSelector); + + await using var session = await browser.OpenAsync( + ConfigJson(connectionStringRef: reference), CancellationToken.None); + + var roots = await session.RootAsync(CancellationToken.None); + roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Fact] + public async Task Open_neitherRefNorLiteral_failsNamingBothWays() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(), CancellationToken.None)); + + ex.Message.ShouldContain("connectionStringRef"); + ex.Message.ShouldContain("connectionString"); + } + + [Fact] + public async Task Open_blankRef_failsRatherThanResolvingAnEmptyVariableName() + { + var browser = new SqlDriverBrowser(SqliteSelector, static _ => "should never be read"); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None)); + + ex.Message.ShouldContain("connectionStringRef"); + } + + // ---- pasted literal, and its precedence ---- + + /// + /// A pasted literal opens a working session, and the session owns the connection — it is still + /// live after OpenAsync returns (the browser did not close it) and dead after the session is + /// disposed (nothing else will). + /// + [Fact] + public async Task Open_pastedLiteral_opensWorkingSessionThatOwnsTheConnection() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var browser = new SqlDriverBrowser(SqliteSelector); + + var session = await browser.OpenAsync( + ConfigJson(connectionString: db.ConnectionString), CancellationToken.None); + + var children = await session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None); + children.ShouldContain(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); + + await session.DisposeAsync(); + + await Should.ThrowAsync(() => + session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None)); + } + + /// + /// Precedence: the pasted literal wins, and the reference is not even read. Proven by a reader + /// that records every call — an assertion on the resulting session alone would still pass if the ref + /// were consulted first and merely lost a tie-break. + /// + [Fact] + public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var reads = new List(); + var logger = new CapturingLogger(); + var browser = new SqlDriverBrowser( + SqliteSelector, + name => + { + reads.Add(name); + return "Data Source=/otopcua-no-such-directory/never.db"; + }, + logger); + + await using var session = await browser.OpenAsync( + ConfigJson(connectionStringRef: "MesStaging", connectionString: db.ConnectionString), + CancellationToken.None); + + reads.ShouldBeEmpty(); + var roots = await session.RootAsync(CancellationToken.None); + roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema); + + // The override is announced, naming the shadowed ref — never any connection text. + logger.Entries.ShouldContain(e => e.Contains("MesStaging", StringComparison.Ordinal)); + logger.Entries.ShouldNotContain(e => e.Contains(db.ConnectionString, StringComparison.OrdinalIgnoreCase)); + } + + // ---- provider availability ---- + + [Fact] + public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild() + { + var browser = new SqlDriverBrowser(); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("not available in this build"); + } + + /// + /// Provider availability is answered before the environment is touched: "this build cannot browse + /// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other. + /// + [Fact] + public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider() + { + var reads = new List(); + var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; }); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldContain("not available in this build"); + reads.ShouldBeEmpty(); + } + + // ---- malformed configuration ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Open_emptyConfig_failsAskingForOne(string configJson) + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(configJson, CancellationToken.None)); + + ex.Message.ShouldContain("configuration"); + } + + /// + /// Malformed JSON must fail as malformed JSON — and must not echo the offending text back, because + /// the text a JSON parser chokes on may itself be a half-pasted connection string. + /// + [Fact] + public async Task Open_malformedJson_failsWithoutEchoingTheConfig() + { + var browser = new SqlDriverBrowser(SqliteSelector); + var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """; + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(configJson, CancellationToken.None)); + + ex.Message.ShouldContain("not valid JSON"); + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + } + + [Fact] + public async Task Open_jsonThatIsNotAnObject_fails() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync("[1,2,3]", CancellationToken.None)); + + ex.Message.ShouldContain("JSON object"); + } + + /// An unknown provider name is a bind failure, and must not echo the config either. + [Fact] + public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("NotARealProvider", connectionString: $"Server=x;Password={Password}"), + CancellationToken.None)); + + ex.Message.ShouldContain("could not be bound"); + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + } + + // ---- credential hygiene ---- + + /// + /// The natural case the task names: a well-formed connection string carrying a password, pointed at a + /// host that does not exist. Nothing about the string may reach the exception. + /// + [Fact] + public async Task Open_unreachableHost_neverLeaksThePastedConnectionString() + { + var connectionString = + $"Server={DeadHost};Database=Mes;User ID=sa;Password={Password};Connect Timeout=1;" + + "TrustServerCertificate=true"; + var browser = new SqlDriverBrowser(); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None)); + + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + ex.ToString().ShouldNotContain(connectionString, Case.Insensitive); + // Still says something useful about *why*. + ex.Message.ShouldContain("could not open a connection"); + } + + /// + /// The leak that is real, and is the reason the parser's message is suppressed rather than trusted. + /// ADO.NET expects a value containing ; to be quoted. An unquoted one splits, and the + /// tail of the password is then parsed as a keyword — which + /// Microsoft.Data.SqlClient echoes verbatim (lower-cased) in + /// Keyword not supported: '…'. The first half of this test is the control: it proves the raw + /// provider really does leak, so the second half asserting the browser does not is not vacuous. + /// + [Fact] + public async Task Open_semicolonBearingPassword_neverLeaksTheProvidersEchoedKeyword() + { + const string tail = "OtOpcUaTailSecret"; + var connectionString = $"Server={DeadHost};Password=Sup3r;{tail};Connect Timeout=1"; + + // Control: the bare provider leaks the tail of the password into its own exception. + var raw = Should.Throw(() => + { + using var connection = new SqlServerDialect().Factory.CreateConnection()!; + connection.ConnectionString = connectionString; + }); + raw.ToString().ShouldContain(tail, Case.Insensitive); + + // The browser does not. + var browser = new SqlDriverBrowser(); + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None)); + + ex.ToString().ShouldNotContain(tail, Case.Insensitive); + ex.Message.ShouldContain("malformed"); + } + + /// + /// Nothing the browser logs — on the success path or the failure path — carries connection text. The + /// ref name is fair game; the string never is. + /// + [Fact] + public async Task Open_neverLogsTheConnectionString() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var logger = new CapturingLogger(); + var browser = new SqlDriverBrowser(SqliteSelector, static _ => null, logger); + + await using (await browser.OpenAsync( + ConfigJson(connectionString: db.ConnectionString), CancellationToken.None)) + { + // Opened purely for its log output. + } + + var dead = $"Data Source=/otopcua-no-such-directory/never.db;Password={Password}"; + await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: dead), CancellationToken.None)); + + logger.Entries.ShouldNotBeEmpty(); + foreach (var entry in logger.Entries) + { + entry.ShouldNotContain(db.ConnectionString, Case.Insensitive); + entry.ShouldNotContain(Password, Case.Insensitive); + entry.ShouldNotContain("Data Source", Case.Insensitive); + } + } + + // ---- helpers ---- + + /// + /// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing + /// JSON metacharacters (backslashes in a Data Source path, quotes in a password) escapes + /// correctly instead of producing malformed JSON the test would then misattribute. + /// + private static string ConfigJson( + string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null) + { + var map = new Dictionary { ["provider"] = provider }; + if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef; + if (connectionString is not null) map["connectionString"] = connectionString; + return JsonSerializer.Serialize(map); + } + + /// Records every formatted log message so the hygiene assertions can read them back. + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = []; + + IDisposable? ILogger.BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add(formatter(state, exception)); + } +} From 48cbc26c34151c55c48bdb98ac54b84b3f6df072 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:06:55 -0400 Subject: [PATCH 19/33] test(sql): env-gated central-SQL integration fixture + read round-trip Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../SqlPollServerFixture.cs | 469 +++++++++++++ .../SqlServerReadTests.cs | 663 ++++++++++++++++++ ...OtOpcUa.Driver.Sql.IntegrationTests.csproj | 39 ++ 4 files changed, 1172 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 033ff3d3..8b786bac 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -95,6 +95,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs new file mode 100644 index 00000000..16c0ed61 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// A real, seeded SQL Server database standing in for the source the Sql driver polls — +/// the live counterpart of the offline SqlitePollFixture, and the only place the shipped +/// Microsoft.Data.SqlClient path, the real INFORMATION_SCHEMA catalog SQL, and genuine +/// T-SQL column types are exercised at all. +/// Env-gated, and the gate is checked before any socket is touched. This fixture opens +/// nothing unless — or plus a +/// password — is set; absent, it records and every test calls +/// Assert.Skip. That is what keeps dotnet test genuinely green (and instant) on the +/// macOS dev box this is usually run from, which is the same contract every other +/// *.IntegrationTests fixture in this tree honours. +/// Safety against the shared central server (design §9). The always-on SQL Server on the +/// docker host hosts ConfigDb for the whole dev rig, so this fixture is deliberately +/// conservative: +/// +/// It uses its own database, — the supplied connection +/// string's catalog is used only to reach master and is otherwise ignored, so a +/// mis-set env var cannot aim the seed at somebody else's schema. +/// A supplied catalog literally named ConfigDb is rejected loudly, at +/// construction, rather than quietly retargeted — it is the signature of a copied-and-pasted +/// connection string, and the operator should know their env var is wrong. +/// It creates the database only if missing and never drops a database, so +/// re-running the suite is safe and an operator-created SqlPollFixture is never destroyed. +/// Only the objects inside its own schema are dropped and recreated, which +/// is what makes the seed deterministic across runs. +/// +/// The seed is a contract, not scaffolding. The constants below mirror +/// SqlitePollFixture's shape — a present value, a present row with a NULL cell, an +/// absent key, and a wide-row table whose newest row is deliberately not its first — so the +/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server +/// paths shows up as a failing assertion rather than as two suites that quietly test different +/// things. has no offline counterpart: it exists to pin +/// SqlServerDialect.MapColumnType against the type names SQL Server actually reports, +/// which a hand-written mapping table cannot do. +/// +public sealed class SqlPollServerFixture : IAsyncLifetime +{ + /// A full ADO.NET connection string for the fixture server. Takes precedence when set. + public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture"; + + /// + /// Server endpoint in host,port form (e.g. 10.100.0.35,14330) — the lighter-weight + /// gate. Requires ; defaults to sa. + /// + public const string EndpointEnvVar = "SQL_TEST_ENDPOINT"; + + /// SQL login for the form. Defaults to sa. + public const string UserEnvVar = "SQL_TEST_USER"; + + /// SQL password for the form. Required by that form. + public const string PasswordEnvVar = "SQL_TEST_PASSWORD"; + + /// The database this fixture creates and seeds. Never ConfigDb, never dropped. + public const string FixtureDatabase = "SqlPollFixture"; + + /// + /// The schema every seeded object lives in. Deliberately not dbo: it scopes the + /// drop-and-recreate to objects this fixture owns, and it makes every authored table a + /// two-part name, so the planner's QuoteQualifiedName ([sqlpoll].[TagValues]) is + /// exercised against a real server rather than only in a unit test. + /// + public const string Schema = "sqlpoll"; + + /// + /// The Application Name the driver's connections carry, so the pooling assertion can count + /// exactly this suite's sessions on a server shared with the whole dev rig. + /// + public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver"; + + /// The Application Name the fixture's own inspection connections carry. + private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture"; + + /// Seconds a connect attempt may take before the fixture calls the server unreachable. + private const int ProbeConnectTimeoutSeconds = 5; + + // ---- key-value (EAV) source ---- + + /// The key-value table: tag_name nvarchar(128), num_value float NULL, sample_ts datetime2. + public const string KeyValueTable = "TagValues"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by every seeded table. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for . + public const double SecondPresentValue = 3.5; + + /// A key whose row exists but whose value cell is NULL. + public const string NullValueKey = "Line1.Temp"; + + /// A key with no row at all — a different outcome from . + public const string AbsentKey = "Line1.Missing"; + + /// The UTC source timestamp seeded for . + public static readonly DateTime PresentKeyTimestampUtc = + new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); + + // ---- wide-row source ---- + + /// The wide-row table: station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2. + public const string WideRowTable = "LatestStatus"; + + /// The wide-row table's row-selector column. + public const string WideRowSelectorColumn = "station_id"; + + /// A station whose row exists, with both value columns populated. + public const string PresentStation = "7"; + + /// 's oven_temp. + public const double PresentStationOvenTemp = 180.5; + + /// 's pressure. + public const double PresentStationPressure = 1.2; + + /// + /// The station holding the newest sample_ts — what a topByTimestamp selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that loses its + /// ORDER BY … DESC or its SELECT TOP 1 picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — what a topByTimestamp plan yields. + public const double NewestStationOvenTemp = 210.25; + + /// A station whose row exists but whose oven_temp cell is NULL. + public const string NullOvenTempStation = "9"; + + /// A station with no row at all. + public const string AbsentStation = "404"; + + /// A view over — the TABLE_TYPE = 'VIEW' catalog row. + public const string WideRowView = "LatestStatusView"; + + // ---- T-SQL type-coverage source ---- + + /// + /// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no + /// authored type, each column's driver type is inferred from what + /// SqlDataReader.GetDataTypeName reports — which is the only way to find out whether + /// SqlServerDialect.MapColumnType's table matches reality rather than matching itself. + /// + public const string TypeProbeTable = "TypeProbe"; + + /// The single-row selector column of . + public const string TypeProbeSelectorColumn = "probe_id"; + + /// The seeded row's value. + public const string TypeProbeRow = "1"; + + /// Seeded bit value. + public const bool ProbeBit = true; + + /// Seeded int value — int.MaxValue, so a narrowing coercion overflows loudly. + public const int ProbeInt = int.MaxValue; + + /// Seeded bigint value — beyond exact double range, so an Int64→Float64 slip shows. + public const long ProbeBigInt = 9007199254740993L; + + /// Seeded real value; exactly representable, so equality is a fair assertion. + public const float ProbeReal = 1.5f; + + /// Seeded float value; exactly representable. + public const double ProbeFloat = 3.25d; + + /// Seeded decimal(18,4) value — the documented Float64 precision collapse. + public const decimal ProbeDecimal = 123.4567m; + + /// Seeded nvarchar(64) value. + public const string ProbeNVarChar = "hello"; + + /// Seeded datetime2(3) value, read back as UTC. + public static readonly DateTime ProbeDateTimeUtc = + new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc); + + /// + /// Why the suite is skipping, or when the fixture is seeded and usable. + /// Tests read this and call Assert.Skip — the house idiom + /// (Snap7ServerFixture / ModbusSimulatorFixture). + /// + public string? SkipReason { get; private set; } + + /// + /// The connection string to hand the code under test: the seeded , + /// tagged with . Empty while is set. + /// + public string ConnectionString { get; private set; } = string.Empty; + + /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. + public string Server { get; private set; } = string.Empty; + + /// Connection string the fixture's own inspection connections use. Empty while skipping. + private string _inspectionConnectionString = string.Empty; + + /// + /// Resolves the gate, creates if missing, and (re)seeds this + /// fixture's schema. + /// The two failure modes are handled deliberately differently: failing to connect is + /// the offline case and becomes a , while failing after the connection + /// opened (DDL, seeding) is a real fault against a reachable server and is allowed to throw — + /// swallowing that would leave the suite silently testing an unseeded database. + /// + /// A task that represents the asynchronous operation. + public async ValueTask InitializeAsync() + { + if (ResolveConnectionString() is not { } supplied) + { + SkipReason = + $"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " + + $"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " + + "the Sql driver's live SQL Server suite. No connection is attempted without it."; + return; + } + + var builder = new SqlConnectionStringBuilder(supplied); + GuardAgainstConfigDb(builder); + if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds; + Server = builder.DataSource; + + // The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase. + var master = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = "master", + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + SqlConnection connection; + try + { + connection = new SqlConnection(master); + await connection.OpenAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + SkipReason = + $"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " + + $"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " + + $"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite."; + return; + } + + await using (connection.ConfigureAwait(false)) + { + // Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever + // made it, keeps everything outside this fixture's own schema. + await ExecuteAsync( + connection, + $"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];") + .ConfigureAwait(false); + } + + ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = DriverApplicationName, + }.ConnectionString; + + _inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + await SeedAsync().ConfigureAwait(false); + } + + /// Holds no long-lived connection, so there is nothing to tear down. + /// A completed task. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// + /// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged + /// with the fixture's own application name so it is never counted by the pooling assertion. + /// + /// An open connection the caller owns and must dispose. + public async Task OpenInspectionConnectionAsync() + { + var connection = new SqlConnection(_inspectionConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + return connection; + } + + /// Two-part name of a seeded object, in the form an authored tag's table carries. + /// The bare table or view name. + /// The schema.table name. + public static string Qualified(string table) => $"{Schema}.{table}"; + + /// + /// Reads the connection-string gate. Returns — touching no socket — when + /// neither form is configured. + /// + private static string? ResolveConnectionString() + { + if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full) + return full; + + if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) + return null; + + // A password is required rather than defaulted: guessing one would turn a misconfiguration into a + // failed login against a shared server, which is how accounts get locked out. + if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) + return null; + + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa"; + return new SqlConnectionStringBuilder + { + DataSource = endpoint, + InitialCatalog = FixtureDatabase, + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + }.ConnectionString; + } + + /// + /// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared + /// ConfigDb. The catalog is ignored either way (everything lands in + /// ), so this exists purely to tell an operator their env var is + /// wrong rather than to prevent damage that could otherwise occur. + /// + /// The supplied catalog is named ConfigDb. + private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder) + { + if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return; + + throw new InvalidOperationException( + $"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " + + $"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " + + "never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog)."); + } + + /// + /// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows. + /// Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract: + /// a leftover row from an older revision of this file would make a passing test meaningless. The + /// blast radius is bounded to the schema, which nothing else creates. + /// + private async Task SeedAsync() + { + var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false); + await using (connection.ConfigureAwait(false)) + { + // Views first: a view over a dropped table blocks nothing, but dropping the table under a live + // view leaves the catalog reporting a column set that no longer resolves. + foreach (var statement in SeedStatements()) + await ExecuteAsync(connection, statement).ConfigureAwait(false); + } + } + + /// + /// The seed, one batch per statement. CREATE SCHEMA and CREATE VIEW must each be the + /// first statement of their batch in T-SQL, which is why this is a list rather than one script. + /// Every numeric literal is composed under : plain + /// interpolation would emit 180,5 on a comma-decimal machine, and T-SQL would read that as + /// two column values rather than as one wrong number — a seed failure with a baffling message. + /// + private static IEnumerable SeedStatements() + { + yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];"; + yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');"; + + yield return $""" + CREATE TABLE [{Schema}].[{KeyValueTable}] ( + [{KeyColumn}] nvarchar(128) NOT NULL, + [{ValueColumn}] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}]) + VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'), + (N'{NullValueKey}', NULL, '2026-07-24T10:00:01'), + (N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE TABLE [{Schema}].[{WideRowTable}] ( + [{WideRowSelectorColumn}] int NOT NULL, + [oven_temp] float NULL, + [pressure] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + // NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A + // topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two + // orderings are kept deliberately different. + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{WideRowTable}] + ([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]) + VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'), + ({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'), + ({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE VIEW [{Schema}].[{WideRowView}] AS + SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}] + FROM [{Schema}].[{WideRowTable}]; + """; + + yield return $""" + CREATE TABLE [{Schema}].[{TypeProbeTable}] ( + [{TypeProbeSelectorColumn}] int NOT NULL, + [bit_col] bit NULL, + [int_col] int NULL, + [bigint_col] bigint NULL, + [real_col] real NULL, + [float_col] float NULL, + [decimal_col] decimal(18,4) NULL, + [nvarchar_col] nvarchar(64) NULL, + [datetime2_col] datetime2(3) NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{TypeProbeTable}] + ([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col], + [decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}]) + VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat}, + {ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03'); + """); + } + + /// Runs one non-query batch. + private static async Task ExecuteAsync(SqlConnection connection, string sql) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } +} + +/// +/// Collection definition so the seed runs once per test session rather than once per test class — +/// the same shape as Snap7ServerCollection. +/// +[CollectionDefinition(Name)] +public sealed class SqlPollServerCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlPollServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs new file mode 100644 index 00000000..c258ff6b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs @@ -0,0 +1,663 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The Sql driver against a real SQL Server, over the Microsoft.Data.SqlClient +/// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which +/// means three things have never been exercised anywhere until this suite: the shipped ADO.NET +/// provider, T-SQL's own syntax (bracket-quoted two-part names, SELECT TOP 1), and the +/// INFORMATION_SCHEMA catalog SQL that SqlServerDialect carries but that no offline test +/// can reach — the SQLite dialect answers those questions with pragma_table_info instead. +/// Env-gated; skipping is the normal outcome. See for +/// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline — +/// which is the point: a live test that goes red on a laptop teaches people to ignore red. +/// Out of scope, deliberately: the frozen-database / BadTimeout gate. That needs a +/// docker pause-able container this suite must never own, because the server it points at is +/// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql +/// container. +/// +[Collection(SqlPollServerCollection.Name)] +public sealed class SqlServerReadTests +{ + private readonly SqlPollServerFixture _sql; + + /// Initializes a new instance of the class. + /// The seeded live-server fixture (collection-scoped). + public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql; + + // ---- lifecycle ---- + + /// + /// Initialize's liveness check (SELECT 1 over a real connection) succeeds and the driver + /// reports Healthy with the endpoint described credential-free. + /// + [Fact] + public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); + health.LastSuccessfulRead.ShouldNotBeNull(); + + // The endpoint description is what every log line and the Admin UI show. It must name the + // database and must not carry the password that reached the provider. + driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase); + driver.Endpoint.ShouldNotContain("Password"); + + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); + } + + // ---- key-value model ---- + + /// The plan's headline case: a seeded key-value row round-trips as a Good snapshot. + [Fact] + public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken); + + var snapshot = snapshots.ShouldHaveSingleItem(); + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue); + // datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it + // through the host's zone, which is the behaviour this asserts against a real column type. + snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc); + } + + /// + /// Two keys on the same table fold into ONE query (… WHERE [tag_name] IN (@k0, @k1)) and are + /// sliced back to the right tags. Against a real server this also proves the parameter binding and + /// the key-column stringification survive a genuine nvarchar key. + /// + [Fact] + public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(2); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue); + } + + /// + /// A present row whose value cell is a real T-SQL NULL is Uncertain, not Bad and not + /// BadNoData — the row WAS read. + /// + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain); + snapshot.Value.ShouldBeNull(); + // The row exists, so its timestamp is still readable — that is exactly what distinguishes this + // from the absent-key case below. + snapshot.SourceTimestampUtc.ShouldNotBeNull(); + } + + /// nullIsBad re-codes the same NULL cell as Bad, and only that cell. + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad); + } + + /// A key the table has no row for is BadNoData with no source timestamp. + [Fact] + public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshot.SourceTimestampUtc.ShouldBeNull(); + } + + // ---- wide-row model ---- + + /// + /// Two columns of one wide row, selected by a whereColumn/whereValue pair, come back from a + /// single query — including the bound selector value coercing from authored text to a real + /// int column server-side. + /// + [Fact] + public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation)); + + var snapshots = await driver.ReadAsync( + ["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"], + TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure); + // A selector matching no row is BadNoData, not an error — and it does not disturb its siblings. + snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + } + + /// + /// The topByTimestamp selector resolves to the newest row. + /// This is the T-SQL-specific leg. The planner emits the row limit through the + /// dialect's SingleRowLimitPrefixSELECT TOP 1 … for T-SQL, where every offline + /// test exercises SQLite's trailing LIMIT 1 instead. The seed makes the newest row not the + /// first-inserted one, so losing either the TOP 1 or the ORDER BY … DESC yields a + /// different, wrong value rather than a coincidentally-right one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp")); + + var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp); + } + + /// A view reads exactly like the table it wraps — the shape operators are told to author. + [Fact] + public async Task ReadAsync_realSqlServer_readsThroughAView() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere( + "Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation, + table: SqlPollServerFixture.WideRowView)); + + var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + } + + // ---- type mapping against real T-SQL column types ---- + + /// + /// With no authored type, each tag's driver type is inferred from the provider's own + /// GetDataTypeName through SqlServerDialect.MapColumnType. This is the only test in + /// the estate where that map is checked against the strings SQL Server actually returns — + /// everywhere else it is fed a hand-written list, i.e. checked against itself. + /// + [Fact] + public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/Bit", "bit_col"), + TypeProbe("Sql/Probe/Int", "int_col"), + TypeProbe("Sql/Probe/BigInt", "bigint_col"), + TypeProbe("Sql/Probe/Real", "real_col"), + TypeProbe("Sql/Probe/Float", "float_col"), + TypeProbe("Sql/Probe/Decimal", "decimal_col"), + TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"), + TypeProbe("Sql/Probe/DateTime2", "datetime2_col")); + + var snapshots = await driver.ReadAsync( + [ + "Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real", + "Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2", + ], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + + // bit → Boolean, not "1"; the CLR type is what the address space declared for the node. + snapshots[0].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBit); + snapshots[1].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeInt); + // bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to + // Float64 would come back as a different number rather than as a type-only difference. + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBigInt); + snapshots[3].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + snapshots[4].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeFloat); + // decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future + // change to that policy cannot land silently. + snapshots[5].Value.ShouldBeOfType() + .ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9); + snapshots[6].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeNVarChar); + snapshots[7].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc); + } + + /// + /// An authored type wins over the inferred column type, and the coercion runs over the + /// provider's real CLR cell rather than over a test double. + /// + [Fact] + public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String), + TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32), + TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64)); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + snapshots[0].Value.ShouldBeOfType() + .ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture)); + snapshots[1].Value.ShouldBeOfType().ShouldBe(1); + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + } + + /// + /// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone, + /// never a thrown read. int.MaxValue into an Int16 is a genuine provider-level + /// OverflowException, not a synthesised one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16), + TypeProbe("Sql/Probe/Float", "float_col")); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch); + snapshots[0].Value.ShouldBeNull(); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + // ---- cancellation + connection lifecycle ---- + + /// + /// A cancelled poll propagates rather than publishing + /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The + /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate, so it is pinned + /// here against the real provider's cancellation path. + /// + [Fact] + public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and + // which of the two arrives is not a contract this driver makes. + await Assert.ThrowsAnyAsync( + async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token)); + + // Cancellation is teardown, not a database verdict: health must be untouched. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET + /// pools them. After many polls the server should still hold a small, non-zero number of + /// sessions for this driver's Application Name: non-zero proves the disposed connections + /// went back to a pool instead of being torn down, and small proves the poll loop is not leaking + /// one per pass. Counting per application name is what makes this safe on a server shared with the + /// whole dev rig. + /// + [Fact] + public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections() + { + SkipUnlessLive(); + + const int polls = 25; + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + for (var i = 0; i < polls; i++) + { + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + var sessions = await CountDriverSessionsAsync(); + sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side"); + sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25"); + } + + // ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ---- + + /// The dialect's schema list — real INFORMATION_SCHEMA.TABLES — sees the seeded schema. + [Fact] + public async Task Catalog_listSchemasSql_returnsTheSeededSchema() + { + SkipUnlessLive(); + + var schemas = await QueryCatalogAsync( + new SqlServerDialect().ListSchemasSql, + _ => { }, + reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA"))); + + schemas.ShouldContain(SqlPollServerFixture.Schema); + } + + /// + /// The dialect's table list returns the seeded relations for the bound schema, and flags the view + /// as VIEW — the TABLE_TYPE the browse session decorates its label from. + /// + [Fact] + public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView() + { + SkipUnlessLive(); + + var rows = await QueryCatalogAsync( + new SqlServerDialect().ListTablesSql, + command => Bind(command, "@schema", SqlPollServerFixture.Schema), + reader => ( + Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")), + Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE")))); + + var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW"); + } + + /// + /// The dialect's column list returns the key-value table's columns in ordinal order (the + /// order the browse tree renders), with DATA_TYPE values the dialect's map recognises. + /// + [Fact] + public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable); + + columns.Select(c => c.Name).ToArray().ShouldBe(new[] + { + SqlPollServerFixture.KeyColumn, + SqlPollServerFixture.ValueColumn, + SqlPollServerFixture.TimestampColumn, + }); + + var dialect = new SqlServerDialect(); + dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar + dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float + dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2 + + // IS_NULLABLE is the third projected column and the browse reads it; prove it is really there. + columns[0].IsNullable.ShouldBe("NO"); + columns[1].IsNullable.ShouldBe("YES"); + } + + /// + /// Every T-SQL family the type-probe table declares maps to the driver type + /// SqlServerDialect.MapColumnType promises — read from the catalog's own + /// DATA_TYPE strings, which is the exact input the browse side-panel feeds it. + /// + [Fact] + public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable); + var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal); + var dialect = new SqlServerDialect(); + + dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean); + dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32); + dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64); + dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32); + dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String); + dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime); + } + + // ---- helpers ---- + + /// Skips the calling test when the live-server gate is not configured or not reachable. + private void SkipUnlessLive() + { + if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason); + } + + /// + /// Builds and initializes a driver over the seeded database. + /// There is no ForProduction / ForTest hatch on + /// — its single public constructor takes the resolved connection string, the dialect and an + /// optional factory, and leaving the factory unset is precisely what makes this suite exercise + /// SqlClientFactory.Instance, the provider the driver ships. + /// + private Task StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags); + + /// + private async Task StartAsync(bool nullIsBad, params RawTagEntry[] tags) + { + var options = new SqlDriverOptions + { + RawTags = tags, + NullIsBad = nullIsBad, + CommandTimeout = TimeSpan.FromSeconds(10), + OperationTimeout = TimeSpan.FromSeconds(15), + }; + + var driver = new SqlDriver( + options, + driverInstanceId: "sql-integration", + dialect: new SqlServerDialect(), + connectionString: _sql.ConnectionString); + + try + { + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + /// A key-value tag over the seeded EAV table. + private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable), + ["keyColumn"] = SqlPollServerFixture.KeyColumn, + ["keyValue"] = key, + ["valueColumn"] = SqlPollServerFixture.ValueColumn, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + }); + + /// A wide-row tag selected by a whereColumn/whereValue pair. + private static RawTagEntry WideRowWhere( + string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn, + ["whereValue"] = station, + }, + }); + + /// A wide-row tag selected by newest timestamp. + private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["topByTimestamp"] = SqlPollServerFixture.TimestampColumn, + }, + }); + + /// A wide-row tag over the single-row type-probe table, optionally with a declared type. + private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null) + { + var config = new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn, + ["whereValue"] = SqlPollServerFixture.TypeProbeRow, + }, + }; + + // Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole + // point of the inference test; present ⇒ it overrides. + if (declared is not null) config["type"] = declared.Value.ToString(); + return Tag(rawPath, config); + } + + /// + /// Wraps an authored TagConfig object as a raw tag entry. + /// Composed as a rather than as an interpolated string literal on + /// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace + /// produces a config the parser silently rejects — which surfaces as BadNodeIdUnknown, i.e. + /// as a plausible-looking test failure about the driver rather than about the test. + /// + private static RawTagEntry Tag(string rawPath, JsonObject config) => + new(rawPath, config.ToJsonString(), WriteIdempotent: false); + + /// Runs one of the dialect's catalog statements against the seeded database. + private async Task> QueryCatalogAsync( + string sql, Action bind, Func project) + { + var connection = await _sql.OpenInspectionConnectionAsync(); + await using (connection.ConfigureAwait(false)) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + bind(command); + + var results = new List(); + var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); + await using (reader.ConfigureAwait(false)) + { + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + results.Add(project(reader)); + } + + return results; + } + } + } + + /// Reads one seeded table's catalog columns through the dialect's ListColumnsSql. + private Task> ReadCatalogColumnsAsync(string table) => + QueryCatalogAsync( + new SqlServerDialect().ListColumnsSql, + command => + { + Bind(command, "@schema", SqlPollServerFixture.Schema); + Bind(command, "@table", table); + }, + reader => ( + Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")), + DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")), + IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE")))); + + /// + /// Counts the server-side sessions carrying the driver's application name. Skips — rather than + /// failing — when the configured login cannot read the DMV, since VIEW SERVER STATE is a + /// property of the credentials the operator supplied and not of the code under test. + /// + private async Task CountDriverSessionsAsync() + { + try + { + var counts = await QueryCatalogAsync( + "SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app", + command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName), + reader => reader.GetInt32(reader.GetOrdinal("n"))); + return counts[0]; + } + catch (SqlException ex) + { + Assert.Skip( + "The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " + + $"pooling cannot be observed from here: {ex.Message}"); + throw; // unreachable; Assert.Skip throws. + } + } + + /// Binds one string catalog parameter, exactly as the browse session does. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj new file mode 100644 index 00000000..2a1e1c4c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj @@ -0,0 +1,39 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + From 3cc80691504a13282dca456cde8ba472bff1a186 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:06:58 -0400 Subject: [PATCH 20/33] feat(sql): factory + connectionStringRef env resolution + string-enum guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlDriverFactoryExtensions turns a deployed DriverConfig blob into a live SqlDriver, and SqlConnectionStringResolver resolves the authored connectionStringRef NAME from Sql__ConnectionStrings__ so credentials never ride in a config blob (design §8.2). Three behaviours worth naming: - String-enum guard. JsonOptions carries JsonStringEnumConverter + camelCase, so `provider` parses whether authored as a name or an ordinal and always round-trips as the NAME — the systemic AdminUI defect where a page serialised an enum numerically against a string-typed DTO. - Config validation lands here because nothing below does it. operationTimeout must be STRICTLY greater than commandTimeout (design §8.3); the reader and the driver stay deliberately usable with the pair inverted so the frozen-database tests can prove the client-side bound fires. Non-positive timeouts / poll interval and maxConcurrentGroups < 1 are rejected too. - Credential hygiene. The resolved connection string reaches the provider and nothing else: messages name the ref, the environment variable, or SqlDriver.Endpoint's credential-free server/database rendering. Both a log-leak and an exception-leak test cover it. DTO changes: - Adds RawTags (List), following the Modbus precedent — the deploy artifact delivers a driver's tags this way and the DTO had no way to receive them, so an authored Sql tag could never reach the driver. - Deletes SqlProbeDto + the `probe` key. It had no consumer: SqlDriver was specified without a background probe loop, and deliberately so — its IHostConnectivityProbe state is a by-product of the Initialize liveness check and of every poll, i.e. a statement about traffic that actually happened rather than a synthetic ping. On-demand connectivity is Task 10's SqlDriverProbe. Shipping a config key that silently does nothing is worse than not shipping it; UnmappedMemberHandling.Skip means a blob that still carries `probe` keeps parsing. allowWrites stays inert (SqlDriver implements no write capability) but an authored `true` now WARNS rather than passing silently — the flag cannot do harm, an operator who believes writes are enabled can. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlDriverConfigDto.cs | 31 +- .../SqlConnectionStringResolver.cs | 62 +++ .../SqlDriverFactoryExtensions.cs | 255 ++++++++++ .../SqlDriverFactoryTests.cs | 452 ++++++++++++++++++ 4 files changed, 787 insertions(+), 13 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs index fac5ee4c..2bca5d24 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; /// @@ -41,19 +43,22 @@ public sealed class SqlDriverConfigDto /// When , a NULL cell publishes Bad rather than the default Uncertain. public bool? NullIsBad { get; init; } - /// Master write kill-switch. v1 is read-only — writes stay disabled regardless. + /// + /// Master write kill-switch. v1 is read-only and this field is inert — the driver does not + /// implement IWritable, so no value here can produce a write. The factory warns when it + /// is authored rather than failing the driver, since the flag cannot do harm but + /// an operator who believes writes are enabled can. + /// public bool? AllowWrites { get; init; } - /// Background connectivity-probe settings. Absent ⇒ the factory default. - public SqlProbeDto? Probe { get; init; } -} - -/// Background connectivity-probe settings inside a (design §5.1). -public sealed class SqlProbeDto -{ - /// Whether the driver runs its background connectivity probe. - public bool? Enabled { get; init; } - - /// Interval between probe attempts. - public TimeSpan? Interval { get; init; } + /// + /// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus + /// the driver TagConfig blob, the same shape every other driver receives. The factory copies + /// these onto the driver's options and the driver maps each through + /// at Initialize. + /// A SQL source has no tag-discovery protocol, so this list is the complete set of tags the + /// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will + /// find its tags later. + /// + public List? RawTags { get; init; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs new file mode 100644 index 00000000..0e76e68d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs @@ -0,0 +1,62 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Resolves a Sql driver config's connectionStringRef — a NAME — into the connection string +/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never +/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff. +/// The environment is read directly, on purpose. A driver factory is invoked through +/// DriverFactoryRegistry's (driverInstanceId, driverConfigJson) closure — there is no +/// IConfiguration, no IServiceProvider and no ambient scope at that seam, and widening the +/// registry's signature for one driver would change every driver in the tree. The +/// Sql__ConnectionStrings__<ref> spelling is exactly the key .NET's environment-variable +/// configuration provider would bind to Sql:ConnectionStrings:<ref>, so an operator can set it +/// the same way they set every other secret and a later move onto IConfiguration needs no +/// re-provisioning. +/// Nothing here ever emits the resolved value. Messages name the environment +/// variable, which is not a secret and is the operator's next action; the value itself is returned +/// to exactly one caller and is otherwise unmentioned. +/// +public static class SqlConnectionStringResolver +{ + /// + /// The environment-variable prefix a connectionStringRef is appended to. The double underscore + /// is .NET's configuration hierarchy separator, so this is the environment spelling of + /// Sql:ConnectionStrings:<ref>. + /// + public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__"; + + /// The environment variable a given connectionStringRef is read from. + /// The authored reference name. + /// The full environment-variable name. + /// is null, empty or whitespace. + public static string EnvironmentVariableFor(string connectionStringRef) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef); + return string.Concat(EnvironmentVariablePrefix, connectionStringRef); + } + + /// + /// Resolves to its connection string. + /// An absent or blank variable is a half-provisioned deployment and throws: handing an + /// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll, + /// far from the missing secret that actually caused it. + /// + /// The authored reference name. + /// The resolved connection string. Treat as a secret — never log it. + /// is null, empty or whitespace. + /// The environment variable is absent or blank. + public static string Resolve(string connectionStringRef) + { + var variable = EnvironmentVariableFor(connectionStringRef); + var value = Environment.GetEnvironmentVariable(variable); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " + + $"variable '{variable}' on every node that runs this driver. The connection string is " + + $"deliberately not carried in the deployed configuration."); + } + + return value; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs new file mode 100644 index 00000000..b1377fd5 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -0,0 +1,255 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Static factory registration helper for — the seam between a deployed +/// DriverConfig blob and a live driver instance. The Server's composition root calls +/// once at startup; the bootstrapper then materialises Sql DriverInstance +/// rows into driver instances. Mirrors ModbusDriverFactoryExtensions. +/// This type owns the config validation the layers below deliberately skip. +/// and stay usable with an inverted +/// operationTimeout/commandTimeout pair on purpose (the frozen-database tests need that +/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of +/// its two independent deadlines. Every knob is checked here, once, at construction. +/// Enum fields are read leniently and written as names. carries a +/// , so a provider authored as an ordinal still parses and a +/// round-trip emits "SqlServer" — the systemic AdminUI defect where a page serialised an enum +/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time. +/// The resolved connection string is a secret and never leaves this method except into the +/// driver. Every log line and every exception message here names the connectionStringRef, the +/// environment variable, or 's credential-free server/database +/// rendering — never the string itself. +/// +public static class SqlDriverFactoryExtensions +{ + /// + /// The driver-type string this factory registers under. + /// Chained off rather than DriverTypeNames.Sql: + /// DriverTypeNamesGuardTests asserts bidirectional parity between the shared constants and the + /// factories actually registered in the process, so the constant may only be added in the change that + /// also wires this factory into the Host (the plan's Task 11, which repoints both at + /// DriverTypeNames.Sql). + /// + public const string DriverTypeName = SqlDriver.DriverTypeName; + + /// + /// How a Sql config blob is read. + /// + /// — accepts an enum written as a name OR as an + /// ordinal, and always writes the name (the enum-serialization guard). + /// — a blob authored against a different + /// schema version must not brick the driver. + /// — the authored spelling + /// (connectionStringRef, rawTags). Reads are case-insensitive anyway; the policy is + /// what makes a write round-trip to the authored shape. + /// + /// + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + /// The serializer options, exposed so tests can assert the enum guard on a real round-trip. + internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions; + + /// + /// Registers the Sql factory with the driver registry. The optional + /// is captured at registration time and used to build a logger per + /// driver instance; without it the driver runs with the null logger. + /// + /// The driver factory registry to register with. + /// Optional logger factory for creating loggers per driver instance. + /// is null. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Builds one driver instance from its config blob. For the Server bootstrapper and tests. + /// The central config DB's identity for this driver instance. + /// The instance's DriverConfig JSON blob. + /// The constructed . + public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + /// + /// Logger-aware overload — used by 's closure when wired through DI. + /// Constructs the driver only: the driver builds its own , because + /// the reader's resolve delegate must read the driver's live authored-tag table, which does not + /// exist until the driver does. + /// No I/O happens here. The database is first contacted by + /// , so a database that is merely down yields a driver in + /// Reconnecting rather than a deployment that cannot be constructed. + /// + /// The central config DB's identity for this driver instance. + /// The instance's DriverConfig JSON blob. + /// Optional logger factory for creating loggers per driver instance. + /// The constructed . + /// An argument is null, empty or whitespace. + /// + /// The blob is not valid JSON, omits connectionStringRef, names a provider this build cannot + /// construct, carries an invalid knob, or the referenced connection string is not provisioned. + /// + public static SqlDriver CreateInstance( + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = Deserialize(driverInstanceId, driverConfigJson); + + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + + $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + + $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); + } + + var dialect = CreateDialect(dto.Provider, driverInstanceId); + + // Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the + // secret in hand — which is exactly where a careless interpolation would leak it. + var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + var options = BuildOptions(dto, driverInstanceId); + + var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!) + ?? NullLogger.Instance; + + if (dto.AllowWrites is true) + { + // Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it + // would brick a deployment for a flag that cannot do harm. The operator who believes writes are + // on, however, can — so this is loud. + logger.LogWarning( + "Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " + + "driver is read-only (it implements no write capability), so no configuration can enable a " + + "write. Remove the flag, or expect reads only.", + driverInstanceId); + } + + var driver = new SqlDriver( + options, + driverInstanceId, + dialect, + connectionString, + factory: null, + logger: loggerFactory?.CreateLogger()); + + // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string + // permitted outside the provider call. + logger.LogInformation( + "Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " + + "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.", + driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count, + options.DefaultPollInterval, options.OperationTimeout); + + return driver; + } + + /// Deserialises the blob, turning a malformed one into an actionable, instance-named failure. + private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson) + { + try + { + return JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' deserialised to null"); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex); + } + } + + /// + /// Selects the provider seam. + /// v1 constructs only. The other members exist on the shipped + /// enum as reserved names; authoring one is a config mistake that must fail at construction with a + /// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database. + /// + private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId) + => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + _ => throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " + + $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."), + }; + + /// + /// Applies the documented defaults and validates every knob (design §5.1 / §8.3). + /// Internal so the defaults and the rejections can be asserted directly, without a database or a + /// provisioned connection string standing between the test and the rule. + /// + /// The deserialised config blob. + /// Named in every rejection message, so a fleet-wide log identifies the row. + /// The typed options the driver is constructed with. + /// A knob is out of range, or the two deadlines are inverted. + internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId) + { + ArgumentNullException.ThrowIfNull(dto); + + var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5); + var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15); + var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10); + var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4; + + RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId); + RequirePositive(operationTimeout, "operationTimeout", driverInstanceId); + RequirePositive(commandTimeout, "commandTimeout", driverInstanceId); + if (maxConcurrentGroups < 1) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " + + $"it must be at least 1."); + } + + // Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the + // server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a + // wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires + // first and the backstop can never be reached, so a deployment quietly runs on one bound instead of + // two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted. + if (operationTimeout <= commandTimeout) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " + + $"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " + + $"be strictly greater than the server-side commandTimeout backstop, or the backstop can " + + $"never fire."); + } + + return new SqlDriverOptions + { + RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [], + DefaultPollInterval = defaultPollInterval, + OperationTimeout = operationTimeout, + CommandTimeout = commandTimeout, + MaxConcurrentGroups = maxConcurrentGroups, + NullIsBad = dto.NullIsBad ?? false, + }; + } + + /// Rejects a non-positive duration, naming the authored field. + private static void RequirePositive(TimeSpan value, string field, string driverInstanceId) + { + if (value <= TimeSpan.Zero) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero."); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs new file mode 100644 index 00000000..0fb8fdb0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -0,0 +1,452 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves the composition layer: (config blob → live driver) and +/// (a connectionStringRef name → the secret it stands for). +/// Three things here are behaviour rather than plumbing, and each has its own test below. +/// (1) The string-enum guard — the driver-page enum-serialization defect that bit the AdminUI shipped +/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the +/// driver at deploy time. The factory's options therefore both accept a numeric provider and write the +/// NAME. (2) Config validationoperationTimeout > commandTimeout is deliberately +/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a +/// deployment silently masks its own server-side backstop. (3) Credential hygiene — the resolved +/// connection string is a secret; it must not reach a log line or an exception message, and the tests below +/// assert that rather than trusting the reviewer. +/// +public sealed class SqlDriverFactoryTests +{ + // ---- the plan's headline legs ---- + + [Fact] + public void CreateInstance_missingConnectionStringRef_throwsActionable() + => Should.Throw(() => + SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null)) + .Message.ShouldContain("connectionStringRef"); + + [Fact] + public void ConnectionStringRef_resolvesFromEnv() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;"); + + SqlConnectionStringResolver.Resolve(name).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"); + } + + // ---- the other half of the enum guard: a NUMERIC provider must still parse ---- + + [Fact] + public void CreateInstance_providerWrittenAsANumber_stillParses() + { + // The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that + // would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the + // options accept both spellings on the way IN, and only ever emit the name on the way OUT. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null); + + driver.DriverType.ShouldBe(SqlDriver.DriverTypeName); + } + + [Fact] + public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal() + { + // A config blob authored against a newer (or older) schema must not brick the driver. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null); + + driver.DriverInstanceId.ShouldBe("d1"); + } + + // ---- connection-string resolution ---- + + [Fact] + public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable() + { + var name = NewRef(); + + var ex = Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); + + // The operator's next action is "set this variable", so the message must name it exactly. + ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); + } + + [Fact] + public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent() + { + // An empty value is a half-provisioned deployment, not a connection string — failing here beats + // handing "" to the provider and reporting an unintelligible ADO.NET error. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), " "); + + Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); + } + + [Fact] + public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef() + { + var name = NewRef(); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}"}""", null)); + + ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); + } + + // ---- provider gate ---- + + [Fact] + public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null)); + + ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("not available in this build"); + } + + // ---- config validation (the reader and the shell deliberately do NOT do this) ---- + + [Fact] + public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected() + { + // Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout + // backstop can never be reached — the deployment silently loses one of its two independent bounds. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} + """, + null)); + + ex.Message.ShouldContain("operationTimeout"); + ex.Message.ShouldContain("commandTimeout"); + } + + [Fact] + public void CreateInstance_equalTimeouts_areAlsoRejected() + { + // "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect + // with a coin toss on top. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"} + """, + null)); + } + + [Theory] + [InlineData("\"commandTimeout\":\"00:00:00\"")] + [InlineData("\"operationTimeout\":\"-00:00:05\"")] + [InlineData("\"defaultPollInterval\":\"00:00:00\"")] + [InlineData("\"maxConcurrentGroups\":0")] + public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson) + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null)); + } + + [Fact] + public void CreateInstance_theDocumentedDefaults_passValidation() + { + // The falsifiability control for the four rejection legs above: an all-defaults config must build. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var options = SqlDriverFactoryExtensions.BuildOptions( + new SqlDriverConfigDto { ConnectionStringRef = name }, "d1"); + + options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10)); + options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15)); + options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5)); + options.MaxConcurrentGroups.ShouldBe(4); + options.NullIsBad.ShouldBeFalse(); + options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout); + } + + // ---- rawTags: how the deploy artifact actually delivers a driver's tags ---- + + [Fact] + public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions() + { + var dto = JsonSerializer.Deserialize( + """ + { + "connectionStringRef":"anything", + "rawTags":[ + {"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}, + {"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false} + ] + } + """, + SqlDriverFactoryExtensions.JsonOptionsForTest)!; + + var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1"); + + options.RawTags.Count.ShouldBe(2); + options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed"); + options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp"); + } + + [Fact] + public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull() + { + var options = SqlDriverFactoryExtensions.BuildOptions( + new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1"); + + options.RawTags.ShouldBeEmpty(); + } + + // ---- v1 is read-only, structurally ---- + + [Fact] + public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly() + { + // allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible + // and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure + // mode is an operator who believes writes are enabled — which only a loud warning closes. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var loggerFactory = new RecordingLoggerFactory(); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); + + driver.ShouldNotBeAssignableTo(); + var warning = loggerFactory.Entries + .Where(e => e.Level >= LogLevel.Warning) + .ShouldHaveSingleItem(); + warning.Message.ShouldContain("allowWrites"); + warning.Message.ShouldContain("read-only"); + } + + [Fact] + public void CreateInstance_withoutAllowWrites_logsNoWarning() + { + // The falsifiability control for the warning above — a warning that always fires proves nothing. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var loggerFactory = new RecordingLoggerFactory(); + + SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory); + + loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty(); + // ...and the factory DID log — so "no warning" is a statement about severity, not about silence. + loggerFactory.Entries.ShouldNotBeEmpty(); + } + + // ---- credential hygiene ---- + + [Fact] + public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint() + { + const string secret = "SuperSecret-PleaseDoNotLogMe"; + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), + $"Server=srv;Database=db;User ID=sa;Password={secret}"); + var loggerFactory = new RecordingLoggerFactory(); + + var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); + + // The credential-free rendering IS emitted — the factory logs where the driver points, which is what + // makes "no connection string" a real constraint rather than "log nothing and call it safe". + driver.Endpoint.ShouldBe("srv/db"); + foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret); + loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into. + } + + [Fact] + public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString() + { + const string secret = "SuperSecret-PleaseDoNotThrowMe"; + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), + $"Server=srv;Database=db;Password={secret}"); + + // Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the + // secret in hand — the case where a careless $"...{connectionString}" would actually leak. + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} + """, + null)); + + ex.ToString().ShouldNotContain(secret); + } + + // ---- registry wiring ---- + + [Fact] + public void Register_registersTheFactoryUnderTheDriversTypeName() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var registry = new DriverFactoryRegistry(); + + SqlDriverFactoryExtensions.Register(registry); + + var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull(); + var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}"""); + driver.ShouldBeOfType(); + driver.DriverType.ShouldBe("Sql"); + } + + [Fact] + public void DriverTypeName_matchesTheDriversOwnConstant() + // Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart. + => SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName); + + // ---- argument guards ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateInstance_blankInputs_areRejected(string blank) + { + Should.Throw(() => + SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null)); + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null)); + } + + [Fact] + public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance() + { + var ex = Should.Throw( + () => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null)); + + ex.Message.ShouldContain("d1"); + } + + /// A per-test connection-string ref, so no two tests can collide over one process-wide env var. + private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}"; +} + +/// +/// Sets one environment variable for the life of the scope and restores its previous value — including +/// restoring it to . Environment variables are process-global, so a test that sets +/// one without restoring it leaks into every test that runs after it in the same process. +/// +internal sealed class EnvironmentVariableScope : IDisposable +{ + private readonly string _name; + private readonly string? _previous; + + /// Sets to , remembering what was there. + /// The environment variable to set. + /// The value to set it to. + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + /// Restores the previous value. + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); +} + +/// +/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted +/// rather than assumed. +/// +internal sealed class RecordingLoggerFactory : ILoggerFactory +{ + private readonly List _entries = []; + + /// The log entries recorded so far, in order. + public IReadOnlyList Entries + { + get { lock (_entries) return [.. _entries]; } + } + + /// + public ILogger CreateLogger(string categoryName) => new Recorder(this); + + /// + public void AddProvider(ILoggerProvider provider) { } + + /// + public void Dispose() { } + + private void Record(LogLevel level, string message) + { + lock (_entries) _entries.Add(new LogEntry(level, message)); + } + + /// One captured log entry. + /// The level it was logged at. + /// The formatted message. + internal sealed record LogEntry(LogLevel Level, string Message); + + private sealed class Recorder(RecordingLoggerFactory owner) : ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => owner.Record(logLevel, formatter(state, exception)); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() { } + } +} From 4c716242ac097a20d5e9884f89e4ec46b77f4aad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:11:32 -0400 Subject: [PATCH 21/33] feat(sql): SqlDriverProbe SELECT-1 liveness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AdminUI "Test Connect" probe for the Sql driver: open a connection, run the dialect's LivenessSql (SELECT 1) under a linked-CTS deadline, return green with latency or red with a reason. Implements IDriverProbe; never throws (malformed JSON, missing connectionStringRef, unprovisioned connection string, a provider open failure, timeout, cancellation all become red results). Parses the SAME factory DTO with the SAME JsonStringEnumConverter as SqlDriverFactoryExtensions (R2-11 factory parity, mirroring ModbusDriverProbe) and resolves connectionStringRef the same way, so a config that Test-Connects is the config that Deploys. Credential hygiene: the resolved connection string never reaches the result message. A provider exception can embed the data source in its OWN message (the real SqlException shape), so the catch-all names the exception TYPE only, never ex.Message. The regression test's fake connection embeds the connection string in its thrown message specifically so surfacing ex.Message would leak it — verified load-bearing by breaking the guard and watching it go red. ForTest injects factory+dialect for the offline SQLite fixture path. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlDriverProbe.cs | 171 +++++++++++++++ .../SqlDriverProbeTests.cs | 198 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs new file mode 100644 index 00000000..79c5b986 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs @@ -0,0 +1,171 @@ +using System.Data.Common; +using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the +/// dialect's SELECT 1 () under a bounded deadline, and report +/// green with latency or red with a reason. Mirrors ModbusDriverProbe. +/// Parses the SAME factory DTO with the SAME converter as +/// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a +/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised +/// provider parses here exactly as it does at the factory, and the same +/// connectionStringRef resolves the same way. +/// Never throws (the contract). Every failure — malformed JSON, a +/// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a +/// timeout, a cancellation — becomes a red , so the AdminUI has nothing to +/// catch. +/// The resolved connection string never reaches the result message. A red result names the +/// failure class, never the string — the same credential discipline the factory and the driver keep. +/// +public sealed class SqlDriverProbe : IDriverProbe +{ + /// + /// Selects the provider seam by . In production this constructs the real + /// ; injects a test dialect (and factory) so the + /// probe can run against the SQLite fixture with no SQL Server and no network. + /// + private readonly Func _dialectFor; + + /// + /// Overrides the dialect's own when non-null — the injection point + /// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's + /// catalog SQL. Null in production: the dialect's own factory is used. + /// + private readonly DbProviderFactory? _factoryOverride; + + /// Initializes a new that constructs the real SQL Server dialect. + public SqlDriverProbe() + : this(DefaultDialectFor, factoryOverride: null) + { + } + + private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride) + { + _dialectFor = dialectFor; + _factoryOverride = factoryOverride; + } + + /// + /// Test seam: a probe that always uses and opens connections from + /// , so the SQLite fixture's create → open → SELECT 1 → close cycle + /// runs unmodified. + /// + /// The provider factory the probe opens connections from. + /// The dialect whose the probe runs. + /// A probe wired to the supplied factory + dialect. + /// A required argument is null. + internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(dialect); + return new SqlDriverProbe(_ => dialect, factory); + } + + /// + public string DriverType => SqlDriver.DriverTypeName; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt. + SqlDriverConfigDto? dto; + try + { + dto = System.Text.Json.JsonSerializer.Deserialize( + configJson, SqlDriverFactoryExtensions.JsonOptions); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException) + { + return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (dto is null) + return new DriverProbeResult(false, "Config JSON deserialized to null.", null); + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null); + + ISqlDialect dialect; + string connectionString; + try + { + dialect = _dialectFor(dto.Provider); + connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + } + catch (InvalidOperationException ex) + { + // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is + // safe and actionable (it names the environment variable the operator must set). + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + + var factory = _factoryOverride ?? dialect.Factory; + return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false); + } + + /// + /// Opens one connection under a linked CTS bounded by , runs the dialect's + /// liveness statement, and returns green with latency. Any failure becomes a red result whose message + /// names the failure class — never the connection string (a provider exception can embed the + /// data source, so its .Message is deliberately not surfaced). + /// + private static async Task RunLivenessAsync( + DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout, + CancellationToken ct) + { + var stopwatch = Stopwatch.StartNew(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout); + + try + { + var connection = factory.CreateConnection() + ?? throw new InvalidOperationException( + $"the {dialect.Provider} provider factory returned no connection."); + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = connectionString; + await connection.OpenAsync(cts.Token).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = dialect.LivenessSql; + await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false); + } + } + + stopwatch.Stop(); + return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return new DriverProbeResult(false, "Probe cancelled.", null); + } + catch (OperationCanceledException) + { + return new DriverProbeResult( + false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); + } + catch (Exception ex) + { + // The provider's own message can carry the data source (host/database), which is not a secret but + // is more than the operator needs — and keeping the message to a fixed class is what guarantees no + // credential-bearing connection string can ever slip through. Name the exception TYPE only. + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + } + + /// Constructs the production dialect for a provider; v1 supports SQL Server only. + private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + _ => throw new InvalidOperationException( + $"provider '{provider}' is not available in this build (only SqlServer is supported)."), + }; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs new file mode 100644 index 00000000..7092695d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -0,0 +1,198 @@ +using System.Data; +using System.Data.Common; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves — the AdminUI "Test Connect" liveness check — against the real +/// SQLite fixture. A probe opens a connection, runs the dialect's SELECT 1, and reports green with +/// latency or red with a reason; it never throws (the contract). +/// The probe parses the SAME factory DTO shape as (R2-11 +/// factory parity, mirroring ModbusDriverProbe), so a config that Tests-green and a config that +/// Deploys are the same config. It resolves the connectionStringRef the same way too — and the +/// tests below assert the resolved connection string never reaches the red-result message. +/// +public sealed class SqlDriverProbeTests +{ + [Fact] + public async Task Probe_liveConnection_returnsGreen() + { + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeTrue(); + result.Latency.ShouldNotBeNull(); + result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); + } + + [Fact] + public async Task Probe_declaresTheSqlDriverType() + => SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()) + .DriverType.ShouldBe(SqlDriver.DriverTypeName); + + [Fact] + public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing() + { + // A connection string that resolves fine but points at nothing openable. The probe must catch the + // provider's failure and hand back a red result, never propagate it. + var probe = SqlDriverProbe.ForTest( + new FailingFactory("simulated open failure"), new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(connectionString: "Data Source=/no/such/place.db"), + TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNullOrWhiteSpace(); + result.Latency.ShouldBeNull(); + } + + [Fact] + public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow() + { + var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + """{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("connectionStringRef"); + } + + [Fact] + public async Task Probe_configThatIsNotJson_returnsRed_notAThrow() + { + var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + "definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy() + { + // R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically + // serialised provider Test-Connects exactly as it Deploys. + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeTrue(); + } + + [Fact] + public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage() + { + const string secret = "Password=DoNotLeakMeIntoAProbeMessage"; + var connectionString = $"Data Source=/no/such.db;{secret}"; + // The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its + // OWN exception message. This fake reproduces that — it throws with the connection string in the + // message — so a probe that surfaced ex.Message would leak, and this test would then go red. + var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(connectionString: connectionString), + TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage"); + } + + [Fact] + public async Task Probe_honoursCancellation_asRed_notAThrow() + { + // A cancelled probe is still a probe result, not an exception the AdminUI has to catch. + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token); + + result.Ok.ShouldBeFalse(); + } + + // ---- helpers ---- + + /// A config blob whose connectionStringRef resolves (for the life of the returned scope) to the + /// fixture's real database, so the probe's SELECT 1 actually runs. + private static string ConfigJson(SqlitePollFixture fixture) + => ConfigJson(connectionString: fixture.ConnectionString); + + private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture) + { + var name = SetRef(fixture.ConnectionString); + return $$"""{"provider":0,"connectionStringRef":"{{name}}"}"""; + } + + /// + /// Builds a config JSON whose connectionStringRef is provisioned to + /// via the process environment — the exact resolution path the + /// factory uses. The env var is set for the test process; each call mints a unique ref so parallel + /// tests cannot collide, and the value is never unset (a harmless per-run leak of a random name). + /// + private static string ConfigJson(string connectionString) + { + var name = SetRef(connectionString); + return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}"""; + } + + private static string SetRef(string connectionString) + { + var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString); + return name; + } + + /// A whose connections throw on open — a database that cannot be + /// reached, without needing a real unreachable endpoint. + private sealed class FailingFactory(string message) : DbProviderFactory + { + public override DbConnection CreateConnection() => new FailingConnection(message); + } + + /// Like , but its open failure embeds the connection string in the + /// thrown exception's message — the realistic provider shape that a naive ex.Message would leak. + private sealed class LeakyFailingFactory : DbProviderFactory + { + public override DbConnection CreateConnection() => new FailingConnection(message: null); + } + + private sealed class FailingConnection(string? message) : DbConnection + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + public override string Database => string.Empty; + public override string DataSource => string.Empty; + public override string ServerVersion => string.Empty; + public override ConnectionState State => ConnectionState.Closed; + + // A null ctor message means "embed the connection string" — the leaky provider shape. + private string FailureMessage => message ?? $"connect failed for {ConnectionString}"; + + public override void Open() => throw new InvalidOperationException(FailureMessage); + + public override Task OpenAsync(CancellationToken cancellationToken) + => throw new InvalidOperationException(FailureMessage); + + public override void Close() { } + public override void ChangeDatabase(string databaseName) { } + protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) + => throw new NotSupportedException(); + protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); + } +} From 14fe89c505664bec5705009c0d13edaf185eceed Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:11:56 -0400 Subject: [PATCH 22/33] feat(sql): AdminUI browser DI + Sql address picker body Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor: a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a column attribute side-panel, manual-entry model/selector fields retained, that composes the per-tag TagConfig blob (KeyValue / WideRow) matched to SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and never persisted into the composed blob or driver config. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Pickers/SqlAddressPickerBody.razor | 441 ++++++++++++++++++ .../EndpointRouteBuilderExtensions.cs | 6 + .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 4 + 3 files changed, 451 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor new file mode 100644 index 00000000..b616571a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor @@ -0,0 +1,441 @@ +@* Sql address picker (read-only v1): + 1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the + table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a + server the AdminUI can't reach, authors a tag entirely from these fields. + 2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the + picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills + `table`, the value/column field, and the inferred `type`. + + The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the + shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/ + `type` enums are matched to the parser precisely; a mismatch would author fine and never read. + + Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the + JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed + TagConfig blob nor back into the driver config — there is no code path here that persists it. *@ +@implements IAsyncDisposable +@using System.Text.Json.Nodes +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Sql +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser +@inject IBrowserSessionService BrowserService +@inject AuthenticationStateProvider AuthState +@inject IAuthorizationService AuthorizationService + +
+
+ + +
+ KeyValue: one row per key. WideRow: many columns of one selected row. +
+
+
+ + +
Schema-qualified, e.g. dbo.TagValues.
+
+
+ + +
Blank ⇒ inferred from the column.
+
+
+ +@if (_model == "KeyValue") +{ +
+
+ + +
+
+ + +
Bound as a parameter — never interpolated.
+
+
+ + +
+
+} +else +{ +
+
+ + +
The column this tag reads from the selected row.
+
+
+ + +
+ @if (_selectorMode == "Where") + { +
+ + +
+
+ + +
+ } + else + { +
+ + +
+ } +
+} + +
+
+ + +
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+
+ +@if (_canOperate) +{ +
+
+
+ + +
+ Leave blank to browse via the driver's connectionStringRef (resolved from + the AdminUI host's Sql__ConnectionStrings__<ref> env var). Paste a + literal only for a one-off browse — it is used for this session and never persisted. +
+
+
+ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + } +
+ @if (_openError is not null) + { +
@_openError
+ } + + @if (_token != Guid.Empty) + { +
+
+ + +
+
+ +
+ @if (_attrsLoading) + { + + } + else if (_attrsError is not null) + { + @_attrsError + } + else if (_attrs is null) + { + Pick a column leaf. + } + else if (_attrs.Count == 0) + { + Column no longer present. + } + else + { + @foreach (var a in _attrs) + { +
+ @a.Name + @a.DriverDataType · @a.SecurityClass +
+ } + } +
+
+
+ } +} + +
+ TagConfig: + @(_built.Length == 0 ? "—" : _built) +
+ +@code { + [Parameter] public string CurrentAddress { get; set; } = ""; + [Parameter] public EventCallback CurrentAddressChanged { get; set; } + + /// Live accessor for the selected driver's DriverConfig JSON (carries + /// connectionStringRef + provider). Fed to BrowserService.OpenAsync("Sql", …). + [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; + + /// Fires with the picker's suggested tag display name (table.column) whenever a + /// column is picked from the tree, so a host editor may default the raw tag's name. Optional — + /// the composed TagConfig blob itself carries no display name (it is not a parser field). + [Parameter] public EventCallback SuggestedDisplayNameChanged { get; set; } + + // Manual/composed tag fields (all editable by hand; browse only prefills a subset). + private string _model = "KeyValue"; + private string _table = ""; + private string _keyColumn = ""; + private string _keyValue = ""; + private string _valueColumn = ""; + private string _columnName = ""; + private string _selectorMode = "Where"; + private string _whereColumn = ""; + private string _whereValue = ""; + private string _topByTimestamp = ""; + private string _timestampColumn = ""; + private string _type = ""; + + // Browse-only, session-scoped state. + private string _adhocConnectionString = ""; + private string _built = ""; + private string _selectedColumnNodeId = ""; + private Guid _token = Guid.Empty; + private bool _opening; + private bool _canOperate; + private string? _openError; + private bool _attrsLoading; + private string? _attrsError; + private IReadOnlyList? _attrs; + + protected override async Task OnInitializedAsync() + { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); + _canOperate = authResult.Succeeded; + _built = Build(); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; + _openError = null; + StateHasChanged(); + try + { + // Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is + // never written to the composed TagConfig blob nor back to the driver config — this is the + // sole place it is read, and it dies with the local variable. + var json = BuildBrowseConfig(); + var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default); + if (result.Ok) { _token = result.Token; } + else { _openError = result.Message; } + } + finally { _opening = false; StateHasChanged(); } + } + + /// + /// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one, + /// a top-level connectionString literal. The literal wins over any connectionStringRef + /// server-side (SqlDriverBrowser precedence) and is session-only. + /// + private string BuildBrowseConfig() + { + var baseJson = GetConfigJson() ?? "{}"; + if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; } + + JsonObject o; + try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); } + catch (System.Text.Json.JsonException) { o = new JsonObject(); } + o["connectionString"] = _adhocConnectionString; + return o.ToJsonString(); + } + + private async Task CloseBrowseAsync() + { + var t = _token; + _token = Guid.Empty; + _attrs = null; + _selectedColumnNodeId = ""; + StateHasChanged(); + if (t != Guid.Empty) { await BrowserService.CloseAsync(t); } + } + + /// + /// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are + /// navigation, not selection. The NodeId is decoded with + /// — never split by hand, because SQL identifiers may contain '.' and '|'. + /// + private async Task OnTreeSelectAsync(BrowseNode node) + { + if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference) + || reference.Kind != SqlBrowseNodeKind.Column) + { + return; + } + + _selectedColumnNodeId = node.NodeId; + _table = $"{reference.Schema}.{reference.Table}"; + if (_model == "WideRow") { _columnName = reference.Column!; } + else { _valueColumn = reference.Column!; } + + // Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified). + await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}"); + + _attrs = null; + _attrsLoading = true; + _attrsError = null; + StateHasChanged(); + try + { + _attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default); + var picked = _attrs.Count > 0 ? _attrs[0] : null; + if (picked is not null) { PrefillType(picked); } + } + catch (Exception ex) { _attrsError = ex.Message; } + finally + { + _attrsLoading = false; + await OnChangedAsync(); + } + } + + /// Re-applies the side-panel column's inferred type (and value-column) on click. + private async Task ApplyAttributeAsync(AttributeInfo a) + { + if (_model == "WideRow") { _columnName = a.Name; } + else { _valueColumn = a.Name; } + PrefillType(a); + await OnChangedAsync(); + } + + /// Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps. + private void PrefillType(AttributeInfo a) + { + if (Enum.TryParse(a.DriverDataType, out _)) { _type = a.DriverDataType; } + } + + private async Task OnManualChangedAsync() => await OnChangedAsync(); + + private async Task OnChangedAsync() + { + _built = Build(); + await CurrentAddressChanged.InvokeAsync(_built); + } + + /// + /// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to + /// SqlEquipmentTagParser. Returns "" until the selected model's required fields are all + /// present, so the "Use this address" button never commits a half-blob the parser would reject. + /// model and type are written as NAME strings (the parser reads them strictly). + /// + private string Build() + { + var table = _table.Trim(); + if (table.Length == 0) { return ""; } + + var o = new JsonObject + { + ["driver"] = "Sql", + ["model"] = _model, + ["table"] = table, + }; + + if (_model == "KeyValue") + { + var keyColumn = _keyColumn.Trim(); + var valueColumn = _valueColumn.Trim(); + var keyValue = _keyValue.Trim(); + if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; } + o["keyColumn"] = keyColumn; + o["keyValue"] = keyValue; + o["valueColumn"] = valueColumn; + } + else // WideRow + { + var columnName = _columnName.Trim(); + if (columnName.Length == 0) { return ""; } + o["columnName"] = columnName; + + var rowSelector = new JsonObject(); + if (_selectorMode == "Where") + { + var whereColumn = _whereColumn.Trim(); + var whereValue = _whereValue.Trim(); + if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; } + rowSelector["whereColumn"] = whereColumn; + rowSelector["whereValue"] = whereValue; + } + else // Newest + { + var topByTimestamp = _topByTimestamp.Trim(); + if (topByTimestamp.Length == 0) { return ""; } + rowSelector["topByTimestamp"] = topByTimestamp; + } + + o["rowSelector"] = rowSelector; + } + + var timestampColumn = _timestampColumn.Trim(); + if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; } + + if (_type.Length > 0) { o["type"] = _type; } + + // v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side). + o["writable"] = false; + + return o.ToJsonString(); + } + + public ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + // Fire-and-forget — don't block circuit teardown on a slow database. + _ = BrowserService.CloseAsync(_token); + } + return ValueTask.CompletedTask; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 11d629c5..f19cbcf7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; using ZB.MOM.WW.Secrets.Ui; namespace ZB.MOM.WW.OtOpcUa.AdminUI; @@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + // Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every + // ctor param has a production default. The universal-browser DI line is deliberately NOT added + // for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction + // (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal). + services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory // → CanBrowse false → pickers gracefully stay manual-entry (design §6). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 10a58694..8e483207 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -39,6 +39,10 @@ + + From a55b7e51ca8fe0fe2359c1d11c31e2691e60beb9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:13:32 -0400 Subject: [PATCH 23/33] =?UTF-8?q?test(sql):=20injection=20regression=20?= =?UTF-8?q?=E2=80=94=20bind=20values,=20reject=20unknown=20identifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the driver's injection guarantee against the SQLite fixture. An authored VALUE ('; DROP TABLE TagValues; --) binds as a DbParameter and can only ever be a key that matches no row (BadNoData); the seed table survives. A hostile IDENTIFIER in table/column position is dialect-quoted into a single nonexistent identifier — inert — and the payload never executes. Scope, stated plainly: design §8.1 also specifies a catalog gate (validate an authored identifier against INFORMATION_SCHEMA, reject an unknown one as BadNodeIdUnknown). That gate does NOT exist in the driver yet and no task here builds it, so a hostile identifier is not rejected up front — it is quoted, the query fails after the connection opened, and the tag Bad-codes as a query failure (BadCommunicationError), not BadNodeIdUnknown. These tests assert what the code actually guarantees — the payload is inert and the table intact — rather than a catalog gate that isn't there. The gate is a tracked follow-up. No implementation change: the reader already binds correctly (Task 7); this suite pins it. Falsifiability-checked — forcing a real DROP before the row-count assertion turns it red, so "table survives" is load-bearing. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlInjectionRegressionTests.cs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs new file mode 100644 index 00000000..784b3dd7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs @@ -0,0 +1,168 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Locks the driver's SQL-injection guarantee against a real database (): +/// an authored value is bound as a parameter and can never execute, and an authored +/// identifier — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes +/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag +/// cannot alter the database; the seed table is intact after every hostile poll. +/// Scope note — what this suite does NOT assume. Design §8.1 also specifies a catalog gate: +/// validate every authored table/column against INFORMATION_SCHEMA before quoting it, so an unknown +/// identifier rejects the tag. No such gate exists in the driver yet (see the "Not yet +/// implemented" note on ), and no task in this workstream builds one. So a hostile +/// identifier here is not rejected as BadNodeIdUnknown — it is bracket-/double-quoted into a +/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes +/// as a query failure (). This suite proves the payload +/// is inert — it does not execute and the table survives — which is the guarantee the code +/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be +/// asserting fiction. +/// +public sealed class SqlInjectionRegressionTests +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15); + + /// The classic payload: were the key concatenated into text, this would drop the table. + private const string DropTablePayload = "'; DROP TABLE TagValues; --"; + + [Fact] + public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives() + { + using var fixture = new SqlitePollFixture(); + var reader = NewReader(fixture, KvTag("Speed", DropTablePayload)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + // Bound, not executed: it is simply a key that matches no row. + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + // The table is still there, with its seeded rows — the DROP never ran. + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); + } + + [Fact] + public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll() + { + // A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own + // parameter and matches nothing; the real key still reads. + using var fixture = new SqlitePollFixture(); + var reader = NewReader(fixture, + KvTag("Evil", DropTablePayload), + KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); + } + + [Fact] + public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives() + { + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + // A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not + // rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table), + // the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs. + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: "TagValues\"; DROP TABLE TagValues; --", + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + // Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate). + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError); + // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched. + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + [Fact] + public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives() + { + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + // The same attack via the value column: quoted, so it becomes a nonexistent column reference; the + // SELECT fails and the tag Bad-codes. Nothing executes. + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: "num_value\"; DROP TABLE TagValues; --", + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + [Fact] + public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase() + { + // QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object). + // The planner throws ArgumentException, which the reader classes as an authoring fault — + // BadConfigurationError — rather than a database failure. Still inert: the table survives. + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: "Tag\0Values", + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + // ---- helpers ---- + + private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags) + { + var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal); + return new SqlPollReader( + fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: rawPath => table.GetValueOrDefault(rawPath)); + } + + private static SqlTagDefinition KvTag(string name, string keyValue) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn); + + /// Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not + /// run. The table name is a test constant, never authored input, so interpolating it here is safe. + private static async Task RowCountAsync(SqlitePollFixture fixture, string table) + { + await using var command = fixture.Connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table}\""; + return Convert.ToInt64(await command.ExecuteScalarAsync()); + } +} From 10a6a37ca5399d74f0bd5594998267ca6f4790fc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:20:24 -0400 Subject: [PATCH 24/33] test(sql): blackhole/timeout live-gate on a dedicated mssql container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the frozen-peer / BadTimeout gate for the Sql driver — the single highest-value integration test. Mirrors the S7 R2-01 blackhole gate: docker pause a DEDICATED mssql mid-poll, assert the next read surfaces BadTimeout within the client-side operationTimeout (≈3s) and well below the server-side CommandTimeout backstop (30s), the driver degrades (Degraded + host Stopped), and docker unpause recovers to Healthy/Running. - Docker/docker-compose.yml: disposable `otopcua-sql-blackhole` mssql on :14333 (never the shared :14330 ConfigDb server) + one-shot seed. - Docker/seed.sql: the two sample tables. - SqlBlackholeTimeoutTests.cs: env-gated (SQL_BLACKHOLE_ENDPOINT); pauses ONLY the hard-coded container name; skips loudly if the endpoint is the shared port 14330; bounds its own wait so a wedged impl fails, not hangs. Offline: clean skip (no socket, no docker shell-out). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Docker/docker-compose.yml | 68 +++ .../Docker/seed.sql | 43 ++ .../SqlBlackholeTimeoutTests.cs | 409 ++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 00000000..eb918f8d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,68 @@ +# Dedicated, DISPOSABLE SQL Server for the Sql driver's blackhole / frozen-peer live gate. +# +# ── SAFETY ───────────────────────────────────────────────────────────────────── +# This stack owns its OWN mssql container, `otopcua-sql-blackhole`, on host port +# 14333. The blackhole gate `docker pause`s THIS container by that hard-coded name. +# It is NOT — and must never be — the rig's shared always-on SQL Server, which is +# `10.100.0.35,14330` and hosts ConfigDb for the whole dev rig. Two independent +# guardrails keep them apart: +# 1. a distinct container_name (`otopcua-sql-blackhole`) — the pause target is a +# compile-time constant in the test, and the shared server is not named this; and +# 2. a distinct host port (14333, not 14330) — the test SKIPS LOUDLY if its +# endpoint resolves to port 14330. +# `restart: "no"` so a paused/killed container stays down rather than respawning. +# +# ── DEPLOY (from this VM; docker runs on the shared Linux host) ────────────────── +# lmxopcua-fix sync sql-blackhole # rsync this Docker/ dir → /opt/otopcua-sql-blackhole/ +# lmxopcua-fix up sql-blackhole # single-service stack (+ one-shot seed), no profile arg +# `lmxopcua-fix` applies the host-side `project=lmxopcua` label; the explicit label +# below keeps the stack discoverable even when brought up with plain `docker compose`. +# +# The one-shot `mssql-seed` service applies seed.sql once the server is healthy, so +# the stack is self-seeding. The blackhole test ALSO seeds defensively on connect +# (create-if-missing, same schema), so a stack that never ran the seed still works. + +name: otopcua-sql-blackhole + +services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: otopcua-sql-blackhole + labels: + project: lmxopcua + restart: "no" + environment: + ACCEPT_EULA: "Y" + # Dev-only sandbox password for a throwaway container. Override via the shell env + # (the same value the test reads from SQL_BLACKHOLE_PASSWORD). + MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}" + MSSQL_PID: "Developer" + ports: + - "14333:1433" + healthcheck: + # -C trusts the self-signed dev cert; the login succeeding is the readiness signal. + test: + - "CMD-SHELL" + - "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -Q 'SELECT 1' || exit 1" + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + + # One-shot seed: waits for the server to pass its healthcheck, applies seed.sql, exits. + mssql-seed: + image: mcr.microsoft.com/mssql/server:2022-latest + labels: + project: lmxopcua + restart: "no" + depends_on: + mssql: + condition: service_healthy + environment: + MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}" + volumes: + - ./seed.sql:/seed.sql:ro + entrypoint: + - "/bin/bash" + - "-c" + - "/opt/mssql-tools18/bin/sqlcmd -C -S mssql -U sa -P \"$${MSSQL_SA_PASSWORD}\" -i /seed.sql" diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql new file mode 100644 index 00000000..6a91d115 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql @@ -0,0 +1,43 @@ +-- Seed for the Sql driver's blackhole / frozen-peer live gate (dedicated container only). +-- +-- Applied once by the compose `mssql-seed` one-shot against the OWNED +-- `otopcua-sql-blackhole` container. The blackhole test (SqlBlackholeTimeoutTests) +-- also creates this exact shape defensively on connect, so the two are kept in +-- parity: a Good baseline poll before the pause depends on `sqlpoll.TagValues` +-- holding the one seeded row. +-- +-- Two sample tables mirror the offline SqlitePollFixture / live SqlPollServerFixture +-- shapes so the gate polls something representative, not a bespoke schema. + +IF DB_ID(N'SqlBlackholeFixture') IS NULL CREATE DATABASE [SqlBlackholeFixture]; +GO + +USE [SqlBlackholeFixture]; +GO + +IF SCHEMA_ID(N'sqlpoll') IS NULL EXEC(N'CREATE SCHEMA [sqlpoll]'); +GO + +-- Key-value (EAV) source: tag_name → num_value, with a source timestamp. +DROP TABLE IF EXISTS [sqlpoll].[TagValues]; +CREATE TABLE [sqlpoll].[TagValues] ( + [tag_name] nvarchar(128) NOT NULL, + [num_value] float NULL, + [sample_ts] datetime2(3) NOT NULL +); +INSERT INTO [sqlpoll].[TagValues] ([tag_name], [num_value], [sample_ts]) +VALUES (N'Line1.Speed', 42.0, '2026-07-24T10:00:00'), + (N'Line1.Pressure', 3.5, '2026-07-24T10:00:01'); +GO + +-- Wide-row source: one row per station, several value columns. +DROP TABLE IF EXISTS [sqlpoll].[LatestStatus]; +CREATE TABLE [sqlpoll].[LatestStatus] ( + [station_id] int NOT NULL, + [oven_temp] float NULL, + [pressure] float NULL, + [sample_ts] datetime2(3) NOT NULL +); +INSERT INTO [sqlpoll].[LatestStatus] ([station_id], [oven_temp], [pressure], [sample_ts]) +VALUES (7, 180.5, 1.2, '2026-07-24T10:00:00'); +GO diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs new file mode 100644 index 00000000..3d9baaa4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs @@ -0,0 +1,409 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The single highest-value integration test for the Sql driver: the frozen-peer / +/// blackhole gate. When the database stops answering mid-poll, a reused pooled connection hangs +/// inside ExecuteReaderAsync; the driver must surface +/// within its client-side operationTimeout (a real linked-CTS cancellation) and NOT wedge the +/// poll loop. This is the exact bug class the repo shipped and fixed once in its S7 driver (async +/// reads ignored the socket read timeout → a frozen peer wedged the poll loop; see +/// S7_1500ConnectTimeoutOutageTests, which this mirrors). +/// The wall-clock bound is the whole point. operationTimeout is set well BELOW +/// commandTimeout here, deliberately inverted from the production rule, so the two backstops +/// are distinguishable: a working client-side cancellation returns at ≈ operationTimeout; if it +/// were broken and only the ADO.NET CommandTimeout server-side backstop fired, the read would +/// return at ≈ commandTimeout instead — which the upper-bound assertion fails on. A test that +/// merely asserted "eventually BadTimeout" would pass even with the client-side cancellation broken. +/// SAFETY — this gate `docker pause`s a DEDICATED container it owns, never shared infra. +/// The pause target is the compile-time constant +/// (otopcua-sql-blackhole), never anything derived from an env var, so a mis-set endpoint can +/// never aim a pause at the rig's shared always-on SQL Server (10.100.0.35,14330, which hosts +/// ConfigDb for the whole rig). As a second guardrail the gate skips loudly if its endpoint +/// resolves to the shared server's port . Bring the dedicated stack up +/// with Docker/docker-compose.yml (container otopcua-sql-blackhole, host port 14333). +/// Env-gated; offline skip is the normal outcome. With unset +/// nothing here opens a socket, shells out to docker, or waits — the suite is instant and green on the +/// macOS dev box, exactly like every other *.IntegrationTests live gate in this tree. +/// +/// Operator run recipe (docker runs on the shared Linux host; drive it from this VM): +/// +/// # 1. Deploy + start the DEDICATED disposable mssql (NOT the shared 14330 server): +/// lmxopcua-fix sync sql-blackhole +/// lmxopcua-fix up sql-blackhole # container otopcua-sql-blackhole on :14333, self-seeds +/// +/// # 2. Point the gate at the dedicated container + tell it how to reach docker: +/// export SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 # the dedicated container; MUST NOT be ,14330 +/// export SQL_BLACKHOLE_PASSWORD=Blackhole_dev_pw1 # matches the compose SA password +/// export SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 # empty ⇒ run docker locally +/// +/// # 3. Run just this gate: +/// dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \ +/// --filter "FullyQualifiedName~SqlBlackholeTimeoutTests" +/// +/// # 4. Tear the disposable stack down: +/// lmxopcua-fix down sql-blackhole +/// +/// +/// +[Trait("Category", "Integration")] +[Trait("Category", "Blackhole")] +public sealed class SqlBlackholeTimeoutTests +{ + /// Endpoint (host,port) of the DEDICATED disposable container. Absent ⇒ offline skip. + private const string EndpointEnvVar = "SQL_BLACKHOLE_ENDPOINT"; + + /// SA password for the dedicated container. Required by the run. + private const string PasswordEnvVar = "SQL_BLACKHOLE_PASSWORD"; + + /// SQL login. Defaults to sa. + private const string UserEnvVar = "SQL_BLACKHOLE_USER"; + + /// + /// Optional SSH target the docker pause/unpause runs through (e.g. + /// dohertj2@10.100.0.35), since docker -H ssh:// does not work from this VM. Empty ⇒ + /// run docker locally. + /// + private const string DockerSshEnvVar = "SQL_BLACKHOLE_DOCKER_SSH"; + + /// + /// The ONLY container this gate ever pauses — a compile-time constant, never derived from the + /// environment. The shared central SQL Server is not named this, so no env misconfiguration can + /// cause this gate to freeze shared infra. It matches Docker/docker-compose.yml's + /// container_name. + /// + private const string ContainerName = "otopcua-sql-blackhole"; + + /// + /// The rig's shared always-on SQL Server port (hosts ConfigDb for the whole rig). An endpoint on + /// this port is refused with a loud skip — the dedicated container must be on a different port. + /// + private const string SharedCentralPort = "14330"; + + /// The database the seed lives in; created if missing (never dropped). + private const string Database = "SqlBlackholeFixture"; + + /// The seeded key-value table + its columns and the one baseline key. + private const string Schema = "sqlpoll"; + private const string KeyValueTable = "TagValues"; + private const string KeyColumn = "tag_name"; + private const string ValueColumn = "num_value"; + private const string TimestampColumn = "sample_ts"; + private const string BaselineKey = "Line1.Speed"; + private const double BaselineValue = 42.0; + + /// The RawPath the driver serves the baseline key under. + private const string RawPath = "Sql/Line1/Speed"; + + // ── deadlines chosen so client-side and server-side backstops are DISTINGUISHABLE ── + + /// Client-side wall-clock ceiling on one group. A frozen peer must surface BadTimeout at ≈ this. + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(3); + + /// + /// ADO.NET server-side backstop — set an order of magnitude ABOVE so + /// that if the client-side cancellation were broken and only this fired, the read would return at + /// ≈ 30 s and the upper-bound assertion would catch it. + /// + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30); + + /// + /// Upper bound on the post-pause read. Comfortably above (docker + /// pause propagation + pooled-connection validation + thread-pool scheduling) yet comfortably below + /// — so a return here proves the CLIENT-side bound fired, not the + /// server-side backstop. + /// + private static readonly TimeSpan UpperBound = TimeSpan.FromSeconds(15); + + /// Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI. + private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60); + + /// + /// Start reading against the dedicated mssql, docker pause it mid-poll, and assert the next + /// read surfaces within (a + /// client-side linked-CTS cancellation, not the poll thread wedging and not the server-side + /// backstop), that the driver degrades (health + host + /// ), and that after docker unpause a subsequent poll recovers + /// to / . + /// + [Fact] + public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged() + { + var ep = Environment.GetEnvironmentVariable(EndpointEnvVar); + Assert.SkipWhen( + string.IsNullOrWhiteSpace(ep), + $"{EndpointEnvVar} not set — offline skip. Bring up Docker/docker-compose.yml (dedicated " + + $"container {ContainerName} on :14333) and export {EndpointEnvVar}/{PasswordEnvVar} to run this gate."); + + var password = Environment.GetEnvironmentVariable(PasswordEnvVar); + Assert.SkipWhen( + string.IsNullOrWhiteSpace(password), + $"{PasswordEnvVar} not set — the blackhole gate needs the dedicated container's SA password."); + + var (host, port) = ParseEndpoint(ep!); + + // ── SAFETY GUARD: refuse to run against the rig's shared central SQL Server. ── + // The pause target (ContainerName) is a constant and could never be the shared container regardless, + // but refusing the shared PORT stops us even connecting-and-polling against shared infra. + Assert.SkipWhen( + string.Equals(port, SharedCentralPort, StringComparison.Ordinal), + $"{EndpointEnvVar} resolves to port {SharedCentralPort} — that is the rig's SHARED always-on SQL " + + "Server (ConfigDb for the whole rig), which this destructive gate must NEVER pause. Point it at " + + $"the dedicated {ContainerName} container (host port 14333), not the shared server."); + + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa"; + var ssh = Environment.GetEnvironmentVariable(DockerSshEnvVar); + var ct = TestContext.Current.CancellationToken; + + // Seed defensively (create-if-missing) so the baseline poll is Good even if compose never ran seed.sql. + await SeedAsync(host, port, user, password!, ct); + + await using var driver = await StartDriverAsync(host, port, user, password!, ct); + + // Good baseline — prove the endpoint answers before we freeze it. + var baseline = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem(); + baseline.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(baseline.Value).ShouldBe(BaselineValue); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + var paused = false; + try + { + // Freeze the container mid-poll: its SQL process is SIGSTOPped, so the pooled connection's next + // ExecuteReaderAsync hangs against a live-but-silent socket — the frozen-peer wedge exactly. + await RunShellCommandAsync(DockerCommand("pause", ssh), ct); + paused = true; + + var clock = Stopwatch.StartNew(); + // Bounded by HardCap so a wedged implementation FAILS here instead of hanging CI. + var snapshots = await ReadOnceAsync(driver, ct).WaitAsync(HardCap, ct); + clock.Stop(); + + var frozen = snapshots.ShouldHaveSingleItem(); + frozen.StatusCode.ShouldBe( + SqlStatusCodes.BadTimeout, + "a frozen database must surface BadTimeout, not a stale Good or another Bad class"); + + // The wall-clock contract: returned at ≈ operationTimeout (client-side cancellation), NOT at + // commandTimeout (server-side backstop) and NOT never. UpperBound < CommandTimeout is what + // distinguishes a working client-side abort from a broken one that only the backstop rescued. + clock.Elapsed.ShouldBeLessThan( + UpperBound, + $"BadTimeout must arrive on the client-side {OperationTimeout.TotalSeconds:0}s bound, not the " + + $"server-side {CommandTimeout.TotalSeconds:0}s CommandTimeout backstop"); + clock.Elapsed.ShouldBeLessThan(CommandTimeout, "the server-side backstop must not be what fired"); + clock.Elapsed.ShouldBeGreaterThanOrEqualTo( + OperationTimeout - TimeSpan.FromSeconds(2), + "returning far faster than operationTimeout would mean a fast-fail misclassified as BadTimeout"); + + // Driver-level classification, not just the reader: an all-BadTimeout poll degrades health and + // stops the host (SqlDriver.ObservePollOutcome), even though the reader returned successfully. + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Stopped); + } + finally + { + if (paused) + { + // ALWAYS restore the container once paused — never leave a frozen container behind. + await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None); + } + } + + // Recovery: after unpause a subsequent poll must return Good and the whole stack must recover. + await PollUntilGoodAsync(driver, ct); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); + } + + // ── helpers ── + + /// Runs one poll of the single baseline reference. + private static Task> ReadOnceAsync(SqlDriver driver, CancellationToken ct) + => driver.ReadAsync([RawPath], ct); + + /// Polls until a Good snapshot returns, or the recovery deadline elapses. + private static async Task PollUntilGoodAsync(SqlDriver driver, CancellationToken ct) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(45); + while (DateTime.UtcNow < deadline) + { + var snapshot = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem(); + if (snapshot.StatusCode == SqlStatusCodes.Good) return; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + throw new Xunit.Sdk.XunitException( + "the driver never recovered to a Good poll within 45 s of docker unpause — the poll loop did not survive the outage"); + } + + /// Builds a driver over the dedicated container, warmed to hold a pooled connection. + private static async Task StartDriverAsync( + string host, string port, string user, string password, CancellationToken ct) + { + var options = new SqlDriverOptions + { + RawTags = [KeyValueTag()], + OperationTimeout = OperationTimeout, + CommandTimeout = CommandTimeout, + }; + + var driver = new SqlDriver( + options, + driverInstanceId: "sql-blackhole", + dialect: new SqlServerDialect(), + connectionString: DriverConnectionString(host, port, user, password)); + + try + { + await driver.InitializeAsync("{}", ct); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + /// The KeyValue tag over the seeded sqlpoll.TagValues baseline row. + private static RawTagEntry KeyValueTag() + { + var config = new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = $"{Schema}.{KeyValueTable}", + ["keyColumn"] = KeyColumn, + ["keyValue"] = BaselineKey, + ["valueColumn"] = ValueColumn, + ["timestampColumn"] = TimestampColumn, + }; + return new RawTagEntry(RawPath, config.ToJsonString(), WriteIdempotent: false); + } + + /// + /// The driver's connection string. Min Pool Size=1 keeps a warm pooled connection so the + /// post-pause read reuses it and hangs inside ExecuteReaderAsync — exercising the + /// query-execution wedge (the S7 bug class) rather than a fresh connect. + /// + private static string DriverConnectionString(string host, string port, string user, string password) + => new SqlConnectionStringBuilder + { + DataSource = $"{host},{port}", + InitialCatalog = Database, + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + ConnectTimeout = 5, + MinPoolSize = 1, + }.ConnectionString; + + /// Creates the database (if missing) and (re)seeds the baseline schema + row. + private static async Task SeedAsync( + string host, string port, string user, string password, CancellationToken ct) + { + var master = new SqlConnectionStringBuilder + { + DataSource = $"{host},{port}", + InitialCatalog = "master", + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + ConnectTimeout = 10, + }.ConnectionString; + + await using (var connection = new SqlConnection(master)) + { + await connection.OpenAsync(ct); + await ExecuteAsync(connection, $"IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];", ct); + } + + var db = new SqlConnectionStringBuilder(master) { InitialCatalog = Database }.ConnectionString; + await using (var connection = new SqlConnection(db)) + { + await connection.OpenAsync(ct); + foreach (var statement in SeedStatements()) + await ExecuteAsync(connection, statement, ct); + } + } + + /// The seed, one batch per statement (T-SQL requires CREATE SCHEMA to be first in its batch). + private static IEnumerable SeedStatements() + { + yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];"; + yield return $""" + CREATE TABLE [{Schema}].[{KeyValueTable}] ( + [{KeyColumn}] nvarchar(128) NOT NULL, + [{ValueColumn}] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}]) + VALUES (N'{BaselineKey}', {BaselineValue}, '2026-07-24T10:00:00'); + """); + } + + /// Runs one non-query batch. + private static async Task ExecuteAsync(SqlConnection connection, string sql, CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(ct); + } + + /// Splits a host,port endpoint. A missing port is rejected — the guard needs the port. + private static (string Host, string Port) ParseEndpoint(string endpoint) + { + var parts = endpoint.Split(',', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 || parts[1].Length == 0) + { + throw new Xunit.Sdk.XunitException( + $"{EndpointEnvVar} must be 'host,port' (e.g. 10.100.0.35,14333) so the shared-server port " + + $"guard can run — got '{endpoint}'."); + } + + return (parts[0], parts[1]); + } + + /// + /// Builds the docker command for the OWNED container only. The verb and the constant container name + /// are the sole inputs — the target is never taken from the environment. + /// + private static string DockerCommand(string verb, string? ssh) + { + var docker = $"docker {verb} {ContainerName}"; + return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\""; + } + + /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does. + private static async Task RunShellCommandAsync(string command, CancellationToken ct) + { + using var proc = new Process + { + StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }, + }; + proc.Start(); + await proc.WaitForExitAsync(ct); + proc.ExitCode.ShouldBe(0, $"docker command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); + // Give the pause/unpause a moment to take effect before the next poll tick. + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } +} From 02f2dafe2a0a7e2ec5d5a7523139770c5e042cd1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:20:59 -0400 Subject: [PATCH 25/33] test(sql): correct the cancelled-token test's overclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its doc said it pinned "the real provider's cancellation path", but the token is cancelled before ReadAsync so the OCE is raised at the reader's SemaphoreSlim.WaitAsync gate, before any SqlConnection opens — it never exercises Microsoft.Data.SqlClient's in-flight command cancellation. Softened (option (a)) to state accurately what it proves: an already- cancelled token propagates as OCE and is never swallowed into a Bad snapshot or an unreachable-database verdict. The in-flight/deadline path is covered by SqlBlackholeTimeoutTests. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlServerReadTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs index c258ff6b..c3fe2e99 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs @@ -322,8 +322,16 @@ public sealed class SqlServerReadTests /// /// A cancelled poll propagates rather than publishing /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The - /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate, so it is pinned - /// here against the real provider's cancellation path. + /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate. + /// What this proves, precisely. The token is already cancelled before + /// ReadAsync is called, so the cancellation is observed at the reader's first checkpoint — + /// SemaphoreSlim.WaitAsync(_, token)before any SqlConnection opens. It + /// therefore does not exercise Microsoft.Data.SqlClient's in-flight cancellation of a + /// running command; that is left to the blackhole gate's deadline path + /// (SqlBlackholeTimeoutTests). What it pins is the driver-shell contract that a cancelled + /// token surfaces as an and is never swallowed into + /// a Bad snapshot or misread as an unreachable-database verdict — an offline-equivalent guarantee, + /// run here over the real provider only to keep it beside its siblings. /// [Fact] public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding() From 443b718158dffae96d598410337bfbd72734cf9d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:27:14 -0400 Subject: [PATCH 26/33] feat(sql): typed AdminUI Sql tag-config model + validator (string enums) SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's accept/reject boundary byte-for-byte on the fields they own: model/type serialize as NAME strings (never numbers), KeyValue requires table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys (top-level and nested rowSelector) survive load->save. Registered in TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName (DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI. The load-bearing test rounds editor output back through SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Uns/TagEditors/SqlTagConfigEditor.razor | 35 +++ .../Uns/TagEditors/SqlTagConfigModel.cs | 212 ++++++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 5 + .../Uns/TagEditors/TagConfigValidator.cs | 3 + .../Uns/SqlTagConfigModelTests.cs | 277 ++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor new file mode 100644 index 00000000..a0016cff --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor @@ -0,0 +1,35 @@ +@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only + wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no + edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields + (KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the + same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor + takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors + +
Sql tag editor — coming soon (Task 20).
+ +@code { + /// The tag's current TagConfig JSON. + [Parameter] public string? ConfigJson { get; set; } + + /// Raised with the updated TagConfig JSON when the model changes. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + /// DriverType of the selected driver — accepted for dispatch uniformity. + [Parameter] public string DriverType { get; set; } = ""; + + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private SqlTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset + // an in-progress edit (mirrors the other typed editors). + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = SqlTagConfigModel.FromJson(ConfigJson); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs new file mode 100644 index 00000000..4ac7c9c2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs @@ -0,0 +1,212 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column +/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the +/// authoritative runtime reader : the model and +/// type enums are written as their NAME strings (never numbers), and +/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys +/// (top-level and inside rowSelector) across a load→save so a future field survives an edit. +/// +public sealed class SqlTagConfigModel +{ + /// Tag→value mapping model. v1 accepts and + /// ; is deferred and rejected. + public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue; + + /// The table or view to read (e.g. dbo.TagValues). Required for every model. + public string? Table { get; set; } + + /// : the column matched against . + public string? KeyColumn { get; set; } + + /// : this tag's key value (bound as a parameter at read time). + public string? KeyValue { get; set; } + + /// : the column the value is read from. + public string? ValueColumn { get; set; } + + /// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models. + public string? TimestampColumn { get; set; } + + /// : the column this tag reads from the selected row. + public string? ColumnName { get; set; } + + /// : which row to read — a where-pair or newest-by-timestamp. + public SqlRowSelectorModel RowSelector { get; set; } = new(); + + /// Optional explicit OPC UA data type override (the blob's type field). null ⇒ the + /// driver infers the type from the result-set column metadata. + public DriverDataType? Type { get; set; } + + private JsonObject _bag = new(); + + // The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's + // strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate). + private string? _invalidModelRaw; + private string? _invalidTypeRaw; + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every + /// original key (so fields this editor doesn't expose survive a load→save). + /// The raw TagConfig JSON string, or null for a new/empty config. + /// The populated . + public static SqlTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + var (model, invalidModel) = ReadEnumStrict(o, "model"); + var (type, invalidType) = ReadEnumStrict(o, "type"); + + var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null; + + return new SqlTagConfigModel + { + Model = model ?? SqlTagModel.KeyValue, + Table = TagConfigJson.GetString(o, "table"), + KeyColumn = TagConfigJson.GetString(o, "keyColumn"), + KeyValue = TagConfigJson.GetString(o, "keyValue"), + ValueColumn = TagConfigJson.GetString(o, "valueColumn"), + TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"), + ColumnName = TagConfigJson.GetString(o, "columnName"), + RowSelector = SqlRowSelectorModel.FromJson(selectorObj), + Type = type, + _bag = o, + _invalidModelRaw = invalidModel, + _invalidTypeRaw = invalidType, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as + /// their name strings) over the preserved key bag. The nested rowSelector object is rebuilt from + /// (preserving its own unknown keys) and omitted entirely when empty. + /// The serialised TagConfig JSON string. + public string ToJson() + { + TagConfigJson.Set(_bag, "model", Model); + TagConfigJson.Set(_bag, "table", Table); + TagConfigJson.Set(_bag, "keyColumn", KeyColumn); + TagConfigJson.Set(_bag, "keyValue", KeyValue); + TagConfigJson.Set(_bag, "valueColumn", ValueColumn); + TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn); + TagConfigJson.Set(_bag, "columnName", ColumnName); + TagConfigJson.Set(_bag, "type", Type); + + var selector = RowSelector.ToJsonObject(); + if (selector is null) { _bag.Remove("rowSelector"); } + else { _bag["rowSelector"] = selector; } + + return TagConfigJson.Serialize(_bag); + } + + /// Validates the model against the exact accept/reject boundary of + /// . Returns an error message, or null when valid. + /// An error message describing the validation failure, or null when the model is valid. + public string? Validate() + { + // Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag. + if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; } + if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; } + + // Query is design §5.4 / P3 — the parser rejects it; the editor must too. + if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; } + + if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; } + + switch (Model) + { + case SqlTagModel.KeyValue: + if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; } + if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; } + if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; } + return null; + + case SqlTagModel.WideRow: + if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; } + // A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but + // must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`. + var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null; + if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp)) + { + return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp."; + } + return null; + + default: + return "model is not a supported Sql tag model."; + } + } + + // Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but- + // non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a + // present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it. + private static (TEnum? value, string? invalidRaw) ReadEnumStrict(JsonObject o, string name) + where TEnum : struct, Enum + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var s)) + { + return (null, null); + } + return Enum.TryParse(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s); + } +} + +/// +/// Typed working model for a tag's nested rowSelector object. +/// Holds a where-pair (/) or a newest-by-timestamp +/// selector (). Preserves unknown keys inside the selector across load→save. +/// +public sealed class SqlRowSelectorModel +{ + /// The column that picks the row (matched against ). + public string? WhereColumn { get; set; } + + /// The value is matched against (bound as a parameter at read time). + /// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring + /// the parser's scalar read. + public string? WhereValue { get; set; } + + /// Newest-row selector: the timestamp column to order by (used when no where-pair is authored). + public string? TopByTimestamp { get; set; } + + private JsonObject _bag = new(); + + /// Loads a selector model from the nested rowSelector JSON object (or a fresh empty model + /// when the object is absent), retaining any unknown keys. + /// The nested rowSelector object, or null when absent. + /// The populated . + public static SqlRowSelectorModel FromJson(JsonObject? o) + { + // Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson). + var bag = o?.DeepClone().AsObject() ?? new JsonObject(); + return new SqlRowSelectorModel + { + WhereColumn = TagConfigJson.GetString(bag, "whereColumn"), + WhereValue = ReadScalarText(bag, "whereValue"), + TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"), + _bag = bag, + }; + } + + /// Rebuilds the nested rowSelector JSON object from the exposed fields over the preserved + /// key bag, or returns null when the selector carries nothing (so the parent omits the key). + /// The rowSelector , or null when empty. + public JsonObject? ToJsonObject() + { + var o = _bag.DeepClone().AsObject(); + TagConfigJson.Set(o, "whereColumn", WhereColumn); + TagConfigJson.Set(o, "whereValue", WhereValue); + TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp); + return o.Count == 0 ? null : o; + } + + // Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw + // token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar). + private static string? ReadScalarText(JsonObject o, string name) + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; } + return v.TryGetValue(out var s) ? s : v.ToJsonString(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 0ec4501b..e10c9a50 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -21,6 +22,10 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), + // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared + // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests + // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index f73e69d4..50ee89af 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -23,6 +24,8 @@ public static class TagConfigValidator [DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(), + // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap. + [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs new file mode 100644 index 00000000..bfd0ad86 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs @@ -0,0 +1,277 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔ +/// parser-input agreement: what writes MUST parse cleanly through +/// (the runtime factory's authoritative reader), and the +/// model/type enums MUST serialise as NAME strings — a numeric enum makes the parser reject +/// the tag (the "authors fine, never reads" defect class this guards). +/// +public sealed class SqlTagConfigModelTests +{ + // ---- the plan's required assertion: string enums + unknown-key survival -------------------- + + [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"); + json.ShouldNotContain("\"type\":8"); // no numeric enum leaks + } + + // ---- defaults ------------------------------------------------------------------------------ + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = SqlTagConfigModel.FromJson(json); + + m.Model.ShouldBe(SqlTagModel.KeyValue); + m.Table.ShouldBeNull(); + m.KeyColumn.ShouldBeNull(); + m.KeyValue.ShouldBeNull(); + m.ValueColumn.ShouldBeNull(); + m.TimestampColumn.ShouldBeNull(); + m.ColumnName.ShouldBeNull(); + m.Type.ShouldBeNull(); + m.RowSelector.WhereColumn.ShouldBeNull(); + m.RowSelector.WhereValue.ShouldBeNull(); + m.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_of_default_model_omits_optional_keys() + { + var json = new SqlTagConfigModel { Table = "t" }.ToJson(); + + json.ShouldContain("\"model\":\"KeyValue\""); + json.ShouldContain("\"table\":\"t\""); + json.ShouldNotContain("type"); // no type set ⇒ key omitted + json.ShouldNotContain("rowSelector"); // no selector ⇒ key omitted + json.ShouldNotContain("timestampColumn"); + } + + // ---- round-trip fidelity through FromJson→ToJson→FromJson ---------------------------------- + + [Fact] + public void Round_trip_preserves_keyValue_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.KeyValue, + Table = "dbo.TagValues", + KeyColumn = "TagName", + KeyValue = "Line1.Speed", + ValueColumn = "Val", + TimestampColumn = "Ts", + Type = DriverDataType.Float64, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.KeyValue); + m2.Table.ShouldBe("dbo.TagValues"); + m2.KeyColumn.ShouldBe("TagName"); + m2.KeyValue.ShouldBe("Line1.Speed"); + m2.ValueColumn.ShouldBe("Val"); + m2.TimestampColumn.ShouldBe("Ts"); + m2.Type.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void Round_trip_preserves_wideRow_wherePair_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { WhereColumn = "DeviceId", WhereValue = "42" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.Table.ShouldBe("dbo.Wide"); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.WhereColumn.ShouldBe("DeviceId"); + m2.RowSelector.WhereValue.ShouldBe("42"); + m2.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void Round_trip_preserves_wideRow_topByTimestamp_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { TopByTimestamp = "Ts" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.TopByTimestamp.ShouldBe("Ts"); + m2.RowSelector.WhereColumn.ShouldBeNull(); + m2.RowSelector.WhereValue.ShouldBeNull(); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys_top_level_and_nested() + { + var json = SqlTagConfigModel + .FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts","futureKey":"keep"},"scaling":2.5}""") + .ToJson(); + + json.ShouldContain("scaling"); // unknown top-level key survives + json.ShouldContain("2.5"); + json.ShouldContain("futureKey"); // unknown nested (rowSelector) key survives + json.ShouldContain("keep"); + json.ShouldContain("\"topByTimestamp\":\"Ts\""); + } + + // ---- Validate() mirrors the parser's accept/reject boundary -------------------------------- + + [Fact] + public void Validate_returns_null_for_valid_keyValue() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_wherePair() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"whereColumn":"id","whereValue":"1"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_topByTimestamp() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_rejects_missing_table() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_valueColumn() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_keyValue_field() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_without_a_selector() + => SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_missing_columnName() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_model_enum() + => SqlTagConfigModel.FromJson("""{"model":"Bogus","table":"t"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_type_enum() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_deferred_query_model() + => SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""") + .Validate().ShouldNotBeNull(); + + // ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader -------------- + + [Theory] + [InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")] + public void ToJson_output_parses_cleanly_through_SqlEquipmentTagParser(string authored) + { + // Round-trip the authored blob through the editor model, exactly as the TagModal save path does. + var roundTripped = SqlTagConfigModel.FromJson(authored).ToJson(); + + // The authoritative runtime reader must accept it — this is the editor-output ⇔ parser-input contract. + SqlEquipmentTagParser.TryParse(roundTripped, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue(); + def.Name.ShouldBe("Plant/Sql/dev1/Speed"); + def.Table.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void ToJson_keyValue_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.KeyValue); + def.Table.ShouldBe("dbo.TagValues"); + def.KeyColumn.ShouldBe("TagName"); + def.KeyValue.ShouldBe("Line1.Speed"); + def.ValueColumn.ShouldBe("Val"); + def.TimestampColumn.ShouldBe("Ts"); + def.DeclaredType.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void ToJson_wideRow_wherePair_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorColumn.ShouldBe("DeviceId"); + def.RowSelectorValue.ShouldBe("42"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_wideRow_topByTimestamp_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorTopByTimestamp.ShouldBe("Ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + } +} From 37f444b99c3b3d1066a02e2cde1d4850c7412091 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:28:17 -0400 Subject: [PATCH 27/33] fix(sql): stop leaking provider exception text; de-dup health classification; guard Faulted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: SqlDriver's Initialize/Read failure paths no longer interpolate the ADO.NET provider's ex.Message into LastError, the thrown message, or host status — only ex.GetType().Name reaches the operator surface; the full exception goes to the log sink via the structured logger's exception parameter. The driver is dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any provider's message discipline (Microsoft.Data.SqlClient's parser echoes an unrecognised keyword lower-cased, which a value-based redactor misses). C1a: replace the vacuous credential test (SQLite's "unable to open" message never contains the connection string, so it passed regardless) with one that fabricates a DbException whose own .Message carries a credential token and drives it through the Initialize liveness-failure path via the injectable factory seam — red against the pre-fix ex.Message interpolation. I1: HandlePollError now ignores the DbException class ReadAsync already classified, so a subscribed-read outage is classified (and logged) once, not twice with the worse message. I2: the poll's Healthy verdict routes through a shared SetHealthUnlessFaulted guard (the one Degrade already used), so a late in-flight poll cannot un-fault a driver a concurrent ReinitializeAsync just faulted. I3: DiscoverAsync warns when materializing an omitted-type tag as String, the only operator signal for the declared-vs-published type mismatch on a numeric column. M1: BuildTagTable wraps the per-entry parse so a stray non-JsonException throw skips the tag instead of stranding health at Initializing (it runs before Initialize's try/catch). M2 left as a documented TODO to avoid touching SqlPollReader. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 89 +++++++-- .../SqlDriverTests.cs | 185 ++++++++++++++++++ 2 files changed, 261 insertions(+), 13 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index c52e07e2..4a1b5491 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -220,10 +220,15 @@ public sealed class SqlDriver } catch (Exception ex) { - // Never interpolate _connectionString — Endpoint is the credential-free rendering. + // Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is + // dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's + // message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an + // unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only + // the exception TYPE reaches LastError/host status; the full exception object goes to the log + // SINK via the structured logger's exception parameter (below), never into a rendered string. var message = - $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message} " + - $"Check the database is up, the network path is open, and the configured credentials are valid."; + $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " + + $"Check the database is reachable and the connection string is valid."; _logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.", _driverInstanceId, Endpoint); WriteHealth(new DriverHealth(DriverState.Faulted, null, message)); @@ -296,6 +301,20 @@ public sealed class SqlDriver var folder = builder.Folder(DriverTypeName, DriverTypeName); foreach (var tag in Tags.Values) { + if (tag.DeclaredType is null) + { + // Discovery cannot infer a column's real type — that needs live result-set metadata, which + // only a poll returns — so an omitted-type tag materialises as String (the same fallback + // ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual, + // possibly numeric, CLR value against that String node. Nothing else signals the operator to + // this declared-vs-published mismatch, so warn and point them at the fix. + _logger.LogWarning( + "Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " + + "will then publish numeric values against a String node. Author an explicit \"type\" on " + + "numeric tags. Driver={DriverInstanceId}", + tag.Name, _driverInstanceId); + } + folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DeclaredType ?? DriverDataType.String, @@ -344,7 +363,9 @@ public sealed class SqlDriver // The reader throws for one reason only: opening a connection failed (IReadable's contract). _logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.", _driverInstanceId, Endpoint); - Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message}"); + // Type name only — never ex.Message (see InitializeAsync for why); the full exception is already + // on the log sink via the LogWarning exception parameter above. + Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}."); TransitionHostTo(HostState.Stopped); throw; } @@ -414,7 +435,7 @@ public sealed class SqlDriver /// cadence rather than backed off from. /// /// The snapshots the reader returned for one poll. - private void ObservePollOutcome(IReadOnlyList snapshots) + internal void ObservePollOutcome(IReadOnlyList snapshots) { if (snapshots.Count == 0) return; @@ -447,7 +468,11 @@ public sealed class SqlDriver if (good == 0) return; // authoring-class Bad only — not a statement about the database. - WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + // Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent + // ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s + // default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back + // to Healthy with no real recovery. Only a successful (re)initialize clears Faulted. + SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); TransitionHostTo(HostState.Running); } @@ -464,6 +489,15 @@ public sealed class SqlDriver /// The exception the poll engine caught. internal void HandlePollError(Exception ex) { + // A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY + // classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine + // then routes that same exception here. Re-degrading would overwrite the good LastError with a worse, + // guidance-free one (and, for a provider whose text echoes credentials, re-open the C1 leak) and log + // the outage a second time. So skip the connection class ReadAsync owns; the only exceptions that + // reach past this guard are the engine's own contract-violation throws — an InvalidOperationException + // carrying no provider text — which nothing else classifies. + if (ex is DbException) return; + _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}", _driverInstanceId, Endpoint); Degrade(ex.Message); @@ -478,8 +512,21 @@ public sealed class SqlDriver private void Degrade(string reason) { var current = ReadHealth(); - if (current.State == DriverState.Faulted) return; - WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason)); + SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason)); + } + + /// + /// Publishes unless the driver is already + /// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize + /// health write routes through here: and the poll's Healthy verdict in + /// , so a late in-flight poll cannot un-fault a driver a concurrent + /// just faulted. + /// + /// The health snapshot to publish when the driver is not Faulted. + private void SetHealthUnlessFaulted(DriverHealth value) + { + if (ReadHealth().State == DriverState.Faulted) return; + WriteHealth(value); } /// Advances LastSuccessfulRead without changing the current state or error. @@ -524,12 +571,26 @@ public sealed class SqlDriver var table = new Dictionary(_options.RawTags.Count, StringComparer.Ordinal); foreach (var entry in _options.RawTags) { - if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition)) - table[entry.RawPath] = definition; - else - _logger.LogWarning( - "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + try + { + if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition)) + table[entry.RawPath] = definition; + else + _logger.LogWarning( + "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + } + catch (Exception ex) + { + // TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's + // try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and + // strand health at Initializing across every DriverInstanceActor retry. Skip the offending + // tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob + // branch above follows — rather than fault the driver over a single entry. + _logger.LogError( + ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}", _driverInstanceId, entry.RawPath); + } } Volatile.Write(ref _tagsByRawPath, table); @@ -618,6 +679,8 @@ public sealed class SqlDriver /// /// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved /// task exception. The task still owns — and disposes — its own connection. + /// TODO(M2): a verbatim duplicate of SqlPollReader.Detach; both live in Driver.Sql and + /// could hoist to one internal helper. Left in place here to keep this change off the reader. /// private static void Detach(Task work) => _ = work.ContinueWith( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs index eb4bee5c..9b7a5bf8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Data.Common; using System.Globalization; using Microsoft.Data.Sqlite; @@ -342,6 +343,132 @@ public sealed class SqlDriverTests } } + // ---- C1a: the credential guarantee, proven load-bearing ---- + + [Fact] + public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface() + { + // The vacuous sibling test drives SQLite's "unable to open database file" path, whose message + // structurally never contains the connection string — so it passes with or without any defensive + // code. This one fabricates a DbException whose OWN .Message carries a credential-like token and + // drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED + // against the pre-fix code that interpolated ex.Message into LastError and the thrown message. + const string leakyMessage = "login failed for user; Password=" + SecretToken; + var driver = new SqlDriver( + new SqlDriverOptions + { + RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], + OperationTimeout = TimeSpan.FromSeconds(15), + CommandTimeout = TimeSpan.FromSeconds(10), + }, + DriverInstanceId, + new SqliteDialect(), + // A credential-free connection string: the ONLY place the token can come from is the exception. + "Server=sqlsrv01;Initial Catalog=Mes", + factory: new ThrowingConnectionFactory(leakyMessage), + logger: CapturingLogger.Null); + await using var _ = driver; + + var thrown = await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Faulted); + health.LastError.ShouldNotBeNullOrWhiteSpace(); + // The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status. + health.LastError!.ShouldNotContain(SecretToken); + thrown.Message.ShouldNotContain(SecretToken); + driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); + // …and it is still actionable: it names the endpoint the operator has to go look at. + health.LastError.ShouldContain("sqlsrv01"); + } + + // ---- I1: no double health-classification on a subscribed-read outage ---- + + [Fact] + public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce() + { + // On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a + // good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll + // engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync + // owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a + // credential-leak path) and logged the outage a second time. + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken)); + + var health = driver.GetHealth(); + // Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked… + health.State.ShouldBe(DriverState.Healthy); + health.LastError.ShouldBeNull(); + // …and it logged nothing (ReadAsync owns the single outage warning). + logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0); + } + + [Fact] + public void HandlePollError_forAnEngineContractViolation_stillDegrades() + { + // The falsifiability control for the guard above: a non-DbException (the poll engine's own + // reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health + // surface. The guard skips only the DbException connection class. + using var fixture = new SqlitePollFixture(); + var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots")); + + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + // ---- I2: a late poll cannot un-fault a Faulted driver ---- + + [Fact] + public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver() + { + // The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to + // the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has + // recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only + // a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write. + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + + // The stale in-flight poll completes with an all-Good batch. + driver.ObservePollOutcome( + [new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]); + + driver.GetHealth().State.ShouldBe(DriverState.Faulted); // stayed Faulted + } + + // ---- I3: an omitted-type tag warns at discovery ---- + + [Fact] + public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne() + { + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver( + fixture, logger, + KvEntry("Speed", SqlitePollFixture.PresentKey), // no "type" → discovered String + TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32")); // explicit "type" + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored")); + logger.Entries.ShouldNotContain(e => + e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored")); + } + // ---- helpers ---- /// A distinctive password token: any assertion that finds it has found a credential leak. @@ -417,6 +544,21 @@ public sealed class SqlDriverTests } """), WriteIdempotent: false); + /// A key-value entry carrying an explicit "type" — so DeclaredType is non-null. + private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type) + => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" + { + "driver": "Sql", + "model": "KeyValue", + "table": "{{SqlitePollFixture.KeyValueTable}}", + "keyColumn": "{{SqlitePollFixture.KeyColumn}}", + "keyValue": "{{keyValue}}", + "valueColumn": "{{SqlitePollFixture.ValueColumn}}", + "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}", + "type": "{{type}}" + } + """), WriteIdempotent: false); + /// The same tag as , already typed — for the reference reader. private static SqlTagDefinition KvDefinition(string rawPath, string keyValue) => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, @@ -487,4 +629,47 @@ public sealed class SqlDriverTests public void Dispose() { } } } + + /// + /// A whose message is under the test's control — stands in for the + /// credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene + /// guarantee can be exercised without a live database. + /// + private sealed class FakeDbException(string message) : DbException(message); + + /// + /// A whose connections always fail to open, with a + /// caller-supplied (credential-bearing) message — the seam that drives the driver's + /// unreachable-database path with a fabricated provider exception. + /// + private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory + { + public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage); + } + + /// A connection that throws on open; every other member is inert. + private sealed class ThrowingConnection(string openFailureMessage) : DbConnection + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => string.Empty; + + public override string DataSource => string.Empty; + + public override string ServerVersion => string.Empty; + + public override ConnectionState State => ConnectionState.Closed; + + public override void Open() => throw new FakeDbException(openFailureMessage); + + public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + + public override void Close() { } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + => throw new NotSupportedException(); + + protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); + } } From 898c7365c48d5d38709545c1df9ee07cf29659dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:29:21 -0400 Subject: [PATCH 28/33] test(sql): close the blackhole gate's leaked-pause race + bound the shell command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the blackhole gate. Two robustness gaps, both bounded to the disposable dedicated container (never shared infra), fixed: - If the outer token fired while 'docker pause' was in flight, 'paused' was set only AFTER the await, so a cancellation there left paused=false while the container could still complete the pause — the finally then skipped unpause and leaked a frozen container. Mark paused BEFORE the await, and make the finally's unpause best-effort (unpausing a never-actually-paused container errors harmlessly, and a cleanup failure must never mask the real test outcome). - The pause/unpause shell commands had no wall-clock bound of their own — only the post-pause read was capped — so a hung SSH handshake would hang CI. Give RunShellCommandAsync its own 30s hard cap that kills the process and throws TimeoutException, distinct from an operator's own cancellation. Offline skip still clean (1 skipped, 9ms, no socket/docker). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../SqlBlackholeTimeoutTests.cs | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs index 3d9baaa4..9cc26756 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs @@ -125,6 +125,9 @@ public sealed class SqlBlackholeTimeoutTests /// Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI. private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60); + /// Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI. + private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30); + /// /// Start reading against the dedicated mssql, docker pause it mid-poll, and assert the next /// read surfaces within (a @@ -178,8 +181,10 @@ public sealed class SqlBlackholeTimeoutTests { // Freeze the container mid-poll: its SQL process is SIGSTOPped, so the pooled connection's next // ExecuteReaderAsync hangs against a live-but-silent socket — the frozen-peer wedge exactly. - await RunShellCommandAsync(DockerCommand("pause", ssh), ct); + // Mark paused BEFORE the await: if `ct` fires mid-command the process may still complete the pause, + // so the finally must attempt an unpause regardless — never leave the container frozen. paused = true; + await RunShellCommandAsync(DockerCommand("pause", ssh), ct); var clock = Stopwatch.StartNew(); // Bounded by HardCap so a wedged implementation FAILS here instead of hanging CI. @@ -212,8 +217,17 @@ public sealed class SqlBlackholeTimeoutTests { if (paused) { - // ALWAYS restore the container once paused — never leave a frozen container behind. - await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None); + // ALWAYS restore the container once a pause was attempted — never leave a frozen container + // behind. Best-effort: unpausing a container that never actually paused (pause command failed) + // errors harmlessly, and a cleanup failure must never mask the real test outcome. + try + { + await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None); + } + catch + { + // swallow — cleanup is best-effort; the container is disposable and torn down by the runbook. + } } } @@ -388,9 +402,16 @@ public sealed class SqlBlackholeTimeoutTests return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\""; } - /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does. + /// + /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded + /// by its own so a hung SSH handshake (unreachable docker host, an auth + /// prompt) fails the test rather than hanging CI indefinitely. The process is killed on timeout. + /// private static async Task RunShellCommandAsync(string command, CancellationToken ct) { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ShellCommandHardCap); + using var proc = new Process { StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") @@ -401,7 +422,17 @@ public sealed class SqlBlackholeTimeoutTests }, }; proc.Start(); - await proc.WaitForExitAsync(ct); + try + { + await proc.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested) + { + try { proc.Kill(entireProcessTree: true); } catch { /* already gone */ } + throw new TimeoutException( + $"shell command did not complete within {ShellCommandHardCap.TotalSeconds:0}s: {command}"); + } + proc.ExitCode.ShouldBe(0, $"docker command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); // Give the pause/unpause a moment to take effect before the next poll tick. await Task.Delay(TimeSpan.FromSeconds(1), ct); From 9b5a96876e87d9cb5737958d2993057ec71e6c94 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:35:09 -0400 Subject: [PATCH 29/33] feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DriverTypeNames.Sql (+ All) as the shared source of truth, and wire the driver into the Host: register the factory in DriverFactoryBootstrap.Register via Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory), add the SqlProbe (= Driver.Sql.SqlDriverProbe) probe via TryAddEnumerable, and add the Driver.Sql project reference to the Host. Default tier A (SQL client is managed + cross-platform, so ShouldStub needs no change). Repoint the interim SqlDriver.DriverTypeName / SqlDriverFactoryExtensions.DriverTypeName literals at DriverTypeNames.Sql; both still resolve to "Sql", so the AdminUI TagConfigEditorMap/TagConfigValidator keys and the DriverTypeName-parity test stay green. The Core guard test discovers factories from its own bin, so it also gains a Driver.Sql project reference — that is the "registered-factory side" that lets DriverTypeNamesGuardTests' bidirectional-parity check pass (4/4 green). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../DriverTypeNames.cs | 4 ++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 12 +++++------- .../SqlDriverFactoryExtensions.cs | 9 +++------ .../Drivers/DriverFactoryBootstrap.cs | 3 +++ .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj | 1 + 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index d4ba0417..bbebd080 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -53,6 +53,9 @@ public static class DriverTypeNames /// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values. public const string Calculation = "Calculation"; + /// Read-only SQL Server table/view poller — publishes columns as OPC UA variables. + public const string Sql = "Sql"; + /// /// Every driver-type string declared above, for callers that need to enumerate /// the full set (e.g. validation of an authored DriverType). @@ -68,5 +71,6 @@ public static class DriverTypeNames OpcUaClient, Galaxy, Calculation, + Sql, ]; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index 4a1b5491..7c566ebe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -29,14 +29,12 @@ public sealed class SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable { /// - /// The driver-type string this driver registers under. - /// Deliberately a local constant, not DriverTypeNames.Sql. - /// DriverTypeNamesGuardTests asserts bidirectional parity between the constants and the - /// driver factories actually registered in the process, so the constant may only be added in the - /// same change that wires the factory (this task ships no factory — see the plan's Task 11, which - /// adds DriverTypeNames.Sql and repoints this constant at it). + /// The driver-type string this driver registers under — , the single + /// source of truth the deploy pipeline stores in DriverInstance.DriverType and every dispatch + /// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so + /// DriverTypeNamesGuardTests' bidirectional-parity check is satisfied). /// - public const string DriverTypeName = "Sql"; + public const string DriverTypeName = DriverTypeNames.Sql; /// Shown for a connection string that names no recognisable server. private const string UnknownEndpoint = "(unknown sql endpoint)"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index b1377fd5..ac7479e3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -29,12 +29,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; public static class SqlDriverFactoryExtensions { /// - /// The driver-type string this factory registers under. - /// Chained off rather than DriverTypeNames.Sql: - /// DriverTypeNamesGuardTests asserts bidirectional parity between the shared constants and the - /// factories actually registered in the process, so the constant may only be added in the change that - /// also wires this factory into the Host (the plan's Task 11, which repoints both at - /// DriverTypeNames.Sql). + /// The driver-type string this factory registers under — chained off + /// , which is + /// . /// public const string DriverTypeName = SqlDriver.DriverTypeName; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 71b5d0b7..2687366e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; using CalculationProbe = Driver.Calculation.CalculationDriverProbe; +using SqlProbe = Driver.Sql.SqlDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); + Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index dd7fbd9a..b3fc5bad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -76,6 +76,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index b89bfe91..325ef67f 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -34,6 +34,7 @@ + From be1df2d1e53555dc9a333fa18609dba2fc7cfb24 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:35:25 -0400 Subject: [PATCH 30/33] feat(sql): SqlTagConfigEditor razor shell Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Uns/TagEditors/SqlTagConfigEditor.razor | 194 +++++++++++++++++- 1 file changed, 185 insertions(+), 9 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor index a0016cff..6a148034 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor @@ -1,12 +1,90 @@ -@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only - wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no - edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields - (KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the - same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor - takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@ +@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit + goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name + serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field + group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched + from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/ + GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by + TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers -
Sql tag editor — coming soon (Task 20).
+
+
+
+
+
+
+
+
+ +@if (_m.Model == SqlTagModel.KeyValue) +{ +
+
+
+
+ +
Bound as a parameter — never interpolated.
+
+
+
+} +else +{ +
+
+ +
The column this tag reads from the selected row.
+
+
+ @if (_selectorMode == "Where") + { +
+
+
+
+ } + else + { +
+
+ } +
+} + +
+
+ +
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+ +
+
+ +@if (_showPicker) +{ + + + +} @code { /// The tag's current TagConfig JSON. @@ -23,13 +101,111 @@ private SqlTagConfigModel _m = new(); private string? _lastConfigJson; + private bool _showPicker; + private string _pickerAddress = ""; - // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset - // an in-progress edit (mirrors the other typed editors). + // Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model + // carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in + // the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state + // the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously + // says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where". + private string _selectorMode = "Where"; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server + // live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors). protected override void OnParametersSet() { if (ConfigJson == _lastConfigJson) { return; } _lastConfigJson = ConfigJson; _m = SqlTagConfigModel.FromJson(ConfigJson); + DeriveSelectorMode(); + } + + // Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves + // the operator's current choice untouched when both selector fields are blank (an in-progress edit). + private void DeriveSelectorMode() + { + if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn)) + { + _selectorMode = "Newest"; + } + else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn)) + { + _selectorMode = "Where"; + } + } + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by + // NAME (never an ordinal cast), matching the model's strict name-string serialisation. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + // "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip + // is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string. + private static TEnum? ParseNullableEnum(object? v) where TEnum : struct, Enum + { + var s = v?.ToString(); + if (string.IsNullOrEmpty(s)) { return null; } + return Enum.TryParse(s, out var r) ? r : null; + } + + // Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the + // key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value + // as present-and-empty, distinct from absent, so those keep e.Value verbatim.) + private static string? NullIfBlank(object? v) + { + var s = v?.ToString(); + return string.IsNullOrWhiteSpace(s) ? null : s; + } + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } + + // The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of + // the mode being left so the composed blob carries exactly one selector (a stale where-pair would win + // over topByTimestamp at read time, silently overriding the operator's choice). + private async Task OnSelectorModeChanged(string? mode) + { + _selectorMode = mode == "Newest" ? "Newest" : "Where"; + await Update(() => + { + if (_selectorMode == "Newest") + { + _m.RowSelector.WhereColumn = null; + _m.RowSelector.WhereValue = null; + } + else + { + _m.RowSelector.TopByTimestamp = null; + } + }); + } + + // The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the + // other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working + // model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that + // the picker never authors. + private async Task OnAddressPicked(string blob) + { + if (string.IsNullOrWhiteSpace(blob)) { return; } + var picked = SqlTagConfigModel.FromJson(blob); + await Update(() => + { + _m.Model = picked.Model; + _m.Table = picked.Table; + _m.KeyColumn = picked.KeyColumn; + _m.KeyValue = picked.KeyValue; + _m.ValueColumn = picked.ValueColumn; + _m.ColumnName = picked.ColumnName; + _m.TimestampColumn = picked.TimestampColumn; + _m.Type = picked.Type; + _m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn; + _m.RowSelector.WhereValue = picked.RowSelector.WhereValue; + _m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp; + }); + DeriveSelectorMode(); } } From 585f827ef3cfb28a044d4dcd511039dc000ad865 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:39:14 -0400 Subject: [PATCH 31/33] fix(sql): close the residual credential leak in HandlePollError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C1 credential-hygiene fix converted InitializeAsync and ReadAsync to surface only ex.GetType().Name, but left HandlePollError building its operator-facing Degrade() message from raw ex.Message, gated only by 'if (ex is DbException) return;'. That guard exists for the I1 double-classification concern, not for message safety, and it is incomplete for the leak vector: a malformed connection string throws ArgumentException from the keyword parser (the same unquoted-';'-in-password shape the sibling SqlDriverBrowser.Sanitize special-cases), which is not a DbException and so reaches Degrade(ex.Message) unredacted. It is unreachable in today's control flow only because the connection string is static and validated identically at Initialize first — an emergent property, not an enforced invariant, and a live per-poll leak the moment a future edit (refresh-on-reinit, a different provider) breaks that assumption. Make the fallback type-only like the other two sites; the full exception still reaches the log sink via the exception parameter. The I1 defer + control tests are unaffected (they assert Degraded, not message text). Pinned by a new test driving a credential-bearing ArgumentException through HandlePollError; verified load-bearing (red against Degrade(ex.Message), green after). Closes the C1 review finding on commit 37f444b9. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs | 12 ++++++----- .../SqlDriverTests.cs | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs index 7c566ebe..9d2396bf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -490,15 +490,17 @@ public sealed class SqlDriver // A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY // classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine // then routes that same exception here. Re-degrading would overwrite the good LastError with a worse, - // guidance-free one (and, for a provider whose text echoes credentials, re-open the C1 leak) and log - // the outage a second time. So skip the connection class ReadAsync owns; the only exceptions that - // reach past this guard are the engine's own contract-violation throws — an InvalidOperationException - // carrying no provider text — which nothing else classifies. + // guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns. if (ex is DbException) return; + // Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised + // (e.g. an ArgumentException from a malformed connection string, which the parser echoes with + // credential fragments) — the operator surface carries only the exception TYPE, never ex.Message. + // The full exception, with its text, still reaches the log SINK via the exception parameter. This is + // the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check. _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}", _driverInstanceId, Endpoint); - Degrade(ex.Message); + Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}."); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs index 9b7a5bf8..9533b8bf 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -424,6 +424,27 @@ public sealed class SqlDriverTests driver.GetHealth().State.ShouldBe(DriverState.Degraded); } + [Fact] + public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem() + { + // The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction — + // canonically the ArgumentException a malformed connection string's keyword parser throws, echoing + // credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is + // not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never + // ex.Message. Pre-fix this degraded with the raw message and leaked the token. + using var fixture = new SqlitePollFixture(); + var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + driver.HandlePollError(new ArgumentException( + "Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastError.ShouldNotBeNull(); + health.LastError.ShouldNotContain(SecretToken); // never the message text + health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type) + } + // ---- I2: a late poll cannot un-fault a Faulted driver ---- [Fact] From b295b24419a46d5cbf572e48c20c535cbf729580 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 20:41:33 -0400 Subject: [PATCH 32/33] feat(adminui): author + configure the Sql driver in /raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-only Sql driver's runtime factory/probe and tag-side editors were wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type list, the DriverConfigModal switch, and the legacy identity dropdown all omitted Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this. - Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider (SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a connection string — label is explicit), optional poll/operation/command timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via JsonStringEnumConverter; a connection-string literal can never be authored (no such field is collected); rawTags (composer-owned) + allowWrites (inert, read-only v1) are never emitted. - Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and DriverIdentitySection legacy dropdown. - Add SqlDriverFormContractTests: the exact JSON the form emits constructs a driver through the real factory, missing connectionStringRef is rejected, and provider round-trips as the "SqlServer" name (never an ordinal). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Shared/Drivers/DriverConfigModal.razor | 3 + .../Drivers/DriverIdentitySection.razor | 1 + .../Shared/Drivers/Forms/SqlDriverForm.razor | 198 ++++++++++++++++++ .../Shared/Raw/RawDriverTypeDialog.razor | 1 + .../Uns/SqlDriverFormContractTests.cs | 89 ++++++++ 5 files changed, 292 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index d2140099..16402154 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -57,6 +57,9 @@ case DriverTypeNames.Galaxy: break; + case DriverTypeNames.Sql: + + break; default:
No typed config form for driver type @_driverType.
break; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor index 5bf707fc..8b90303c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor @@ -36,6 +36,7 @@ +
Cannot be changed after creation — drives the actor type that owns this instance.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor new file mode 100644 index 00000000..744aee9f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor @@ -0,0 +1,198 @@ +@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device + endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name, + resolved at Initialize), so no connection string is ever authored into the config. Hosted by + DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires + DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the + factory's string-typed deserialize accepts them. *@ +@using System.Text.Json +@using System.Text.Json.Nodes +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts + +@* Connection *@ +
+
Database connection
+
+
+
+ + + @* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@ + + +
Only Microsoft SQL Server is supported in v1.
+
+
+ + +
+ Name of the Sql__ConnectionStrings__<ref> env var set on the driver + AdminUI hosts — + not a connection string. The credential is resolved at startup and never stored in the config. +
+ @if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef)) + { +
Connection string ref is required.
+ } +
+
+
+
+ +@* Polling / timeouts *@ +
+
Polling & timeouts
+
+
+
+ + +
Blank = factory default. Applied to groups without their own interval.
+
+
+ + +
Client-side per-query deadline. Should exceed the command timeout.
+
+
+ + +
ADO.NET server-side CommandTimeout backstop.
+
+
+ + +
Blank = factory default. Caps concurrent group queries (pool guard).
+
+
+
+
+ +@* Value handling *@ +
+
Value handling
+
+
+
+
+ + +
+
Unchecked = factory default (a NULL cell publishes Uncertain).
+
+
+
+ + +
+
+ Read-only v1 does not implement writes — this flag is inert and is not authored. +
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + // camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize. + // WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and + // drops the composer-owned rawTags (never authored here). + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto(); + _form = FormModel.FromDto(dto); + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings. + /// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only). + public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static SqlDriverConfigDto? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + /// Flat mutable mirror of the driver-level fields of . TimeSpans are + /// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not + /// mirrored — the composer owns rawTags and v1 is read-only. + public sealed class FormModel + { + public SqlProvider Provider { get; set; } = SqlProvider.SqlServer; + public string? ConnectionStringRef { get; set; } + public int? DefaultPollIntervalSeconds { get; set; } + public int? OperationTimeoutSeconds { get; set; } + public int? CommandTimeoutSeconds { get; set; } + public int? MaxConcurrentGroups { get; set; } + public bool NullIsBad { get; set; } + + public static FormModel FromDto(SqlDriverConfigDto d) => new() + { + Provider = d.Provider, + ConnectionStringRef = d.ConnectionStringRef, + DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval), + OperationTimeoutSeconds = ToSeconds(d.OperationTimeout), + CommandTimeoutSeconds = ToSeconds(d.CommandTimeout), + MaxConcurrentGroups = d.MaxConcurrentGroups, + NullIsBad = d.NullIsBad ?? false, + }; + + public SqlDriverConfigDto ToDto() => new() + { + Provider = Provider, + ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(), + DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds), + OperationTimeout = ToTimeSpan(OperationTimeoutSeconds), + CommandTimeout = ToTimeSpan(CommandTimeoutSeconds), + MaxConcurrentGroups = MaxConcurrentGroups, + // Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites + // and rawTags are deliberately left null so they are omitted from the authored blob. + NullIsBad = NullIsBad ? true : null, + }; + + private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null; + private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index e43dc14a..24b37fa2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -68,6 +68,7 @@ ("FOCAS", DriverTypeNames.FOCAS), ("OpcUaClient", DriverTypeNames.OpcUaClient), ("Galaxy", DriverTypeNames.Galaxy), + ("Sql", DriverTypeNames.Sql), ("Calculation", "Calculation"), ]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs new file mode 100644 index 00000000..8bc28e88 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Guards the AdminUI SqlDriverForm's driver-config output against the runtime factory that consumes +/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms: +/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative +/// reader, ; and (2) the +/// serialization contract — provider as a NAME string (never an ordinal), camelCase keys, and no +/// composer-owned rawTags / inert allowWrites leaking into the authored blob. A numeric enum +/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards. +/// +public sealed class SqlDriverFormContractTests +{ + // The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls). + private static readonly JsonSerializerOptions FormOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + // ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ---- + + [Theory] + // Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory). + [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")] + // Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout). + [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")] + public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson) + { + var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest"); + var previous = Environment.GetEnvironmentVariable(envVar); + Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;"); + try + { + var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson); + driver.ShouldNotBeNull(); + } + finally + { + Environment.SetEnvironmentVariable(envVar, previous); + } + } + + [Fact] + public void Factory_rejects_a_blob_missing_connectionStringRef() + { + // The form marks connectionStringRef required; the factory is the authoritative enforcement. + var ex = Should.Throw( + () => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}""")); + ex.Message.ShouldContain("connectionStringRef"); + } + + // ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals -------- + + [Fact] + public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields() + { + // Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set). + var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" }; + + var json = JsonSerializer.Serialize(dto, FormOptions); + + json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}"""); + json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read + json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form + json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored + json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies + } + + [Fact] + public void Provider_round_trips_as_the_SqlServer_name() + { + var json = JsonSerializer.Serialize( + new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions); + + json.ShouldContain("\"SqlServer\""); + JsonSerializer.Deserialize(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer); + } +} From 57a5bb3c603aa6cc73d1e8b50aa2df37066cb239 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 20:59:27 -0400 Subject: [PATCH 33/33] chore(docker-dev): wire Sql__ConnectionStrings__DevSql for the Sql driver live gate Adds the named connection-string ref the Sql poll driver resolves (connectionStringRef: "DevSql") to both central nodes' env, pointing at a DevSql database on the rig's own sql container. Committed-dev-secret exception, same posture as the ConfigDb line above it. This is the env wiring the Task 21 live /run gate used to author + deploy a Sql driver and read a live value (dbo.TagValues.Line1.Speed = 42.5) back over OPC UA. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- docker-dev/docker-compose.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index a76868d8..0dfe2a73 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -272,6 +272,12 @@ services: OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + # Sql poll driver — the named connection string a Sql driver instance references by + # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver + # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at + # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue) + # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above. + Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-1" @@ -391,6 +397,12 @@ services: OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + # Sql poll driver — the named connection string a Sql driver instance references by + # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver + # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at + # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue) + # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above. + Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-2"