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