Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee12568cab | |||
| e27eb77187 | |||
| fcc7ed698e | |||
| 4a8c9badce | |||
| 650e27075f | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| 20be5416b9 | |||
| 47e9cd56ef | |||
| 2f38b5b285 | |||
| 20100c36ad | |||
| eed6617784 | |||
| 8d0f60ec51 | |||
| e787aa572b |
@@ -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** | S–M | 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) | S–M | **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) | S–M | 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) | M–L | 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 & 0xFF, crc >> 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 & 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 & 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><kind>:<part>[|<part>…]</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__<ref></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:<ref></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;
|
||||
}
|
||||
}
|
||||
-27
@@ -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);
|
||||
-14
@@ -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 > 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__<ref></c> spelling is exactly the key .NET's environment-variable
|
||||
/// configuration provider would bind to <c>Sql:ConnectionStrings:<ref></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:<ref></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 <key>, <value>[, <ts>] FROM <table> WHERE <key> 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 <cols> FROM <table> WHERE <whereColumn> = @w</c>, or — for a
|
||||
/// <c>topByTimestamp</c> selector — the single-row form
|
||||
/// <c>SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit></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 > 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 0–255 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>
|
||||
+15
@@ -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
|
||||
|
||||
-441
@@ -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__<ref></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;
|
||||
}
|
||||
}
|
||||
-35
@@ -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
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+97
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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";
|
||||
}
|
||||
+94
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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 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 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();
|
||||
}
|
||||
}
|
||||
+110
@@ -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));
|
||||
}
|
||||
}
|
||||
+47
@@ -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>();
|
||||
}
|
||||
-204
@@ -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);
|
||||
}
|
||||
}
|
||||
-50
@@ -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
|
||||
-440
@@ -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);
|
||||
}
|
||||
}
|
||||
-39
@@ -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 > 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><Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /></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;
|
||||
}
|
||||
}
|
||||
-44
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user