diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
new file mode 100644
index 00000000..c52e07e2
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -0,0 +1,694 @@
+using System.Data.Common;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The Sql driver instance: a read-only Equipment-kind driver that polls SQL tables/views
+/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
+/// ModbusDriver — this type owns lifecycle, the authored RawPath table, health, host
+/// connectivity, and the subscription overlay, while
+/// owns the read path.
+/// Read-only is structural, not configured. is deliberately not
+/// implemented, so no amount of config (and no WriteOperate role) can produce a write; every
+/// discovered variable is . A future write feature is a
+/// new capability, not a flag flip.
+/// Health is classified here, because nothing below does it. The reader honours
+/// literally: it throws only when the database cannot be reached, and turns
+/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
+/// successfully, so sees a clean tick, does not back off, and does
+/// not call . Without below, a driver whose
+/// every value is would keep reporting
+/// . See that method for the exact rule and for why it does not
+/// manufacture an exception to force backoff.
+///
+public sealed class SqlDriver
+ : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
+{
+ ///
+ /// The driver-type string this driver registers under.
+ /// Deliberately a local constant, not DriverTypeNames.Sql.
+ /// DriverTypeNamesGuardTests asserts bidirectional parity between the constants and the
+ /// driver factories actually registered in the process, so the constant may only be added in the
+ /// same change that wires the factory (this task ships no factory — see the plan's Task 11, which
+ /// adds DriverTypeNames.Sql and repoints this constant at it).
+ ///
+ public const string DriverTypeName = "Sql";
+
+ /// Shown for a connection string that names no recognisable server.
+ private const string UnknownEndpoint = "(unknown sql endpoint)";
+
+ /// Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).
+ private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
+
+ /// Connection-string keys that name the server, in the order they are consulted.
+ private static readonly string[] ServerKeys =
+ ["server", "data source", "datasource", "host", "address", "addr", "network address"];
+
+ /// Connection-string keys that name the database.
+ private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
+
+ // ---- instance fields (grouped at top for auditability) ----
+
+ private readonly SqlDriverOptions _options;
+ private readonly string _driverInstanceId;
+ private readonly ISqlDialect _dialect;
+ private readonly DbProviderFactory _factory;
+
+ ///
+ /// The resolved connection string — a secret. It is passed to the provider and to nothing
+ /// else: every log line, health message and host status carries instead.
+ ///
+ private readonly string _connectionString;
+
+ private readonly ILogger _logger;
+
+ ///
+ /// The authored RawPath → definition table, held as an immutable snapshot swapped atomically
+ /// rather than as a mutated dictionary (the shape ModbusDriver uses).
+ /// rebuilds this table while poll loops are reading it; clearing and
+ /// refilling a shared under that concurrency is a torn read at
+ /// best and an inside a poll at worst.
+ ///
+ private IReadOnlyDictionary _tagsByRawPath =
+ new Dictionary(StringComparer.Ordinal);
+
+ /// Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.
+ private readonly EquipmentTagRefResolver _resolver;
+
+ ///
+ /// The read path. Built once, in the constructor, and not injected: its resolve
+ /// delegate must read this driver's live authored table, which does not exist before the driver does.
+ ///
+ private readonly SqlPollReader _reader;
+
+ /// Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.
+ private readonly PollGroupEngine _poll;
+
+ // Single logical host: one driver instance dials one connection string. HostName is the endpoint
+ // description (server[/database]) so the Admin UI shows operators where to look.
+ private readonly object _probeLock = new();
+ private HostState _hostState = HostState.Unknown;
+ private DateTime _hostStateChangedUtc = DateTime.UtcNow;
+
+ private DriverHealth _health = new(DriverState.Unknown, null, null);
+
+ /// Occurs when a subscribed tag's value or quality changes.
+ public event EventHandler? OnDataChange;
+
+ /// Occurs when the database endpoint transitions between reachable and unreachable.
+ public event EventHandler? OnHostStatusChanged;
+
+ // ---- ctor + identity ----
+
+ /// Initializes a new Sql driver instance.
+ /// The driver's typed configuration (the factory applies the documented defaults).
+ /// The central config DB's identity for this driver instance.
+ ///
+ /// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
+ ///
+ ///
+ /// The already-resolved connection string. The driver never resolves a
+ /// connectionStringRef itself and never logs this value.
+ ///
+ ///
+ /// Creates the provider's connections. Defaults to 's factory; passed
+ /// explicitly by tests that need to observe or delay connection creation.
+ ///
+ /// Optional; defaults to a no-op logger.
+ /// A required reference argument is null.
+ /// or is blank.
+ public SqlDriver(
+ SqlDriverOptions options,
+ string driverInstanceId,
+ ISqlDialect dialect,
+ string connectionString,
+ DbProviderFactory? factory = null,
+ ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(dialect);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
+
+ _options = options;
+ _driverInstanceId = driverInstanceId;
+ _dialect = dialect;
+ _factory = factory ?? dialect.Factory;
+ _connectionString = connectionString;
+ _logger = logger ?? NullLogger.Instance;
+ Endpoint = DescribeEndpoint(connectionString);
+
+ _resolver = new EquipmentTagRefResolver(Lookup);
+ _reader = new SqlPollReader(
+ _factory,
+ connectionString,
+ dialect,
+ commandTimeout: options.CommandTimeout,
+ operationTimeout: options.OperationTimeout,
+ maxConcurrentGroups: options.MaxConcurrentGroups,
+ nullIsBad: options.NullIsBad,
+ resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
+ logger: _logger);
+ _poll = new PollGroupEngine(
+ reader: ReadAsync,
+ onChange: (handle, tagRef, snapshot) =>
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
+ onError: HandlePollError,
+ backoffCap: PollBackoffCap);
+ }
+
+ ///
+ public string DriverInstanceId => _driverInstanceId;
+
+ ///
+ public string DriverType => DriverTypeName;
+
+ ///
+ /// The credential-free description of the database this instance polls — server/database where
+ /// the connection string names both. This is the only rendering of the connection string that may
+ /// appear in a log, a health message, or the Admin UI.
+ ///
+ public string Endpoint { get; }
+
+ /// Host identifier surfaced through — the endpoint description.
+ public string HostName => Endpoint;
+
+ /// Active polled-subscription count. Diagnostics + disposal assertions.
+ internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
+
+ /// The current authored-tag snapshot. Read through a barrier — swaps it.
+ private IReadOnlyDictionary Tags => Volatile.Read(ref _tagsByRawPath);
+
+ /// The resolver's lookup: RawPath → authored definition, or null on a miss.
+ private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath);
+
+ // ---- IDriver lifecycle ----
+
+ ///
+ /// Builds the authored RawPath table and proves the database is reachable.
+ /// is not re-parsed here: the driver serves the
+ /// typed it was constructed with, exactly as ModbusDriver does
+ /// (config parsing belongs to the factory, which builds a fresh instance).
+ /// The table is built first because it is pure and cannot fail; the liveness check is the only
+ /// I/O, and on failure this method records and rethrows —
+ /// DriverInstanceActor reads a throw as InitializeFailed and lands in Reconnecting with its
+ /// retry timer running, which is exactly the recovery a database that is merely down needs.
+ ///
+ /// The driver configuration JSON (unused; see remarks).
+ /// Cancellation token for the operation.
+ /// A task that represents the asynchronous operation.
+ /// The database could not be reached.
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ BuildTagTable();
+
+ try
+ {
+ await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller tore the initialization down; not a database verdict.
+ WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Never interpolate _connectionString — Endpoint is the credential-free rendering.
+ var message =
+ $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message} " +
+ $"Check the database is up, the network path is open, and the configured credentials are valid.";
+ _logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
+ _driverInstanceId, Endpoint);
+ WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
+ TransitionHostTo(HostState.Stopped);
+ throw new InvalidOperationException(message, ex);
+ }
+
+ WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ TransitionHostTo(HostState.Running);
+ _logger.LogInformation(
+ "Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
+ _driverInstanceId, Endpoint, Tags.Count);
+ }
+
+ ///
+ public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ await ShutdownAsync(cancellationToken).ConfigureAwait(false);
+ await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ var lastRead = ReadHealth().LastSuccessfulRead;
+ await TeardownAsync().ConfigureAwait(false);
+ WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
+ }
+
+ ///
+ public DriverHealth GetHealth() => ReadHealth();
+
+ /// No caches, no symbol table, no connection between polls — the footprint is the tag table.
+ /// Always zero.
+ public long GetMemoryFootprint() => 0;
+
+ /// Nothing optional to flush: the authored table is required for correctness.
+ /// Cancellation token for the operation.
+ /// A completed task.
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ // ---- ITagDiscovery ----
+
+ ///
+ /// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
+ /// could only produce the same nodes.
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ /// False — replays authored tags rather than enumerating the backend, so
+ /// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
+ /// a separate surface: Driver.Sql.Browser.)
+ ///
+ public bool SupportsOnlineDiscovery => false;
+
+ ///
+ /// Streams the authored tags as read-only variables.
+ /// An undeclared tag materialises as — the same fallback
+ /// uses for an unrecognised column type — because a column's
+ /// real type is only known once a poll has returned result-set metadata, which has not happened at
+ /// discovery time. Author "type" on a numeric tag.
+ ///
+ /// The address-space builder to stream discovered nodes into.
+ /// Cancellation token for the operation.
+ /// A completed task — discovery is synchronous over the authored table.
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var folder = builder.Folder(DriverTypeName, DriverTypeName);
+ foreach (var tag in Tags.Values)
+ {
+ folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
+ FullName: tag.Name,
+ DriverDataType: tag.DeclaredType ?? DriverDataType.String,
+ IsArray: false,
+ ArrayDim: null,
+ // v1 is read-only: no tag, and no configuration, can widen this.
+ SecurityClass: SecurityClassification.ViewOnly,
+ IsHistorized: false,
+ IsAlarm: false,
+ WriteIdempotent: false));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ // ---- IReadable ----
+
+ ///
+ /// Reads a batch, delegating to and classifying what came back
+ /// ().
+ ///
+ /// The RawPaths to read.
+ /// Cancellation token for the operation.
+ /// One snapshot per reference, at the same index.
+ /// The database could not be reached — the engine backs off on this.
+ public async Task> ReadAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ // Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
+ // unreachable" and degrade a perfectly healthy driver.
+ ArgumentNullException.ThrowIfNull(fullReferences);
+
+ try
+ {
+ var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
+ ObservePollOutcome(snapshots);
+ return snapshots;
+ }
+ catch (OperationCanceledException)
+ {
+ // Teardown, not a database verdict — leave health and host state exactly as they were.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // The reader throws for one reason only: opening a connection failed (IReadable's contract).
+ _logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
+ _driverInstanceId, Endpoint);
+ Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.Message}");
+ TransitionHostTo(HostState.Stopped);
+ throw;
+ }
+ }
+
+ // ---- ISubscribable (polling overlay via the shared engine) ----
+
+ ///
+ /// Registers a polled subscription.
+ /// A non-positive falls back to the configured
+ /// ; anything faster than
+ /// is floored by the engine.
+ ///
+ /// The RawPaths to poll.
+ /// The requested publishing interval.
+ /// Cancellation token for the operation.
+ /// The subscription handle.
+ public Task SubscribeAsync(
+ IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
+ {
+ var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
+ return Task.FromResult(_poll.Subscribe(fullReferences, interval));
+ }
+
+ ///
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ _poll.Unsubscribe(handle);
+ return Task.CompletedTask;
+ }
+
+ // ---- IHostConnectivityProbe ----
+
+ ///
+ /// The single logical host this instance dials. There is no background probe loop: the state is a
+ /// by-product of the Initialize-time liveness check and of every poll, so the report is always a
+ /// statement about traffic that actually happened rather than about a synthetic ping.
+ ///
+ /// A one-element list describing the configured database endpoint.
+ public IReadOnlyList GetHostStatuses()
+ {
+ lock (_probeLock)
+ return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
+ }
+
+ // ---- health + host-state classification ----
+
+ ///
+ /// Classifies one poll's snapshots into a health verdict and a host state.
+ ///
+ /// - Any Good snapshot ⇒ the database answered: LastSuccessfulRead advances and the
+ /// host is Running.
+ /// - Any connection-class Bad snapshot ( /
+ /// ) ⇒ Degraded; when nothing at all was Good,
+ /// the host is Stopped.
+ /// - Bad codes that are authoring facts — an unresolvable RawPath, an absent row, a
+ /// type mismatch, a rejected definition — change nothing. A tag typo must never report the
+ /// database as down and send an operator to the wrong system.
+ ///
+ /// Why this does not throw to force poll-engine backoff. Backoff would require turning
+ /// an all-BadTimeout batch into an exception, and the engine's exception path publishes
+ /// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
+ /// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
+ /// already bounded by operationTimeout and the reader never holds more than
+ /// maxConcurrentGroups connections however long the database stays frozen. So a frozen
+ /// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
+ /// cadence rather than backed off from.
+ ///
+ /// The snapshots the reader returned for one poll.
+ private void ObservePollOutcome(IReadOnlyList snapshots)
+ {
+ if (snapshots.Count == 0) return;
+
+ var good = 0;
+ var unreachable = 0;
+ foreach (var snapshot in snapshots)
+ {
+ if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
+ else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
+ }
+
+ if (unreachable > 0)
+ {
+ Degrade(
+ $"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
+ $"{Endpoint} on the last poll.");
+ if (good > 0)
+ {
+ // Partial: something answered, so the endpoint is up — but the driver is not healthy.
+ TouchLastSuccessfulRead();
+ TransitionHostTo(HostState.Running);
+ }
+ else
+ {
+ TransitionHostTo(HostState.Stopped);
+ }
+
+ return;
+ }
+
+ if (good == 0) return; // authoring-class Bad only — not a statement about the database.
+
+ WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ TransitionHostTo(HostState.Running);
+ }
+
+ /// True for the status codes that mean "the database did not answer", as opposed to "the data is not there".
+ private static bool IsConnectionClass(uint statusCode)
+ => statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
+
+ ///
+ /// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does not
+ /// touch host state: the engine also reports its own contract violations here, which say nothing
+ /// about connectivity — the unreachable-database transition is raised by ,
+ /// which knows the exception came from the reader.
+ ///
+ /// The exception the poll engine caught.
+ internal void HandlePollError(Exception ex)
+ {
+ _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
+ _driverInstanceId, Endpoint);
+ Degrade(ex.Message);
+ }
+
+ ///
+ /// Degrades health, preserving LastSuccessfulRead and never downgrading
+ /// — a config-level verdict that only a successful read or an
+ /// operator reinitialize may clear.
+ ///
+ /// The operator-facing reason, which must never carry the connection string.
+ private void Degrade(string reason)
+ {
+ var current = ReadHealth();
+ if (current.State == DriverState.Faulted) return;
+ WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
+ }
+
+ /// Advances LastSuccessfulRead without changing the current state or error.
+ private void TouchLastSuccessfulRead()
+ {
+ var current = ReadHealth();
+ WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
+ }
+
+ /// Barrier-protected read of the multi-threaded health field.
+ private DriverHealth ReadHealth() => Volatile.Read(ref _health);
+
+ /// Barrier-protected publish of a new health snapshot.
+ private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
+
+ /// Records a host-state change and raises — on transitions only.
+ private void TransitionHostTo(HostState newState)
+ {
+ HostState old;
+ lock (_probeLock)
+ {
+ old = _hostState;
+ if (old == newState) return;
+ _hostState = newState;
+ _hostStateChangedUtc = DateTime.UtcNow;
+ }
+
+ OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
+ }
+
+ // ---- authored tag table ----
+
+ ///
+ /// Maps every authored through
+ /// and publishes the result as one atomic snapshot. A blob that does not map is logged and
+ /// skipped, never thrown: one malformed tag must not take the whole driver — and therefore every
+ /// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
+ /// publishes rather than someone else's row.
+ ///
+ private void BuildTagTable()
+ {
+ var table = new Dictionary(_options.RawTags.Count, StringComparer.Ordinal);
+ foreach (var entry in _options.RawTags)
+ {
+ if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
+ table[entry.RawPath] = definition;
+ else
+ _logger.LogWarning(
+ "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ _driverInstanceId, entry.RawPath);
+ }
+
+ Volatile.Write(ref _tagsByRawPath, table);
+ }
+
+ // ---- liveness ----
+
+ ///
+ /// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
+ /// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
+ /// long-lived connection here would only be a second thing to keep alive.
+ /// Bounded by wall-clock, not only by the token (the R2-01 / STAB-14 lesson): some ADO.NET
+ /// providers implement the async path synchronously, and a wedged socket can hang inside the
+ /// provider's own cancellation handshake. If Initialize hung there, DriverInstanceActor's init
+ /// task would never complete and the driver would sit in Connecting forever with no retry. The work
+ /// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
+ /// an abandoned attempt still owns and disposes its own connection.
+ ///
+ /// The caller's token.
+ /// A task that completes when the database has answered.
+ private async Task VerifyLivenessAsync(CancellationToken cancellationToken)
+ {
+ var budget = _options.CommandTimeout;
+ var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ Task work;
+ try
+ {
+ deadline.CancelAfter(budget);
+ work = Task.Run(
+ async () =>
+ {
+ try { await PingAsync(deadline.Token).ConfigureAwait(false); }
+ finally { deadline.Dispose(); }
+ },
+ CancellationToken.None);
+ }
+ catch
+ {
+ // Ownership of the CTS transfers to the work task; release it only if that task never started.
+ deadline.Dispose();
+ throw;
+ }
+
+ try
+ {
+ await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ Detach(work);
+ throw new TimeoutException(
+ $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Our deadline fired and the provider DID honour the linked token.
+ Detach(work);
+ throw new TimeoutException(
+ $"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
+ }
+ }
+
+ /// Opens a connection and executes the dialect's cheapest proof-of-life statement.
+ private async Task PingAsync(CancellationToken cancellationToken)
+ {
+ var connection = _factory.CreateConnection()
+ ?? throw new InvalidOperationException(
+ $"the {_dialect.Provider} provider factory returned no connection.");
+
+ await using (connection.ConfigureAwait(false))
+ {
+ connection.ConnectionString = _connectionString;
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+ command.CommandText = _dialect.LivenessSql;
+ // ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
+ command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
+ await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
+ /// task exception. The task still owns — and disposes — its own connection.
+ ///
+ private static void Detach(Task work)
+ => _ = work.ContinueWith(
+ static t => _ = t.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+
+ // ---- endpoint description ----
+
+ ///
+ /// Renders a connection string as server/database, reading only those two keys.
+ /// This is the only function permitted to look at the connection string outside the
+ /// provider call, and it exists so that no other code is ever tempted to log the string itself:
+ /// credentials live in User ID / Password / Authentication, which are never
+ /// read here. A string that cannot be parsed yields — an unusable
+ /// description must not become a crash at construction.
+ ///
+ /// The resolved connection string.
+ /// A credential-free description safe to log and display.
+ private static string DescribeEndpoint(string connectionString)
+ {
+ try
+ {
+ var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+ var server = FirstValue(builder, ServerKeys);
+ if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
+ var database = FirstValue(builder, DatabaseKeys);
+ return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
+ }
+ catch (ArgumentException)
+ {
+ return UnknownEndpoint;
+ }
+ }
+
+ /// Reads the first of the builder carries; null when it carries none.
+ private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
+ {
+ foreach (var key in keys)
+ {
+ // DbConnectionStringBuilder's key comparison is case-insensitive.
+ if (builder.TryGetValue(key, out var value) && value is not null)
+ {
+ var text = value.ToString();
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+ }
+
+ return null;
+ }
+
+ // ---- teardown ----
+
+ ///
+ /// Performs the same teardown as , so a caller that only uses
+ /// await using does not leak the poll loops.
+ ///
+ /// A task that represents the asynchronous operation.
+ public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
+
+ ///
+ /// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
+ /// to call any number of times, in any order, which is what makes
+ /// ShutdownAsync-then-DisposeAsync (and ) safe.
+ /// There is no connection to close: the reader opens and disposes one per poll.
+ ///
+ private async Task TeardownAsync()
+ {
+ await _poll.DisposeAsync().ConfigureAwait(false);
+ Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal));
+ _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
new file mode 100644
index 00000000..6157fb20
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
@@ -0,0 +1,56 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// The typed form of a Sql driver instance's configuration — what
+/// becomes once the factory has applied its defaults (design §5.1).
+/// Mirrors ModbusDriverOptions: the driver instance is constructed with these, and its
+/// serves them rather than re-parsing the config JSON.
+/// No connection string here. The resolved connection string is a separate constructor
+/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.
+///
+public sealed class SqlDriverOptions
+{
+ ///
+ /// The authored raw tags this driver serves. The deploy artifact hands each authored raw Tag
+ /// as a (RawPath identity + driver TagConfig blob); the driver maps
+ /// each through into its RawPath → definition table.
+ /// A SQL source has no tag-discovery protocol — the driver serves exactly these.
+ ///
+ public IReadOnlyList RawTags { get; init; } = [];
+
+ ///
+ /// Poll interval used when a subscriber does not ask for one (design §4: defaultPollInterval,
+ /// default 5 s). A subscriber that names an interval gets that interval, floored by
+ /// .
+ ///
+ public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
+ /// publishes for that group's tags.
+ ///
+ public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// The ADO.NET CommandTimeout server-side backstop (default 10 s), also the bound on the
+ /// Initialize-time liveness check.
+ /// Authoring must keep strictly greater than this (design §8.3);
+ /// inverted, the client-side abort always fires first and masks the backstop. That rule is
+ /// enforced by config validation in the factory, not here — the reader deliberately stays usable
+ /// with the order inverted so the frozen-database tests can prove the client-side bound fires.
+ ///
+ public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
+
+ /// Cap on concurrently executing group queries — and therefore on concurrently open connections.
+ public int MaxConcurrentGroups { get; init; } = 4;
+
+ ///
+ /// When , a present row whose value cell is NULL publishes
+ /// instead of the default .
+ /// It never governs an absent row, which is always .
+ ///
+ public bool NullIsBad { get; init; }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs
new file mode 100644
index 00000000..eb4bee5c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs
@@ -0,0 +1,490 @@
+using System.Data.Common;
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
+
+///
+/// Proves the shell — the part deliberately does not
+/// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery,
+/// the poll-engine subscription overlay, and the health + host-connectivity surface.
+/// The health assertions are the point of this class. The reader returns Bad-coded snapshots
+/// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades
+/// health on its own: a frozen database yields all-BadTimeout snapshots and a perfectly successful
+/// tick. If the shell does not classify what came back, the driver reports
+/// while every value is Bad — the failure mode these tests exist to
+/// prevent, together with its inverse (a tag typo must NOT report the database down).
+///
+public sealed class SqlDriverTests
+{
+ private const string DriverInstanceId = "sql-1";
+
+ // ---- discovery + initialize ----
+
+ [Fact]
+ public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.DriverType.ShouldBe("Sql");
+ driver.DriverInstanceId.ShouldBe(DriverInstanceId);
+ ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
+ ((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
+
+ var capture = new CapturingBuilder();
+ await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
+
+ capture.Variables.ShouldContain(v =>
+ v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
+ // v1 is read-only by construction, not by configuration.
+ driver.ShouldNotBeAssignableTo();
+ }
+
+ [Fact]
+ public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe()
+ {
+ using var fixture = new SqlitePollFixture();
+ var logger = new CapturingLogger();
+ await using var driver = NewDriver(
+ fixture,
+ logger,
+ KvEntry("Speed", SqlitePollFixture.PresentKey),
+ new RawTagEntry("Broken", "not json at all", WriteIdempotent: false));
+
+ // A single malformed blob must never fail the whole driver.
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ var capture = new CapturingBuilder();
+ await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
+ capture.Variables.Count.ShouldBe(1);
+ capture.Variables[0].Info.FullName.ShouldBe("Speed");
+
+ logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken"));
+
+ // …and the skipped tag resolves to nothing rather than to someone else's row.
+ var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None);
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
+ }
+
+ [Fact]
+ public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(
+ fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
+
+ // Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in
+ // Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not
+ // seal as a silently-connected driver.
+ var thrown = await Should.ThrowAsync(
+ async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
+
+ var health = driver.GetHealth();
+ health.State.ShouldBe(DriverState.Faulted);
+ health.LastError.ShouldNotBeNullOrWhiteSpace();
+ // Actionable: it names the endpoint the operator has to go look at…
+ health.LastError!.ShouldContain(NoSuchDatabaseName);
+ thrown.Message.ShouldContain(NoSuchDatabaseName);
+ // …and never the credential-bearing connection string.
+ health.LastError.ShouldNotContain(SecretToken);
+ thrown.Message.ShouldNotContain(SecretToken);
+ driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
+ driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken);
+ }
+
+ [Fact]
+ public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain()
+ {
+ using var fixture = new SqlitePollFixture();
+ // SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection
+ // string is unreachable until the directory exists, and reachable the moment it does.
+ var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}");
+ var connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = Path.Combine(directory, "late.db"),
+ }.ToString();
+
+ await using var driver = NewDriver(
+ fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey));
+
+ await Should.ThrowAsync(
+ async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+
+ try
+ {
+ Directory.CreateDirectory(directory);
+
+ await driver.ReinitializeAsync(ConfigJson, CancellationToken.None);
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
+ // The authored table survived the round trip — a recovered driver still serves its tags.
+ var capture = new CapturingBuilder();
+ await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
+ capture.Variables.Count.ShouldBe(1);
+ }
+ finally
+ {
+ SqliteConnection.ClearAllPools();
+ try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
+ }
+ }
+
+ // ---- IReadable delegation ----
+
+ [Fact]
+ public async Task ReadAsync_delegatesToTheReader_valueForValue()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(
+ fixture,
+ KvEntry("Speed", SqlitePollFixture.PresentKey),
+ KvEntry("Temp", SqlitePollFixture.NullValueKey),
+ KvEntry("Missing", SqlitePollFixture.AbsentKey));
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ var reference = new SqlPollReader(
+ fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
+ commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
+ maxConcurrentGroups: 4, nullIsBad: false,
+ resolve: rawPath => rawPath switch
+ {
+ "Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey),
+ "Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey),
+ "Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey),
+ _ => null,
+ });
+
+ string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"];
+ var throughDriver = await driver.ReadAsync(refs, CancellationToken.None);
+ var throughReader = await reference.ReadAsync(refs, CancellationToken.None);
+
+ throughDriver.Count.ShouldBe(throughReader.Count);
+ for (var i = 0; i < throughDriver.Count; i++)
+ {
+ throughDriver[i].Value.ShouldBe(throughReader[i].Value);
+ throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode);
+ throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc);
+ }
+ }
+
+ // ---- the sustained-timeout decision ----
+
+ [Fact]
+ public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(
+ fixture, fixture.ConnectionString, CapturingLogger.Null,
+ // Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while
+ // the server-side backstop would still be waiting — the reader's frozen-peer shape.
+ operationTimeout: TimeSpan.FromMilliseconds(500),
+ commandTimeout: TimeSpan.FromSeconds(30),
+ rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]);
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ // A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests).
+ using var locker = fixture.OpenNewConnection();
+ using (var begin = locker.CreateCommand())
+ {
+ begin.CommandText = "BEGIN EXCLUSIVE";
+ begin.ExecuteNonQuery();
+ }
+
+ try
+ {
+ var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None);
+
+ // The reader does exactly what it promises — Bad-coded snapshots, no exception…
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
+ // …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health.
+ var health = driver.GetHealth();
+ health.State.ShouldBe(DriverState.Degraded);
+ health.LastError.ShouldNotBeNullOrWhiteSpace();
+ driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
+ }
+ finally
+ {
+ using var rollback = locker.CreateCommand();
+ rollback.CommandText = "ROLLBACK";
+ rollback.ExecuteNonQuery();
+ }
+ }
+
+ [Fact]
+ public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand()
+ {
+ // The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when
+ // the Bad codes are connection-class. An authoring typo reporting "the database is down" would send
+ // an operator to the wrong system entirely.
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+ (await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
+
+ var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None);
+
+ snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
+ }
+
+ // ---- ISubscribable (poll-engine overlay) ----
+
+ [Fact]
+ public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ var first = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var changes = 0;
+ driver.OnDataChange += (_, args) =>
+ {
+ Interlocked.Increment(ref changes);
+ first.TrySetResult(args);
+ };
+
+ var handle = await driver.SubscribeAsync(
+ ["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
+
+ var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ initial.FullReference.ShouldBe("Speed");
+ initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
+ initial.SubscriptionHandle.ShouldBe(handle);
+
+ await driver.UnsubscribeAsync(handle, CancellationToken.None);
+ driver.ActiveSubscriptionCount.ShouldBe(0);
+
+ // Unsubscribe awaits the loop task, so nothing may arrive after it returns.
+ var afterUnsubscribe = Volatile.Read(ref changes);
+ await Task.Delay(400, TestContext.Current.CancellationToken);
+ Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
+ }
+
+ // ---- disposal ----
+
+ [Fact]
+ public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent()
+ {
+ using var fixture = new SqlitePollFixture();
+ var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+
+ var changes = 0;
+ driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes);
+ _ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
+ await Task.Delay(300, TestContext.Current.CancellationToken);
+
+ await driver.DisposeAsync();
+ driver.ActiveSubscriptionCount.ShouldBe(0);
+
+ var afterDispose = Volatile.Read(ref changes);
+ await Task.Delay(400, TestContext.Current.CancellationToken);
+ Volatile.Read(ref changes).ShouldBe(afterDispose);
+
+ // Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw.
+ await driver.ShutdownAsync(CancellationToken.None);
+ await Should.NotThrowAsync(async () => await driver.DisposeAsync());
+ }
+
+ // ---- host identity ----
+
+ [Fact]
+ public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials()
+ {
+ using var fixture = new SqlitePollFixture();
+ const string connectionString =
+ "Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";";
+ await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null);
+
+ var hosts = driver.GetHostStatuses();
+
+ hosts.Count.ShouldBe(1);
+ hosts[0].HostName.ShouldContain("sqlsrv01");
+ hosts[0].HostName.ShouldContain("MesStaging");
+ hosts[0].HostName.ShouldNotContain(SecretToken);
+ hosts[0].HostName.ShouldNotContain("svc_ot");
+ hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet
+ }
+
+ [Fact]
+ public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition()
+ {
+ using var fixture = new SqlitePollFixture();
+ await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
+ var transitions = new List();
+ driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } };
+
+ await driver.InitializeAsync(ConfigJson, CancellationToken.None);
+ // A second successful poll must not re-raise — the event is for transitions, not for ticks.
+ await driver.ReadAsync(["Speed"], CancellationToken.None);
+
+ lock (transitions)
+ {
+ transitions.Count.ShouldBe(1);
+ transitions[0].OldState.ShouldBe(HostState.Unknown);
+ transitions[0].NewState.ShouldBe(HostState.Running);
+ }
+ }
+
+ // ---- helpers ----
+
+ /// A distinctive password token: any assertion that finds it has found a credential leak.
+ private const string SecretToken = "hunter2-do-not-log";
+
+ /// The unreachable connection string's database file name, used as the actionable-error probe.
+ private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir";
+
+ ///
+ /// The driver's DriverConfig JSON. The shell serves its typed options (the factory, Task 9,
+ /// owns parsing) exactly as ModbusDriver does, so this is deliberately inert.
+ ///
+ private const string ConfigJson = """{"provider":"SqlServer"}""";
+
+ private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
+ => NewDriver(fixture, CapturingLogger.Null, rawTags);
+
+ private static SqlDriver NewDriver(
+ SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
+ => NewDriver(fixture, fixture.ConnectionString, logger, rawTags);
+
+ private static SqlDriver NewDriver(
+ SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags)
+ => NewDriver(
+ fixture, connectionString, logger,
+ operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10),
+ rawTags: rawTags);
+
+ ///
+ /// Builds the driver through its production constructor. That constructor is the injection
+ /// seam — dialect, provider factory and already-resolved connection string are all parameters — so no
+ /// test-only factory is needed on the product type (Task 9 resolves the same three from config).
+ ///
+ private static SqlDriver NewDriver(
+ SqlitePollFixture fixture,
+ string connectionString,
+ CapturingLogger logger,
+ TimeSpan operationTimeout,
+ TimeSpan commandTimeout,
+ IReadOnlyList rawTags)
+ => new(
+ new SqlDriverOptions
+ {
+ RawTags = rawTags,
+ OperationTimeout = operationTimeout,
+ CommandTimeout = commandTimeout,
+ },
+ DriverInstanceId,
+ new SqliteDialect(),
+ connectionString,
+ factory: fixture.Factory,
+ logger: logger);
+
+ /// A connection string whose database cannot be opened — the directory does not exist.
+ private static string Unreachable() => new SqliteConnectionStringBuilder
+ {
+ DataSource = Path.Combine(
+ Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"),
+ Password = SecretToken,
+ }.ToString();
+
+ /// One authored raw tag: a key-value TagConfig blob over the fixture's EAV table.
+ private static RawTagEntry KvEntry(string rawPath, string keyValue)
+ => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
+ {
+ "driver": "Sql",
+ "model": "KeyValue",
+ "table": "{{SqlitePollFixture.KeyValueTable}}",
+ "keyColumn": "{{SqlitePollFixture.KeyColumn}}",
+ "keyValue": "{{keyValue}}",
+ "valueColumn": "{{SqlitePollFixture.ValueColumn}}",
+ "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
+ }
+ """), WriteIdempotent: false);
+
+ /// The same tag as , already typed — for the reference reader.
+ private static SqlTagDefinition KvDefinition(string rawPath, string keyValue)
+ => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
+ KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
+ ValueColumn: SqlitePollFixture.ValueColumn,
+ TimestampColumn: SqlitePollFixture.TimestampColumn);
+
+ /// Records everything the driver streams into the address space.
+ private sealed class CapturingBuilder : IAddressSpaceBuilder
+ {
+ /// The folders created, in order.
+ public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
+
+ /// The variables registered, in order.
+ public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ Folders.Add((browseName, displayName));
+ return this;
+ }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ Variables.Add((browseName, attributeInfo));
+ return new Handle(attributeInfo.FullName);
+ }
+
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+
+ private sealed class Handle(string fullReference) : IVariableHandle
+ {
+ public string FullReference => fullReference;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
+
+ private sealed class Sink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args) { }
+ }
+ }
+ }
+
+ /// Captures the driver's log so "skipped and logged" can be asserted rather than assumed.
+ private sealed class CapturingLogger : ILogger
+ {
+ /// A logger that records nothing — for the tests that do not assert on logging.
+ public static CapturingLogger Null { get; } = new();
+
+ /// Every record written, level + rendered message.
+ public List<(LogLevel Level, string Message)> Entries { get; } = [];
+
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static NullScope Instance { get; } = new();
+
+ public void Dispose() { }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
index 47c8bf1f..6b5c15d4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
@@ -1,5 +1,12 @@
+
+
+ $(NoWarn);OTOPCUA0001
+
+
net10.0
enable