Compare commits

..

15 Commits

Author SHA1 Message Date
Joseph Doherty ee12568cab refactor(modbus-rtu): distinct CrcMismatch/UnitMismatch desync reasons
v2-ci / build (pull_request) Successful in 4m1s
v2-ci / unit-tests (pull_request) Failing after 13m33s
The RTU framer overloaded DesyncReason.TruncatedFrame for three distinct
failures — a genuine short read, a CRC-16 validation failure, and a
CRC-valid reply from the wrong RS-485 slave. Split the latter two into
dedicated CrcMismatch / UnitMismatch enum members so a future .Reason
consumer (metrics, operator diagnostics) can tell a wiring/EMI CRC fault
apart from a mis-addressed multi-drop reply. Behaviour is unchanged — all
three still throw ModbusTransportDesyncException and tear the socket down.

Framing tests now assert the specific Reason on each path.

Follow-up #12 from the Modbus RTU-over-TCP plan.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-25 15:13:48 -04:00
Joseph Doherty e27eb77187 docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result (PASSED)
v2-ci / build (pull_request) Successful in 3m45s
v2-ci / unit-tests (pull_request) Failing after 13m54s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:50:10 -04:00
Joseph Doherty fcc7ed698e harden(modbus-rtu): factory throws on unknown transport + document RTU FC-shape assumption
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:33:49 -04:00
Joseph Doherty 4a8c9badce docs(modbus-rtu): note the co-running :5021 rtu_over_tcp fixture in Docker README + Dockerfile
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:36:00 -04:00
Joseph Doherty 650e27075f test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:29:10 -04:00
Joseph Doherty 132e7b63f6 feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:19:15 -04:00
Joseph Doherty 13bd9820b0 feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:12:12 -04:00
Joseph Doherty 1d90bf3611 feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:08:10 -04:00
Joseph Doherty 20be5416b9 feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:04:34 -04:00
Joseph Doherty 47e9cd56ef feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:56:19 -04:00
Joseph Doherty 2f38b5b285 refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:47:18 -04:00
Joseph Doherty 20100c36ad feat(modbus-rtu): validate response unit + cover partial-read/truncation in ModbusRtuFraming
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:42:25 -04:00
Joseph Doherty eed6617784 feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:33:59 -04:00
Joseph Doherty 8d0f60ec51 feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:30:05 -04:00
Joseph Doherty e787aa572b feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:28:30 -04:00
84 changed files with 1581 additions and 11813 deletions
-6
View File
@@ -29,9 +29,6 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
@@ -93,9 +90,6 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
@@ -30,7 +30,7 @@ it reaches 📝.
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|---|---|---|---|---|---|---|
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | ML | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
@@ -92,16 +92,30 @@ marginal cost.
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
### Modbus RTU — 📝 Plan ready
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
gateway is the only shipped mode, so there is no serial hardware gate.
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
`framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
path was taken; **no stdlib fallback server was needed.**
- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
approved-to-merge.
- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
- AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
operator hint) — set to `RtuOverTcp`, saved.
- **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
- Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
- Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
**5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
### SQL poll — 📝 Plan ready
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
/// </summary>
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
/// <summary>
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
/// </summary>
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
/// <summary>
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
/// successful write to a tag carried value V and a new write of V arrives, the second
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
/// do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
Tcp,
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
RtuOverTcp,
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
/// no socket or framing knowledge lives here.
/// </summary>
public static class ModbusCrc
{
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
/// <returns>The 16-bit CRC value.</returns>
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
var carry = (crc & 0x0001) != 0;
crc >>= 1;
if (carry)
{
crc ^= 0xA001;
}
}
}
return crc;
}
/// <summary>
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
/// </summary>
/// <param name="body">The frame body to checksum.</param>
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc &amp; 0xFF, crc &gt;&gt; 8]</c>.</returns>
public static byte[] Append(ReadOnlySpan<byte> body)
{
var crc = Compute(body);
var result = new byte[body.Length + 2];
body.CopyTo(result);
result[^2] = (byte)(crc & 0xFF);
result[^1] = (byte)(crc >> 8);
return result;
}
}
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
/// <param name="options">Driver configuration options.</param>
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => new ModbusTcpTransport(
o.Host, o.Port, o.Timeout, o.AutoReconnect,
keepAlive: o.KeepAlive,
idleDisconnect: o.IdleDisconnectTimeout,
reconnect: o.Reconnect));
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
@@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions
public string? Family { get; init; }
/// <summary>Gets or sets the Melsec subfamily.</summary>
public string? MelsecSubFamily { get; init; }
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
public string? Transport { get; init; }
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
public int? AutoProhibitReprobeMs { get; init; }
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
/// matching how the driver will actually run.</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns>
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe.");
var transportMode = ModbusTransportMode.Tcp;
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
{
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
}
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
}
/// <inheritdoc />
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
// Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
// autoReconnect=false: this is a one-shot probe, no retry loops.
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
{
Host = host,
Port = port,
Timeout = timeout,
Transport = t.Transport,
AutoReconnect = false,
});
await using (transport.ConfigureAwait(false))
{
try
@@ -0,0 +1,187 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
/// </summary>
/// <remarks>
/// <para>
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
/// is one of three shapes:
/// </para>
/// <list type="table">
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
/// <item>
/// <term>Exception (<c>fc &amp; 0x80</c>)</term>
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Read (FC 01/02/03/04)</term>
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Write echo (FC 05/06/15/16)</term>
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
/// </item>
/// </list>
/// <para>
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
/// function code (<c>fc &amp; 0x7F</c>) and exception code.
/// </para>
/// <para>
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
/// unit-mismatch desync.
/// </para>
/// </remarks>
public static class ModbusRtuFraming
{
/// <summary>
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
/// (appended low byte first).
/// </summary>
/// <param name="unitId">The RTU slave/unit address.</param>
/// <param name="pdu">The bare PDU (function code + data).</param>
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
{
var frame = new byte[1 + pdu.Length];
frame[0] = unitId;
pdu.CopyTo(frame.AsSpan(1));
return ModbusCrc.Append(frame);
}
/// <summary>
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
/// </summary>
/// <param name="stream">The byte stream to read the response from.</param>
/// <param name="expectedUnit">
/// The unit id the request was addressed to. The response's address byte is validated against
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
/// </param>
/// <param name="ct">A token to cancel the read.</param>
/// <returns>The bare response PDU (function code followed by its data).</returns>
/// <exception cref="ModbusTransportDesyncException">
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
/// address byte did not match <paramref name="expectedUnit"/>.
/// </exception>
/// <exception cref="ModbusException">
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
/// </exception>
public static async Task<byte[]> ReadResponsePduAsync(
Stream stream, byte expectedUnit, CancellationToken ct)
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
var header = new byte[2];
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
var fc = header[1];
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
{
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
var rest = new byte[byteCount + 2];
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, bc, rest);
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
trailing = 4 + 2;
}
var tail = new byte[trailing];
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
}
/// <summary>
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
/// except the CRC bytes, then validates the response address against
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
/// </summary>
private static byte[] ValidateAndStrip(
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
{
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
var frame = new byte[header.Length + middle.Length + tail.Length];
header.CopyTo(frame);
middle.CopyTo(frame.AsSpan(header.Length));
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
// CRC covers everything except the trailing 2 CRC bytes.
var body = frame.AsSpan(0, frame.Length - 2);
var expected = ModbusCrc.Compute(body);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.CrcMismatch,
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
var addr = frame[0];
if (addr != expectedUnit)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.UnitMismatch,
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
// Bare PDU = frame minus leading addr and trailing CRC.
var pdu = body[1..].ToArray();
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
if ((pdu[0] & 0x80) != 0)
{
var fc = (byte)(pdu[0] & 0x7F);
var exCode = pdu[1];
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
}
return pdu;
}
/// <summary>
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
/// truncation desync if the stream ends first. Mirrors
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
/// </summary>
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
read += n;
}
}
}
@@ -0,0 +1,190 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
/// instead of MBAP.
/// </summary>
/// <remarks>
/// <para>
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
/// interleaved responses, at most one transaction may be on the bus at a time — the
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
/// diagnostics convenience.
/// </para>
/// <para>
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
/// second failure bubbles up so the driver's health surface reflects the real state.
/// </para>
/// <para>
/// Every transaction runs under a per-op deadline (a linked
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
/// </para>
/// <para>
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
/// single-flight, and deadline can be exercised with no socket — in that path
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
/// skipped (a raw injected stream cannot reconnect).
/// </para>
/// </remarks>
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
{
private readonly ModbusSocketLifecycle? _life;
private readonly Stream? _testStream;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
/// <param name="port">The TCP port of the Modbus gateway.</param>
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusRtuOverTcpTransport(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_timeout = timeout;
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
{
_timeout = timeout;
_testStream = testStream;
}
/// <summary>
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
/// </summary>
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
/// <param name="timeout">The per-op response deadline.</param>
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) =>
// The ForTest seam is handed a pre-connected stream — nothing to dial.
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
// at a time — serialise every send/receive under the gate.
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
// against silent NAT / firewall reaping.
if (_life is not null && _life.ShouldReconnectForIdle())
{
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life?.MarkSuccess();
return result;
}
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — a second failure propagates so health/status
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
// lifecycle to reconnect).
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
}
}
finally
{
_gate.Release();
}
}
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
private Stream? CurrentStream => _life?.Stream ?? _testStream;
/// <summary>
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
/// caller-cancel handling.
/// </summary>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var stream = CurrentStream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
}
catch (ModbusTransportDesyncException)
{
// Framing violation: the stream is desynchronized — never reuse it.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalise as a desync so the
// reconnect retry / status mapping engages.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
@@ -0,0 +1,221 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
/// the lifecycle knows nothing about MBAP / RTU wire layout.
/// </summary>
/// <remarks>
/// <para>
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
/// keep-alive / idle behaviour is written once and composed by both transports. The
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="ConnectWithBackoffAsync"/>.
/// </para>
/// <para>
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
/// in reasonable time even when the application is mostly idle.
/// </para>
/// </remarks>
public sealed class ModbusSocketLifecycle : IAsyncDisposable
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private TcpClient? _client;
private NetworkStream? _stream;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
/// <param name="port">The TCP port of the Modbus server.</param>
/// <param name="timeout">The timeout for socket operations.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusSocketLifecycle(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
}
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
public NetworkStream? Stream => _stream;
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
public bool AutoReconnect => _autoReconnect;
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous reconnect operation.</returns>
public async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary>
/// Reports whether the socket has been idle longer than the configured idle-disconnect
/// threshold and should be proactively torn down + reconnected before the next transaction.
/// Always <see langword="false"/> when no idle-disconnect timeout is configured.
/// </summary>
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
public bool ShouldReconnectForIdle() =>
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
/// <returns>A task representing the asynchronous teardown operation.</returns>
public async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
internal static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
}
}
@@ -3,13 +3,19 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
/// support concurrent transactions, but the single-flight model keeps the wire trace
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary>
/// <remarks>
/// <para>
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
/// reuses with different framing.
/// </para>
/// <para>
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// </remarks>
public sealed class ModbusTcpTransport : IModbusTransport
{
private readonly string _host;
private readonly int _port;
private readonly ModbusSocketLifecycle _life;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private ushort _nextTx;
private bool _disposed;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_stream is null) throw new InvalidOperationException("Transport not connected");
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
// NAT / firewall reaping where the socket looks alive locally but the upstream side
// dropped it minutes ago.
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
if (_life.ShouldReconnectForIdle())
{
await TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow;
_life.MarkSuccess();
return result;
}
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — if it fails again, let it propagate so
// health/status reflect reality.
await TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow;
_life.MarkSuccess();
return result;
}
}
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
}
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
private async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary>
/// Executes exactly one Modbus transaction on the current socket.
/// </summary>
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
/// </remarks>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_stream is null) throw new InvalidOperationException("Transport not connected");
var stream = _life.Stream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
cts.CancelAfter(_timeout);
try
{
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
throw new ModbusTransportDesyncException(
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
// error — the socket is still coherent, so it MUST propagate without teardown.
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
catch (ModbusTransportDesyncException)
{
// Framing violation: the socket is desynchronized — never reuse it.
await TearDownAsync().ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalize as a desync so the
// reconnect retry / status mapping engages.
await TearDownAsync().ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
private static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
private async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
{
if (_disposed) return;
_disposed = true;
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
@@ -1,9 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown /
/// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
/// mismatch or truncated MBAP length). Unlike a Modbus <em>exception PDU</em> (a well-formed
/// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
/// <em>exception PDU</em> (a well-formed
/// protocol-level error where the socket is still coherent and the request must simply
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is
/// unusable and MUST be torn down before this is thrown.
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
/// <summary>The response MBAP transaction id did not match the request's.</summary>
TxIdMismatch,
/// <summary>The response MBAP length field was below the mandatory minimum (truncated frame).</summary>
/// <summary>
/// The frame ended before it was complete — the MBAP length field was below the mandatory
/// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
/// </summary>
TruncatedFrame,
/// <summary>The RTU frame's trailing CRC-16 did not validate over the received bytes.</summary>
CrcMismatch,
/// <summary>
/// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
/// RS-485 multi-drop bus, a reply from the wrong slave.
/// </summary>
UnitMismatch,
}
/// <summary>Gets the reason the socket was classified desynchronized.</summary>
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
/// exactly one place.
/// </summary>
public static class ModbusTransportFactory
{
/// <summary>
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
/// </summary>
/// <param name="options">Driver configuration options.</param>
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
/// </exception>
public static IModbusTransport Create(ModbusDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.Transport switch
{
ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
ModbusTransportMode.Tcp => new ModbusTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
_ => throw new ArgumentOutOfRangeException(
nameof(options), options.Transport, "Unknown Modbus transport mode."),
};
}
}
@@ -1,201 +0,0 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
public enum SqlBrowseNodeKind
{
/// <summary>A schema — the browse root level.</summary>
Schema,
/// <summary>A table or view inside a schema.</summary>
Table,
/// <summary>A column of a table or view — the terminal (leaf) level.</summary>
Column,
}
/// <summary>A decoded browse <c>NodeId</c>.</summary>
/// <param name="Kind">Which catalog level this reference addresses.</param>
/// <param name="Schema">The schema name. Always present.</param>
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
public readonly record struct SqlBrowseNodeRef(
SqlBrowseNodeKind Kind,
string Schema,
string? Table,
string? Column);
/// <summary>
/// Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
/// on expand / attribute fetch / column commit.
/// <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
/// <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
/// inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
/// both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
/// <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
/// + table <c>b</c>, and a table named <c>x|y</c> 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.
/// </para>
/// <para><b>The encoding.</b> <c>&lt;kind&gt;:&lt;part&gt;[|&lt;part&gt;…]</c>, where <c>kind</c> is one
/// of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
/// can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
/// <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
/// readable (<c>column:dbo|TagValues|num_value</c>). 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.</para>
/// <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
/// column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. 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.</para>
/// </summary>
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";
/// <summary>Encodes a schema-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
public static string ForSchema(string schema) => Encode(SchemaKind, schema);
/// <summary>Encodes a table-level NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
/// <summary>Encodes a column-level (leaf) NodeId.</summary>
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
/// <param name="column">The column name, exactly as the catalog reports it.</param>
/// <returns>The encoded NodeId.</returns>
/// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
public static string ForColumn(string schema, string table, string column) =>
Encode(ColumnKind, schema, table, column);
/// <summary>Attempts to decode a NodeId.</summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
/// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
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;
}
}
/// <summary>
/// Decodes a NodeId, throwing when it is malformed.
/// <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
/// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
/// </summary>
/// <param name="nodeId">The NodeId to decode.</param>
/// <returns>The decoded reference.</returns>
/// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
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:<schema>', 'table:<schema>|<table>' or " +
"'column:<schema>|<table>|<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);
}
}
/// <summary>
/// Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
/// <c>false</c> on a dangling trailing escape, which cannot be produced by
/// <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
/// </summary>
private static bool TrySplit(string payload, out List<string> 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;
}
}
@@ -1,304 +0,0 @@
using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <summary>
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
/// column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
/// two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
/// the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
/// never needs an identifier in text.</para>
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
/// <see cref="DisposeAsync"/>.</b> 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.</para>
/// <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
/// 20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); 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.</para>
/// </summary>
internal sealed class SqlBrowseSession : IBrowseSession
{
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
private const string TableTypeColumn = "TABLE_TYPE";
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Column alias carrying the SQL type family name.</summary>
private const string DataTypeColumn = "DATA_TYPE";
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
/// <summary>
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
/// </summary>
private const string ReadOnlySecurityClass = "ViewOnly";
private readonly DbConnection _connection;
private readonly ISqlDialect _dialect;
/// <summary>
/// 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.
/// </summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
/// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
/// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
}
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <inheritdoc />
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <inheritdoc />
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
var schemas = await QueryAsync(
_dialect.ListSchemasSql,
static _ => { },
static reader => ReadString(reader, SchemaColumn),
cancellationToken).ConfigureAwait(false);
var nodes = new List<BrowseNode>(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;
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<BrowseNode>> 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<BrowseNode>();
}
}
/// <inheritdoc />
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
public async Task<IReadOnlyList<AttributeInfo>> 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<AttributeInfo>();
}
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<AttributeInfo>();
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
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<IReadOnlyList<BrowseNode>> 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<BrowseNode>(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<IReadOnlyList<BrowseNode>> ExpandTableAsync(
string schema, string table, CancellationToken ct)
{
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
var nodes = new List<BrowseNode>(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;
}
/// <summary>
/// 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.
/// </summary>
private Task<List<(string Name, string DataType)>> 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);
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
/// </summary>
private async Task<List<T>> QueryAsync<T>(
string sql,
Action<DbCommand> bind,
Func<DbDataReader, T> project,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var results = new List<T>();
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) { }
}
}
/// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
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();
}
}
@@ -1,401 +0,0 @@
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;
/// <summary>
/// Opens a <b>transient</b> database connection from form-supplied JSON for the AdminUI schema picker,
/// and hands it to a <see cref="SqlBrowseSession"/>. The AdminUI's <c>BrowseSessionRegistry</c> (its idle
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
/// registry of its own.
/// <para><b>Two ways to name the database, and only one of them is persisted.</b>
/// <c>connectionStringRef</c> is the deployed form: a name resolved <em>in the AdminUI process</em> from
/// the environment as <c>Sql__ConnectionStrings__&lt;ref&gt;</c> (design §4.4), so a deployed artifact
/// never carries credentials. <c>connectionString</c> is an ad-hoc, <b>session-only</b> 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.</para>
/// <para><b>Precedence: a pasted literal wins over a ref</b>, and the ref is then not even read. The
/// literal cannot arrive from a persisted blob — <see cref="SqlDriverConfigDto"/> 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 <em>ref</em>, never any connection text) so
/// the override is visible rather than silent.</para>
/// <para><b>Credential hygiene is the point of this type.</b> 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 <see cref="Sanitize"/> before it leaves this
/// class.</para>
/// </summary>
public sealed class SqlDriverBrowser : IDriverBrowser
{
/// <summary>
/// Prefix of the environment variable a <c>connectionStringRef</c> resolves through — the
/// double-underscore form .NET configuration uses for the <c>Sql:ConnectionStrings:&lt;ref&gt;</c>
/// key path.
/// </summary>
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
/// <summary>
/// Connection-string keys whose <em>values</em> 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 <b>not</b> redacted — "server X was not found"
/// is the diagnostic the operator needs, and a hostname is not a credential.
/// </summary>
private static readonly string[] CredentialKeys =
[
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
];
/// <summary>
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
/// knobs are authored as their <em>names</em>, so the browser parses a given <c>DriverConfig</c> blob
/// exactly as the runtime factory does. <see cref="JsonStringEnumConverter"/> also accepts ordinals,
/// so a numeric config still binds.
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly Func<SqlProvider, ISqlDialect?> _dialectSelector;
private readonly Func<string, string?> _environmentReader;
private readonly ILogger<SqlDriverBrowser> _logger;
/// <summary>Creates a browser. Every dependency has a production default, so DI may construct it bare.</summary>
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (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.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
/// Injectable because environment variables are process-global, and a test that sets one races every
/// other test in the assembly.
/// </param>
/// <param name="logger">Optional; defaults to <see cref="NullLogger{T}"/>.</param>
public SqlDriverBrowser(
Func<SqlProvider, ISqlDialect?>? dialectSelector = null,
Func<string, string?>? environmentReader = null,
ILogger<SqlDriverBrowser>? logger = null)
{
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
_logger = logger ?? NullLogger<SqlDriverBrowser>.Instance;
}
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name, e.g. <c>Sql__ConnectionStrings__MesStaging</c>.</returns>
public static string EnvironmentVariableFor(string connectionStringRef) =>
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
/// the caller reports it as unavailable rather than crashing on a null dialect.
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
/// a <c>connectionStringRef</c> nor a literal, resolves a ref that is not set in the environment, or
/// the connection could not be opened.
/// </exception>
public async Task<IBrowseSession> 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);
}
/// <summary>
/// Opens one transient connection and hands ownership to the session. <b>The session closes the
/// connection on its own disposal</b>, 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.
/// </summary>
private static async Task<IBrowseSession> 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);
}
}
/// <summary>
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
/// form field), never as an empty connection string.
/// </summary>
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}<ref>', 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;
}
/// <summary>How the connection string was obtained, for the log. Carries no connection text.</summary>
private static string DescribeSource(string? literal, string? reference) =>
!string.IsNullOrWhiteSpace(literal)
? "a pasted literal (session-only, not persisted)"
: $"connectionStringRef '{reference}'";
/// <summary>
/// Pulls the ad-hoc <c>connectionString</c> literal out of the raw JSON. It is read here rather than
/// off the DTO because <see cref="SqlDriverConfigDto"/> deliberately has no such property — the
/// persisted contract must not be able to carry a credential — and the DTO's
/// <see cref="JsonUnmappedMemberHandling.Skip"/> would silently drop it.
/// </summary>
/// <returns>The literal, or <see langword="null"/> when absent, blank, or not a JSON string.</returns>
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;
}
/// <summary>
/// Parses the raw configuration. The <see cref="JsonException"/> is <b>not</b> 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.
/// </summary>
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.)");
}
}
/// <summary>
/// Binds the typed configuration. A bind failure (e.g. an unknown <c>provider</c> name) is reported
/// with the parser's own message redacted against the literal, which is known by now.
/// </summary>
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(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)));
}
}
/// <summary>
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
/// <para><b>Live-probed, not assumed.</b> <c>Microsoft.Data.SqlClient</c> and
/// <c>Microsoft.Data.Sqlite</c> keep connection-string <em>values</em> out of their
/// <c>SqlException</c>/<c>SqliteException</c> text (a failed connect says "the server was not found",
/// never what it was handed). Their connection-string <b>parser</b> is the exception: it reports an
/// unrecognised keyword by quoting it, and a value carrying an unquoted <c>;</c> — 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. <c>Password=Sup3r;SecretTail</c> yields
/// <c>Keyword not supported: 'secrettail;connect timeout'.</c> — the credential, in the message,
/// lower-cased and therefore invisible to a naive ordinal search for the value.</para>
/// <para>So the parser's message (an <see cref="ArgumentException"/>) is <b>never</b> surfaced. Every
/// other failure is additionally passed through the substring redactor as a backstop, and when that
/// fires anywhere in the exception <em>chain</em> the inner exception is dropped too — keeping it
/// would re-expose through <c>ToString()</c> exactly what the message just removed.</para>
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
private static List<string> CollectSecrets(string? connectionString)
{
var secrets = new List<string>();
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<string> secrets)
{
foreach (var secret in secrets)
{
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
private static string Redact(string text, List<string> secrets)
{
foreach (var secret in secrets)
{
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
}
return text;
}
}
@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
</ItemGroup>
</Project>
@@ -1,64 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The <c>DriverConfig</c> blob shape for a <c>Sql</c> 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.
/// <para><b>No connection string lives here.</b> Only <see cref="ConnectionStringRef"/> — a name resolved at
/// Initialize from the environment / secret store (e.g. <c>Sql__ConnectionStrings__MesStaging</c>) — so a
/// deployed artifact never carries database credentials.</para>
/// <para>Enum-valued fields are enum-typed (not strings) so a <c>JsonStringEnumConverter</c> round-trips
/// their <em>names</em> rather than their ordinals. The converter is applied by the factory's
/// <c>JsonSerializerOptions</c> (Task 9), not by an attribute on this DTO — mirroring the driver
/// enum-serialization trap that bit the AdminUI driver pages.</para>
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
/// Name of the connection string to resolve from the environment / secret store at Initialize —
/// <b>never</b> the connection string itself.
/// </summary>
public string? ConnectionStringRef { get; init; }
/// <summary>Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default.</summary>
public TimeSpan? DefaultPollInterval { get; init; }
/// <summary>
/// Per-query wall-clock deadline enforced client-side (a breach surfaces <c>BadTimeout</c>). Must be
/// greater than <see cref="CommandTimeout"/>, which is only the server-side backstop (design §8.3).
/// </summary>
public TimeSpan? OperationTimeout { get; init; }
/// <summary>ADO.NET <c>CommandTimeout</c> backstop (seconds granularity server-side).</summary>
public TimeSpan? CommandTimeout { get; init; }
/// <summary>Cap on concurrently executing group queries — the connection-pool guard.</summary>
public int? MaxConcurrentGroups { get; init; }
/// <summary>When <see langword="true"/>, a NULL cell publishes Bad rather than the default Uncertain.</summary>
public bool? NullIsBad { get; init; }
/// <summary>
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
/// an operator who believes writes are enabled can.
/// </summary>
public bool? AllowWrites { get; init; }
/// <summary>
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
/// the driver <c>TagConfig</c> blob, the same shape every other driver receives. The factory copies
/// these onto the driver's options and the driver maps each through
/// <see cref="SqlEquipmentTagParser.TryParse"/> at Initialize.
/// <para>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.</para>
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
}
@@ -1,131 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Pure mapper from an authored raw tag's <c>TagConfig</c> JSON (design §5.2 / §5.3) to a
/// <see cref="SqlTagDefinition"/>, mirroring <c>ModbusTagDefinitionFactory.FromTagConfig</c>. The blob is
/// recognised by a leading <c>{</c> plus a <c>"driver":"Sql"</c> marker or a <c>"model"</c> discriminator;
/// the <c>model</c> and <c>type</c> enums are read with
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so a present-but-invalid (typo'd) value rejects
/// the whole tag — the driver then surfaces <c>BadNodeIdUnknown</c> instead of silently defaulting to a
/// different model and publishing a misleading <c>Good</c> (R2-11).
/// <para><b>No SQL is built here.</b> The parser only captures strings; the reader binds value fields as
/// <c>DbParameter</c>s and validates + quotes identifier fields. Tag input is never concatenated into a
/// command text, so a hostile <c>keyValue</c> is stored — and must be stored — verbatim.</para>
/// </summary>
public static class SqlEquipmentTagParser
{
/// <summary>
/// Maps an authored <c>TagConfig</c> blob to a typed definition keyed by <paramref name="rawPath"/>.
/// Returns <see langword="false"/> — never throws — for anything the driver must not serve: a
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd <c>model</c> or
/// <c>type</c> enum, a missing model-required field, or the P3-deferred
/// <see cref="SqlTagModel.Query"/> model.
/// </summary>
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
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; }
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string.</summary>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>
/// 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 <c>whereValue</c> of either shape is captured
/// verbatim for later parameter binding. Null when absent or not a scalar.
/// </summary>
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,
};
}
}
@@ -1,39 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
Oracle,
}
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel
{
/// <summary>
/// Key-value (EAV) table: one row per tag, selected by <c>keyColumn = keyValue</c>, value read from
/// <c>valueColumn</c>.
/// </summary>
KeyValue,
/// <summary>
/// Wide row: one row holds many signals as columns; the tag names its <c>columnName</c> and the row is
/// picked by a row selector (a <c>whereColumn</c>/<c>whereValue</c> pair, or newest-by-timestamp).
/// </summary>
WideRow,
/// <summary>Named arbitrary-SELECT query (design §5.4). <b>Deferred to P3 — not accepted by v1's parser.</b></summary>
Query,
}
@@ -1,47 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's <c>TagConfig</c> blob
/// (design §5.2 / §5.3), produced by <see cref="SqlEquipmentTagParser.TryParse"/>.
/// <para><b>Every string here is captured verbatim from the authored blob and is NEVER concatenated
/// into SQL.</b> Value-bearing fields (<see cref="KeyValue"/>, <see cref="RowSelectorValue"/>) are bound
/// as <c>DbParameter</c>s by the reader; identifier-bearing fields (<see cref="Table"/>,
/// <see cref="KeyColumn"/>, <see cref="ValueColumn"/>, <see cref="TimestampColumn"/>,
/// <see cref="ColumnName"/>, <see cref="RowSelectorColumn"/>,
/// <see cref="RowSelectorTopByTimestamp"/>) must be validated against <c>INFORMATION_SCHEMA</c> and
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
/// legitimate values, so the parser deliberately does not.</para>
/// </summary>
/// <param name="Name">
/// The tag's identity — its <b>RawPath</b>, 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.
/// </param>
/// <param name="Model">Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.</param>
/// <param name="Table">The table or view to read (e.g. <c>dbo.TagValues</c>).</param>
/// <param name="KeyColumn"><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</param>
/// <param name="KeyValue"><see cref="SqlTagModel.KeyValue"/>: this tag's key — <b>bound</b>, never interpolated.</param>
/// <param name="ValueColumn"><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</param>
/// <param name="TimestampColumn">Optional source-timestamp column; absent ⇒ the driver stamps the poll time.</param>
/// <param name="ColumnName"><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</param>
/// <param name="RowSelectorColumn"><see cref="SqlTagModel.WideRow"/>: the <c>whereColumn</c> that picks the row.</param>
/// <param name="RowSelectorValue"><see cref="SqlTagModel.WideRow"/>: the <c>whereValue</c> — <b>bound</b>, never interpolated.</param>
/// <param name="RowSelectorTopByTimestamp"><see cref="SqlTagModel.WideRow"/>: newest-row selector — the timestamp column to order by, when no where-pair is authored.</param>
/// <param name="DeclaredType">
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
/// driver infers the type from the result set's column metadata.
/// </param>
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);
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -1,107 +0,0 @@
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;
/// <summary>
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
/// connections, commands, parameters and readers; they do <b>not</b> abstract the three things that differ
/// per backend — <b>identifier quoting</b>, the <b>metadata-catalog SQL</b>, and <b>row-limit syntax</b>.
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
/// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Not yet implemented:</b> 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 <see cref="QuoteIdentifier"/>
/// straight from the authored <c>TagConfig</c> blob, so <b>quoting is currently the only defence</b>,
/// not a backstop behind an upstream filter. Scrutinise it accordingly.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
/// <see cref="DbProviderFactory"/>).</para>
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
SqlProvider Provider { get; }
/// <summary>
/// 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.
/// </summary>
DbProviderFactory Factory { get; }
/// <summary>
/// Quotes <b>one</b> 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.
/// <para><b>One part only.</b> A multi-part name such as <c>dbo.TagValues</c> 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.</para>
/// </summary>
/// <param name="ident">The bare, unquoted identifier — as returned by the catalog, not pre-quoted.</param>
/// <returns>The quoted identifier, ready to concatenate into a command text.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
string QuoteIdentifier(string ident);
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
string LivenessSql { get; }
/// <summary>
/// The fragment that goes <b>immediately after <c>SELECT</c></b> to limit a statement to one row —
/// T-SQL's <c>"TOP 1 "</c>. Empty for a dialect that spells the limit at the end of the statement (see
/// <see cref="SingleRowLimitSuffix"/>).
/// <para><b>Include the trailing space</b> when non-empty: the planner concatenates
/// <c>"SELECT " + SingleRowLimitPrefix + columns</c> with no separator of its own, so an empty fragment
/// must leave the statement byte-identical to an unlimited one.</para>
/// </summary>
/// <remarks>
/// <para><b>Why a prefix/suffix pair rather than one <c>ApplyRowLimit(sql, n)</c> method.</b> The two
/// spellings sit at opposite ends of the statement — SQL Server writes
/// <c>SELECT TOP 1 … ORDER BY … DESC</c>, while SQLite/Postgres/MySQL write
/// <c>SELECT … ORDER BY … DESC LIMIT 1</c>. 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.</para>
/// <para><b>Why "single row" rather than a row count.</b> The only limit v1 emits is the wide-row
/// <c>topByTimestamp</c> selector's newest-row pick. Modelling an arbitrary <c>n</c> would be untested
/// surface, and <c>FETCH FIRST</c>/<c>ROWNUM</c> dialects need more than a substituted number anyway.
/// Widen this to a method when a feature actually needs <c>n &gt; 1</c>.</para>
/// </remarks>
string SingleRowLimitPrefix { get; }
/// <summary>
/// The fragment appended to the <b>very end</b> of a statement to limit it to one row —
/// <c>" LIMIT 1"</c> for SQLite/Postgres/MySQL. Empty for a dialect that limits after <c>SELECT</c>
/// (see <see cref="SingleRowLimitPrefix"/>).
/// <para><b>Include the leading space</b> when non-empty, for the same reason the prefix carries a
/// trailing one: the planner appends it directly to the finished statement.</para>
/// <para>Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
/// emits <b>both</b> unconditionally — a dialect needing both ends (or neither) is expressible without
/// touching the call site.</para>
/// </summary>
string SingleRowLimitSuffix { get; }
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
string ListTablesSql { get; }
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
string ListColumnsSql { get; }
/// <summary>
/// Folds a catalog data-type family name (as <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> reports it —
/// e.g. <c>nvarchar</c>, never <c>nvarchar(50)</c>) onto a <see cref="DriverDataType"/>.
/// <para>Must <b>never throw</b>: a browse over a table holding one exotic column must still render.
/// An unrecognised family falls back to <see cref="DriverDataType.String"/>.</para>
/// </summary>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
DriverDataType MapColumnType(string sqlDataType);
}
@@ -1,62 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Resolves a <c>Sql</c> driver config's <c>connectionStringRef</c> — 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.
/// <para><b>The environment is read directly, on purpose.</b> A driver factory is invoked through
/// <c>DriverFactoryRegistry</c>'s <c>(driverInstanceId, driverConfigJson)</c> closure — there is no
/// <c>IConfiguration</c>, no <c>IServiceProvider</c> and no ambient scope at that seam, and widening the
/// registry's signature for one driver would change every driver in the tree. The
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c> spelling is exactly the key .NET's environment-variable
/// configuration provider would bind to <c>Sql:ConnectionStrings:&lt;ref&gt;</c>, so an operator can set it
/// the same way they set every other secret and a later move onto <c>IConfiguration</c> needs no
/// re-provisioning.</para>
/// <para><b>Nothing here ever emits the resolved value.</b> Messages name the environment
/// <em>variable</em>, 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.</para>
/// </summary>
public static class SqlConnectionStringResolver
{
/// <summary>
/// The environment-variable prefix a <c>connectionStringRef</c> is appended to. The double underscore
/// is .NET's configuration hierarchy separator, so this is the environment spelling of
/// <c>Sql:ConnectionStrings:&lt;ref&gt;</c>.
/// </summary>
public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
/// <summary>The environment variable a given <c>connectionStringRef</c> is read from.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The full environment-variable name.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
public static string EnvironmentVariableFor(string connectionStringRef)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
}
/// <summary>
/// Resolves <paramref name="connectionStringRef"/> to its connection string.
/// <para>An absent <b>or blank</b> 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.</para>
/// </summary>
/// <param name="connectionStringRef">The authored reference name.</param>
/// <returns>The resolved connection string. <b>Treat as a secret</b> — never log it.</returns>
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">The environment variable is absent or blank.</exception>
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;
}
}
@@ -1,757 +0,0 @@
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;
/// <summary>
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
/// <see cref="SqlPollReader"/> owns the read path.
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
/// new capability, not a flag flip.</para>
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
/// <see cref="IReadable"/> 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
/// <em>successfully</em>, so <see cref="PollGroupEngine"/> sees a clean tick, does not back off, and does
/// not call <see cref="HandlePollError"/>. Without <see cref="ObservePollOutcome"/> below, a driver whose
/// every value is <see cref="SqlStatusCodes.BadTimeout"/> would keep reporting
/// <see cref="DriverState.Healthy"/>. See that method for the exact rule and for why it does not
/// manufacture an exception to force backoff.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
{
/// <summary>
/// The driver-type string this driver registers under.
/// <para><b>Deliberately a local constant, not <c>DriverTypeNames.Sql</c>.</b>
/// <c>DriverTypeNamesGuardTests</c> 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 <c>DriverTypeNames.Sql</c> and repoints this constant at it).</para>
/// </summary>
public const string DriverTypeName = "Sql";
/// <summary>Shown for a connection string that names no recognisable server.</summary>
private const string UnknownEndpoint = "(unknown sql endpoint)";
/// <summary>Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>Connection-string keys that name the server, in the order they are consulted.</summary>
private static readonly string[] ServerKeys =
["server", "data source", "datasource", "host", "address", "addr", "network address"];
/// <summary>Connection-string keys that name the database.</summary>
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;
/// <summary>
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
/// </summary>
private readonly string _connectionString;
private readonly ILogger<SqlDriver> _logger;
/// <summary>
/// The authored RawPath → definition table, held as an <b>immutable snapshot swapped atomically</b>
/// rather than as a mutated dictionary (the shape <c>ModbusDriver</c> uses).
/// <see cref="ReinitializeAsync"/> rebuilds this table while poll loops are reading it; clearing and
/// refilling a shared <see cref="Dictionary{TKey,TValue}"/> under that concurrency is a torn read at
/// best and an <see cref="InvalidOperationException"/> inside a poll at worst.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary>
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
/// delegate must read this driver's live authored table, which does not exist before the driver does.
/// </summary>
private readonly SqlPollReader _reader;
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
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);
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- ctor + identity ----
/// <summary>Initializes a new <c>Sql</c> driver instance.</summary>
/// <param name="options">The driver's typed configuration (the factory applies the documented defaults).</param>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="dialect">
/// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
/// </param>
/// <param name="connectionString">
/// The <b>already-resolved</b> connection string. The driver never resolves a
/// <c>connectionStringRef</c> itself and never logs this value.
/// </param>
/// <param name="factory">
/// Creates the provider's connections. Defaults to <paramref name="dialect"/>'s factory; passed
/// explicitly by tests that need to observe or delay connection creation.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
public SqlDriver(
SqlDriverOptions options,
string driverInstanceId,
ISqlDialect dialect,
string connectionString,
DbProviderFactory? factory = null,
ILogger<SqlDriver>? 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<SqlDriver>.Instance;
Endpoint = DescribeEndpoint(connectionString);
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(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);
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => DriverTypeName;
/// <summary>
/// The credential-free description of the database this instance polls — <c>server/database</c> 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.
/// </summary>
public string Endpoint { get; }
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
public string HostName => Endpoint;
/// <summary>Active polled-subscription count. Diagnostics + disposal assertions.</summary>
internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
/// <summary>The resolver's lookup: RawPath → authored definition, or null on a miss.</summary>
private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath);
// ---- IDriver lifecycle ----
/// <summary>
/// Builds the authored RawPath table and proves the database is reachable.
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
/// <para>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 <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> 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.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">The database could not be reached.</exception>
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 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.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));
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);
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
/// <returns>Always zero.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <summary>
/// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
/// could only produce the same nodes.
/// </summary>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>
/// False — <see cref="DiscoverAsync"/> 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: <c>Driver.Sql.Browser</c>.)
/// </summary>
public bool SupportsOnlineDiscovery => false;
/// <summary>
/// Streams the authored tags as read-only variables.
/// <para>An undeclared tag materialises as <see cref="DriverDataType.String"/> — the same fallback
/// <see cref="ISqlDialect.MapColumnType"/> 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 <c>"type"</c> on a numeric tag.</para>
/// </summary>
/// <param name="builder">The address-space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task — discovery is synchronous over the authored table.</returns>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
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,
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 ----
/// <summary>
/// Reads a batch, delegating to <see cref="SqlPollReader"/> and classifying what came back
/// (<see cref="ObservePollOutcome"/>).
/// </summary>
/// <param name="fullReferences">The RawPaths to read.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="DbException">The database could not be reached — the engine backs off on this.</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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);
// 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;
}
}
// ---- ISubscribable (polling overlay via the shared engine) ----
/// <summary>
/// Registers a polled subscription.
/// <para>A non-positive <paramref name="publishingInterval"/> falls back to the configured
/// <see cref="SqlDriverOptions.DefaultPollInterval"/>; anything faster than
/// <see cref="PollGroupEngine.DefaultMinInterval"/> is floored by the engine.</para>
/// </summary>
/// <param name="fullReferences">The RawPaths to poll.</param>
/// <param name="publishingInterval">The requested publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The subscription handle.</returns>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
return Task.FromResult(_poll.Subscribe(fullReferences, interval));
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// 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.
/// </summary>
/// <returns>A one-element list describing the configured database endpoint.</returns>
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
// ---- health + host-state classification ----
/// <summary>
/// Classifies one poll's snapshots into a health verdict and a host state.
/// <list type="bullet">
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
/// host is Running.</item>
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
/// the host is Stopped.</item>
/// <item>Bad codes that are <b>authoring</b> 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.</item>
/// </list>
/// <para><b>Why this does not throw to force poll-engine backoff.</b> Backoff would require turning
/// an all-<c>BadTimeout</c> 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 <c>operationTimeout</c> and the reader never holds more than
/// <c>maxConcurrentGroups</c> 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.</para>
/// </summary>
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> 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.
// 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);
}
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
private static bool IsConnectionClass(uint statusCode)
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
/// <summary>
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
/// touch host state: the engine also reports its own contract violations here, which say nothing
/// about connectivity — the unreachable-database transition is raised by <see cref="ReadAsync"/>,
/// which knows the exception came from the reader.
/// </summary>
/// <param name="ex">The exception the poll engine caught.</param>
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);
}
/// <summary>
/// Degrades health, preserving <c>LastSuccessfulRead</c> and never downgrading
/// <see cref="DriverState.Faulted"/> — a config-level verdict that only a successful read or an
/// operator reinitialize may clear.
/// </summary>
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
private void Degrade(string reason)
{
var current = ReadHealth();
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
}
/// <summary>
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
/// <see cref="ReinitializeAsync"/> just faulted.
/// </summary>
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
private void SetHealthUnlessFaulted(DriverHealth value)
{
if (ReadHealth().State == DriverState.Faulted) return;
WriteHealth(value);
}
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
private void TouchLastSuccessfulRead()
{
var current = ReadHealth();
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
}
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
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 ----
/// <summary>
/// Maps every authored <see cref="RawTagEntry"/> through <see cref="SqlEquipmentTagParser.TryParse"/>
/// and publishes the result as one atomic snapshot. A blob that does not map is <b>logged and
/// skipped</b>, 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 <see cref="SqlStatusCodes.BadNodeIdUnknown"/> rather than someone else's row.
/// </summary>
private void BuildTagTable()
{
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
foreach (var entry in _options.RawTags)
{
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);
}
// ---- liveness ----
/// <summary>
/// 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.
/// <para><b>Bounded by wall-clock, not only by the token</b> (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, <c>DriverInstanceActor</c>'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.</para>
/// </summary>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the database has answered.</returns>
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.");
}
}
/// <summary>Opens a connection and executes the dialect's cheapest proof-of-life statement.</summary>
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);
}
}
}
/// <summary>
/// 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.
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
// ---- endpoint description ----
/// <summary>
/// Renders a connection string as <c>server/database</c>, reading only those two keys.
/// <para>This is the <b>only</b> 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 <c>User ID</c> / <c>Password</c> / <c>Authentication</c>, which are never
/// read here. A string that cannot be parsed yields <see cref="UnknownEndpoint"/> — an unusable
/// description must not become a crash at construction.</para>
/// </summary>
/// <param name="connectionString">The resolved connection string.</param>
/// <returns>A credential-free description safe to log and display.</returns>
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;
}
}
/// <summary>Reads the first of <paramref name="keys"/> the builder carries; null when it carries none.</summary>
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 ----
/// <summary>
/// Performs the same teardown as <see cref="ShutdownAsync"/>, so a caller that only uses
/// <c>await using</c> does not leak the poll loops.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
/// <summary>
/// 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
/// <c>ShutdownAsync</c>-then-<c>DisposeAsync</c> (and <see cref="ReinitializeAsync"/>) safe.
/// <para>There is no connection to close: the reader opens and disposes one per poll.</para>
/// </summary>
private async Task TeardownAsync()
{
await _poll.DisposeAsync().ConfigureAwait(false);
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
}
}
@@ -1,255 +0,0 @@
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;
/// <summary>
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
/// <c>operationTimeout</c>/<c>commandTimeout</c> 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.</para>
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
/// rendering — never the string itself.</para>
/// </summary>
public static class SqlDriverFactoryExtensions
{
/// <summary>
/// The driver-type string this factory registers under.
/// <para>Chained off <see cref="SqlDriver.DriverTypeName"/> rather than <c>DriverTypeNames.Sql</c>:
/// <c>DriverTypeNamesGuardTests</c> 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
/// <c>DriverTypeNames.Sql</c>).</para>
/// </summary>
public const string DriverTypeName = SqlDriver.DriverTypeName;
/// <summary>
/// How a <c>Sql</c> config blob is read.
/// <list type="bullet">
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
/// ordinal, and always writes the name (the enum-serialization guard).</item>
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
/// schema version must not brick the driver.</item>
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
/// what makes a <em>write</em> round-trip to the authored shape.</item>
/// </list>
/// </summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
/// <summary>
/// Registers the <c>Sql</c> factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
/// driver instance; without it the driver runs with the null logger.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
/// exist until the driver does.</para>
/// <para>No I/O happens here. The database is first contacted by
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
/// Reconnecting rather than a deployment that cannot be constructed.</para>
/// </summary>
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
/// <exception cref="InvalidOperationException">
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
/// </exception>
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}<ref>' 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<SqlDriver>());
// 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;
}
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
{
try
{
return JsonSerializer.Deserialize<SqlDriverConfigDto>(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);
}
}
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> 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.</para>
/// </summary>
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."),
};
/// <summary>
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
/// <para>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.</para>
/// </summary>
/// <param name="dto">The deserialised config blob.</param>
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
/// <returns>The typed options the driver is constructed with.</returns>
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
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,
};
}
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
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.");
}
}
}
@@ -1,56 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The typed form of a <c>Sql</c> driver instance's configuration — what
/// <see cref="SqlDriverConfigDto"/> becomes once the factory has applied its defaults (design §5.1).
/// Mirrors <c>ModbusDriverOptions</c>: the driver instance is constructed with these, and its
/// <see cref="SqlDriver.InitializeAsync"/> serves them rather than re-parsing the config JSON.
/// <para><b>No connection string here.</b> 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.</para>
/// </summary>
public sealed class SqlDriverOptions
{
/// <summary>
/// The authored raw tags this driver serves. The deploy artifact hands each authored raw <c>Tag</c>
/// as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob); the driver maps
/// each through <see cref="SqlEquipmentTagParser.TryParse"/> into its RawPath → definition table.
/// A SQL source has no tag-discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Poll interval used when a subscriber does not ask for one (design §4: <c>defaultPollInterval</c>,
/// default 5 s). A subscriber that names an interval gets that interval, floored by
/// <see cref="PollGroupEngine.DefaultMinInterval"/>.
/// </summary>
public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
/// publishes <see cref="SqlStatusCodes.BadTimeout"/> for that group's tags.
/// </summary>
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
/// <summary>
/// The ADO.NET <c>CommandTimeout</c> server-side backstop (default 10 s), also the bound on the
/// Initialize-time liveness check.
/// <para>Authoring must keep <see cref="OperationTimeout"/> strictly greater than this (design §8.3);
/// inverted, the client-side abort always fires first and masks the backstop. <b>That rule is
/// enforced by config validation in the factory, not here</b> — the reader deliberately stays usable
/// with the order inverted so the frozen-database tests can prove the client-side bound fires.</para>
/// </summary>
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>Cap on concurrently executing group queries — and therefore on concurrently open connections.</summary>
public int MaxConcurrentGroups { get; init; } = 4;
/// <summary>
/// When <see langword="true"/>, a present row whose value cell is NULL publishes
/// <see cref="SqlStatusCodes.Bad"/> instead of the default <see cref="SqlStatusCodes.Uncertain"/>.
/// It never governs an absent row, which is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </summary>
public bool NullIsBad { get; init; }
}
@@ -1,171 +0,0 @@
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;
/// <summary>
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
/// <para><b>Parses the SAME factory DTO with the SAME converter as
/// <see cref="SqlDriverFactoryExtensions"/></b> (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
/// <c>provider</c> parses here exactly as it does at the factory, and the same
/// <c>connectionStringRef</c> resolves the same way.</para>
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
/// catch.</para>
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
/// </summary>
public sealed class SqlDriverProbe : IDriverProbe
{
/// <summary>
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
/// probe can run against the SQLite fixture with no SQL Server and no network.
/// </summary>
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
/// <summary>
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
/// catalog SQL. Null in production: the dialect's own factory is used.
/// </summary>
private readonly DbProviderFactory? _factoryOverride;
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
public SqlDriverProbe()
: this(DefaultDialectFor, factoryOverride: null)
{
}
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
{
_dialectFor = dialectFor;
_factoryOverride = factoryOverride;
}
/// <summary>
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
/// runs unmodified.
/// </summary>
/// <param name="factory">The provider factory the probe opens connections from.</param>
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
/// <returns>A probe wired to the supplied factory + dialect.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(dialect);
return new SqlDriverProbe(_ => dialect, factory);
}
/// <inheritdoc />
public string DriverType => SqlDriver.DriverTypeName;
/// <inheritdoc />
public async Task<DriverProbeResult> 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<SqlDriverConfigDto>(
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);
}
/// <summary>
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
/// data source, so its <c>.Message</c> is deliberately not surfaced).
/// </summary>
private static async Task<DriverProbeResult> 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);
}
}
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
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)."),
};
}
@@ -1,250 +0,0 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Folds a set of <see cref="SqlTagDefinition"/>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.
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> 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
/// <c>SqlGroupPlannerTests</c>.</para>
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
/// interpolation of tag content into SQL anywhere in this type.</para>
/// </summary>
public static class SqlGroupPlanner
{
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
private const string KeyMarkerPrefix = "@k";
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
private const string RowSelectorMarker = "@w";
/// <summary>
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
/// <para>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.</para>
/// </summary>
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
/// <returns>One plan per distinct group key.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
/// <exception cref="ArgumentException">
/// 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
/// <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
/// </exception>
/// <exception cref="NotSupportedException">
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
/// <b>Deliberately loud rather than skipped:</b> 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.
/// </exception>
public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(dialect);
// Ordered grouping: the dictionary decides membership, the list decides emission order.
var order = new List<object>();
var groups = new Dictionary<object, List<SqlTagDefinition>>();
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<SqlQueryPlan>(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;
}
/// <summary>
/// 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.
/// <list type="bullet">
/// <item>
/// <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
/// Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
/// would make one tag publish the other's column.
/// </item>
/// <item>
/// <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
/// — i.e. the table plus the whole row selector, exactly design §5.3's
/// <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
/// deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
/// one row), and they are added to the SELECT list instead.
/// </item>
/// </list>
/// <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
/// two tags authored <c>dbo.T</c> and <c>DBO.T</c> 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.</para>
/// </summary>
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),
};
/// <summary>
/// Emits <c>SELECT &lt;key&gt;, &lt;value&gt;[, &lt;ts&gt;] FROM &lt;table&gt; WHERE &lt;key&gt; IN (@k0..@kN)</c>.
/// The key column is selected because the reader indexes rows by it to slice values back per member.
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
/// </summary>
private static SqlQueryPlan BuildKeyValuePlan(
object groupKey, List<SqlTagDefinition> 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<string>(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<string>(members.Count);
var values = new List<object?>(members.Count);
var seen = new HashSet<string>(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);
}
/// <summary>
/// Emits <c>SELECT &lt;cols&gt; FROM &lt;table&gt; WHERE &lt;whereColumn&gt; = @w</c>, or — for a
/// <c>topByTimestamp</c> selector — the single-row form
/// <c>SELECT &lt;limit&gt; &lt;cols&gt; FROM &lt;table&gt; ORDER BY &lt;ts&gt; DESC &lt;limit&gt;</c>, whose
/// row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
/// <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
/// row in the result already matches the bound value, so reading it back adds nothing.</para>
/// </summary>
private static SqlQueryPlan BuildWideRowPlan(
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
{
var first = members[0];
var selected = new List<string>(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))
{
// 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 ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
dialect.SingleRowLimitSuffix);
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));
}
/// <summary>
/// Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
/// <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
/// <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
/// form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
/// empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
/// whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
/// </summary>
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));
}
/// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
=> string.Join(", ", columns.Select(dialect.QuoteIdentifier));
/// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
private static void AddDistinct(List<string> target, string value)
{
if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
}
/// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
/// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
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).");
}
@@ -1,839 +0,0 @@
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;
/// <summary>
/// Executes one poll pass: resolves each requested reference to a <see cref="SqlTagDefinition"/>, folds
/// the definitions into the minimum number of queries via <see cref="SqlGroupPlanner"/>, runs one
/// command per group under a bounded deadline, and slices each result set back to per-tag
/// <see cref="DataValueSnapshot"/>s — <b>positionally aligned with the caller's reference list</b>
/// (design §3.2). Structurally the SQL analogue of <c>ModbusDriver.ReadCoalescedAsync</c>: group → one
/// round-trip → slice back.
/// <para><b>N in, N out, in order.</b> 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 <see cref="IReadable"/> contract) — see <see cref="SqlStatusCodes"/> 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 <em>one tag's value onto another tag's
/// node</em>.</para>
/// <para><b>The whole call throws only when the database is unreachable</b> — that is, when opening a
/// connection fails. That surfaces to <see cref="PollGroupEngine"/> as a poll failure and earns the
/// capped-exponential backoff; the driver's <c>IHostConnectivityProbe</c> 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 <em>after</em> the connection opened is the group's problem, not the
/// server's, and Bad-codes that group only.</para>
/// <para><b>Deadlines (design §8.3, the R2-01 frozen-peer lesson).</b> Two independent mechanisms, both
/// required. <c>CommandTimeout</c> is the <em>server-side</em> backstop and bounds nothing on a wedged
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
/// <see cref="RunGroupAsync"/>. A frozen database yields <see cref="SqlStatusCodes.BadTimeout"/>
/// snapshots — it must never wedge the poll thread.</para>
/// <para><b>Not a driver.</b> 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
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
/// </summary>
public sealed class SqlPollReader
{
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
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<string, SqlTagDefinition?> _resolve;
private readonly ILogger _logger;
/// <summary>
/// Caps concurrently executing group queries — and therefore concurrently held connections
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
/// timed-out-but-still-running group releasing into a disposed semaphore.
/// </summary>
private readonly SemaphoreSlim _gate;
private long _lastContractWarningTicks;
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
/// <param name="factory">
/// Creates the provider's connections. Passed explicitly rather than taken from
/// <paramref name="dialect"/> so the driver can hand in an instrumented or pre-configured factory.
/// </param>
/// <param name="connectionString">
/// The resolved connection string (never the authored <c>connectionStringRef</c> — secrets are
/// resolved by the driver at Initialize, design §8.2).
/// </param>
/// <param name="dialect">Supplies identifier quoting, row-limit syntax, and column-type mapping.</param>
/// <param name="commandTimeout">
/// The ADO.NET <c>CommandTimeout</c> backstop. Rounded <b>up</b> to whole seconds and floored at 1,
/// because ADO.NET reads <c>CommandTimeout = 0</c> as "wait forever" — the one value this option
/// must never silently become.
/// </param>
/// <param name="operationTimeout">
/// 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
/// <see cref="SqlStatusCodes.BadTimeout"/>.
/// <para>Authoring should keep this strictly greater than <paramref name="commandTimeout"/>
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
/// backstop. That rule is <b>not</b> enforced here — it belongs to config validation, and this type
/// stays usable for the tests that must deliberately invert it.</para>
/// </param>
/// <param name="maxConcurrentGroups">
/// 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 <see cref="RunGroupAsync"/> for why a high value is safe under
/// <c>Microsoft.Data.SqlClient</c> but wants sizing against thread-pool headroom under a provider
/// that blocks synchronously.
/// </param>
/// <param name="nullIsBad">
/// <see langword="true"/> publishes <see cref="SqlStatusCodes.Bad"/> for a NULL value cell instead
/// of the default <see cref="SqlStatusCodes.Uncertain"/>. It governs a <em>present</em> row with a
/// NULL cell only — an absent row is always <see cref="SqlStatusCodes.BadNoData"/>.
/// </param>
/// <param name="resolve">
/// RawPath → tag definition; <see langword="null"/> on a miss. Shaped to match
/// <see cref="EquipmentTagRefResolver{TDef}"/>'s lookup so the driver can pass its authored table
/// straight through.
/// </param>
/// <param name="logger">Optional; defaults to a no-op logger.</param>
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is blank.</exception>
/// <exception cref="ArgumentOutOfRangeException">A timeout is non-positive, or the cap is below 1.</exception>
public SqlPollReader(
DbProviderFactory factory,
string connectionString,
ISqlDialect dialect,
TimeSpan commandTimeout,
TimeSpan operationTimeout,
int maxConcurrentGroups,
bool nullIsBad,
Func<string, SqlTagDefinition?> 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);
}
/// <summary>
/// Reads every reference in one poll pass. Matches <see cref="IReadable.ReadAsync"/>'s shape so the
/// driver shell can delegate to it directly.
/// </summary>
/// <param name="fullReferences">The RawPaths to read. Empty yields an empty result and touches no connection.</param>
/// <param name="cancellationToken">
/// The caller's token. Its cancellation <b>propagates</b> as an
/// <see cref="OperationCanceledException"/> — the engine asked the poll to stop, and nobody is
/// waiting for the snapshots. This is deliberately asymmetric with an <c>operationTimeout</c>
/// breach, which is a data-quality fact clients must see and so becomes
/// <see cref="SqlStatusCodes.BadTimeout"/> snapshots.
/// </param>
/// <returns>One snapshot per reference, at the same index.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fullReferences"/> is null.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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<SqlTagDefinition>(fullReferences.Count);
var slots = new Dictionary<SqlTagDefinition, List<int>>();
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;
}
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
private async Task ReadGroupsAsync(
List<SqlTagDefinition> definitions,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
IReadOnlyList<SqlQueryPlan> 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<GroupResult>[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);
}
/// <summary>
/// Runs one group's query under a hard wall-clock ceiling.
/// <para><b>Why the ceiling is <c>Task.WaitAsync</c> and not just a linked
/// <c>CancelAfter</c>.</b> A linked token only bounds a
/// provider that <em>honours</em> 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
/// <c>ExecuteReaderAsync</c> 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 <c>WaitAsync</c> so
/// control returns at the deadline regardless.</para>
/// <para>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.</para>
/// <para><b>Thread-pool scheduling and what a BadTimeout therefore means.</b> The group's clock
/// starts before <c>Task.Run</c>, so in principle a group could burn budget queueing for a pool
/// thread rather than waiting on the database. <c>TaskCreationOptions.LongRunning</c> (a dedicated
/// OS thread) was considered and <b>rejected</b>: 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.
/// <c>Microsoft.Data.SqlClient</c>'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
/// <em>zero</em> pool threads however long it stays frozen, and the driver cannot starve itself no
/// matter how high <c>maxConcurrentGroups</c> 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. <b>Consequence for outage diagnosis (e.g. blackhole-testing a
/// paused SQL Server):</b> under <c>Microsoft.Data.SqlClient</c> a
/// <see cref="SqlStatusCodes.BadTimeout"/> means the database did not answer inside
/// <c>operationTimeout</c> — 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
/// <c>maxConcurrentGroups</c> below the process's readily-available worker count there.</para>
/// <para><b>The concurrency slot is released by the work, not by the waiter.</b> A timed-out group
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
/// <c>maxConcurrentGroups</c> 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.</para>
/// </summary>
private async Task<GroupResult> 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<GroupResult> 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;
}
}
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
private TimeSpan Remaining(Stopwatch clock)
{
var remaining = _operationTimeout - clock.Elapsed;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>Records the deadline breach and detaches the abandoned work.</summary>
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}, '{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;
}
/// <summary>
/// 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.
/// </summary>
private static void Detach(Task work)
=> _ = work.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
/// <summary>
/// Opens a connection, executes the plan's single command, and materialises the result set.
/// <para>The rows are read into memory <b>before</b> 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.</para>
/// </summary>
private async Task<GroupResult> 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;
}
}
}
/// <summary>
/// Reads every row into memory, projected onto <see cref="SqlQueryPlan.SelectedColumns"/>. 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.
/// </summary>
private async Task<GroupResult> 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<string, int>(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<object?[]>();
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);
}
/// <summary>Reads a column's provider type name, falling back to the dialect's unknown-type default.</summary>
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;
}
}
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
private void Slice(
SqlQueryPlan plan,
GroupResult outcome,
Dictionary<SqlTagDefinition, List<int>> 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). 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)
{
// 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, wideRow, readAt)
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
}
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
{
var index = new Dictionary<string, object?[]>(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;
}
/// <summary>
/// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector.
/// <para><b>Last row wins</b> — the same tie-break <see cref="IndexByKey"/> applies, so both models
/// degrade the same way. It is deterministic only <em>within one result set</em>: the where-pair form
/// emits no <c>ORDER BY</c>, 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.</para>
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
/// <c>TOP 1</c> with no <c>ORDER BY</c> 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
/// (<c>Rows.Count &gt; 1</c>), converting a loud misconfiguration into a permanently silent one —
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
/// form keeps its row limit because there the <c>ORDER BY</c> 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.</para>
/// </summary>
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];
}
/// <summary>Maps one member's cell to a snapshot: value, quality, and source timestamp.</summary>
private DataValueSnapshot MapMember(
SqlQueryPlan plan,
SqlTagDefinition member,
GroupResult outcome,
Dictionary<string, object?[]>? byKey,
object?[]? wideRow,
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 = wideRow;
}
// 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);
}
/// <summary>Reads a row's source timestamp, or null when there is no column, no value, or no parse.</summary>
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;
}
/// <summary>
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
/// type matches the type the address space declared for the node. Never throws.
/// </summary>
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;
}
}
/// <summary>
/// Normalises a timestamp cell to UTC. A naive <c>datetime</c> (no offset — the common SQL Server
/// shape) is <b>assumed</b> 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.
/// </summary>
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),
};
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
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;
}
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
private sealed class GroupResult
{
private static readonly IReadOnlyList<object?[]> NoRows = [];
private static readonly IReadOnlyDictionary<string, int> NoColumns =
new Dictionary<string, int>(StringComparer.Ordinal);
private GroupResult(
uint failureStatus,
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
{
FailureStatus = failureStatus;
Rows = rows;
ColumnIndex = columnIndex;
TypeNames = typeNames;
}
/// <summary>The group exceeded its wall-clock deadline (design §8.3).</summary>
public static GroupResult TimedOut { get; } =
new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []);
/// <summary>The connection was fine but the query was not.</summary>
public static GroupResult Failed { get; } =
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
public uint FailureStatus { get; }
/// <summary>The materialised rows, projected onto the plan's selected columns.</summary>
public IReadOnlyList<object?[]> Rows { get; }
/// <summary>Selected column name → its position within each row array.</summary>
public IReadOnlyDictionary<string, int> ColumnIndex { get; }
/// <summary>Each selected column's provider type name, for dialect type inference.</summary>
public IReadOnlyList<string> TypeNames { get; }
/// <summary>True when the query ran; a zero-row result set is still a success.</summary>
public bool Succeeded => FailureStatus == SqlStatusCodes.Good;
/// <summary>A completed result set.</summary>
/// <param name="rows">The materialised rows.</param>
/// <param name="columnIndex">Selected column name → row-array position.</param>
/// <param name="typeNames">Each selected column's provider type name.</param>
/// <returns>A successful outcome.</returns>
public static GroupResult Ok(
IReadOnlyList<object?[]> rows,
IReadOnlyDictionary<string, int> columnIndex,
IReadOnlyList<string> typeNames)
=> new(SqlStatusCodes.Good, rows, columnIndex, typeNames);
}
}
/// <summary>
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
/// them back with.
/// <para><b>Why a driver-local table rather than a shared one.</b> Every driver in this tree carries its
/// own (<c>TwinCATStatusMapper</c>, <c>AbCipStatusMapper</c>, <c>FocasStatusMapper</c>,
/// <c>AbLegacyStatusMapper</c>, Galaxy's <c>StatusCodeMap</c>) — there is no shared helper in
/// <c>Core.Abstractions</c>, because drivers deliberately do not reference the OPC UA SDK and
/// <see cref="DataValueSnapshot.StatusCode"/> is a bare <see cref="uint"/>. The values below were read
/// off <c>Opc.Ua.StatusCodes</c> rather than copied from a sibling driver, since two of those tables
/// carry transcription errors.</para>
/// </summary>
public static class SqlStatusCodes
{
/// <summary>The value is good. (<c>Good</c>)</summary>
public const uint Good = 0x00000000u;
/// <summary>Quality is uncertain with no more specific reason — a NULL cell under <c>nullIsBad=false</c>.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Quality is bad with no more specific reason — a NULL cell under <c>nullIsBad=true</c>.</summary>
public const uint Bad = 0x80000000u;
/// <summary>
/// No row was returned for this tag — the key is absent from the result set, or the wide-row
/// selector matched nothing. <b>Deliberately distinct from a NULL cell:</b> the row is gone, versus
/// the row is there and the value is not.
/// </summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>The group exceeded its client-side wall-clock deadline (design §8.3).</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>The reference resolves to no authored tag.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The query failed after the connection opened.</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The cell could not be coerced to the tag's effective data type.</summary>
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>A tag definition the planner rejected — an authoring fault, not a database fault.</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>A defect in the reader itself; never expected in the field.</summary>
public const uint BadInternalError = 0x80020000u;
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
private const uint SeverityMask = 0xC0000000u;
/// <summary>True when <paramref name="statusCode"/> is in the Good quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Good.</returns>
public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u;
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Uncertain.</returns>
public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain;
/// <summary>True when <paramref name="statusCode"/> is in the Bad quality class.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>True when the code's severity bits are Bad.</returns>
public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad;
}
@@ -1,90 +0,0 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
/// shares a <see cref="GroupKey"/> reads from the <b>same</b> result set, so a poll pass executes one plan
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
/// <param name="Model">
/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
/// <see cref="SqlTagModel.KeyValue"/> indexes rows by <see cref="KeyColumn"/> and matches each member's
/// <see cref="SqlTagDefinition.KeyValue"/>; <see cref="SqlTagModel.WideRow"/> takes the single row and
/// reads each member's <see cref="SqlTagDefinition.ColumnName"/>.
/// </param>
/// <param name="GroupKey">
/// 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.
/// </param>
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
/// <param name="ParameterNames">
/// The parameter markers appearing in <see cref="SqlText"/>, in the order they appear, aligned index-for-index
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// 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.
/// </param>
/// <param name="SelectedColumns">
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// </param>
public sealed record SqlQueryPlan(
SqlTagModel Model,
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> 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<T> holding a List<T> is downcastable, and a
// mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary.
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
/// <summary>The tags this plan feeds, in input order; duplicates preserved.</summary>
public IReadOnlyList<SqlTagDefinition> Members { get; } = [.. Members];
/// <summary>The bare SELECT-list column names, in result-set ordinal order.</summary>
public IReadOnlyList<string> SelectedColumns { get; } = [.. SelectedColumns];
/// <summary>
/// The key column all members are matched on — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant,
/// so <c>Members[0]</c> is authoritative.
/// </summary>
public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
/// <summary>
/// The column every member's value is read from — <see langword="null"/> for any model but
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant.
/// </summary>
public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
/// <summary>
/// The shared source-timestamp column, or <see langword="null"/> when the group has none (the reader
/// then stamps the poll time). <see cref="SqlTagModel.KeyValue"/> only — it is part of that model's
/// group key, so it is uniform. A <see cref="SqlTagModel.WideRow"/> plan deliberately allows members to
/// carry <em>different</em> timestamp columns (all of them are selected), so the reader must read
/// <see cref="SqlTagDefinition.TimestampColumn"/> per member there.
/// </summary>
public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
}
@@ -1,151 +0,0 @@
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;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class SqlServerDialect : ISqlDialect
{
/// <summary>
/// T-SQL's regular-identifier ceiling (<c>sysname</c> = <c>nvarchar(128)</c>). 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.
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.SqlServer;
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
/// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
/// separator at the call site.
/// </summary>
public string SingleRowLimitPrefix => "TOP 1 ";
/// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
/// <inheritdoc/>
public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
/// <inheritdoc/>
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";
/// <summary>
/// Brackets one identifier part, doubling any embedded <c>]</c> — the T-SQL escape rule, and the only
/// metacharacter that could terminate a bracketed identifier early. <c>[</c>, quotes, semicolons and
/// comment markers are inert inside brackets and are therefore passed through unchanged.
/// </summary>
/// <remarks>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="System.Globalization.UnicodeCategory.Format"/> character — bidi overrides, zero-width
/// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the <em>rendered</em>
/// 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.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is currently the only defence, not a backstop.</b> 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 <c>TagConfig</c> table/column arrives unfiltered.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The bracket-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQL Server identifier.</exception>
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));
// 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), "]");
}
/// <summary>
/// Folds an <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> family name onto a
/// <see cref="DriverDataType"/> per design §3.7.
/// </summary>
/// <remarks>
/// <para><b>Precision caveat:</b> <c>decimal</c> / <c>numeric</c> / <c>money</c> / <c>smallmoney</c>
/// collapse to <see cref="DriverDataType.Float64"/> in v1. A .NET <c>decimal</c> carries more
/// significant digits than a <c>double</c>, so a high-precision column (e.g. <c>decimal(38,10)</c>)
/// loses low-order digits. Authoring an explicit <c>"type": "String"</c> on the tag preserves the exact
/// textual value when that matters.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>xml</c>,
/// <c>geography</c>, <c>hierarchyid</c>, <c>sql_variant</c>, a CLR UDT, <c>varbinary</c>) rather than
/// crash on it. Null/blank input takes the same fallback.</para>
/// <para><b>Deliberate non-mappings:</b> SQL Server's <c>timestamp</c>/<c>rowversion</c> is an opaque
/// 8-byte row version, <em>not</em> a date — it falls back to <see cref="DriverDataType.String"/>
/// rather than being mistaken for a <see cref="DriverDataType.DateTime"/>. <c>time</c> is a
/// time-of-day span, likewise not a point in time, and takes the same fallback.</para>
/// <para><c>tinyint</c> widens to <see cref="DriverDataType.Int16"/> (it is unsigned 0255 in T-SQL, so
/// a signed 8-bit type would not hold it, and the driver type set has no <c>Byte</c>).</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
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,
};
}
}
@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -27,6 +27,16 @@
}
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusTransportMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.</div>
</div>
<div class="col-md-3">
<label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@@ -290,6 +300,9 @@
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
// Wire transport (Tcp = Modbus/TCP MBAP; RtuOverTcp = RTU framing over a serial→Ethernet gateway)
public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;
// Transport flags
public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
@@ -337,6 +350,7 @@
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily,
Transport = o.Transport,
AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead,
@@ -387,6 +401,7 @@
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family,
MelsecSubFamily = MelsecSubFamily,
Transport = Transport,
WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions
@@ -1,441 +0,0 @@
@* 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
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Model</label>
<select class="form-select form-select-sm" @bind="_model" @bind:after="OnManualChangedAsync">
<option value="KeyValue">KeyValue</option>
<option value="WideRow">WideRow</option>
</select>
<div class="form-text">
KeyValue: one row per key. WideRow: many columns of one selected row.
</div>
</div>
<div class="col-md-5">
<label class="form-label">Table or view</label>
<input type="text" class="form-control form-control-sm mono" placeholder="dbo.TagValues"
@bind="_table" @bind:after="OnManualChangedAsync" />
<div class="form-text">Schema-qualified, e.g. <code>dbo.TagValues</code>.</div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="OnManualChangedAsync">
<option value="">(infer)</option>
@foreach (var t in Enum.GetValues<DriverDataType>())
{
<option value="@t">@t</option>
}
</select>
<div class="form-text">Blank ⇒ inferred from the column.</div>
</div>
</div>
@if (_model == "KeyValue")
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Key column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="tag_name"
@bind="_keyColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-4">
<label class="form-label">Key value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Line1.Speed"
@bind="_keyValue" @bind:after="OnManualChangedAsync" />
<div class="form-text">Bound as a parameter — never interpolated.</div>
</div>
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="num_value"
@bind="_valueColumn" @bind:after="OnManualChangedAsync" />
</div>
</div>
}
else
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="oven_temp"
@bind="_columnName" @bind:after="OnManualChangedAsync" />
<div class="form-text">The column this tag reads from the selected row.</div>
</div>
<div class="col-md-4">
<label class="form-label">Row selector</label>
<select class="form-select form-select-sm" @bind="_selectorMode" @bind:after="OnManualChangedAsync">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select>
</div>
@if (_selectorMode == "Where")
{
<div class="col-md-2">
<label class="form-label">Where column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="station_id"
@bind="_whereColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">Where value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="7"
@bind="_whereValue" @bind:after="OnManualChangedAsync" />
</div>
}
else
{
<div class="col-md-4">
<label class="form-label">Order-by (timestamp) column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_topByTimestamp" @bind:after="OnManualChangedAsync" />
</div>
}
</div>
}
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_timestampColumn" @bind:after="OnManualChangedAsync" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div>
</div>
</div>
@if (_canOperate)
{
<hr class="my-3" />
<div class="row g-3">
<div class="col-md-12">
<label class="form-label small">Ad-hoc connection string <span class="text-muted">(session-only, never saved)</span></label>
<input type="password" class="form-control form-control-sm mono" autocomplete="off"
placeholder="Server=…;Database=…;User Id=…;Password=…"
@bind="_adhocConnectionString" />
<div class="form-text">
Leave blank to browse via the driver's <code>connectionStringRef</code> (resolved from
the AdminUI host's <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var). Paste a
literal only for a one-off browse — it is used for this session and never persisted.
</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
@onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse database
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
</div>
@if (_openError is not null)
{
<div class="text-danger small mt-2">@_openError</div>
}
@if (_token != Guid.Empty)
{
<div class="row g-3 mt-1">
<div class="col-md-7">
<label class="form-label small">Schemas → tables → columns</label>
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
SelectedNodeId="@_selectedColumnNodeId" />
</div>
<div class="col-md-5">
<label class="form-label small">Picked column</label>
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_attrsLoading)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else if (_attrsError is not null)
{
<span class="text-danger small">@_attrsError</span>
}
else if (_attrs is null)
{
<span class="text-muted small">Pick a column leaf.</span>
}
else if (_attrs.Count == 0)
{
<span class="text-muted small">Column no longer present.</span>
}
else
{
@foreach (var a in _attrs)
{
<div class="d-flex justify-content-between align-items-center py-1"
style="cursor:pointer" @onclick="@(() => ApplyAttributeAsync(a))">
<span class="mono small">@a.Name</span>
<span class="text-muted small">@a.DriverDataType · @a.SecurityClass</span>
</div>
}
}
</div>
</div>
</div>
}
}
<div class="mt-3">
<span class="text-muted small">TagConfig:</span>
<code class="mono ms-2">@(_built.Length == 0 ? "—" : _built)</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
/// <summary>Live accessor for the selected driver's <c>DriverConfig</c> JSON (carries
/// <c>connectionStringRef</c> + <c>provider</c>). Fed to <c>BrowserService.OpenAsync("Sql", …)</c>.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Fires with the picker's suggested tag display name (<c>table.column</c>) 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).</summary>
[Parameter] public EventCallback<string> 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<AttributeInfo>? _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(); }
}
/// <summary>
/// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one,
/// a top-level <c>connectionString</c> literal. The literal wins over any <c>connectionStringRef</c>
/// server-side (SqlDriverBrowser precedence) and is session-only.
/// </summary>
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); }
}
/// <summary>
/// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are
/// navigation, not selection. The NodeId is decoded with <see cref="SqlBrowseNodeId.TryParse"/>
/// — never split by hand, because SQL identifiers may contain '.' and '|'.
/// </summary>
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();
}
}
/// <summary>Re-applies the side-panel column's inferred type (and value-column) on click.</summary>
private async Task ApplyAttributeAsync(AttributeInfo a)
{
if (_model == "WideRow") { _columnName = a.Name; }
else { _valueColumn = a.Name; }
PrefillType(a);
await OnChangedAsync();
}
/// <summary>Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps.</summary>
private void PrefillType(AttributeInfo a)
{
if (Enum.TryParse<DriverDataType>(a.DriverDataType, out _)) { _type = a.DriverDataType; }
}
private async Task OnManualChangedAsync() => await OnChangedAsync();
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
/// <summary>
/// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to
/// <c>SqlEquipmentTagParser</c>. 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.
/// <c>model</c> and <c>type</c> are written as NAME strings (the parser reads them strictly).
/// </summary>
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;
}
}
@@ -1,35 +0,0 @@
@* 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
<div class="text-muted small">Sql tag editor — coming soon (Task 20).</div>
@code {
/// <summary>The tag's current TagConfig JSON.</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Raised with the updated TagConfig JSON when the model changes.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
[Parameter] public Func<string> 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);
}
}
@@ -11,7 +11,6 @@ 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;
@@ -75,11 +74,6 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// 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<IDriverBrowser, SqlDriverBrowser>();
// 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).
@@ -1,212 +0,0 @@
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;
/// <summary>
/// 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 <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
/// (top-level and inside <c>rowSelector</c>) across a load→save so a future field survives an edit.
/// </summary>
public sealed class SqlTagConfigModel
{
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
public string? Table { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
public string? KeyColumn { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
public string? KeyValue { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
public string? ValueColumn { get; set; }
/// <summary>Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.</summary>
public string? TimestampColumn { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</summary>
public string? ColumnName { get; set; }
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
public SqlRowSelectorModel RowSelector { get; set; } = new();
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
/// driver infers the type from the result-set column metadata.</summary>
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;
/// <summary>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).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="SqlTagConfigModel"/>.</returns>
public static SqlTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var (model, invalidModel) = ReadEnumStrict<SqlTagModel>(o, "model");
var (type, invalidType) = ReadEnumStrict<DriverDataType>(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,
};
}
/// <summary>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 <c>rowSelector</c> object is rebuilt from
/// <see cref="RowSelector"/> (preserving its own unknown keys) and omitted entirely when empty.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
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);
}
/// <summary>Validates the model against the exact accept/reject boundary of
/// <see cref="SqlEquipmentTagParser.TryParse"/>. Returns an error message, or <c>null</c> when valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
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<TEnum>(JsonObject o, string name)
where TEnum : struct, Enum
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var s))
{
return (null, null);
}
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
}
}
/// <summary>
/// Typed working model for a <see cref="SqlTagModel.WideRow"/> tag's nested <c>rowSelector</c> object.
/// Holds a where-pair (<see cref="WhereColumn"/>/<see cref="WhereValue"/>) or a newest-by-timestamp
/// selector (<see cref="TopByTimestamp"/>). Preserves unknown keys inside the selector across load→save.
/// </summary>
public sealed class SqlRowSelectorModel
{
/// <summary>The column that picks the row (matched against <see cref="WhereValue"/>).</summary>
public string? WhereColumn { get; set; }
/// <summary>The value <see cref="WhereColumn"/> 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.</summary>
public string? WhereValue { get; set; }
/// <summary>Newest-row selector: the timestamp column to order by (used when no where-pair is authored).</summary>
public string? TopByTimestamp { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a selector model from the nested <c>rowSelector</c> JSON object (or a fresh empty model
/// when the object is absent), retaining any unknown keys.</summary>
/// <param name="o">The nested <c>rowSelector</c> object, or <c>null</c> when absent.</param>
/// <returns>The populated <see cref="SqlRowSelectorModel"/>.</returns>
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,
};
}
/// <summary>Rebuilds the nested <c>rowSelector</c> JSON object from the exposed fields over the preserved
/// key bag, or returns <c>null</c> when the selector carries nothing (so the parent omits the key).</summary>
/// <returns>The <c>rowSelector</c> <see cref="JsonObject"/>, or <c>null</c> when empty.</returns>
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<string>(out var s) ? s : v.ToJsonString();
}
}
@@ -1,5 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -22,10 +21,6 @@ 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),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -1,5 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -24,8 +23,6 @@ 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(),
};
/// <summary>
@@ -39,10 +39,6 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
</ItemGroup>
</Project>
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
# compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/
EXPOSE 5020
# 5020 = the shared port for the standard/dl205/mitsubishi/s7_1500/exception_injection
# profiles (only one binds it at a time); 5021 = the rtu_over_tcp profile, which co-runs
# with standard. Image-metadata honesty only — compose's ports: mapping doesn't need EXPOSE.
EXPOSE 5020 5021
# Default to the standard profile; docker-compose.yml overrides per service.
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
@@ -2,15 +2,16 @@
The Modbus driver's integration tests talk to a
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
pinned Docker container. One image, per-profile service in compose, same
port binding (`5020`) regardless of which profile is live. Docker is the
only supported launch path — a fresh clone needs Docker Desktop and
nothing else.
pinned Docker container. One image, per-profile service in compose. Most
profiles bind `:5020`, so only one of *those* runs at a time; the
`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
`standard`. Docker is the only supported launch path — a fresh clone needs
Docker Desktop and nothing else.
| File | Purpose |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection` bind `:5020`, so only one of those runs at a time; `rtu_over_tcp` binds `:5021` and can run alongside `standard`) |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
@@ -43,10 +44,11 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
```
Only one profile binds `:5020` at a time; switch by stopping the current
service + starting another. The integration tests discriminate by a
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
wrong profile is live.
Only one of the `:5020` profiles binds at a time; switch by stopping the
current service + starting another. (`rtu_over_tcp` is the exception — it
binds `:5021` and co-runs with `standard`.) The integration tests
discriminate by a separate `MODBUS_SIM_PROFILE` env var so they skip
correctly when the wrong profile is live.
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
@@ -78,6 +78,30 @@ services:
"--json_file", "/fixtures/s7_1500.json"
]
# RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
# RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
# gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
# the `standard` profile: two families, two ports, no conflict. Serves
# rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
rtu_over_tcp:
profiles: ["rtu_over_tcp"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-rtu_over_tcp
labels:
project: lmxopcua
restart: "no"
ports:
- "5021:5021"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/rtu_over_tcp.json"
]
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
@@ -0,0 +1,97 @@
{
"_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5021,
"framer": "rtu",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 2048,
"di size": 2048,
"hr size": 2048,
"ir size": 2048,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 31],
[100, 100],
[200, 209],
[1024, 1055],
[1100, 1109]
],
"uint16": [
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
{"addr": 100, "value": 0,
"action": "increment",
"parameters": {"minval": 0, "maxval": 65535}},
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
],
"bits": [
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}
@@ -0,0 +1,82 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
/// still passes `dotnet test` cleanly.
/// </summary>
/// <remarks>
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
/// session rather than per test.
/// </remarks>
public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
{
// :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
// standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
// CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
// serial→Ethernet Modbus gateway, or a locally-running container.
private const string DefaultEndpoint = "10.100.0.35:5021";
private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
public string Host { get; }
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
public int Port { get; }
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
public string? SkipReason { get; }
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
public ModbusRtuOverTcpFixture()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
Host = parts[0];
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
try
{
// Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
// "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
// .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
// rationale as ModbusSimulatorFixture.)
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
{
public const string Name = "ModbusRtuOverTcp";
}
@@ -0,0 +1,94 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// End-to-end round-trip against the <c>rtu_over_tcp.json</c> pymodbus profile (or a real
/// serial→Ethernet Modbus gateway when <c>MODBUS_RTU_SIM_ENDPOINT</c> points at one), driving the
/// full <see cref="ModbusDriver"/> + real <see cref="ModbusRtuOverTcpTransport"/> stack with
/// <see cref="ModbusTransportMode.RtuOverTcp"/>. Proves the driver reads a seeded holding register
/// and writes+reads-back a scratch register when the wire carries RTU framing
/// (<c>[addr][PDU][CRC-16]</c>, no MBAP header) rather than Modbus/TCP.
/// </summary>
/// <remarks>
/// pymodbus 3.13's simulator honors <c>framer=rtu</c> on a TCP server — confirmed on the wire
/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
/// <c>01 03 02 00 05 78 47</c>, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
/// </remarks>
[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
/// <summary>Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR5",
Region: ModbusRegion.HoldingRegisters,
Address: 5,
DataType: ModbusDataType.UInt16,
Writable: false),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0u); // Good
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
}
/// <summary>Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR200",
Region: ModbusRegion.HoldingRegisters,
Address: 200,
DataType: ModbusDataType.UInt16,
Writable: true),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync(
[new WriteRequest("HR200", (ushort)4242)],
TestContext.Current.CancellationToken);
writes.Count.ShouldBe(1);
writes[0].StatusCode.ShouldBe(0u);
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
back.Count.ShouldBe(1);
back[0].StatusCode.ShouldBe(0u);
back[0].Value.ShouldBe((ushort)4242);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusCrcTests
{
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
// on the wire it is appended low-byte-first.
[Theory]
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
=> ModbusCrc.Compute(frame).ShouldBe(expected);
[Fact]
public void AppendLowByteFirst_writes_lo_then_hi()
{
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var withCrc = ModbusCrc.Append(body);
withCrc.Length.ShouldBe(body.Length + 2);
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500&#160;ms</c> to
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
/// configured timing. <c>ModbusTcpTransport.ClampToWholeSeconds</c> rounds up to a minimum
/// configured timing. <c>ModbusSocketLifecycle.ClampToWholeSeconds</c> rounds up to a minimum
/// of 1&#160;second.
/// </summary>
[Trait("Category", "Unit")]
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
[InlineData(60_000, 60)]
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
{
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
}
/// <summary>Verifies that negative time spans are treated as one second.</summary>
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
// valid until the operator fixes the config.
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
}
}
@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuFramingTests
{
private static MemoryStream Canned(params byte[] frame) => new(frame);
[Fact]
public void BuildAdu_prefixes_unit_and_appends_crc()
{
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task ReadResponse_FC03_returns_bare_pdu()
{
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
var frame = ModbusCrc.Append(body);
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
// addr=01 fc=0x83 exc=0x02 + CRC
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.FunctionCode.ShouldBe((byte)0x03);
ex.ExceptionCode.ShouldBe((byte)0x02);
}
[Fact]
public async Task ReadResponse_bad_crc_throws_desync()
{
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
frame[^1] ^= 0xFF; // corrupt CRC high byte
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
}
[Fact]
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
{
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
}
[Fact]
public async Task ReadResponse_wrong_unit_address_throws_desync()
{
// CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
}
[Fact]
public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
{
// Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
// the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
await using var s = new DribbleStream(frame, chunk: 2);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
}
[Fact]
public async Task ReadResponse_stream_ends_early_throws_desync()
{
// Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
// -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var truncated = frame[..4];
await using var s = new DribbleStream(truncated, chunk: 2);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// <summary>
/// A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
/// per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
/// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
/// path that a single-shot <see cref="MemoryStream"/> never reaches.
/// </summary>
private sealed class DribbleStream(byte[] data, int chunk) : Stream
{
private int _pos;
public override int Read(byte[] buffer, int offset, int count)
{
var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
if (n <= 0) return 0;
Array.Copy(data, _pos, buffer, offset, n);
_pos += n;
return n;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
if (n <= 0) return ValueTask.FromResult(0);
data.AsSpan(_pos, n).CopyTo(buffer.Span);
_pos += n;
return ValueTask.FromResult(n);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => data.Length;
public override long Position { get => _pos; set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> seam —
/// no real socket. The fake records the request ADU the transport writes and replays a canned RTU
/// response on read, or blocks forever (honouring cancellation) when <c>stall</c> is set.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusRtuOverTcpTransportTests
{
[Fact]
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
{
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
{
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
await Should.ThrowAsync<ModbusException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
{
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<ModbusTransportDesyncException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
/// <summary>
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
/// </summary>
private sealed class CapturingDuplexStream : Stream
{
private readonly byte[] _response;
private readonly bool _stall;
private readonly MemoryStream _written = new();
private int _readPos;
public CapturingDuplexStream(byte[] respondBytes, bool stall = false)
{
_response = respondBytes;
_stall = stall;
}
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
public byte[] Written => _written.ToArray();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
if (_stall)
{
// Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels.
var tcs = new TaskCompletionSource();
await using (ct.Register(() => tcs.TrySetCanceled(ct)))
await tcs.Task.ConfigureAwait(false);
return 0; // unreachable — the await above always throws when cancelled
}
var remaining = _response.Length - _readPos;
if (remaining <= 0) return 0;
var n = Math.Min(remaining, buffer.Length);
_response.AsSpan(_readPos, n).CopyTo(buffer.Span);
_readPos += n;
return n;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
await _written.WriteAsync(buffer, ct).ConfigureAwait(false);
}
public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Pins the socket-lifecycle surface extracted from <see cref="ModbusTcpTransport"/> into the
/// reusable <see cref="ModbusSocketLifecycle"/> (Task 3). The clamp helper moved with it, and a
/// connect to a dead port must still surface the underlying socket failure.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusSocketLifecycleTests
{
/// <summary>Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.</summary>
/// <param name="seconds">The input duration in seconds.</param>
/// <param name="expected">The expected clamped value in whole seconds.</param>
[Theory]
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
[InlineData(2.0, 2)]
[InlineData(-5.0, 1)] // negative clamps to 1
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
/// <summary>Verifies a connect to a dead port surfaces the underlying socket failure.</summary>
[Fact]
public async Task Connect_to_dead_port_surfaces_socket_failure()
{
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
autoReconnect: false);
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
life.ConnectAsync(TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportConfigRoundTripTests
{
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
private static readonly JsonSerializerOptions _adminOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Transport_serializes_as_a_string_not_a_number()
{
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(opts, _adminOpts);
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
}
[Fact]
public void Factory_binds_RtuOverTcp_from_string_config()
{
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
}
[Fact]
public void Factory_defaults_Transport_to_Tcp_when_omitted()
{
const string json = """{ "host": "10.0.0.10" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportFactoryTests
{
[Fact]
public void Tcp_mode_builds_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void RtuOverTcp_mode_builds_rtu_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_options_build_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void Unknown_transport_mode_throws()
=> Should.Throw<ArgumentOutOfRangeException>(
() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
}
@@ -0,0 +1,15 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportModeTests
{
[Fact]
public void Tcp_is_the_default_zero_member()
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
[Fact]
public void Exactly_two_members_ship_in_v1()
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Confirms the driver's default transport-factory closure — and, by extension, the
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTransportWiringTests
{
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
{
using var driver = new ModbusDriver(opts, "wire-test");
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
return factory(opts);
}
[Fact]
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_closure_builds_tcp_transport_for_Tcp()
=> BuildDefaultTransport(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
@@ -1,204 +0,0 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Records how many command executions are ever in flight at once, and how long each one is artificially
/// held open. Shared by <see cref="ConcurrencyProbingDbConnection"/> and the commands it creates.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class ConcurrencyProbe
{
private int _inFlight;
private int _peak;
/// <summary>How long each command execution is held before it reaches the real provider.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120);
/// <summary>The greatest number of executions observed in flight simultaneously.</summary>
public int Peak => Volatile.Read(ref _peak);
/// <summary>Marks the start of one command execution.</summary>
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;
}
}
/// <summary>Marks the end of one command execution.</summary>
public void Exit() => Interlocked.Decrement(ref _inFlight);
}
/// <summary>
/// A pass-through <see cref="DbConnection"/> that hands out commands instrumented with a
/// <see cref="ConcurrencyProbe"/>. Everything else delegates to the wrapped connection, so the code under
/// test runs against a real SQLite catalog.
/// </summary>
/// <param name="inner">The real connection to delegate to. Not owned — the caller disposes it.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString
{
get => inner.ConnectionString;
set => inner.ConnectionString = value!;
}
/// <inheritdoc />
public override string Database => inner.Database;
/// <inheritdoc />
public override string DataSource => inner.DataSource;
/// <inheritdoc />
public override string ServerVersion => inner.ServerVersion;
/// <inheritdoc />
public override ConnectionState State => inner.State;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
/// <inheritdoc />
public override void Close() => inner.Close();
/// <inheritdoc />
public override void Open() => inner.Open();
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
inner.BeginTransaction(isolationLevel);
/// <inheritdoc />
protected override DbCommand CreateDbCommand() =>
new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe);
}
/// <summary>
/// A pass-through <see cref="DbCommand"/> that reports every execution to a <see cref="ConcurrencyProbe"/>
/// and holds it open for the probe's delay, so overlapping executions are observable.
/// </summary>
/// <param name="inner">The real command to delegate to.</param>
/// <param name="owner">The connection that created this command.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe)
: DbCommand
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string CommandText
{
get => inner.CommandText;
set => inner.CommandText = value!;
}
/// <inheritdoc />
public override int CommandTimeout
{
get => inner.CommandTimeout;
set => inner.CommandTimeout = value;
}
/// <inheritdoc />
public override CommandType CommandType
{
get => inner.CommandType;
set => inner.CommandType = value;
}
/// <inheritdoc />
public override bool DesignTimeVisible
{
get => inner.DesignTimeVisible;
set => inner.DesignTimeVisible = value;
}
/// <inheritdoc />
public override UpdateRowSource UpdatedRowSource
{
get => inner.UpdatedRowSource;
set => inner.UpdatedRowSource = value;
}
/// <inheritdoc />
protected override DbConnection? DbConnection
{
get => owner;
set { /* The session never reassigns the connection; the wrapped command keeps its own. */ }
}
/// <inheritdoc />
protected override DbParameterCollection DbParameterCollection => inner.Parameters;
/// <inheritdoc />
protected override DbTransaction? DbTransaction
{
get => inner.Transaction;
set => inner.Transaction = value;
}
/// <inheritdoc />
public override void Cancel() => inner.Cancel();
/// <inheritdoc />
public override int ExecuteNonQuery() => inner.ExecuteNonQuery();
/// <inheritdoc />
public override object? ExecuteScalar() => inner.ExecuteScalar();
/// <inheritdoc />
public override void Prepare() => inner.Prepare();
/// <inheritdoc />
protected override DbParameter CreateDbParameter() => inner.CreateParameter();
/// <inheritdoc />
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
probe.Enter();
try
{
Thread.Sleep(probe.Delay);
return inner.ExecuteReader(behavior);
}
finally
{
probe.Exit();
}
}
/// <inheritdoc />
protected override async Task<DbDataReader> 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();
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) inner.Dispose();
base.Dispose(disposing);
}
}
@@ -1,128 +0,0 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// 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
/// <c>.</c> and <c>|</c> (inside a quoted identifier), so the round-trip has to hold for those too.
/// </summary>
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");
}
/// <summary>
/// The exact case the plan's <c>schema.table|column</c> sketch cannot express: <c>main.a.b|c</c> reads
/// equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c> + table <c>b</c>.
/// </summary>
[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);
}
/// <summary>
/// Two genuinely different columns must never collide on one NodeId. Under the plan's literal
/// <c>schema.table|column</c> sketch both of these render as <c>a.b|c</c>.
/// </summary>
[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");
}
/// <summary>A table NodeId and a schema NodeId are never confusable, whatever the names contain.</summary>
[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<ArgumentException>(() => SqlBrowseNodeId.Parse(nodeId));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void EmptyIdentifiers_areRejectedAtEncode(string? identifier)
{
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForSchema(identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForTable("main", identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!));
}
}
@@ -1,374 +0,0 @@
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;
/// <summary>
/// Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
/// <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
/// that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
/// with a quoting helper bolted on.
/// </summary>
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);
}
/// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
[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));
}
/// <summary>
/// An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
/// <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
/// still render.
/// </summary>
[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));
}
/// <summary>
/// The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
/// <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
/// this is the case that proves the encoding, not just the codec unit test.
/// </summary>
[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));
}
/// <summary>
/// A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
/// instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
/// </summary>
[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));
}
/// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
[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();
}
/// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
[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();
}
/// <summary>
/// A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
/// message — the same contract the Galaxy and OPC UA client sessions have, and what
/// <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
/// <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
/// </summary>
[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<ArgumentException>(
() => session.ExpandAsync(nodeId, CancellationToken.None));
expand.Message.ShouldNotBeNullOrWhiteSpace();
await Should.ThrowAsync<ArgumentException>(
() => session.AttributesAsync(nodeId, CancellationToken.None));
}
/// <summary>
/// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
/// Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
/// assertion would pass with the gate deleted.
/// </summary>
[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);
}
/// <summary>
/// Ownership: the session takes over the connection it is handed and closes it on dispose. The
/// AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
/// not close the connection, nothing would — every reaped picker would leak one.
/// </summary>
[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<ObjectDisposedException>(
() => session.RootAsync(CancellationToken.None));
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// The AdminUI bounds every call with a 20 s linked CTS
/// (<c>BrowserSessionService.PerCallTimeout</c>) — 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.
/// </summary>
[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<OperationCanceledException>(() => session.RootAsync(cts.Token));
await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
}
/// <summary>
/// <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
/// refreshed it would be evicted out from under an actively browsing operator.
/// </summary>
[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);
}
}
@@ -1,384 +0,0 @@
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;
/// <summary>
/// Covers <see cref="SqlDriverBrowser"/>'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.
/// <para>The successful-open cases run through the linked <see cref="SqliteDialect"/> against the real
/// seeded <see cref="SqliteBrowseFixture"/>, injected via the browser's <c>dialectSelector</c>
/// 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 <c>SqlDriver</c>'s <c>factory</c>
/// parameter.</para>
/// </summary>
public sealed class SqlDriverBrowserTests
{
/// <summary>
/// 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.
/// </summary>
private const string Password = "Sup3rSecret-OtOpcUa-Probe";
/// <summary>
/// A host that cannot resolve, so the connect fails in DNS rather than on a timer. <c>.invalid</c> is
/// reserved by RFC 2606 and can never be registered.
/// </summary>
private const string DeadHost = "otopcua-no-such-host.invalid";
private static Func<SqlProvider, ISqlDialect?> 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<SqlServerDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
// ---- connection-string reference resolution ----
/// <summary>The plan's headline case: an unresolvable ref must name the exact variable to set.</summary>
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
/// <summary>
/// The default (production) path: no injected reader, a real process environment variable. Uses a
/// run-unique name and restores it in a <c>finally</c>, so a failure cannot leave it set for the rest
/// of the assembly — environment variables are process-global and every other test shares them.
/// </summary>
[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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None));
ex.Message.ShouldContain("connectionStringRef");
}
// ---- pasted literal, and its precedence ----
/// <summary>
/// A pasted literal opens a working session, and <b>the session owns the connection</b> — it is still
/// live after <c>OpenAsync</c> returns (the browser did not close it) and dead after the session is
/// disposed (nothing else will).
/// </summary>
[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<ObjectDisposedException>(() =>
session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// Precedence: the pasted literal wins, and the reference is <b>not even read</b>. 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.
/// </summary>
[Fact]
public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var reads = new List<string>();
var logger = new CapturingLogger<SqlDriverBrowser>();
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<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
browser.OpenAsync(configJson, CancellationToken.None));
ex.Message.ShouldContain("configuration");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Open_malformedJson_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """;
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
browser.OpenAsync("[1,2,3]", CancellationToken.None));
ex.Message.ShouldContain("JSON object");
}
/// <summary>An unknown <c>provider</c> name is a bind failure, and must not echo the config either.</summary>
[Fact]
public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig()
{
var browser = new SqlDriverBrowser(SqliteSelector);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
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 ----
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(() =>
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");
}
/// <summary>
/// The leak that is real, and is the reason the parser's message is suppressed rather than trusted.
/// <para>ADO.NET expects a value containing <c>;</c> to be quoted. An unquoted one splits, and the
/// tail of the password is then parsed as a keyword — which
/// <c>Microsoft.Data.SqlClient</c> echoes verbatim (lower-cased) in
/// <c>Keyword not supported: '…'</c>. 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.</para>
/// </summary>
[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<ArgumentException>(() =>
{
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<InvalidOperationException>(() =>
browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None));
ex.ToString().ShouldNotContain(tail, Case.Insensitive);
ex.Message.ShouldContain("malformed");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Open_neverLogsTheConnectionString()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var logger = new CapturingLogger<SqlDriverBrowser>();
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<Exception>(() =>
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 ----
/// <summary>
/// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing
/// JSON metacharacters (backslashes in a <c>Data Source</c> path, quotes in a password) escapes
/// correctly instead of producing malformed JSON the test would then misattribute.
/// </summary>
private static string ConfigJson(
string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null)
{
var map = new Dictionary<string, object?> { ["provider"] = provider };
if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef;
if (connectionString is not null) map["connectionString"] = connectionString;
return JsonSerializer.Serialize(map);
}
/// <summary>Records every formatted log message so the hygiene assertions can read them back.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Entries { get; } = [];
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(formatter(state, exception));
}
}
@@ -1,190 +0,0 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// 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 <c>sqlite_schema</c> rows, real
/// <c>pragma_table_info</c> output, real parameter binding — with no SQL Server and no network.
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c></b>, for the same reason
/// <c>SqlitePollFixture</c> 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.</para>
/// <para><b>The seed is a contract.</b> Every table below exists to pin one browse behaviour, and the
/// awkward ones are the point: <see cref="AwkwardTable"/> and <see cref="AwkwardColumn"/> carry the
/// <c>.</c> and <c>|</c> characters that a naive <c>schema.table|column</c> NodeId would mis-parse, and
/// <see cref="HostileTable"/> is a live injection probe — if any catalog query concatenated a name
/// instead of binding it, expanding that table drops <see cref="KeyValueTable"/> and the assertions say
/// so.</para>
/// </summary>
public sealed class SqliteBrowseFixture : IAsyncDisposable
{
/// <summary>The ordinary key-value table, mirroring the poll fixture's shape.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>REAL</c>, i.e. <c>Float64</c>.</summary>
public const string RealColumn = "num_value";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>TEXT</c>, i.e. <c>String</c>.</summary>
public const string TextColumn = "tag_name";
/// <summary>A view over <see cref="KeyValueTable"/> — the browser must list views beside tables.</summary>
public const string ViewName = "TagValuesView";
/// <summary>
/// A table whose name contains BOTH NodeId metacharacters. A <c>schema.table|column</c> NodeId cannot
/// represent this unambiguously — <c>main.a.b|c</c> reads equally as schema <c>main.a</c>, table
/// <c>b</c>. Legal in SQL Server inside a quoted identifier, so the encoding must survive it.
/// </summary>
public const string AwkwardTable = "a.b|c";
/// <summary>A column name carrying the separator character.</summary>
public const string AwkwardColumn = "x|y";
/// <summary>A column name carrying a dot and the escape character.</summary>
public const string AwkwardColumn2 = @"d.o\t";
/// <summary>
/// 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.
/// </summary>
public const string HostileTable = "'); DROP TABLE TagValues; --";
/// <summary>The single column of <see cref="HostileTable"/>.</summary>
public const string HostileColumn = "hostile_col";
/// <summary>A table whose columns carry types no dialect map recognises.</summary>
public const string ExoticTable = "Exotic";
/// <summary>An <see cref="ExoticTable"/> column declared with a type outside the map's vocabulary.</summary>
public const string ExoticColumn = "shape_col";
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
public const string NonExistentTable = "NoSuchTable";
private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
{
_databasePath = databasePath;
ConnectionString = connectionString;
Connection = connection;
}
/// <summary>The connection string over the temporary database file.</summary>
public string ConnectionString { get; }
/// <summary>
/// A long-lived open connection over the seeded database — what tests hand
/// <c>SqlBrowseSession</c>. Independent of any other connection over the same file.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>
/// The NodeId of SQLite's one and only schema, encoded exactly as
/// <c>SqlBrowseSession.RootAsync</c> emits it.
/// </summary>
public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema);
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
/// <returns>The ready fixture.</returns>
public static async Task<SqliteBrowseFixture> 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);
}
/// <summary>Opens a brand-new connection over the same database.</summary>
/// <returns>An open connection the caller owns.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Counts rows in a table, on a connection of its own so a disposed session cannot affect it.</summary>
/// <param name="table">The (unquoted) table name to count.</param>
/// <returns>The row count, or <c>-1</c> when the table does not exist.</returns>
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;
}
}
/// <summary>Closes the fixture connection, clears the pool, and deletes the temporary file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
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.
}
}
/// <summary>
/// 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
/// <c>pragma_table_info</c> (and therefore <c>MapColumnType</c>) nothing real to report.
/// </summary>
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);
}
}
@@ -1,50 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!--
TEST-ONLY, same rationale as Driver.Sql.Tests: SQLite is the offline substrate that lets the
schema browser run against a real DbConnection (real parameter binding, real catalog rows) with
no SQL Server and no network. No product project references SQLite. The bundle_e_sqlite3 line is
the surgical direct pin that promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
<!--
LINKED, not duplicated. SqliteDialect is the only non-SQL-Server ISqlDialect in the tree and is
therefore the falsifiability control for the whole seam: it proves the browser runs the *dialect's*
catalog SQL rather than INFORMATION_SCHEMA with a quoting function attached. Linking the one file
(rather than referencing Driver.Sql.Tests) keeps a second copy from drifting while avoiding a
test-project-to-test-project reference. The file is public + self-contained for exactly this reason
— see its class docs.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
</ItemGroup>
</Project>
@@ -1,68 +0,0 @@
# 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"
@@ -1,43 +0,0 @@
-- 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
@@ -1,440 +0,0 @@
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;
/// <summary>
/// The single highest-value integration test for the <c>Sql</c> driver: the <b>frozen-peer /
/// blackhole gate</b>. When the database stops answering mid-poll, a reused pooled connection hangs
/// inside <c>ExecuteReaderAsync</c>; the driver must surface <see cref="SqlStatusCodes.BadTimeout"/>
/// within its client-side <c>operationTimeout</c> (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
/// <c>S7_1500ConnectTimeoutOutageTests</c>, which this mirrors).
/// <para><b>The wall-clock bound is the whole point.</b> <c>operationTimeout</c> is set well BELOW
/// <c>commandTimeout</c> here, deliberately inverted from the production rule, so the two backstops
/// are distinguishable: a working client-side cancellation returns at ≈ <c>operationTimeout</c>; if it
/// were broken and only the ADO.NET <c>CommandTimeout</c> server-side backstop fired, the read would
/// return at ≈ <c>commandTimeout</c> instead — which the upper-bound assertion fails on. A test that
/// merely asserted "eventually BadTimeout" would pass even with the client-side cancellation broken.</para>
/// <para><b>SAFETY — this gate `docker pause`s a DEDICATED container it owns, never shared infra.</b>
/// The pause target is the compile-time constant <see cref="ContainerName"/>
/// (<c>otopcua-sql-blackhole</c>), 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 (<c>10.100.0.35,14330</c>, which hosts
/// ConfigDb for the whole rig). As a second guardrail the gate <b>skips loudly</b> if its endpoint
/// resolves to the shared server's port <see cref="SharedCentralPort"/>. Bring the dedicated stack up
/// with <c>Docker/docker-compose.yml</c> (container <c>otopcua-sql-blackhole</c>, host port 14333).</para>
/// <para><b>Env-gated; offline skip is the normal outcome.</b> With <see cref="EndpointEnvVar"/> 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 <c>*.IntegrationTests</c> live gate in this tree.</para>
/// <para>
/// <b>Operator run recipe</b> (docker runs on the shared Linux host; drive it from this VM):
/// <code>
/// # 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
/// </code>
/// </para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "Blackhole")]
public sealed class SqlBlackholeTimeoutTests
{
/// <summary>Endpoint (<c>host,port</c>) of the DEDICATED disposable container. Absent ⇒ offline skip.</summary>
private const string EndpointEnvVar = "SQL_BLACKHOLE_ENDPOINT";
/// <summary>SA password for the dedicated container. Required by the run.</summary>
private const string PasswordEnvVar = "SQL_BLACKHOLE_PASSWORD";
/// <summary>SQL login. Defaults to <c>sa</c>.</summary>
private const string UserEnvVar = "SQL_BLACKHOLE_USER";
/// <summary>
/// Optional SSH target the <c>docker pause</c>/<c>unpause</c> runs through (e.g.
/// <c>dohertj2@10.100.0.35</c>), since <c>docker -H ssh://</c> does not work from this VM. Empty ⇒
/// run docker locally.
/// </summary>
private const string DockerSshEnvVar = "SQL_BLACKHOLE_DOCKER_SSH";
/// <summary>
/// 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 <c>Docker/docker-compose.yml</c>'s
/// <c>container_name</c>.
/// </summary>
private const string ContainerName = "otopcua-sql-blackhole";
/// <summary>
/// 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.
/// </summary>
private const string SharedCentralPort = "14330";
/// <summary>The database the seed lives in; created if missing (never dropped).</summary>
private const string Database = "SqlBlackholeFixture";
/// <summary>The seeded key-value table + its columns and the one baseline key.</summary>
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;
/// <summary>The RawPath the driver serves the baseline key under.</summary>
private const string RawPath = "Sql/Line1/Speed";
// ── deadlines chosen so client-side and server-side backstops are DISTINGUISHABLE ──
/// <summary>Client-side wall-clock ceiling on one group. A frozen peer must surface BadTimeout at ≈ this.</summary>
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(3);
/// <summary>
/// ADO.NET server-side backstop — set an order of magnitude ABOVE <see cref="OperationTimeout"/> 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.
/// </summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Upper bound on the post-pause read. Comfortably above <see cref="OperationTimeout"/> (docker
/// pause propagation + pooled-connection validation + thread-pool scheduling) yet comfortably below
/// <see cref="CommandTimeout"/> — so a return here proves the CLIENT-side bound fired, not the
/// server-side backstop.
/// </summary>
private static readonly TimeSpan UpperBound = TimeSpan.FromSeconds(15);
/// <summary>Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI.</summary>
private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60);
/// <summary>Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI.</summary>
private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30);
/// <summary>
/// Start reading against the dedicated mssql, <c>docker pause</c> it mid-poll, and assert the next
/// read surfaces <see cref="SqlStatusCodes.BadTimeout"/> within <see cref="OperationTimeout"/> (a
/// client-side linked-CTS cancellation, not the poll thread wedging and not the server-side
/// backstop), that the driver degrades (health <see cref="DriverState.Degraded"/> + host
/// <see cref="HostState.Stopped"/>), and that after <c>docker unpause</c> a subsequent poll recovers
/// to <see cref="DriverState.Healthy"/> / <see cref="HostState.Running"/>.
/// </summary>
[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.
// 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.
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 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.
}
}
}
// 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 ──
/// <summary>Runs one poll of the single baseline reference.</summary>
private static Task<IReadOnlyList<DataValueSnapshot>> ReadOnceAsync(SqlDriver driver, CancellationToken ct)
=> driver.ReadAsync([RawPath], ct);
/// <summary>Polls until a Good snapshot returns, or the recovery deadline elapses.</summary>
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");
}
/// <summary>Builds a driver over the dedicated container, warmed to hold a pooled connection.</summary>
private static async Task<SqlDriver> 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;
}
/// <summary>The KeyValue tag over the seeded <c>sqlpoll.TagValues</c> baseline row.</summary>
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);
}
/// <summary>
/// The driver's connection string. <c>Min Pool Size=1</c> keeps a warm pooled connection so the
/// post-pause read reuses it and hangs inside <c>ExecuteReaderAsync</c> — exercising the
/// query-execution wedge (the S7 bug class) rather than a fresh connect.
/// </summary>
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;
/// <summary>Creates the database (if missing) and (re)seeds the baseline schema + row.</summary>
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);
}
}
/// <summary>The seed, one batch per statement (T-SQL requires CREATE SCHEMA to be first in its batch).</summary>
private static IEnumerable<string> 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');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
}
/// <summary>Splits a <c>host,port</c> endpoint. A missing port is rejected — the guard needs the port.</summary>
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]);
}
/// <summary>
/// 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.
/// </summary>
private static string DockerCommand(string verb, string? ssh)
{
var docker = $"docker {verb} {ContainerName}";
return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\"";
}
/// <summary>
/// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded
/// by its own <see cref="ShellCommandHardCap"/> 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.
/// </summary>
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("\"", "\\\"")}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
};
proc.Start();
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);
}
}
@@ -1,469 +0,0 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// A real, seeded <b>SQL Server</b> database standing in for the source the <c>Sql</c> driver polls —
/// the live counterpart of the offline <c>SqlitePollFixture</c>, and the only place the shipped
/// <c>Microsoft.Data.SqlClient</c> path, the real <c>INFORMATION_SCHEMA</c> catalog SQL, and genuine
/// T-SQL column types are exercised at all.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="ConnectionStringEnvVar"/> — or <see cref="EndpointEnvVar"/> plus a
/// password — is set; absent, it records <see cref="SkipReason"/> and every test calls
/// <c>Assert.Skip</c>. That is what keeps <c>dotnet test</c> genuinely green (and instant) on the
/// macOS dev box this is usually run from, which is the same contract every other
/// <c>*.IntegrationTests</c> fixture in this tree honours.</para>
/// <para><b>Safety against the shared central server (design §9).</b> The always-on SQL Server on the
/// docker host hosts <c>ConfigDb</c> for the whole dev rig, so this fixture is deliberately
/// conservative:</para>
/// <list type="bullet">
/// <item>It uses <b>its own database</b>, <see cref="FixtureDatabase"/> — the supplied connection
/// string's catalog is used only to reach <c>master</c> and is otherwise <b>ignored</b>, so a
/// mis-set env var cannot aim the seed at somebody else's schema.</item>
/// <item>A supplied catalog literally named <c>ConfigDb</c> is rejected <b>loudly, at
/// construction</b>, 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.</item>
/// <item>It <b>creates the database only if missing</b> and <b>never drops a database</b>, so
/// re-running the suite is safe and an operator-created <c>SqlPollFixture</c> is never destroyed.
/// Only the objects inside its own <see cref="Schema"/> schema are dropped and recreated, which
/// is what makes the seed deterministic across runs.</item>
/// </list>
/// <para><b>The seed is a contract, not scaffolding.</b> The constants below mirror
/// <c>SqlitePollFixture</c>'s shape — a present value, a present row with a <b>NULL</b> cell, an
/// <b>absent</b> 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. <see cref="TypeProbeTable"/> has no offline counterpart: it exists to pin
/// <c>SqlServerDialect.MapColumnType</c> against the type names SQL Server <em>actually</em> reports,
/// which a hand-written mapping table cannot do.</para>
/// </summary>
public sealed class SqlPollServerFixture : IAsyncLifetime
{
/// <summary>A full ADO.NET connection string for the fixture server. Takes precedence when set.</summary>
public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";
/// <summary>
/// Server endpoint in <c>host,port</c> form (e.g. <c>10.100.0.35,14330</c>) — the lighter-weight
/// gate. Requires <see cref="PasswordEnvVar"/>; <see cref="UserEnvVar"/> defaults to <c>sa</c>.
/// </summary>
public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";
/// <summary>SQL login for the <see cref="EndpointEnvVar"/> form. Defaults to <c>sa</c>.</summary>
public const string UserEnvVar = "SQL_TEST_USER";
/// <summary>SQL password for the <see cref="EndpointEnvVar"/> form. Required by that form.</summary>
public const string PasswordEnvVar = "SQL_TEST_PASSWORD";
/// <summary>The database this fixture creates and seeds. Never <c>ConfigDb</c>, never dropped.</summary>
public const string FixtureDatabase = "SqlPollFixture";
/// <summary>
/// The schema every seeded object lives in. Deliberately <b>not</b> <c>dbo</c>: it scopes the
/// drop-and-recreate to objects this fixture owns, and it makes every authored <c>table</c> a
/// two-part name, so the planner's <c>QuoteQualifiedName</c> (<c>[sqlpoll].[TagValues]</c>) is
/// exercised against a real server rather than only in a unit test.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>
/// The <c>Application Name</c> 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.
/// </summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";
/// <summary>The <c>Application Name</c> the fixture's own inspection connections carry.</summary>
private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name nvarchar(128), num_value float NULL, sample_ts datetime2</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>A key whose row <b>exists</b> but whose value cell is <c>NULL</c>.</summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>A key with <b>no row at all</b> — a different outcome from <see cref="NullValueKey"/>.</summary>
public const string AbsentKey = "Line1.Missing";
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc =
new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2</c>.</summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that loses its
/// <c>ORDER BY … DESC</c> or its <c>SELECT TOP 1</c> picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — what a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
/// <summary>A view over <see cref="WideRowTable"/> — the <c>TABLE_TYPE = 'VIEW'</c> catalog row.</summary>
public const string WideRowView = "LatestStatusView";
// ---- T-SQL type-coverage source ----
/// <summary>
/// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
/// authored <c>type</c>, each column's driver type is inferred from what
/// <c>SqlDataReader.GetDataTypeName</c> reports — which is the only way to find out whether
/// <c>SqlServerDialect.MapColumnType</c>'s table matches reality rather than matching itself.
/// </summary>
public const string TypeProbeTable = "TypeProbe";
/// <summary>The single-row selector column of <see cref="TypeProbeTable"/>.</summary>
public const string TypeProbeSelectorColumn = "probe_id";
/// <summary>The seeded row's <see cref="TypeProbeSelectorColumn"/> value.</summary>
public const string TypeProbeRow = "1";
/// <summary>Seeded <c>bit</c> value.</summary>
public const bool ProbeBit = true;
/// <summary>Seeded <c>int</c> value — <c>int.MaxValue</c>, so a narrowing coercion overflows loudly.</summary>
public const int ProbeInt = int.MaxValue;
/// <summary>Seeded <c>bigint</c> value — beyond exact <c>double</c> range, so an Int64→Float64 slip shows.</summary>
public const long ProbeBigInt = 9007199254740993L;
/// <summary>Seeded <c>real</c> value; exactly representable, so equality is a fair assertion.</summary>
public const float ProbeReal = 1.5f;
/// <summary>Seeded <c>float</c> value; exactly representable.</summary>
public const double ProbeFloat = 3.25d;
/// <summary>Seeded <c>decimal(18,4)</c> value — the documented Float64 precision collapse.</summary>
public const decimal ProbeDecimal = 123.4567m;
/// <summary>Seeded <c>nvarchar(64)</c> value.</summary>
public const string ProbeNVarChar = "hello";
/// <summary>Seeded <c>datetime2(3)</c> value, read back as UTC.</summary>
public static readonly DateTime ProbeDateTimeUtc =
new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);
/// <summary>
/// Why the suite is skipping, or <see langword="null"/> when the fixture is seeded and usable.
/// Tests read this and call <c>Assert.Skip</c> — the house idiom
/// (<c>Snap7ServerFixture</c> / <c>ModbusSimulatorFixture</c>).
/// </summary>
public string? SkipReason { get; private set; }
/// <summary>
/// The connection string to hand the code under test: the seeded <see cref="FixtureDatabase"/>,
/// tagged with <see cref="DriverApplicationName"/>. Empty while <see cref="SkipReason"/> is set.
/// </summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Connection string the fixture's own inspection connections use. Empty while skipping.</summary>
private string _inspectionConnectionString = string.Empty;
/// <summary>
/// Resolves the gate, creates <see cref="FixtureDatabase"/> if missing, and (re)seeds this
/// fixture's schema.
/// <para>The two failure modes are handled deliberately differently: <b>failing to connect</b> is
/// the offline case and becomes a <see cref="SkipReason"/>, while <b>failing after the connection
/// opened</b> (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.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
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);
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// 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.
/// </summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public async Task<SqlConnection> OpenInspectionConnectionAsync()
{
var connection = new SqlConnection(_inspectionConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
return connection;
}
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Reads the connection-string gate. Returns <see langword="null"/> — touching no socket — when
/// neither form is configured.
/// </summary>
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;
}
/// <summary>
/// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
/// <c>ConfigDb</c>. The catalog is ignored either way (everything lands in
/// <see cref="FixtureDatabase"/>), so this exists purely to tell an operator their env var is
/// wrong rather than to prevent damage that could otherwise occur.
/// </summary>
/// <exception cref="InvalidOperationException">The supplied catalog is named <c>ConfigDb</c>.</exception>
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).");
}
/// <summary>
/// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
/// <para>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 <see cref="Schema"/> schema, which nothing else creates.</para>
/// </summary>
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);
}
}
/// <summary>
/// The seed, one batch per statement. <c>CREATE SCHEMA</c> and <c>CREATE VIEW</c> must each be the
/// first statement of their batch in T-SQL, which is why this is a list rather than one script.
/// <para>Every numeric literal is composed under <see cref="CultureInfo.InvariantCulture"/>: plain
/// interpolation would emit <c>180,5</c> 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.</para>
/// </summary>
private static IEnumerable<string> 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');
""");
}
/// <summary>Runs one non-query batch.</summary>
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);
}
}
}
/// <summary>
/// Collection definition so the seed runs once per test session rather than once per test class —
/// the same shape as <c>Snap7ServerCollection</c>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture<SqlPollServerFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPollServer";
}
@@ -1,671 +0,0 @@
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;
/// <summary>
/// The <c>Sql</c> driver against a <b>real SQL Server</b>, over the <c>Microsoft.Data.SqlClient</c>
/// 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, <c>SELECT TOP 1</c>), and the
/// <c>INFORMATION_SCHEMA</c> catalog SQL that <c>SqlServerDialect</c> carries but that no offline test
/// can reach — the SQLite dialect answers those questions with <c>pragma_table_info</c> instead.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="SqlPollServerFixture"/> 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.</para>
/// <para><b>Out of scope, deliberately:</b> the frozen-database / <c>BadTimeout</c> gate. That needs a
/// <c>docker pause</c>-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.</para>
/// </summary>
[Collection(SqlPollServerCollection.Name)]
public sealed class SqlServerReadTests
{
private readonly SqlPollServerFixture _sql;
/// <summary>Initializes a new instance of the <see cref="SqlServerReadTests"/> class.</summary>
/// <param name="sql">The seeded live-server fixture (collection-scoped).</param>
public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql;
// ---- lifecycle ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c> over a real connection) succeeds and the driver
/// reports Healthy with the endpoint described credential-free.
/// </summary>
[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 ----
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
[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);
}
/// <summary>
/// Two keys on the same table fold into ONE query (<c>… WHERE [tag_name] IN (@k0, @k1)</c>) 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 <c>nvarchar</c> key.
/// </summary>
[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);
}
/// <summary>
/// A present row whose value cell is a real T-SQL <c>NULL</c> is Uncertain, not Bad and not
/// BadNoData — the row WAS read.
/// </summary>
[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();
}
/// <summary><c>nullIsBad</c> re-codes the same NULL cell as Bad, and only that cell.</summary>
[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);
}
/// <summary>A key the table has no row for is BadNoData with no source timestamp.</summary>
[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 ----
/// <summary>
/// Two columns of one wide row, selected by a <c>whereColumn/whereValue</c> pair, come back from a
/// single query — including the bound selector value coercing from authored text to a real
/// <c>int</c> column server-side.
/// </summary>
[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);
}
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the T-SQL-specific leg.</b> The planner emits the row limit through the
/// dialect's <c>SingleRowLimitPrefix</c> — <c>SELECT TOP 1 …</c> for T-SQL, where every offline
/// test exercises SQLite's trailing <c>LIMIT 1</c> instead. The seed makes the newest row not the
/// first-inserted one, so losing either the <c>TOP 1</c> or the <c>ORDER BY … DESC</c> yields a
/// different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[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);
}
/// <summary>A view reads exactly like the table it wraps — the shape operators are told to author.</summary>
[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 ----
/// <summary>
/// With no authored <c>type</c>, each tag's driver type is inferred from the provider's own
/// <c>GetDataTypeName</c> through <c>SqlServerDialect.MapColumnType</c>. This is the only test in
/// the estate where that map is checked against the strings SQL Server <em>actually</em> returns —
/// everywhere else it is fed a hand-written list, i.e. checked against itself.
/// </summary>
[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<bool>().ShouldBe(SqlPollServerFixture.ProbeBit);
snapshots[1].Value.ShouldBeOfType<int>().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<long>().ShouldBe(SqlPollServerFixture.ProbeBigInt);
snapshots[3].Value.ShouldBeOfType<float>().ShouldBe(SqlPollServerFixture.ProbeReal);
snapshots[4].Value.ShouldBeOfType<double>().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<double>()
.ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9);
snapshots[6].Value.ShouldBeOfType<string>().ShouldBe(SqlPollServerFixture.ProbeNVarChar);
snapshots[7].Value.ShouldBeOfType<DateTime>().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc);
}
/// <summary>
/// An authored <c>type</c> wins over the inferred column type, and the coercion runs over the
/// provider's real CLR cell rather than over a test double.
/// </summary>
[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<string>()
.ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture));
snapshots[1].Value.ShouldBeOfType<int>().ShouldBe(1);
snapshots[2].Value.ShouldBeOfType<double>().ShouldBe(SqlPollServerFixture.ProbeReal);
}
/// <summary>
/// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone,
/// never a thrown read. <c>int.MaxValue</c> into an <c>Int16</c> is a genuine provider-level
/// <c>OverflowException</c>, not a synthesised one.
/// </summary>
[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 ----
/// <summary>
/// A cancelled poll propagates <see cref="OperationCanceledException"/> 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.
/// <para><b>What this proves, precisely.</b> The token is <em>already</em> cancelled before
/// <c>ReadAsync</c> is called, so the cancellation is observed at the reader's first checkpoint —
/// <c>SemaphoreSlim.WaitAsync(_, token)</c> — <b>before any <c>SqlConnection</c> opens</b>. It
/// therefore does <b>not</b> exercise <c>Microsoft.Data.SqlClient</c>'s in-flight cancellation of a
/// running command; that is left to the blackhole gate's deadline path
/// (<c>SqlBlackholeTimeoutTests</c>). What it pins is the driver-shell contract that a cancelled
/// token surfaces as an <see cref="OperationCanceledException"/> and is <b>never</b> 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.</para>
/// </summary>
[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<OperationCanceledException>(
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);
}
/// <summary>
/// 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 <b>small, non-zero</b> number of
/// sessions for this driver's <c>Application Name</c>: 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.
/// </summary>
[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) ----
/// <summary>The dialect's schema list — real <c>INFORMATION_SCHEMA.TABLES</c> — sees the seeded schema.</summary>
[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);
}
/// <summary>
/// The dialect's table list returns the seeded relations for the bound schema, and flags the view
/// as <c>VIEW</c> — the <c>TABLE_TYPE</c> the browse session decorates its label from.
/// </summary>
[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");
}
/// <summary>
/// The dialect's column list returns the key-value table's columns <b>in ordinal order</b> (the
/// order the browse tree renders), with <c>DATA_TYPE</c> values the dialect's map recognises.
/// </summary>
[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");
}
/// <summary>
/// Every T-SQL family the type-probe table declares maps to the driver type
/// <c>SqlServerDialect.MapColumnType</c> promises — read from the catalog's own
/// <c>DATA_TYPE</c> strings, which is the exact input the browse side-panel feeds it.
/// </summary>
[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 ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database.
/// <para><b>There is no <c>ForProduction</c> / <c>ForTest</c> hatch on <see cref="SqlDriver"/></b>
/// — 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
/// <c>SqlClientFactory.Instance</c>, the provider the driver ships.</para>
/// </summary>
private Task<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags);
/// <inheritdoc cref="StartAsync(RawTagEntry[])"/>
private async Task<SqlDriver> 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;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
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,
});
/// <summary>A wide-row tag selected by a <c>whereColumn</c>/<c>whereValue</c> pair.</summary>
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,
},
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
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,
},
});
/// <summary>A wide-row tag over the single-row type-probe table, optionally with a declared type.</summary>
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);
}
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry.
/// <para>Composed as a <see cref="JsonObject"/> 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 <c>BadNodeIdUnknown</c>, i.e.
/// as a plausible-looking test failure about the driver rather than about the test.</para>
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
/// <summary>Runs one of the dialect's catalog statements against the seeded database.</summary>
private async Task<List<T>> QueryCatalogAsync<T>(
string sql, Action<DbCommand> bind, Func<DbDataReader, T> 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<T>();
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;
}
}
}
/// <summary>Reads one seeded table's catalog columns through the dialect's <c>ListColumnsSql</c>.</summary>
private Task<List<(string Name, string DataType, string IsNullable)>> 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"))));
/// <summary>
/// Counts the server-side sessions carrying the driver's application name. Skips — rather than
/// failing — when the configured login cannot read the DMV, since <c>VIEW SERVER STATE</c> is a
/// property of the credentials the operator supplied and not of the code under test.
/// </summary>
private async Task<int> 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.
}
}
/// <summary>Binds one string catalog parameter, exactly as the browse session does.</summary>
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);
}
}
@@ -1,39 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise the real Microsoft.Data.SqlClient path — the analyzer's documented intentional case
("move the suppression into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -1,452 +0,0 @@
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;
/// <summary>
/// Proves the composition layer: <see cref="SqlDriverFactoryExtensions"/> (config blob → live driver) and
/// <see cref="SqlConnectionStringResolver"/> (a <c>connectionStringRef</c> name → the secret it stands for).
/// <para>Three things here are behaviour rather than plumbing, and each has its own test below.
/// <b>(1) The string-enum guard</b> — 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 <c>provider</c> and write the
/// NAME. <b>(2) Config validation</b> — <c>operationTimeout &gt; commandTimeout</c> 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. <b>(3) Credential hygiene</b> — 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.</para>
/// </summary>
public sealed class SqlDriverFactoryTests
{
// ---- the plan's headline legs ----
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
}
[Fact]
public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef()
{
var name = NewRef();
var ex = Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<SqlDriverConfigDto>(
"""
{
"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<IWritable>();
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<InvalidOperationException>(() => 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<SqlDriver>();
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<ArgumentException>(() =>
SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null));
Should.Throw<ArgumentException>(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null));
}
[Fact]
public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance()
{
var ex = Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null));
ex.Message.ShouldContain("d1");
}
/// <summary>A per-test connection-string ref, so no two tests can collide over one process-wide env var.</summary>
private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}";
}
/// <summary>
/// Sets one environment variable for the life of the scope and restores its previous value — including
/// restoring it to <see langword="null"/>. 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.
/// </summary>
internal sealed class EnvironmentVariableScope : IDisposable
{
private readonly string _name;
private readonly string? _previous;
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/>, remembering what was there.</summary>
/// <param name="name">The environment variable to set.</param>
/// <param name="value">The value to set it to.</param>
public EnvironmentVariableScope(string name, string? value)
{
_name = name;
_previous = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
/// <summary>Restores the previous value.</summary>
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
}
/// <summary>
/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted
/// rather than assumed.
/// </summary>
internal sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly List<LogEntry> _entries = [];
/// <summary>The log entries recorded so far, in order.</summary>
public IReadOnlyList<LogEntry> Entries
{
get { lock (_entries) return [.. _entries]; }
}
/// <inheritdoc/>
public ILogger CreateLogger(string categoryName) => new Recorder(this);
/// <inheritdoc/>
public void AddProvider(ILoggerProvider provider) { }
/// <inheritdoc/>
public void Dispose() { }
private void Record(LogLevel level, string message)
{
lock (_entries) _entries.Add(new LogEntry(level, message));
}
/// <summary>One captured log entry.</summary>
/// <param name="Level">The level it was logged at.</param>
/// <param name="Message">The formatted message.</param>
internal sealed record LogEntry(LogLevel Level, string Message);
private sealed class Recorder(RecordingLoggerFactory owner) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> owner.Record(logLevel, formatter(state, exception));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
@@ -1,198 +0,0 @@
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;
/// <summary>
/// Proves <see cref="SqlDriverProbe"/> — the AdminUI "Test Connect" liveness check — against the real
/// SQLite fixture. A probe opens a connection, runs the dialect's <c>SELECT 1</c>, and reports green with
/// latency or red with a reason; it <b>never throws</b> (the <see cref="IDriverProbe"/> contract).
/// <para>The probe parses the SAME factory DTO shape as <see cref="SqlDriverFactoryExtensions"/> (R2-11
/// factory parity, mirroring <c>ModbusDriverProbe</c>), so a config that Tests-green and a config that
/// Deploys are the same config. It resolves the <c>connectionStringRef</c> the same way too — and the
/// tests below assert the resolved connection string never reaches the red-result message.</para>
/// </summary>
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 ----
/// <summary>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.</summary>
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}}"}""";
}
/// <summary>
/// Builds a config JSON whose <c>connectionStringRef</c> is provisioned to
/// <paramref name="connectionString"/> 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).
/// </summary>
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;
}
/// <summary>A <see cref="DbProviderFactory"/> whose connections throw on open — a database that cannot be
/// reached, without needing a real unreachable endpoint.</summary>
private sealed class FailingFactory(string message) : DbProviderFactory
{
public override DbConnection CreateConnection() => new FailingConnection(message);
}
/// <summary>Like <see cref="FailingFactory"/>, but its open failure embeds the connection string in the
/// thrown exception's message — the realistic provider shape that a naive <c>ex.Message</c> would leak.</summary>
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();
}
}
@@ -1,675 +0,0 @@
using System.Data;
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;
/// <summary>
/// Proves the <see cref="SqlDriver"/> shell — the part <see cref="SqlPollReader"/> 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.
/// <para><b>The health assertions are the point of this class.</b> 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-<c>BadTimeout</c> snapshots and a perfectly successful
/// <see cref="PollGroupEngine"/> tick. If the shell does not classify what came back, the driver reports
/// <see cref="DriverState.Healthy"/> 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).</para>
/// </summary>
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<IWritable>();
}
[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<Exception>(
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<Exception>(
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<DataChangeEventArgs>(
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<HostStatusChangedEventArgs>();
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);
}
}
// ---- 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<Exception>(
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<Exception>(
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 ----
/// <summary>A distinctive password token: any assertion that finds it has found a credential leak.</summary>
private const string SecretToken = "hunter2-do-not-log";
/// <summary>The unreachable connection string's database file name, used as the actionable-error probe.</summary>
private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";
/// <summary>
/// The driver's <c>DriverConfig</c> JSON. The shell serves its typed options (the factory, Task 9,
/// owns parsing) exactly as <c>ModbusDriver</c> does, so this is deliberately inert.
/// </summary>
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);
/// <summary>
/// Builds the driver through its production constructor. That constructor <b>is</b> 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).
/// </summary>
private static SqlDriver NewDriver(
SqlitePollFixture fixture,
string connectionString,
CapturingLogger logger,
TimeSpan operationTimeout,
TimeSpan commandTimeout,
IReadOnlyList<RawTagEntry> rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = operationTimeout,
CommandTimeout = commandTimeout,
},
DriverInstanceId,
new SqliteDialect(),
connectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>A connection string whose database cannot be opened — the directory does not exist.</summary>
private static string Unreachable() => new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
Password = SecretToken,
}.ToString();
/// <summary>One authored raw tag: a key-value <c>TagConfig</c> blob over the fixture's EAV table.</summary>
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);
/// <summary>A key-value entry carrying an explicit <c>"type"</c> — so <c>DeclaredType</c> is non-null.</summary>
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);
/// <summary>The same tag as <see cref="KvEntry"/>, already typed — for the reference reader.</summary>
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);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The folders created, in order.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>The variables registered, in order.</summary>
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) { }
}
}
}
/// <summary>Captures the driver's log so "skipped and logged" can be asserted rather than assumed.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
/// <summary>A logger that records nothing — for the tests that do not assert on logging.</summary>
public static CapturingLogger Null { get; } = new();
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbException"/> 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.
/// </summary>
private sealed class FakeDbException(string message) : DbException(message);
/// <summary>
/// A <see cref="DbProviderFactory"/> 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.
/// </summary>
private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory
{
public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage);
}
/// <summary>A connection that throws <see cref="FakeDbException"/> on open; every other member is inert.</summary>
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();
}
}
@@ -1,199 +0,0 @@
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;
/// <summary>
/// Pins <see cref="SqlEquipmentTagParser.TryParse"/> against its documented contract: what it accepts, what
/// it captures verbatim, and — the larger half — everything it must <b>reject</b>. 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 <em>wrong</em> model while publishing a confident <c>Good</c> (R2-11).
/// </summary>
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();
// ---- 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();
}
@@ -1,386 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// 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 <b>every authored value is a bound
/// parameter and never text</b>.
/// <para>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.</para>
/// </summary>
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<ArgumentException>(() => 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<NotSupportedException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
[Fact]
public void AWideRowTagWithNoRowSelectorAtAll_throws()
{
var tag = WideTag("A", "dbo.T", "oven_temp");
Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
}
@@ -1,168 +0,0 @@
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;
/// <summary>
/// Locks the driver's SQL-injection guarantee against a real database (<see cref="SqlitePollFixture"/>):
/// an authored <em>value</em> is bound as a parameter and can never execute, and an authored
/// <em>identifier</em> — 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.
/// <para><b>Scope note — what this suite does NOT assume.</b> Design §8.1 also specifies a catalog gate:
/// validate every authored table/column against <c>INFORMATION_SCHEMA</c> before quoting it, so an unknown
/// identifier <em>rejects the tag</em>. <b>No such gate exists in the driver yet</b> (see the "Not yet
/// implemented" note on <see cref="ISqlDialect"/>), and no task in this workstream builds one. So a hostile
/// identifier here is <b>not</b> rejected as <c>BadNodeIdUnknown</c> — 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 (<see cref="SqlStatusCodes.BadCommunicationError"/>). This suite proves the payload
/// is <em>inert</em> — 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.</para>
/// </summary>
public sealed class SqlInjectionRegressionTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
/// <summary>The classic payload: were the key concatenated into text, this would drop the table.</summary>
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);
/// <summary>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.</summary>
private static async Task<long> RowCountAsync(SqlitePollFixture fixture, string table)
{
await using var command = fixture.Connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
}
@@ -1,651 +0,0 @@
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;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Proves <see cref="SqlPollReader"/> against a real database (<see cref="SqlitePollFixture"/>): 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 <b>does not wedge the caller</b>.
/// <para><b>Each test owns its own fixture.</b> 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.</para>
/// </summary>
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);
}
[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]
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();
Execute(locker, "BEGIN EXCLUSIVE");
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
{
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()
{
// 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<OperationCanceledException>(
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<DbException>(
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<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null));
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null));
Should.Throw<ArgumentException>(() => new SqlPollReader(
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
}
[Fact]
public async Task ReadAsync_rejectsANullReferenceList()
=> await Should.ThrowAsync<ArgumentNullException>(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));
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);
/// <summary>
/// Adds a second <see cref="SqlitePollFixture.WideRowTable"/> row for the station the wide-row tests
/// select, breaking that model's one-row-per-selector contract.
/// </summary>
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')
"""));
/// <summary>Runs one statement on a caller-owned connection.</summary>
private static void Execute(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>
/// Polls <paramref name="condition"/> until it holds or <paramref name="limit"/> elapses. Bounded on
/// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it
/// does not.
/// </summary>
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan limit)
{
var clock = Stopwatch.StartNew();
while (clock.Elapsed < limit)
{
if (condition()) return true;
await Task.Delay(25);
}
return condition();
}
/// <summary>The driver's RawPath→definition table, standing in for <c>EquipmentTagRefResolver</c>.</summary>
private static Func<string, SqlTagDefinition?> 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);
/// <summary>
/// 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.
/// </summary>
private sealed class RecordingLogger : ILogger
{
/// <summary>Every record written, level + rendered message.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class Scope : IDisposable
{
public static Scope Instance { get; } = new();
public void Dispose() { }
}
}
/// <summary>
/// A <see cref="DbProviderFactory"/> 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 <see cref="SqlPollReader"/> deliberately owns its connections end to end.
/// <para>The optional <c>createDelay</c> 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.</para>
/// </summary>
private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory
{
private readonly object _sync = new();
private int _live;
private int _peak;
private int _created;
/// <summary>How many connections the reader has asked the factory for.</summary>
public int Created { get { lock (_sync) { return _created; } } }
/// <summary>How many created connections have not yet closed.</summary>
public int Live { get { lock (_sync) { return _live; } } }
/// <summary>The high-water mark of <see cref="Live"/>.</summary>
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--; }
}
}
}
@@ -1,34 +0,0 @@
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<T>, a consumer downcast could corrupt every later poll that reused it.
[Fact]
public void Plan_doesNotAliasTheCallersLists()
{
var names = new List<string> { "@k0" };
var values = new List<object?> { "v" };
var members = new List<SqlTagDefinition>
{
new("raw", SqlTagModel.KeyValue, "t") { KeyColumn = "k", KeyValue = "v", ValueColumn = "c" },
};
var selected = new List<string> { "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);
}
}
@@ -1,143 +0,0 @@
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;
/// <summary>
/// Golden tests for the driver's SQL-injection boundary. Values are always bound as
/// <c>DbParameter</c>s; identifiers cannot be, so <see cref="SqlServerDialect.QuoteIdentifier"/> 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.
/// </summary>
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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
[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");
}
[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]
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<ArgumentException>(() => 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<ArgumentException>(() => 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);
}
@@ -1,154 +0,0 @@
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;
/// <summary>
/// A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
/// be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
/// real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
/// <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
/// <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
/// <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
/// <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
/// (<c>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</c>)
/// rather than referencing this project, so it must not depend on anything else defined here.</para>
/// </summary>
public sealed class SqliteDialect : ISqlDialect
{
/// <summary>
/// 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.
/// </summary>
public const string MainSchema = "main";
/// <summary>
/// Reports <see cref="SqlProvider.Odbc"/>.
/// <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> 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.</para>
/// <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
/// not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
/// <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
/// <see cref="Provider"/> 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.</para>
/// </summary>
public SqlProvider Provider => SqlProvider.Odbc;
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
/// position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
/// ends on the seam.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// SQLite has no schema namespace, so the schema level is the single literal
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
/// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
/// <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
/// honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
/// </summary>
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";
/// <summary>
/// Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
/// <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
/// <b>bound parameter</b> — 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.
/// <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
/// </summary>
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";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
/// only metacharacter that could close a quoted identifier early.
/// </summary>
/// <remarks>
/// Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'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.
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quote-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQLite identifier.</exception>
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), "\"");
}
/// <summary>
/// Folds a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
/// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
/// declares every seeded column explicitly.
/// </summary>
/// <remarks>
/// <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
/// parameterised like <c>VARCHAR(50)</c>) declaration falls back to
/// <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
/// <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
/// <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
/// <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
/// </remarks>
/// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
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,
};
}
}
@@ -1,212 +0,0 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// 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
/// <see cref="DbConnection"/>: real parameter binding, real provider type coercion, real
/// <see cref="DBNull"/>, real "no such row".
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c>.</b> The reader opens a <em>fresh</em>
/// connection per poll (<c>Factory.CreateConnection()</c> → set <see cref="ConnectionString"/> →
/// <c>OpenAsync</c>) 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.</para>
/// <para><b>The fixture is a contract, not scaffolding.</b> 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 <b>NULL</b>, and a key that is
/// <b>absent</b> entirely. Change a constant and you are changing what the reader is proven to do.</para>
/// </summary>
public sealed class SqlitePollFixture : IDisposable
{
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by both seeded tables.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>
/// A key whose row <b>exists</b> but whose value cell is <c>NULL</c> — the case that must not be
/// confused with <see cref="AbsentKey"/>: the row was read, the value is simply not there.
/// </summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>
/// A key with <b>no row at all</b>. Distinct from <see cref="NullValueKey"/> on purpose: a missing row
/// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
/// </summary>
public const string AbsentKey = "Line1.Missing";
// ---- wide-row source ----
/// <summary>
/// The wide-row table:
/// <c>LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT)</c>.
/// </summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> row selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
/// <c>ORDER BY … DESC</c> (or the row limit) picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — the value a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
private readonly string _databasePath;
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public string ConnectionString { get; }
/// <summary>
/// The provider factory to hand the code under test, matching the reader's
/// <c>(DbProviderFactory factory, string connectionString, …)</c> seam.
/// <para>This is the <em>real</em> <see cref="SqliteFactory"/>, 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.</para>
/// </summary>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <summary>
/// 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.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>Opens a brand-new connection, exactly as the reader does on each poll.</summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Runs one non-query statement against <see cref="Connection"/>, for test-local arrangement.</summary>
/// <param name="sql">The statement to execute.</param>
public void Execute(string sql)
{
using var command = Connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>Closes the fixture's connection, clears the pool, and deletes the temporary database file.</summary>
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.
}
}
/// <summary>
/// Applies the schema and rows described by this type's constants.
/// <para>Every column is <b>explicitly declared</b>. 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 <c>pragma_table_info</c> (and therefore
/// <see cref="SqliteDialect.MapColumnType"/>) something real to report.</para>
/// </summary>
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();
}
}
@@ -1,263 +0,0 @@
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;
/// <summary>
/// Proves the offline substrate the reader tests will stand on: that <see cref="SqlitePollFixture"/>
/// survives the reader's real per-poll connection lifecycle, and that a
/// <see cref="SqlGroupPlanner"/>-produced plan <b>executes</b> — parses, binds, and returns the seeded
/// rows — rather than merely looking right as a string.
/// <para>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.</para>
/// </summary>
public sealed class SqlitePollFixtureTests : IClassFixture<SqlitePollFixture>
{
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<ArgumentException>(() => dialect.QuoteIdentifier("x\0y"));
Should.Throw<ArgumentException>(() => 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<string> 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<string>();
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);
/// <summary>
/// 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
/// <see cref="SqlQueryPlan.SelectedColumns"/>.
/// </summary>
private List<Dictionary<string, object?>> 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<Dictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(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;
}
}
@@ -1,44 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
exercise driver-level behavior — the analyzer's documented intentional case ("move the
suppression into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
<PropertyGroup>
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!--
TEST-ONLY. SQLite backs SqlitePollFixture so the poll reader can be exercised against a real
DbConnection offline; no product project takes a dependency on it (Driver.Sql ships
Microsoft.Data.SqlClient only). The bundle_e_sqlite3 line is the same surgical direct pin
Core.AlarmHistorian carries — it promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range that Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
See Directory.Packages.props.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
}
[Fact]
public void Round_trip_preserves_Transport_mode()
{
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
}
}
@@ -1,277 +0,0 @@
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;
/// <summary>
/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔
/// parser-input agreement: what <see cref="SqlTagConfigModel.ToJson"/> writes MUST parse cleanly through
/// <see cref="SqlEquipmentTagParser.TryParse"/> (the runtime factory's authoritative reader), and the
/// <c>model</c>/<c>type</c> enums MUST serialise as NAME strings — a numeric enum makes the parser reject
/// the tag (the "authors fine, never reads" defect class this guards).
/// </summary>
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();
}
}