diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index b6647bd2..8b786bac 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -29,6 +29,9 @@ + + + @@ -90,6 +93,9 @@ + + + diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index a76868d8..0dfe2a73 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -272,6 +272,12 @@ services: OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + # Sql poll driver — the named connection string a Sql driver instance references by + # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver + # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at + # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue) + # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above. + Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-1" @@ -391,6 +397,12 @@ services: OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + # Sql poll driver — the named connection string a Sql driver instance references by + # `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver + # node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at + # the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue) + # + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above. + Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-2" diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index d4ba0417..bbebd080 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -53,6 +53,9 @@ public static class DriverTypeNames /// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values. public const string Calculation = "Calculation"; + /// Read-only SQL Server table/view poller — publishes columns as OPC UA variables. + public const string Sql = "Sql"; + /// /// Every driver-type string declared above, for callers that need to enumerate /// the full set (e.g. validation of an authored DriverType). @@ -68,5 +71,6 @@ public static class DriverTypeNames OpcUaClient, Galaxy, Calculation, + Sql, ]; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs new file mode 100644 index 00000000..e55c0bf6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseNodeId.cs @@ -0,0 +1,201 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// Which catalog level a browse NodeId addresses. +public enum SqlBrowseNodeKind +{ + /// A schema — the browse root level. + Schema, + + /// A table or view inside a schema. + Table, + + /// A column of a table or view — the terminal (leaf) level. + Column, +} + +/// A decoded browse NodeId. +/// Which catalog level this reference addresses. +/// The schema name. Always present. +/// The table/view name; null for . +/// The column name; null unless . +public readonly record struct SqlBrowseNodeRef( + SqlBrowseNodeKind Kind, + string Schema, + string? Table, + string? Column); + +/// +/// Codec for the browse NodeIds the SQL schema browser hands the picker, and the picker hands back +/// on expand / attribute fetch / column commit. +/// Why not the design's literal schema.table|column. That sketch assumes . and +/// | cannot occur inside an identifier. They can: SQL Server permits essentially any character +/// inside a quoted (bracketed) identifier, and SqlServerDialect.QuoteIdentifier deliberately passes +/// both through — only ], control characters and Unicode format characters are special to it. So +/// main.a.b|c decodes equally as schema main + table a.b and as schema main.a +/// + table b, and a table named x|y loses its second half entirely. That is not a cosmetic +/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author +/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads. +/// +/// The encoding. <kind>:<part>[|<part>…], where kind is one +/// of schema / table / column and every part is escaped so no identifier character +/// can be mistaken for structure: a literal \ becomes \\ and a literal | becomes +/// \|. . needs no escape at all — it is not structural here — so the common case stays +/// readable (column:dbo|TagValues|num_value). The kind prefix carries the arity, so decoding never +/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is +/// rejected rather than truncated or padded. +/// Public on purpose, unlike the session itself: the AdminUI picker body decodes a committed +/// column NodeId back into schema/table/column to compose the tag's TagConfig. Encode and decode +/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type +/// exists to prevent. +/// +public static class SqlBrowseNodeId +{ + private const char Separator = '|'; + private const char Escape = '\\'; + private const char KindTerminator = ':'; + + private const string SchemaKind = "schema"; + private const string TableKind = "table"; + private const string ColumnKind = "column"; + + /// Encodes a schema-level NodeId. + /// The schema name, exactly as the catalog reports it. + /// The encoded NodeId. + /// is null, empty or whitespace. + public static string ForSchema(string schema) => Encode(SchemaKind, schema); + + /// Encodes a table-level NodeId. + /// The schema name, exactly as the catalog reports it. + /// The table or view name, exactly as the catalog reports it. + /// The encoded NodeId. + /// Either name is null, empty or whitespace. + public static string ForTable(string schema, string table) => Encode(TableKind, schema, table); + + /// Encodes a column-level (leaf) NodeId. + /// The schema name, exactly as the catalog reports it. + /// The table or view name, exactly as the catalog reports it. + /// The column name, exactly as the catalog reports it. + /// The encoded NodeId. + /// Any name is null, empty or whitespace. + public static string ForColumn(string schema, string table, string column) => + Encode(ColumnKind, schema, table, column); + + /// Attempts to decode a NodeId. + /// The NodeId to decode. + /// The decoded reference on success; default otherwise. + /// true when is a well-formed SQL browse NodeId. + public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference) + { + reference = default; + if (string.IsNullOrWhiteSpace(nodeId)) return false; + + var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal); + if (terminator <= 0) return false; + + var kind = nodeId[..terminator]; + if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false; + if (parts.Exists(string.IsNullOrWhiteSpace)) return false; + + switch (kind) + { + case SchemaKind when parts.Count == 1: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null); + return true; + case TableKind when parts.Count == 2: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null); + return true; + case ColumnKind when parts.Count == 3: + reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]); + return true; + default: + return false; + } + } + + /// + /// Decodes a NodeId, throwing when it is malformed. + /// The message deliberately does not echo the offending value: a NodeId can carry an + /// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log. + /// + /// The NodeId to decode. + /// The decoded reference. + /// The value is not a well-formed SQL browse NodeId. + public static SqlBrowseNodeRef Parse(string? nodeId) + { + if (TryParse(nodeId, out var reference)) return reference; + throw new ArgumentException( + "Not a SQL schema-browse node. Expected 'schema:', 'table:|' or " + + "'column:|
|'. Re-open the browser and expand from the root.", + nameof(nodeId)); + } + + private static string Encode(string kind, params string[] parts) + { + var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16)); + builder.Append(kind).Append(KindTerminator); + for (var i = 0; i < parts.Length; i++) + { + if (string.IsNullOrWhiteSpace(parts[i])) + { + throw new ArgumentException( + "A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts)); + } + + if (i > 0) builder.Append(Separator); + AppendEscaped(builder, parts[i]); + } + + return builder.ToString(); + } + + private static void AppendEscaped(StringBuilder builder, string part) + { + foreach (var ch in part) + { + if (ch is Separator or Escape) builder.Append(Escape); + builder.Append(ch); + } + } + + /// + /// Splits an encoded payload on unescaped separators, unescaping as it goes. Returns + /// false on a dangling trailing escape, which cannot be produced by + /// and therefore means the value was hand-made or truncated. + /// + private static bool TrySplit(string payload, out List parts) + { + parts = []; + var current = new StringBuilder(payload.Length); + var escaped = false; + + foreach (var ch in payload) + { + if (escaped) + { + current.Append(ch); + escaped = false; + continue; + } + + switch (ch) + { + case Escape: + escaped = true; + break; + case Separator: + parts.Add(current.ToString()); + current.Clear(); + break; + default: + current.Append(ch); + break; + } + } + + if (escaped) return false; + parts.Add(current.ToString()); + return true; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs new file mode 100644 index 00000000..06c05d8f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -0,0 +1,304 @@ +using System.Data; +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// +/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the +/// column's mapped DriverDataType in the attribute side-panel (design §4.1, mirroring Galaxy's +/// two-stage object-then-attribute pick). Created by SqlDriverBrowser on picker open and owned by +/// the AdminUI's BrowseSessionRegistry, whose TTL reaper disposes idle sessions. +/// Every level is the dialect's catalog SQL ( / +/// / ) — nothing here +/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is +/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId +/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here. +/// is deliberately not used on this path — the browse +/// never needs an identifier in text. +/// Connection ownership: this session owns the connection it is handed and closes it on +/// . It does not open one, and it never re-opens a closed one. The handoff +/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until +/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook — +/// if the session did not close it, nothing would, and every reaped picker would leak a pooled +/// connection. +/// No timeout of its own. The AdminUI already bounds each root/expand/attributes call with a +/// 20-second linked CTS (BrowserSessionService.PerCallTimeout); this session's job is simply to +/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second +/// independent deadline here would only produce two competing, differently-worded failures. +/// +internal sealed class SqlBrowseSession : IBrowseSession +{ + /// Column alias every dialect's projects. + private const string SchemaColumn = "TABLE_SCHEMA"; + + /// Column alias every dialect's projects. + private const string TableNameColumn = "TABLE_NAME"; + + /// Column alias carrying BASE TABLE / VIEW. + private const string TableTypeColumn = "TABLE_TYPE"; + + /// Column alias every dialect's projects. + private const string ColumnNameColumn = "COLUMN_NAME"; + + /// Column alias carrying the SQL type family name. + private const string DataTypeColumn = "DATA_TYPE"; + + /// The TABLE_TYPE value marking a view rather than a base table. + private const string ViewTableType = "VIEW"; + + /// + /// The security class every browsed column reports. The Sql driver is read-only in v1 + /// (design §4.3), so there is nothing else a picked column could be. + /// + private const string ReadOnlySecurityClass = "ViewOnly"; + + private readonly DbConnection _connection; + private readonly ISqlDialect _dialect; + + /// + /// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and + /// the AdminUI tree happily fires several expands at once when an operator clicks quickly. + /// + private readonly SemaphoreSlim _gate = new(1, 1); + + private volatile bool _disposed; + + /// Constructs a session over an already-open connection, which it takes ownership of. + /// The open connection to browse. Closed by . + /// The dialect supplying the catalog SQL and the column-type map. + internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _dialect = dialect ?? throw new ArgumentNullException(nameof(dialect)); + } + + /// + public Guid Token { get; } = Guid.NewGuid(); + + /// + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + /// + public async Task> RootAsync(CancellationToken cancellationToken) + { + var schemas = await QueryAsync( + _dialect.ListSchemasSql, + static _ => { }, + static reader => ReadString(reader, SchemaColumn), + cancellationToken).ConfigureAwait(false); + + var nodes = new List(schemas.Count); + foreach (var schema in schemas) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForSchema(schema), + DisplayName: schema, + Kind: BrowseNodeKind.Folder, + HasChildrenHint: true)); + } + + return nodes; + } + + /// + /// is not a SQL schema-browse node. + public async Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var reference = SqlBrowseNodeId.Parse(nodeId); + switch (reference.Kind) + { + case SqlBrowseNodeKind.Schema: + return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false); + case SqlBrowseNodeKind.Table: + return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken) + .ConfigureAwait(false); + default: + // A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before + // it has re-read the node's Kind. + LastUsedUtc = DateTime.UtcNow; + return Array.Empty(); + } + } + + /// + /// is not a SQL schema-browse node. + public async Task> AttributesAsync( + string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var reference = SqlBrowseNodeId.Parse(nodeId); + if (reference.Kind != SqlBrowseNodeKind.Column) + { + // Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is + // uniform and returns empty for every node. + LastUsedUtc = DateTime.UtcNow; + return Array.Empty(); + } + + var columns = await ReadColumnsAsync( + reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false); + + // Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the + // catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation + // that legitimately carries both "Value" and "value". + foreach (var column in columns) + { + if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue; + return + [ + new AttributeInfo( + Name: column.Name, + DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), + IsArray: false, + SecurityClass: ReadOnlySecurityClass), + ]; + } + + // The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is + // the honest answer; the picker simply has nothing to prefill. + return Array.Empty(); + } + + /// + /// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper + /// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI. + /// + /// A task that represents the asynchronous operation. + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + try { await _connection.DisposeAsync().ConfigureAwait(false); } + catch { /* best-effort: the connection may already be broken or closed. */ } + + try { _gate.Dispose(); } + catch { /* best-effort: a concurrent second dispose already tore it down. */ } + } + + private async Task> ExpandSchemaAsync(string schema, CancellationToken ct) + { + var rows = await QueryAsync( + _dialect.ListTablesSql, + command => Bind(command, "@schema", schema), + static reader => ( + Name: ReadString(reader, TableNameColumn), + Type: ReadOptionalString(reader, TableTypeColumn)), + ct).ConfigureAwait(false); + + var nodes = new List(rows.Count); + foreach (var (name, type) in rows) + { + if (string.IsNullOrWhiteSpace(name)) continue; + var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase); + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForTable(schema, name), + // The label is decorated, never the NodeId — a view and a table of the same name in the same + // schema cannot exist, so the suffix is presentation only. + DisplayName: isView ? $"{name} (view)" : name, + Kind: BrowseNodeKind.Folder, + HasChildrenHint: true)); + } + + return nodes; + } + + private async Task> ExpandTableAsync( + string schema, string table, CancellationToken ct) + { + var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false); + + var nodes = new List(columns.Count); + foreach (var column in columns) + { + if (string.IsNullOrWhiteSpace(column.Name)) continue; + nodes.Add(new BrowseNode( + NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name), + DisplayName: column.Name, + Kind: BrowseNodeKind.Leaf, + HasChildrenHint: false)); + } + + return nodes; + } + + /// + /// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather + /// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less + /// relation, and neither is a reason to fail an operator's click. + /// + private Task> ReadColumnsAsync( + string schema, string table, CancellationToken ct) => + QueryAsync( + _dialect.ListColumnsSql, + command => + { + Bind(command, "@schema", schema); + Bind(command, "@table", table); + }, + static reader => ( + Name: ReadString(reader, ColumnNameColumn), + DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty), + ct); + + /// + /// Runs one catalog query under the gate and projects every row. The disposed check happens + /// inside the gate wait so a dispose racing an in-flight call cannot be missed, and + /// only advances on a call that actually completed. + /// + private async Task> QueryAsync( + string sql, + Action bind, + Func project, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var results = new List(); + await using var command = _connection.CreateCommand(); + command.CommandText = sql; + bind(command); + + await using var reader = await command + .ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + results.Add(project(reader)); + + LastUsedUtc = DateTime.UtcNow; + return results; + } + finally + { + // A dispose that raced this call has already disposed the gate; releasing it then is harmless to + // ignore and must not mask the real result. + try { _gate.Release(); } + catch (ObjectDisposedException) { } + } + } + + /// Binds one catalog-query parameter. The only way a name reaches the database. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private static string ReadString(DbDataReader reader, string columnName) => + ReadOptionalString(reader, columnName) ?? string.Empty; + + private static string? ReadOptionalString(DbDataReader reader, string columnName) + { + var ordinal = reader.GetOrdinal(columnName); + return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs new file mode 100644 index 00000000..bd43f089 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -0,0 +1,401 @@ +using System.Data.Common; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; + +/// +/// Opens a transient database connection from form-supplied JSON for the AdminUI schema picker, +/// and hands it to a . The AdminUI's BrowseSessionRegistry (its idle +/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a +/// registry of its own. +/// Two ways to name the database, and only one of them is persisted. +/// connectionStringRef is the deployed form: a name resolved in the AdminUI process from +/// the environment as Sql__ConnectionStrings__<ref> (design §4.4), so a deployed artifact +/// never carries credentials. connectionString is an ad-hoc, session-only literal an +/// operator may paste to browse a database that has no ref yet; it is used for this one connection and +/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it +/// anywhere. +/// Precedence: a pasted literal wins over a ref, and the ref is then not even read. The +/// literal cannot arrive from a persisted blob — has no such property, so +/// the driver factory would drop it — which makes its presence proof that an operator typed it just now. +/// A config carrying both logs a warning (naming the shadowed ref, never any connection text) so +/// the override is visible rather than silent. +/// Credential hygiene is the point of this type. A connection string is a secret. It is +/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field, +/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their +/// own errors, every failure on the open path goes through before it leaves this +/// class. +/// +public sealed class SqlDriverBrowser : IDriverBrowser +{ + /// + /// Prefix of the environment variable a connectionStringRef resolves through — the + /// double-underscore form .NET configuration uses for the Sql:ConnectionStrings:<ref> + /// key path. + /// + public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__"; + + /// + /// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each + /// browse call at 20 s; this only stops a pathological provider-side connect from outliving the + /// picker entirely. + /// + private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30); + + /// + /// Connection-string keys whose values are redacted out of any provider error text. The + /// whole connection string is redacted unconditionally; these are the parts that could survive as a + /// fragment. Server / database names are deliberately not redacted — "server X was not found" + /// is the diagnostic the operator needs, and a hostname is not a credential. + /// + private static readonly string[] CredentialKeys = + [ + "password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token", + ]; + + /// + /// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued + /// knobs are authored as their names, so the browser parses a given DriverConfig blob + /// exactly as the runtime factory does. also accepts ordinals, + /// so a numeric config still binds. + /// + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly Func _dialectSelector; + private readonly Func _environmentReader; + private readonly ILogger _logger; + + /// Creates a browser. Every dependency has a production default, so DI may construct it bare. + /// + /// Maps an authored onto the dialect that browses it, or + /// for a provider this build does not carry. Defaults to + /// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2 + /// provider gets added without touching this class. + /// + /// + /// Reads one environment variable. Defaults to . + /// Injectable because environment variables are process-global, and a test that sets one races every + /// other test in the assembly. + /// + /// Optional; defaults to . + public SqlDriverBrowser( + Func? dialectSelector = null, + Func? environmentReader = null, + ILogger? logger = null) + { + _dialectSelector = dialectSelector ?? DefaultDialectSelector; + _environmentReader = environmentReader ?? Environment.GetEnvironmentVariable; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// + /// Sourced from , the driver's interim local constant, rather + /// than from DriverTypeNames — the shared constant set is added by the driver-factory task, + /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is + /// registered against it. Repointing that one constant repoints this too. + /// + public string DriverType => SqlDriver.DriverTypeName; + + /// The environment variable a connectionStringRef resolves through. + /// The authored reference name. + /// The full environment-variable name, e.g. Sql__ConnectionStrings__MesStaging. + public static string EnvironmentVariableFor(string connectionStringRef) => + ConnectionStringEnvironmentPrefix + connectionStringRef; + + /// + /// The providers this build can browse. v1 constructs SQL Server only; every other + /// member is a reserved name, so it resolves to and + /// the caller reports it as unavailable rather than crashing on a null dialect. + /// + /// The authored provider. + /// The dialect, or when this build does not carry the provider. + public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => + provider == SqlProvider.SqlServer ? new SqlServerDialect() : null; + + /// + /// + /// The configuration is absent / malformed, names a provider this build does not carry, names neither + /// a connectionStringRef nor a literal, resolves a ref that is not set in the environment, or + /// the connection could not be opened. + /// + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(configJson)) + { + throw new InvalidOperationException( + "The Sql browser requires a driver configuration; none was supplied."); + } + + var literal = ReadPastedConnectionString(configJson); + var config = Deserialize(configJson, literal); + + // Provider first: "this build cannot browse Postgres" is true regardless of how the database is + // named, and answering it before touching the environment keeps the two failures from masking. + var dialect = _dialectSelector(config.Provider) + ?? throw new InvalidOperationException( + $"Sql provider '{config.Provider}' is not available in this build; v1 browses " + + $"'{SqlProvider.SqlServer}' only."); + + var reference = config.ConnectionStringRef; + var connectionString = ResolveConnectionString(literal, reference); + + _logger.LogInformation( + "AdminUI Sql browse session opening (provider {Provider}, connection from {Source})", + config.Provider, + DescribeSource(literal, reference)); + + return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false); + } + + /// + /// Opens one transient connection and hands ownership to the session. The session closes the + /// connection on its own disposal, so the only disposal this method owns is the failure path — + /// disposing on success would double-close and leave the picker with a dead session. + /// + private static async Task OpenSessionAsync( + ISqlDialect dialect, string connectionString, CancellationToken cancellationToken) + { + var connection = dialect.Factory.CreateConnection() + ?? throw new InvalidOperationException( + $"The ADO.NET provider for '{dialect.Provider}' returned no connection object."); + + try + { + connection.ConnectionString = connectionString; + + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectCts.CancelAfter(ConnectBudget); + await connection.OpenAsync(connectCts.Token).ConfigureAwait(false); + + return new SqlBrowseSession(connection, dialect); + } + catch (Exception ex) + { + try { await connection.DisposeAsync().ConfigureAwait(false); } + catch { /* best-effort: the original failure is the useful one. */ } + + if (ex is OperationCanceledException) throw; + throw Sanitize(ex, connectionString); + } + } + + /// + /// Picks the connection string, literal first. A blank literal is treated as absent (an untouched + /// form field), never as an empty connection string. + /// + private string ResolveConnectionString(string? literal, string? reference) + { + if (!string.IsNullOrWhiteSpace(literal)) + { + if (!string.IsNullOrWhiteSpace(reference)) + { + _logger.LogWarning( + "Sql browse: a pasted connection string was supplied alongside connectionStringRef " + + "'{Ref}'. The pasted value wins for this session only and is not persisted.", + reference); + } + + return literal; + } + + if (string.IsNullOrWhiteSpace(reference)) + { + throw new InvalidOperationException( + "The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved " + + $"from the environment as '{ConnectionStringEnvironmentPrefix}', or paste a " + + "connection string into 'connectionString' for an ad-hoc browse."); + } + + var variable = EnvironmentVariableFor(reference); + var resolved = _environmentReader(variable); + if (string.IsNullOrWhiteSpace(resolved)) + { + // Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next + // move is to set this one name on the AdminUI host. + throw new InvalidOperationException( + $"connectionStringRef '{reference}' does not resolve: environment variable " + + $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse " + + "connection strings in its own process), or paste a connection string for an ad-hoc " + + "browse."); + } + + return resolved; + } + + /// How the connection string was obtained, for the log. Carries no connection text. + private static string DescribeSource(string? literal, string? reference) => + !string.IsNullOrWhiteSpace(literal) + ? "a pasted literal (session-only, not persisted)" + : $"connectionStringRef '{reference}'"; + + /// + /// Pulls the ad-hoc connectionString literal out of the raw JSON. It is read here rather than + /// off the DTO because deliberately has no such property — the + /// persisted contract must not be able to carry a credential — and the DTO's + /// would silently drop it. + /// + /// The literal, or when absent, blank, or not a JSON string. + private static string? ReadPastedConnectionString(string configJson) + { + using var document = ParseDocument(configJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration must be a JSON object."); + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase)) + continue; + + if (property.Value.ValueKind != JsonValueKind.String) return null; + var value = property.Value.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + return null; + } + + /// + /// Parses the raw configuration. The is not attached or echoed: + /// at this point nothing has been extracted, so there is no known secret to redact against, and the + /// malformed text may itself be a pasted connection string. + /// + private static JsonDocument ParseDocument(string configJson) + { + try + { + return JsonDocument.Parse(configJson); + } + catch (JsonException) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration is not valid JSON. (The parser's message is " + + "withheld because the malformed text may carry a connection string.)"); + } + } + + /// + /// Binds the typed configuration. A bind failure (e.g. an unknown provider name) is reported + /// with the parser's own message redacted against the literal, which is known by now. + /// + private static SqlDriverConfigDto Deserialize(string configJson, string? literal) + { + try + { + return JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException( + "The Sql browser's driver configuration deserialized to null."); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "The Sql browser's driver configuration could not be bound: " + + Redact(ex.Message, CollectSecrets(literal))); + } + } + + /// + /// Turns a provider-side open failure into an exception that cannot carry the connection string. + /// Live-probed, not assumed. Microsoft.Data.SqlClient and + /// Microsoft.Data.Sqlite keep connection-string values out of their + /// SqlException/SqliteException text (a failed connect says "the server was not found", + /// never what it was handed). Their connection-string parser is the exception: it reports an + /// unrecognised keyword by quoting it, and a value carrying an unquoted ; — legal inside a + /// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a + /// keyword and echoed verbatim. Password=Sup3r;SecretTail yields + /// Keyword not supported: 'secrettail;connect timeout'. — the credential, in the message, + /// lower-cased and therefore invisible to a naive ordinal search for the value. + /// So the parser's message (an ) is never surfaced. Every + /// other failure is additionally passed through the substring redactor as a backstop, and when that + /// fires anywhere in the exception chain the inner exception is dropped too — keeping it + /// would re-expose through ToString() exactly what the message just removed. + /// + private static InvalidOperationException Sanitize(Exception ex, string connectionString) + { + if (ex is ArgumentException) + { + return new InvalidOperationException( + "The Sql browser could not open a connection: the connection string is malformed, or " + + "carries a keyword this provider does not support. (The provider's own message is " + + "withheld because it quotes connection-string text verbatim; check for an unquoted ';' " + + "or '=' inside a value such as the password.)"); + } + + var secrets = CollectSecrets(connectionString); + var leaked = ContainsAny(ex.ToString(), secrets); + var reason = Redact(ex.Message, secrets); + + var message = leaked + ? "The Sql browser could not open a connection: " + reason + + " (the provider's error echoed the connection string, so it was redacted and the inner " + + "exception withheld)" + : "The Sql browser could not open a connection: " + reason; + + return new InvalidOperationException(message, leaked ? null : ex); + } + + /// + /// The substrings that must never leave this class: the whole connection string, and the values of + /// its credential-bearing keys (which could surface on their own). + /// + private static List CollectSecrets(string? connectionString) + { + var secrets = new List(); + if (string.IsNullOrWhiteSpace(connectionString)) return secrets; + + secrets.Add(connectionString); + + try + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + foreach (var key in CredentialKeys) + { + if (!builder.TryGetValue(key, out var value)) continue; + var text = value?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text); + } + } + catch (ArgumentException) + { + // A malformed connection string has no parseable parts; the whole-string entry still stands. + } + + // Longest first, so redacting a short credential value cannot chop a longer secret into + // unredactable halves. + secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length)); + return secrets; + } + + private static bool ContainsAny(string text, List secrets) + { + foreach (var secret in secrets) + { + if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true; + } + + return false; + } + + private static string Redact(string text, List secrets) + { + foreach (var secret in secrets) + { + text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase); + } + + return text; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj new file mode 100644 index 00000000..daa01255 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser + + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs new file mode 100644 index 00000000..2bca5d24 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// The DriverConfig blob shape for a Sql driver instance (design §5.1). Deserialised by the +/// driver factory, which applies the documented defaults for every absent (null) field and maps the result +/// onto the driver's typed options. +/// No connection string lives here. Only — a name resolved at +/// Initialize from the environment / secret store (e.g. Sql__ConnectionStrings__MesStaging) — so a +/// deployed artifact never carries database credentials. +/// Enum-valued fields are enum-typed (not strings) so a JsonStringEnumConverter round-trips +/// their names rather than their ordinals. The converter is applied by the factory's +/// JsonSerializerOptions (Task 9), not by an attribute on this DTO — mirroring the driver +/// enum-serialization trap that bit the AdminUI driver pages. +/// +public sealed class SqlDriverConfigDto +{ + /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs. + public SqlProvider Provider { get; init; } = SqlProvider.SqlServer; + + /// + /// Name of the connection string to resolve from the environment / secret store at Initialize — + /// never the connection string itself. + /// + public string? ConnectionStringRef { get; init; } + + /// Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default. + public TimeSpan? DefaultPollInterval { get; init; } + + /// + /// Per-query wall-clock deadline enforced client-side (a breach surfaces BadTimeout). Must be + /// greater than , which is only the server-side backstop (design §8.3). + /// + public TimeSpan? OperationTimeout { get; init; } + + /// ADO.NET CommandTimeout backstop (seconds granularity server-side). + public TimeSpan? CommandTimeout { get; init; } + + /// Cap on concurrently executing group queries — the connection-pool guard. + public int? MaxConcurrentGroups { get; init; } + + /// When , a NULL cell publishes Bad rather than the default Uncertain. + public bool? NullIsBad { get; init; } + + /// + /// Master write kill-switch. v1 is read-only and this field is inert — the driver does not + /// implement IWritable, so no value here can produce a write. The factory warns when it + /// is authored rather than failing the driver, since the flag cannot do harm but + /// an operator who believes writes are enabled can. + /// + public bool? AllowWrites { get; init; } + + /// + /// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus + /// the driver TagConfig blob, the same shape every other driver receives. The factory copies + /// these onto the driver's options and the driver maps each through + /// at Initialize. + /// A SQL source has no tag-discovery protocol, so this list is the complete set of tags the + /// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will + /// find its tags later. + /// + public List? RawTags { get; init; } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs new file mode 100644 index 00000000..006089f8 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a +/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is +/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator; +/// the model and type enums are read with +/// , so a present-but-invalid (typo'd) value rejects +/// the whole tag — the driver then surfaces BadNodeIdUnknown instead of silently defaulting to a +/// different model and publishing a misleading Good (R2-11). +/// No SQL is built here. The parser only captures strings; the reader binds value fields as +/// DbParameters and validates + quotes identifier fields. Tag input is never concatenated into a +/// command text, so a hostile keyValue is stored — and must be stored — verbatim. +/// +public static class SqlEquipmentTagParser +{ + /// + /// Maps an authored TagConfig blob to a typed definition keyed by . + /// Returns — never throws — for anything the driver must not serve: a + /// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or + /// type enum, a missing model-required field, or the P3-deferred + /// model. + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid Sql tag blob. + public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(reference)) return false; + if (reference.TrimStart().FirstOrDefault() != '{') return false; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + + // Recognition: either the explicit driver marker or the model discriminator must be present, + // so another driver's TagConfig blob is not mistaken for a Sql one. + var driver = ReadString(root, "driver"); + var hasModel = root.TryGetProperty("model", out _); + if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false; + + // Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag. + if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false; + if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false; + DriverDataType? declaredType = + root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String + ? type + : null; + + var table = ReadString(root, "table"); + if (string.IsNullOrWhiteSpace(table)) return false; + var timestampColumn = ReadString(root, "timestampColumn"); + + switch (model) + { + case SqlTagModel.KeyValue: + { + var keyColumn = ReadString(root, "keyColumn"); + var keyValue = ReadString(root, "keyValue"); + var valueColumn = ReadString(root, "valueColumn"); + if (string.IsNullOrWhiteSpace(keyColumn) + || string.IsNullOrWhiteSpace(valueColumn) + || keyValue is null) + return false; + def = new SqlTagDefinition( + Name: rawPath, Model: model, Table: table!, + KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn, + TimestampColumn: timestampColumn, DeclaredType: declaredType); + return true; + } + + case SqlTagModel.WideRow: + { + var columnName = ReadString(root, "columnName"); + if (string.IsNullOrWhiteSpace(columnName)) return false; + string? selectorColumn = null, selectorValue = null, topByTimestamp = null; + if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object) + { + selectorColumn = ReadString(sel, "whereColumn"); + selectorValue = ReadScalar(sel, "whereValue"); + topByTimestamp = ReadString(sel, "topByTimestamp"); + } + // A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp. + var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null; + if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false; + def = new SqlTagDefinition( + Name: rawPath, Model: model, Table: table!, + TimestampColumn: timestampColumn, ColumnName: columnName, + RowSelectorColumn: hasWherePair ? selectorColumn : null, + RowSelectorValue: hasWherePair ? selectorValue : null, + RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp, + DeclaredType: declaredType); + return true; + } + + default: + // SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model + // the runtime cannot read. + return false; + } + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + } + + /// Reads a string-valued property; null when absent or not a JSON string. + private static string? ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + /// + /// Reads a scalar property as its textual form — a JSON string yields its contents, a number or + /// boolean yields its raw token — so an authored whereValue of either shape is captured + /// verbatim for later parameter binding. Null when absent or not a scalar. + /// + private static string? ReadScalar(JsonElement o, string name) + { + if (!o.TryGetProperty(name, out var e)) return null; + return e.ValueKind switch + { + JsonValueKind.String => e.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(), + _ => null, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs new file mode 100644 index 00000000..67879307 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer. +public enum SqlProvider +{ + /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs. + SqlServer, + + /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1. + Postgres, + + /// MySQL / MariaDB — reserved; not constructed in v1. + MySql, + + /// Generic ODBC — reserved for P2; not constructed in v1. + Odbc, + + /// Oracle — reserved; not constructed in v1. + Oracle, +} + +/// Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3). +public enum SqlTagModel +{ + /// + /// Key-value (EAV) table: one row per tag, selected by keyColumn = keyValue, value read from + /// valueColumn. + /// + KeyValue, + + /// + /// Wide row: one row holds many signals as columns; the tag names its columnName and the row is + /// picked by a row selector (a whereColumn/whereValue pair, or newest-by-timestamp). + /// + WideRow, + + /// Named arbitrary-SELECT query (design §5.4). Deferred to P3 — not accepted by v1's parser. + Query, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs new file mode 100644 index 00000000..755e2b9d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's TagConfig blob +/// (design §5.2 / §5.3), produced by . +/// Every string here is captured verbatim from the authored blob and is NEVER concatenated +/// into SQL. Value-bearing fields (, ) are bound +/// as DbParameters by the reader; identifier-bearing fields (, +/// , , , +/// , , +/// ) must be validated against INFORMATION_SCHEMA and +/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt +/// legitimate values, so the parser deliberately does not. +/// +/// +/// The tag's identity — its RawPath, exactly as handed to the parser. The driver's forward +/// router keys published values on this, so it must never be derived from the blob's contents. +/// +/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only. +/// The table or view to read (e.g. dbo.TagValues). +/// : the column matched against . +/// : this tag's key — bound, never interpolated. +/// : the column the value is read from. +/// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. +/// : the column this tag reads from the selected row. +/// : the whereColumn that picks the row. +/// : the whereValuebound, never interpolated. +/// : newest-row selector — the timestamp column to order by, when no where-pair is authored. +/// +/// Optional explicit type override from the blob's type field. ⇒ the +/// driver infers the type from the result set's column metadata. +/// +public sealed record SqlTagDefinition( + string Name, + SqlTagModel Model, + string Table, + string? KeyColumn = null, + string? KeyValue = null, + string? ValueColumn = null, + string? TimestampColumn = null, + string? ColumnName = null, + string? RowSelectorColumn = null, + string? RowSelectorValue = null, + string? RowSelectorTopByTimestamp = null, + DriverDataType? DeclaredType = null); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj new file mode 100644 index 00000000..1c81c26e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs new file mode 100644 index 00000000..9b15d22e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -0,0 +1,107 @@ +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The provider seam (design §2.2). ADO.NET's base types abstract +/// connections, commands, parameters and readers; they do not abstract the three things that differ +/// per backend — identifier quoting, the metadata-catalog SQL, and row-limit syntax. +/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser. +/// This interface is the driver's SQL-injection boundary. Every runtime value is +/// bound as a and never reaches a command text. Identifiers cannot be +/// parameterized in SQL, so the few that must appear as text are emitted through +/// . Any code path that builds SQL by concatenating an authored tag field +/// without going through is a defect (design §8.1). +/// Not yet implemented: design §8.1 also specifies that an authored table/column is +/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier +/// rejects the tag. No such gate exists yet — an identifier reaches +/// straight from the authored TagConfig blob, so quoting is currently the only defence, +/// not a backstop behind an upstream filter. Scrutinise it accordingly. +/// Public because Driver.Sql.Browser consumes it — the catalog SQL is the browse +/// engine, so it is shared rather than duplicated. Implementations own their provider package; +/// no provider-specific type appears in this signature ( is the abstract +/// ). +/// +public interface ISqlDialect +{ + /// Which backend this dialect speaks. v1 constructs only. + SqlProvider Provider { get; } + + /// + /// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the + /// abstract base type so consumers never bind to a concrete provider package. + /// + DbProviderFactory Factory { get; } + + /// + /// Quotes one identifier part so it can be safely embedded in a command text, escaping the + /// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather + /// than emitting it. + /// One part only. A multi-part name such as dbo.TagValues must be split by the + /// caller and each part quoted separately; passing the dotted form yields a single (nonexistent) + /// identifier — safe, but not what the caller meant. + /// + /// The bare, unquoted identifier — as returned by the catalog, not pre-quoted. + /// The quoted identifier, ready to concatenate into a command text. + /// The value cannot be a valid catalog identifier. + string QuoteIdentifier(string ident); + + /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL). + string LivenessSql { get; } + + /// + /// The fragment that goes immediately after SELECT to limit a statement to one row — + /// T-SQL's "TOP 1 ". Empty for a dialect that spells the limit at the end of the statement (see + /// ). + /// Include the trailing space when non-empty: the planner concatenates + /// "SELECT " + SingleRowLimitPrefix + columns with no separator of its own, so an empty fragment + /// must leave the statement byte-identical to an unlimited one. + /// + /// + /// Why a prefix/suffix pair rather than one ApplyRowLimit(sql, n) method. The two + /// spellings sit at opposite ends of the statement — SQL Server writes + /// SELECT TOP 1 … ORDER BY … DESC, while SQLite/Postgres/MySQL write + /// SELECT … ORDER BY … DESC LIMIT 1. A single method taking the SELECT list could only serve the + /// prefix position; a single method taking the whole statement would move statement assembly out of the + /// planner and into every dialect. Two fragments keep the planner the sole author of statement shape + /// and let each dialect fill in the end it uses. + /// Why "single row" rather than a row count. The only limit v1 emits is the wide-row + /// topByTimestamp selector's newest-row pick. Modelling an arbitrary n would be untested + /// surface, and FETCH FIRST/ROWNUM dialects need more than a substituted number anyway. + /// Widen this to a method when a feature actually needs n > 1. + /// + string SingleRowLimitPrefix { get; } + + /// + /// The fragment appended to the very end of a statement to limit it to one row — + /// " LIMIT 1" for SQLite/Postgres/MySQL. Empty for a dialect that limits after SELECT + /// (see ). + /// Include the leading space when non-empty, for the same reason the prefix carries a + /// trailing one: the planner appends it directly to the finished statement. + /// Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner + /// emits both unconditionally — a dialect needing both ends (or neither) is expressible without + /// touching the call site. + /// + string SingleRowLimitSuffix { get; } + + /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. + string ListSchemasSql { get; } + + /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema. + string ListTablesSql { get; } + + /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table. + string ListColumnsSql { get; } + + /// + /// Folds a catalog data-type family name (as INFORMATION_SCHEMA.COLUMNS.DATA_TYPE reports it — + /// e.g. nvarchar, never nvarchar(50)) onto a . + /// Must never throw: a browse over a table holding one exotic column must still render. + /// An unrecognised family falls back to . + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + DriverDataType MapColumnType(string sqlDataType); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs new file mode 100644 index 00000000..0e76e68d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs @@ -0,0 +1,62 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Resolves a Sql driver config's connectionStringRef — a NAME — into the connection string +/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never +/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff. +/// The environment is read directly, on purpose. A driver factory is invoked through +/// DriverFactoryRegistry's (driverInstanceId, driverConfigJson) closure — there is no +/// IConfiguration, no IServiceProvider and no ambient scope at that seam, and widening the +/// registry's signature for one driver would change every driver in the tree. The +/// Sql__ConnectionStrings__<ref> spelling is exactly the key .NET's environment-variable +/// configuration provider would bind to Sql:ConnectionStrings:<ref>, so an operator can set it +/// the same way they set every other secret and a later move onto IConfiguration needs no +/// re-provisioning. +/// Nothing here ever emits the resolved value. Messages name the environment +/// variable, which is not a secret and is the operator's next action; the value itself is returned +/// to exactly one caller and is otherwise unmentioned. +/// +public static class SqlConnectionStringResolver +{ + /// + /// The environment-variable prefix a connectionStringRef is appended to. The double underscore + /// is .NET's configuration hierarchy separator, so this is the environment spelling of + /// Sql:ConnectionStrings:<ref>. + /// + public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__"; + + /// The environment variable a given connectionStringRef is read from. + /// The authored reference name. + /// The full environment-variable name. + /// is null, empty or whitespace. + public static string EnvironmentVariableFor(string connectionStringRef) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef); + return string.Concat(EnvironmentVariablePrefix, connectionStringRef); + } + + /// + /// Resolves to its connection string. + /// An absent or blank variable is a half-provisioned deployment and throws: handing an + /// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll, + /// far from the missing secret that actually caused it. + /// + /// The authored reference name. + /// The resolved connection string. Treat as a secret — never log it. + /// is null, empty or whitespace. + /// The environment variable is absent or blank. + public static string Resolve(string connectionStringRef) + { + var variable = EnvironmentVariableFor(connectionStringRef); + var value = Environment.GetEnvironmentVariable(variable); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " + + $"variable '{variable}' on every node that runs this driver. The connection string is " + + $"deliberately not carried in the deployed configuration."); + } + + return value; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs new file mode 100644 index 00000000..9d2396bf --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs @@ -0,0 +1,757 @@ +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 — , the single + /// source of truth the deploy pipeline stores in DriverInstance.DriverType and every dispatch + /// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so + /// DriverTypeNamesGuardTests' bidirectional-parity check is satisfied). + /// + public const string DriverTypeName = DriverTypeNames.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 OR ex.Message onto the operator surface. The driver is + // dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's + // message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an + // unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only + // the exception TYPE reaches LastError/host status; the full exception object goes to the log + // SINK via the structured logger's exception parameter (below), never into a rendered string. + var message = + $"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " + + $"Check the database is reachable and the connection string is valid."; + _logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.", + _driverInstanceId, Endpoint); + WriteHealth(new DriverHealth(DriverState.Faulted, null, message)); + TransitionHostTo(HostState.Stopped); + throw new InvalidOperationException(message, ex); + } + + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + TransitionHostTo(HostState.Running); + _logger.LogInformation( + "Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).", + _driverInstanceId, Endpoint, Tags.Count); + } + + /// + 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) + { + if (tag.DeclaredType is null) + { + // Discovery cannot infer a column's real type — that needs live result-set metadata, which + // only a poll returns — so an omitted-type tag materialises as String (the same fallback + // ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual, + // possibly numeric, CLR value against that String node. Nothing else signals the operator to + // this declared-vs-published mismatch, so warn and point them at the fix. + _logger.LogWarning( + "Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " + + "will then publish numeric values against a String node. Author an explicit \"type\" on " + + "numeric tags. Driver={DriverInstanceId}", + tag.Name, _driverInstanceId); + } + + folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( + FullName: tag.Name, + DriverDataType: tag.DeclaredType ?? DriverDataType.String, + IsArray: false, + ArrayDim: null, + // v1 is read-only: no tag, and no configuration, can widen this. + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + + return Task.CompletedTask; + } + + // ---- IReadable ---- + + /// + /// 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); + // Type name only — never ex.Message (see InitializeAsync for why); the full exception is already + // on the log sink via the LogWarning exception parameter above. + Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}."); + TransitionHostTo(HostState.Stopped); + throw; + } + } + + // ---- ISubscribable (polling overlay via the shared engine) ---- + + /// + /// 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. + internal 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. + + // Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent + // ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s + // default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back + // to Healthy with no real recovery. Only a successful (re)initialize clears Faulted. + SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + TransitionHostTo(HostState.Running); + } + + /// 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) + { + // A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY + // classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine + // then routes that same exception here. Re-degrading would overwrite the good LastError with a worse, + // guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns. + if (ex is DbException) return; + + // Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised + // (e.g. an ArgumentException from a malformed connection string, which the parser echoes with + // credential fragments) — the operator surface carries only the exception TYPE, never ex.Message. + // The full exception, with its text, still reaches the log SINK via the exception parameter. This is + // the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check. + _logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}", + _driverInstanceId, Endpoint); + Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}."); + } + + /// + /// 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(); + SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason)); + } + + /// + /// Publishes unless the driver is already + /// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize + /// health write routes through here: and the poll's Healthy verdict in + /// , so a late in-flight poll cannot un-fault a driver a concurrent + /// just faulted. + /// + /// The health snapshot to publish when the driver is not Faulted. + private void SetHealthUnlessFaulted(DriverHealth value) + { + if (ReadHealth().State == DriverState.Faulted) return; + WriteHealth(value); + } + + /// Advances LastSuccessfulRead without changing the current state or error. + 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) + { + try + { + if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition)) + table[entry.RawPath] = definition; + else + _logger.LogWarning( + "Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + } + catch (Exception ex) + { + // TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's + // try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and + // strand health at Initializing across every DriverInstanceActor retry. Skip the offending + // tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob + // branch above follows — rather than fault the driver over a single entry. + _logger.LogError( + ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}", + _driverInstanceId, entry.RawPath); + } + } + + Volatile.Write(ref _tagsByRawPath, table); + } + + // ---- liveness ---- + + /// + /// 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. + /// TODO(M2): a verbatim duplicate of SqlPollReader.Detach; both live in Driver.Sql and + /// could hoist to one internal helper. Left in place here to keep this change off the reader. + /// + private static void Detach(Task work) + => _ = work.ContinueWith( + 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/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs new file mode 100644 index 00000000..ac7479e3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Static factory registration helper for — the seam between a deployed +/// DriverConfig blob and a live driver instance. The Server's composition root calls +/// once at startup; the bootstrapper then materialises Sql DriverInstance +/// rows into driver instances. Mirrors ModbusDriverFactoryExtensions. +/// This type owns the config validation the layers below deliberately skip. +/// and stay usable with an inverted +/// operationTimeout/commandTimeout pair on purpose (the frozen-database tests need that +/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of +/// its two independent deadlines. Every knob is checked here, once, at construction. +/// Enum fields are read leniently and written as names. carries a +/// , so a provider authored as an ordinal still parses and a +/// round-trip emits "SqlServer" — the systemic AdminUI defect where a page serialised an enum +/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time. +/// The resolved connection string is a secret and never leaves this method except into the +/// driver. Every log line and every exception message here names the connectionStringRef, the +/// environment variable, or 's credential-free server/database +/// rendering — never the string itself. +/// +public static class SqlDriverFactoryExtensions +{ + /// + /// The driver-type string this factory registers under — chained off + /// , which is + /// . + /// + public const string DriverTypeName = SqlDriver.DriverTypeName; + + /// + /// How a Sql config blob is read. + /// + /// — accepts an enum written as a name OR as an + /// ordinal, and always writes the name (the enum-serialization guard). + /// — a blob authored against a different + /// schema version must not brick the driver. + /// — the authored spelling + /// (connectionStringRef, rawTags). Reads are case-insensitive anyway; the policy is + /// what makes a write round-trip to the authored shape. + /// + /// + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + /// The serializer options, exposed so tests can assert the enum guard on a real round-trip. + internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions; + + /// + /// Registers the Sql factory with the driver registry. The optional + /// is captured at registration time and used to build a logger per + /// driver instance; without it the driver runs with the null logger. + /// + /// The driver factory registry to register with. + /// Optional logger factory for creating loggers per driver instance. + /// is null. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Builds one driver instance from its config blob. For the Server bootstrapper and tests. + /// The central config DB's identity for this driver instance. + /// The instance's DriverConfig JSON blob. + /// The constructed . + public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + /// + /// Logger-aware overload — used by 's closure when wired through DI. + /// Constructs the driver only: the driver builds its own , because + /// the reader's resolve delegate must read the driver's live authored-tag table, which does not + /// exist until the driver does. + /// No I/O happens here. The database is first contacted by + /// , so a database that is merely down yields a driver in + /// Reconnecting rather than a deployment that cannot be constructed. + /// + /// The central config DB's identity for this driver instance. + /// The instance's DriverConfig JSON blob. + /// Optional logger factory for creating loggers per driver instance. + /// The constructed . + /// An argument is null, empty or whitespace. + /// + /// The blob is not valid JSON, omits connectionStringRef, names a provider this build cannot + /// construct, carries an invalid knob, or the referenced connection string is not provisioned. + /// + public static SqlDriver CreateInstance( + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = Deserialize(driverInstanceId, driverConfigJson); + + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " + + $"Author the NAME of a connection string (the value itself is supplied out-of-band through " + + $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}' environment variable)."); + } + + var dialect = CreateDialect(dto.Provider, driverInstanceId); + + // Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the + // secret in hand — which is exactly where a careless interpolation would leak it. + var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + var options = BuildOptions(dto, driverInstanceId); + + var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!) + ?? NullLogger.Instance; + + if (dto.AllowWrites is true) + { + // Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it + // would brick a deployment for a flag that cannot do harm. The operator who believes writes are + // on, however, can — so this is loud. + logger.LogWarning( + "Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " + + "driver is read-only (it implements no write capability), so no configuration can enable a " + + "write. Remove the flag, or expect reads only.", + driverInstanceId); + } + + var driver = new SqlDriver( + options, + driverInstanceId, + dialect, + connectionString, + factory: null, + logger: loggerFactory?.CreateLogger()); + + // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string + // permitted outside the provider call. + logger.LogInformation( + "Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " + + "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.", + driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count, + options.DefaultPollInterval, options.OperationTimeout); + + return driver; + } + + /// Deserialises the blob, turning a malformed one into an actionable, instance-named failure. + private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson) + { + try + { + return JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' deserialised to null"); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex); + } + } + + /// + /// Selects the provider seam. + /// v1 constructs only. The other members exist on the shipped + /// enum as reserved names; authoring one is a config mistake that must fail at construction with a + /// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database. + /// + private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId) + => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + _ => throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " + + $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."), + }; + + /// + /// Applies the documented defaults and validates every knob (design §5.1 / §8.3). + /// Internal so the defaults and the rejections can be asserted directly, without a database or a + /// provisioned connection string standing between the test and the rule. + /// + /// The deserialised config blob. + /// Named in every rejection message, so a fleet-wide log identifies the row. + /// The typed options the driver is constructed with. + /// A knob is out of range, or the two deadlines are inverted. + internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId) + { + ArgumentNullException.ThrowIfNull(dto); + + var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5); + var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15); + var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10); + var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4; + + RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId); + RequirePositive(operationTimeout, "operationTimeout", driverInstanceId); + RequirePositive(commandTimeout, "commandTimeout", driverInstanceId); + if (maxConcurrentGroups < 1) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " + + $"it must be at least 1."); + } + + // Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the + // server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a + // wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires + // first and the backstop can never be reached, so a deployment quietly runs on one bound instead of + // two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted. + if (operationTimeout <= commandTimeout) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " + + $"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " + + $"be strictly greater than the server-side commandTimeout backstop, or the backstop can " + + $"never fire."); + } + + return new SqlDriverOptions + { + RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [], + DefaultPollInterval = defaultPollInterval, + OperationTimeout = operationTimeout, + CommandTimeout = commandTimeout, + MaxConcurrentGroups = maxConcurrentGroups, + NullIsBad = dto.NullIsBad ?? false, + }; + } + + /// Rejects a non-positive duration, naming the authored field. + private static void RequirePositive(TimeSpan value, string field, string driverInstanceId) + { + if (value <= TimeSpan.Zero) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero."); + } + } +} diff --git a/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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs new file mode 100644 index 00000000..79c5b986 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs @@ -0,0 +1,171 @@ +using System.Data.Common; +using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// The AdminUI "Test Connect" liveness check for the Sql driver: open a connection, run the +/// dialect's SELECT 1 () under a bounded deadline, and report +/// green with latency or red with a reason. Mirrors ModbusDriverProbe. +/// Parses the SAME factory DTO with the SAME converter as +/// (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a +/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised +/// provider parses here exactly as it does at the factory, and the same +/// connectionStringRef resolves the same way. +/// Never throws (the contract). Every failure — malformed JSON, a +/// missing connectionStringRef, an unprovisioned connection string, a provider open failure, a +/// timeout, a cancellation — becomes a red , so the AdminUI has nothing to +/// catch. +/// The resolved connection string never reaches the result message. A red result names the +/// failure class, never the string — the same credential discipline the factory and the driver keep. +/// +public sealed class SqlDriverProbe : IDriverProbe +{ + /// + /// Selects the provider seam by . In production this constructs the real + /// ; injects a test dialect (and factory) so the + /// probe can run against the SQLite fixture with no SQL Server and no network. + /// + private readonly Func _dialectFor; + + /// + /// Overrides the dialect's own when non-null — the injection point + /// the SQLite tests use to hand in SqliteFactory while keeping the test dialect's + /// catalog SQL. Null in production: the dialect's own factory is used. + /// + private readonly DbProviderFactory? _factoryOverride; + + /// Initializes a new that constructs the real SQL Server dialect. + public SqlDriverProbe() + : this(DefaultDialectFor, factoryOverride: null) + { + } + + private SqlDriverProbe(Func dialectFor, DbProviderFactory? factoryOverride) + { + _dialectFor = dialectFor; + _factoryOverride = factoryOverride; + } + + /// + /// Test seam: a probe that always uses and opens connections from + /// , so the SQLite fixture's create → open → SELECT 1 → close cycle + /// runs unmodified. + /// + /// The provider factory the probe opens connections from. + /// The dialect whose the probe runs. + /// A probe wired to the supplied factory + dialect. + /// A required argument is null. + internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(dialect); + return new SqlDriverProbe(_ => dialect, factory); + } + + /// + public string DriverType => SqlDriver.DriverTypeName; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt. + SqlDriverConfigDto? dto; + try + { + dto = System.Text.Json.JsonSerializer.Deserialize( + configJson, SqlDriverFactoryExtensions.JsonOptions); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException) + { + return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (dto is null) + return new DriverProbeResult(false, "Config JSON deserialized to null.", null); + if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef)) + return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null); + + ISqlDialect dialect; + string connectionString; + try + { + dialect = _dialectFor(dto.Provider); + connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!); + } + catch (InvalidOperationException ex) + { + // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is + // safe and actionable (it names the environment variable the operator must set). + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + + var factory = _factoryOverride ?? dialect.Factory; + return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false); + } + + /// + /// Opens one connection under a linked CTS bounded by , runs the dialect's + /// liveness statement, and returns green with latency. Any failure becomes a red result whose message + /// names the failure class — never the connection string (a provider exception can embed the + /// data source, so its .Message is deliberately not surfaced). + /// + private static async Task RunLivenessAsync( + DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout, + CancellationToken ct) + { + var stopwatch = Stopwatch.StartNew(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout); + + try + { + var connection = factory.CreateConnection() + ?? throw new InvalidOperationException( + $"the {dialect.Provider} provider factory returned no connection."); + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = connectionString; + await connection.OpenAsync(cts.Token).ConfigureAwait(false); + + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = dialect.LivenessSql; + await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false); + } + } + + stopwatch.Stop(); + return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return new DriverProbeResult(false, "Probe cancelled.", null); + } + catch (OperationCanceledException) + { + return new DriverProbeResult( + false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); + } + catch (Exception ex) + { + // The provider's own message can carry the data source (host/database), which is not a secret but + // is more than the operator needs — and keeping the message to a fixed class is what guarantees no + // credential-bearing connection string can ever slip through. Name the exception TYPE only. + return new DriverProbeResult( + false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null); + } + } + + /// Constructs the production dialect for a provider; v1 supports SQL Server only. + private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + _ => throw new InvalidOperationException( + $"provider '{provider}' is not available in this build (only SqlServer is supported)."), + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs new file mode 100644 index 00000000..73510d1d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -0,0 +1,250 @@ +using System.Globalization; +using System.Text; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Folds a set of s into the minimum number of parameterized queries +/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock. +/// The GroupKey is the correctness contract. Two tags share a query only when every +/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell; +/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by +/// SqlGroupPlannerTests. +/// Injection boundary (design §8.1). Identifiers reach the command text through +/// and nothing else; every authored value becomes a +/// @k0/@w marker with the value carried in . There is no +/// interpolation of tag content into SQL anywhere in this type. +/// +public static class SqlGroupPlanner +{ + /// The parameter-marker prefix for a key-value model's IN list. + private const string KeyMarkerPrefix = "@k"; + + /// The single parameter marker for a wide-row model's row selector. + private const string RowSelectorMarker = "@w"; + + /// + /// Groups by source and emits one per group. + /// Deterministic: groups appear in the input order of their first member, members keep their input + /// order within a group, and parameters keep the first-appearance order of their distinct values — so a + /// given tag list always yields byte-identical SQL. + /// + /// The tags to plan. An empty sequence yields no plans. + /// Supplies identifier quoting and the provider's row-limit syntax. + /// One plan per distinct group key. + /// or is null. + /// + /// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an + /// identifier cannot be quoted (an empty table-name part, a control character — see + /// ). rejects all of these + /// up front, so reaching one here means a caller built a definition by hand with a broken invariant. + /// + /// + /// A tag carries (design §5.4, deferred to P3). + /// Deliberately loud rather than skipped: silently dropping a tag would leave an authored node + /// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice. + /// + public static IReadOnlyList Plan(IEnumerable tags, ISqlDialect dialect) + { + ArgumentNullException.ThrowIfNull(tags); + ArgumentNullException.ThrowIfNull(dialect); + + // Ordered grouping: the dictionary decides membership, the list decides emission order. + var order = new List(); + var groups = new Dictionary>(); + + foreach (var tag in tags) + { + ArgumentNullException.ThrowIfNull(tag); + var key = BuildGroupKey(tag); + if (!groups.TryGetValue(key, out var members)) + { + members = []; + groups.Add(key, members); + order.Add(key); + } + + members.Add(tag); + } + + var plans = new List(order.Count); + foreach (var key in order) + { + var members = groups[key]; + plans.Add(members[0].Model switch + { + SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect), + SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect), + _ => throw UnsupportedModel(members[0]), + }); + } + + return plans; + } + + /// + /// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose + /// first element is the model, so a key-value key can never compare equal to a wide-row key. + /// + /// + /// (model, table, keyColumn, valueColumn, timestampColumn). + /// Every one of those shapes the emitted SELECT: sharing a plan across two valueColumns + /// would make one tag publish the other's column. + /// + /// + /// (model, table, whereColumn, whereValue, topByTimestamp) + /// — i.e. the table plus the whole row selector, exactly design §5.3's + /// (table, rowSelector). The members' own columnName/timestampColumn are + /// deliberately not in the key: differing there is the point of the model (many columns, + /// one row), and they are added to the SELECT list instead. + /// + /// + /// String comparison is ordinal. SQL Server object names are usually case-insensitive, so + /// two tags authored dbo.T and DBO.T plan as two groups. That costs one extra round-trip + /// and is never wrong; guessing the server's collation here could fold two genuinely different sources. + /// + private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch + { + SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn), + SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue, + tag.RowSelectorTopByTimestamp), + _ => throw UnsupportedModel(tag), + }; + + /// + /// Emits SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN). + /// The key column is selected because the reader indexes rows by it to slice values back per member. + /// Keys are de-duplicated (ordinal) before binding: two tags authored against the same + /// keyValue bind one parameter but stay two members, so both nodes are fed from the one row. + /// + private static SqlQueryPlan BuildKeyValuePlan( + object groupKey, List members, ISqlDialect dialect) + { + var first = members[0]; + var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first); + var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first); + var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn; + + var selected = new List(3); + AddDistinct(selected, keyColumn); + AddDistinct(selected, valueColumn); + if (timestampColumn is not null) AddDistinct(selected, timestampColumn); + + // A keyValue may legitimately be the empty string, so only null is a broken invariant. + var names = new List(members.Count); + var values = new List(members.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + var keyValue = member.KeyValue + ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member); + if (!seen.Add(keyValue)) continue; + names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture)); + values.Add(keyValue); + } + + var sql = new StringBuilder("SELECT ") + .Append(QuoteList(selected, dialect)) + .Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect)) + .Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn)) + .Append(" IN (").AppendJoin(", ", names).Append(')') + .ToString(); + + return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected); + } + + /// + /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w, or — for a + /// topByTimestamp selector — the single-row form + /// SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit>, whose + /// row limit comes from / + /// (T-SQL fills the prefix: SELECT TOP 1 …). + /// The SELECT list is each member's columnName (distinct, in first-appearance order), + /// followed by each distinct member timestampColumn — so members of one row may carry different + /// timestamp columns without splitting the group. The where-column itself is not selected: every + /// row in the result already matches the bound value, so reading it back adds nothing. + /// + private static SqlQueryPlan BuildWideRowPlan( + object groupKey, List members, ISqlDialect dialect) + { + var first = members[0]; + + var selected = new List(members.Count + 1); + foreach (var member in members) + AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member)); + foreach (var member in members) + if (!Blank(member.TimestampColumn)) + AddDistinct(selected, member.TimestampColumn!); + + var table = QuoteQualifiedName(first.Table, dialect); + var columns = QuoteList(selected, dialect); + + // A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated. + if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null) + { + var sql = string.Concat( + "SELECT ", columns, " FROM ", table, + " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker); + return new SqlQueryPlan( + SqlTagModel.WideRow, groupKey, sql, + [RowSelectorMarker], [first.RowSelectorValue], members, selected); + } + + if (!Blank(first.RowSelectorTopByTimestamp)) + { + // The row limit is dialect syntax and sits at opposite ends of the statement per provider + // (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted + // unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix. + var sql = string.Concat( + "SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table, + " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC", + dialect.SingleRowLimitSuffix); + return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected); + } + + throw new ArgumentException( + $"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" + + "whereValue pair or a rowSelector.topByTimestamp column.", nameof(members)); + } + + /// + /// Quotes a possibly multi-part object name by splitting on . and quoting each part — + /// dbo.TagValues becomes [dbo].[TagValues]. + /// quotes exactly one part; handing it the dotted + /// form would yield [dbo.TagValues], which is safe but names an object that does not exist. An + /// empty part (dbo..T) is rejected by the dialect rather than emitted. As a consequence an object + /// whose real name contains a literal . cannot be authored — an accepted v1 limitation. + /// + private static string QuoteQualifiedName(string name, ISqlDialect dialect) + { + if (Blank(name)) + throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name)); + + return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier)); + } + + /// Quotes each already-de-duplicated column name and joins them into a SELECT list. + private static string QuoteList(IReadOnlyList columns, ISqlDialect dialect) + => string.Join(", ", columns.Select(dialect.QuoteIdentifier)); + + /// Appends unless an ordinal-equal entry is already present. + private static void AddDistinct(List target, string value) + { + if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value); + } + + /// True when the string is null, empty, or all whitespace — i.e. cannot be an identifier. + private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value); + + /// Returns a required identifier field, or throws naming both the tag and the field. + private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag) + => Blank(value) ? throw MissingField(field, tag) : value!; + + private static ArgumentException MissingField(string field, SqlTagDefinition tag) + => new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'."); + + private static NotSupportedException UnsupportedModel(SqlTagDefinition tag) + => new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " + + "v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4)."); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs new file mode 100644 index 00000000..ee1dce0f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -0,0 +1,839 @@ +using System.Data.Common; +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Executes one poll pass: resolves each requested reference to a , folds +/// the definitions into the minimum number of queries via , runs one +/// command per group under a bounded deadline, and slices each result set back to per-tag +/// s — positionally aligned with the caller's reference list +/// (design §3.2). Structurally the SQL analogue of ModbusDriver.ReadCoalescedAsync: group → one +/// round-trip → slice back. +/// N in, N out, in order. The returned list always has exactly one entry per requested +/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never +/// exceptions (the contract) — see for which code +/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags, +/// so a slicing defect does not fail loudly — it publishes one tag's value onto another tag's +/// node. +/// The whole call throws only when the database is unreachable — that is, when opening a +/// connection fails. That surfaces to as a poll failure and earns the +/// capped-exponential backoff; the driver's IHostConnectivityProbe is what scopes Bad quality +/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for +/// an outage. A query that fails after the connection opened is the group's problem, not the +/// server's, and Bad-codes that group only. +/// Deadlines (design §8.3, the R2-01 frozen-peer lesson). Two independent mechanisms, both +/// required. CommandTimeout is the server-side backstop and bounds nothing on a wedged +/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see +/// . A frozen database yields +/// snapshots — it must never wedge the poll thread. +/// Not a driver. This type owns the read path only; it deliberately holds no health state, +/// no subscription state and no connection between polls, so the driver shell can wrap it in +/// and own those concerns. +/// +public sealed class SqlPollReader +{ + /// Minimum spacing between "your source violates the query contract" warnings, per reader. + private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1); + + private readonly DbProviderFactory _factory; + private readonly string _connectionString; + private readonly ISqlDialect _dialect; + private readonly TimeSpan _operationTimeout; + private readonly int _commandTimeoutSeconds; + private readonly bool _nullIsBad; + private readonly Func _resolve; + private readonly ILogger _logger; + + /// + /// Caps concurrently executing group queries — and therefore concurrently held connections + /// (design §8.4). Never disposed: a only needs disposal once its + /// AvailableWaitHandle has been touched, and not disposing it also removes the hazard of a + /// timed-out-but-still-running group releasing into a disposed semaphore. + /// + private readonly SemaphoreSlim _gate; + + private long _lastContractWarningTicks; + + /// Initializes a new instance of the class. + /// + /// Creates the provider's connections. Passed explicitly rather than taken from + /// so the driver can hand in an instrumented or pre-configured factory. + /// + /// + /// The resolved connection string (never the authored connectionStringRef — secrets are + /// resolved by the driver at Initialize, design §8.2). + /// + /// Supplies identifier quoting, row-limit syntax, and column-type mapping. + /// + /// The ADO.NET CommandTimeout backstop. Rounded up to whole seconds and floored at 1, + /// because ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value this option + /// must never silently become. + /// + /// + /// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening + /// the connection, and running the query. A breach Bad-codes that group with + /// . + /// Authoring should keep this strictly greater than + /// (design §8.3): inverted, the client-side abort always fires first and masks the server-side + /// backstop. That rule is not enforced here — it belongs to config validation, and this type + /// stays usable for the tests that must deliberately invert it. + /// + /// + /// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot + /// until it truly finishes, so this is a hard ceiling on connections even against a frozen database. + /// See for why a high value is safe under + /// Microsoft.Data.SqlClient but wants sizing against thread-pool headroom under a provider + /// that blocks synchronously. + /// + /// + /// publishes for a NULL value cell instead + /// of the default . It governs a present row with a + /// NULL cell only — an absent row is always . + /// + /// + /// RawPath → tag definition; on a miss. Shaped to match + /// 's lookup so the driver can pass its authored table + /// straight through. + /// + /// Optional; defaults to a no-op logger. + /// A required reference argument is null. + /// is blank. + /// A timeout is non-positive, or the cap is below 1. + public SqlPollReader( + DbProviderFactory factory, + string connectionString, + ISqlDialect dialect, + TimeSpan commandTimeout, + TimeSpan operationTimeout, + int maxConcurrentGroups, + bool nullIsBad, + Func resolve, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(dialect); + ArgumentNullException.ThrowIfNull(resolve); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString)); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1); + + _factory = factory; + _connectionString = connectionString; + _dialect = dialect; + _operationTimeout = operationTimeout; + _commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds)); + _nullIsBad = nullIsBad; + _resolve = resolve; + _logger = logger ?? NullLogger.Instance; + _gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups); + } + + /// + /// Reads every reference in one poll pass. Matches 's shape so the + /// driver shell can delegate to it directly. + /// + /// The RawPaths to read. Empty yields an empty result and touches no connection. + /// + /// The caller's token. Its cancellation propagates as an + /// — the engine asked the poll to stop, and nobody is + /// waiting for the snapshots. This is deliberately asymmetric with an operationTimeout + /// breach, which is a data-quality fact clients must see and so becomes + /// snapshots. + /// + /// One snapshot per reference, at the same index. + /// is null. + /// was cancelled. + /// The database could not be reached (opening a connection failed). + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + + var results = new DataValueSnapshot[fullReferences.Count]; + if (results.Length == 0) return results; + + var readAt = DateTime.UtcNow; + + // Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner, + // whose members are the sole thing the slice-back walks. + var definitions = new List(fullReferences.Count); + var slots = new Dictionary>(); + for (var i = 0; i < fullReferences.Count; i++) + { + var definition = fullReferences[i] is { } reference ? _resolve(reference) : null; + if (definition is null) + { + results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt); + continue; + } + + definitions.Add(definition); + if (!slots.TryGetValue(definition, out var positions)) + slots[definition] = positions = []; + positions.Add(i); + } + + if (definitions.Count > 0) + await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false); + + // Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes + // blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause. + for (var i = 0; i < results.Length; i++) + results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt); + + return results; + } + + /// Plans the resolved definitions, runs every group concurrently, and slices the results back. + private async Task ReadGroupsAsync( + List definitions, + Dictionary> slots, + DataValueSnapshot[] results, + DateTime readAt, + CancellationToken cancellationToken) + { + IReadOnlyList plans; + try + { + plans = SqlGroupPlanner.Plan(definitions, _dialect); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + // A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so + // reaching here means a definition was built past the parser. It is an authoring fault, not a + // database fault: Bad-code the planned tags rather than throwing and triggering connection backoff. + _logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.", + definitions.Count); + foreach (var definition in definitions) + { + if (!slots.TryGetValue(definition, out var positions)) continue; + var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt); + foreach (var position in positions) results[position] = snapshot; + } + + return; + } + + // Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group + // carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by + // plans.Count × operationTimeout. + var groups = new Task[plans.Count]; + for (var g = 0; g < plans.Count; g++) + groups[g] = RunGroupAsync(plans[g], cancellationToken); + + // WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never + // leaves a sibling group running against a connection nobody is watching. + var outcomes = await Task.WhenAll(groups).ConfigureAwait(false); + + for (var g = 0; g < plans.Count; g++) + Slice(plans[g], outcomes[g], slots, results, readAt); + } + + /// + /// Runs one group's query under a hard wall-clock ceiling. + /// Why the ceiling is Task.WaitAsync and not just a linked + /// CancelAfter. A linked token only bounds a + /// provider that honours it. The S7 R2-01 finding was exactly a provider whose async path + /// ignored its deadline, and ADO.NET has the same shape: some providers implement + /// ExecuteReaderAsync synchronously, and even a genuinely async one can hang inside its own + /// cancellation handshake against a frozen socket. Both are used here: the linked token so a + /// well-behaved provider unwinds cleanly and releases its connection, and WaitAsync so + /// control returns at the deadline regardless. + /// The work is started on the thread pool so a provider that blocks synchronously blocks a + /// pool thread rather than the caller — without that, a synchronous provider never yields the task + /// there would be to bound. + /// Thread-pool scheduling and what a BadTimeout therefore means. The group's clock + /// starts before Task.Run, so in principle a group could burn budget queueing for a pool + /// thread rather than waiting on the database. TaskCreationOptions.LongRunning (a dedicated + /// OS thread) was considered and rejected: it would create and tear down one thread per group + /// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost + /// paid against a hazard that cannot arise with the provider this driver actually ships. + /// Microsoft.Data.SqlClient's async path is genuinely asynchronous: the delegate reaches its + /// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks + /// zero pool threads however long it stays frozen, and the driver cannot starve itself no + /// matter how high maxConcurrentGroups goes. The queueing scenario needs a provider that + /// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose, + /// and is not a shipped configuration. Consequence for outage diagnosis (e.g. blackhole-testing a + /// paused SQL Server): under Microsoft.Data.SqlClient a + /// means the database did not answer inside + /// operationTimeout — it cannot be an artefact of this driver's own pool pressure. Under a + /// synchronously-blocking provider it may also include queueing delay, so size + /// maxConcurrentGroups below the process's readily-available worker count there. + /// The concurrency slot is released by the work, not by the waiter. A timed-out group + /// keeps its slot until it truly finishes, so a frozen database can never accumulate more than + /// maxConcurrentGroups connections however long it stays frozen. The slot wait is itself + /// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout. + /// + private async Task RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken) + { + var clock = Stopwatch.StartNew(); + + if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false)) + return GroupResult.TimedOut; + + var handedOff = false; + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task work; + try + { + var budget = Remaining(clock); + if (budget <= TimeSpan.Zero) return GroupResult.TimedOut; + deadline.CancelAfter(budget); + + work = Task.Run( + async () => + { + try + { + return await QueryAsync(plan, deadline.Token).ConfigureAwait(false); + } + finally + { + _gate.Release(); + deadline.Dispose(); + } + }, + CancellationToken.None); + handedOff = true; + } + finally + { + // Ownership of both the slot and the CTS transfers to the work task; release them here only on + // the paths where that task was never created. + if (!handedOff) + { + _gate.Release(); + deadline.Dispose(); + } + } + + try + { + return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Our wall-clock bound fired while the provider was still inside the call. + return TimedOut(plan, work); + } + catch (OperationCanceledException) + { + // Either the caller pulled the plug, or our deadline fired and the provider DID honour the + // linked token. Both leave the work running with nobody to observe its fault. + if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work); + Detach(work); + throw; + } + } + + /// How much of this group's operationTimeout budget is left; never negative. + private TimeSpan Remaining(Stopwatch clock) + { + var remaining = _operationTimeout - clock.Elapsed; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// Records the deadline breach and detaches the abandoned work. + private GroupResult TimedOut(SqlQueryPlan plan, Task work) + { + // The table is named for the same reason the contract warnings name it: under a partial outage this + // line repeats every poll, and "which source is frozen" is the only question the operator has. + _logger.LogWarning( + "Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " + + "timeout; publishing BadTimeout.", + plan.Model, plan.Members[0].Table, plan.Members.Count, + (int)_operationTimeout.TotalMilliseconds); + + Detach(work); + return GroupResult.TimedOut; + } + + /// + /// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task + /// exception. The task still owns its connection and still releases the concurrency slot when it + /// finally completes — that is what keeps a frozen database from accumulating connections. + /// + private static void Detach(Task work) + => _ = work.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// + /// Opens a connection, executes the plan's single command, and materialises the result set. + /// The rows are read into memory before the reader is disposed — the slice-back needs + /// random access across members, and holding a reader (and therefore a connection) open across that + /// work is exactly how a poll loop exhausts a pool. + /// + private async Task QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken) + { + var connection = _factory.CreateConnection() + ?? throw new InvalidOperationException( + $"The {_dialect.Provider} provider factory returned no connection."); + + await using (connection.ConfigureAwait(false)) + { + connection.ConnectionString = _connectionString; + + // Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which + // is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff). + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + try + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = plan.SqlText; + command.CommandTimeout = _commandTimeoutSeconds; + for (var i = 0; i < plan.ParameterNames.Count; i++) + { + var parameter = command.CreateParameter(); + // Bound by the plan's own index pairing — never by re-deriving the marker convention. + parameter.ParameterName = plan.ParameterNames[i]; + parameter.Value = plan.Parameters[i] ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // The connection opened, so the server is up — this query is what failed (an identifier that + // is not in the catalog, a lock, a permission). That is this group's problem alone. + _logger.LogWarning( + ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}", + plan.Model, plan.Members.Count, plan.SqlText); + return GroupResult.Failed; + } + } + } + + /// + /// Reads every row into memory, projected onto . Row + /// fetching stays on the async path so a result set that streams slowly still honours the deadline + /// token mid-read, rather than only at the ExecuteReader boundary. + /// + private async Task MaterialiseAsync( + SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken) + { + var selected = plan.SelectedColumns; + var ordinals = new int[selected.Count]; + var typeNames = new string[selected.Count]; + var columnIndex = new Dictionary(selected.Count, StringComparer.Ordinal); + for (var c = 0; c < selected.Count; c++) + { + // GetOrdinal rather than the position in SelectedColumns: the list is documented to be in + // ordinal order, but resolving it against the live result set is what makes a wrong slice + // impossible rather than merely unlikely. + ordinals[c] = reader.GetOrdinal(selected[c]); + typeNames[c] = SafeTypeName(reader, ordinals[c]); + columnIndex[selected[c]] = c; + } + + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var row = new object?[selected.Count]; + for (var c = 0; c < selected.Count; c++) + row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]); + rows.Add(row); + } + + return GroupResult.Ok(rows, columnIndex, typeNames); + } + + /// Reads a column's provider type name, falling back to the dialect's unknown-type default. + private static string SafeTypeName(DbDataReader reader, int ordinal) + { + try + { + return reader.GetDataTypeName(ordinal) ?? string.Empty; + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException + or IndexOutOfRangeException) + { + // A provider that declines to report a type name must not fail the poll — the tag's declared + // type (or the dialect's String fallback) covers it. + return string.Empty; + } + } + + /// Maps one group's outcome onto every slot its members occupy in the caller's list. + private void Slice( + SqlQueryPlan plan, + GroupResult outcome, + Dictionary> slots, + DataValueSnapshot[] results, + DateTime readAt) + { + // KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the + // where-column is not even in the result set (design §5.3). Both selections happen ONCE per group — + // they are group-wide facts, and doing them per member would report a contract violation N times. + var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue + ? IndexByKey(plan, outcome) + : null; + var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow + ? SelectWideRow(plan, outcome) + : null; + + foreach (var member in plan.Members) + { + // Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed. + if (!slots.TryGetValue(member, out var positions)) continue; + + var snapshot = outcome.Succeeded + ? MapMember(plan, member, outcome, byKey, wideRow, readAt) + : new DataValueSnapshot(null, outcome.FailureStatus, null, readAt); + + foreach (var position in positions) results[position] = snapshot; + } + } + + /// + /// Builds the key → row index a key-value plan slices through. Later rows win, so a source that + /// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the + /// violation is reported through a rate-limited warning rather than silently. + /// + private Dictionary IndexByKey(SqlQueryPlan plan, GroupResult outcome) + { + var index = new Dictionary(StringComparer.Ordinal); + if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt)) + return index; + + var duplicated = 0; + foreach (var row in outcome.Rows) + { + // A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be + // matched by a tag whose keyValue is legitimately the empty string. + if (row[keyAt] is not { } keyCell) continue; + + // Stringified because the bound keyValue is authored text while the column may be any type + // (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out. + var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture); + if (key is null) continue; + if (!index.TryAdd(key, row)) + { + index[key] = row; + duplicated++; + } + } + + if (duplicated > 0 && ShouldWarnAboutContract()) + { + _logger.LogWarning( + "Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " + + "last row wins. The key-value model requires one row per key — point the tag at a " + + "current-values table or an operator-authored latest-per-key view.", + plan.Members[0].Table, duplicated); + } + + return index; + } + + /// + /// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector. + /// Last row wins — the same tie-break applies, so both models + /// degrade the same way. It is deterministic only within one result set: the where-pair form + /// emits no ORDER BY, so across polls the "last" row is whatever the server returned last, + /// i.e. storage order. That is why more than one row is a reported contract violation and not merely + /// a tie to break — the model's premise (design §5.3) is that the selector identifies one row. + /// Why the where-pair form is deliberately NOT given the dialect's single-row limit. A + /// TOP 1 with no ORDER BY is not deterministic either — it returns whichever row the + /// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary + /// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous + /// (Rows.Count > 1), converting a loud misconfiguration into a permanently silent one — + /// precisely the failure mode this class's summary calls its top risk. The topByTimestamp + /// form keeps its row limit because there the ORDER BY makes "the first row" a defined + /// answer. The accepted cost is that an ambiguous selector materialises every matching row for one + /// poll; the warning is what gets it fixed. + /// + private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome) + { + if (outcome.Rows.Count == 0) return null; + if (outcome.Rows.Count > 1 && ShouldWarnAboutContract()) + { + var first = plan.Members[0]; + var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn) + ? string.Concat("topByTimestamp ", first.RowSelectorTopByTimestamp) + : string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue); + _logger.LogWarning( + "Sql poll source '{Table}' returned {Rows} rows for the wide-row selector '{Selector}' in " + + "one poll; the last row read wins, and the query carries no ORDER BY, so which row that is " + + "depends on storage order. The wide-row model requires a selector matching exactly one row " + + "— narrow the whereColumn/whereValue pair, or point the tag at a latest-row view.", + first.Table, outcome.Rows.Count, selector); + } + + return outcome.Rows[^1]; + } + + /// Maps one member's cell to a snapshot: value, quality, and source timestamp. + private DataValueSnapshot MapMember( + SqlQueryPlan plan, + SqlTagDefinition member, + GroupResult outcome, + Dictionary? byKey, + object?[]? wideRow, + DateTime readAt) + { + object?[]? row; + string? valueColumn; + string? timestampColumn; + if (plan.Model == SqlTagModel.KeyValue) + { + valueColumn = plan.ValueColumn; + timestampColumn = plan.TimestampColumn; + row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null; + } + else + { + valueColumn = member.ColumnName; + // A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are + // selected), which is why the plan-level TimestampColumn is null for this model by design. + timestampColumn = member.TimestampColumn; + row = wideRow; + } + + // No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell. + if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt); + + // A timestamp that will not parse falls back to the poll clock rather than poisoning a good value. + var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt; + + if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt)) + { + // The planner selects every member's value column, so this is unreachable short of plan/result + // drift — loud rather than silently publishing someone else's cell. + _logger.LogError( + "Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.", + member.Name, valueColumn); + return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt); + } + + var cell = row[valueAt]; + if (cell is null) + { + return new DataValueSnapshot( + null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt); + } + + var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]); + if (!TryCoerce(cell, target, out var value)) + { + _logger.LogWarning( + "Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.", + member.Name, cell.GetType().Name, target); + return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt); + } + + return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt); + } + + /// Reads a row's source timestamp, or null when there is no column, no value, or no parse. + private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn) + { + if (string.IsNullOrWhiteSpace(timestampColumn)) return null; + if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null; + if (row[at] is not { } cell) return null; + return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null; + } + + /// + /// Coerces a provider cell to the tag's effective , so the published CLR + /// type matches the type the address space declared for the node. Never throws. + /// + private static bool TryCoerce(object cell, DriverDataType target, out object? value) + { + try + { + value = target switch + { + DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture), + DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture), + DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture), + DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture), + DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture), + DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture), + // decimal/numeric collapse here, with the documented precision caveat (design §8.6). + DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture), + DriverDataType.DateTime => ToUtc(cell), + _ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty, + }; + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException + or OverflowException or ArgumentException) + { + value = null; + return false; + } + } + + /// + /// Normalises a timestamp cell to UTC. A naive datetime (no offset — the common SQL Server + /// shape) is assumed to already be UTC rather than reinterpreted through the server's local + /// zone, which would silently shift every source timestamp by the host's offset. + /// + private static DateTime ToUtc(object cell) => cell switch + { + DateTime { Kind: DateTimeKind.Utc } utc => utc, + DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(), + DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc), + DateTimeOffset offset => offset.UtcDateTime, + string text => DateTime.Parse( + text, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), + _ => DateTime.SpecifyKind( + Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc), + }; + + /// Rate-limits the source-contract warning so a misconfigured table cannot flood the log. + private bool ShouldWarnAboutContract() + { + var now = Stopwatch.GetTimestamp(); + var last = Interlocked.Read(ref _lastContractWarningTicks); + if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false; + return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last; + } + + /// One group's outcome: either a materialised result set, or the status its members inherit. + private sealed class GroupResult + { + private static readonly IReadOnlyList NoRows = []; + private static readonly IReadOnlyDictionary NoColumns = + new Dictionary(StringComparer.Ordinal); + + private GroupResult( + uint failureStatus, + IReadOnlyList rows, + IReadOnlyDictionary columnIndex, + IReadOnlyList typeNames) + { + FailureStatus = failureStatus; + Rows = rows; + ColumnIndex = columnIndex; + TypeNames = typeNames; + } + + /// The group exceeded its wall-clock deadline (design §8.3). + public static GroupResult TimedOut { get; } = + new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []); + + /// The connection was fine but the query was not. + public static GroupResult Failed { get; } = + new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []); + + /// The status every member inherits when is false. + public uint FailureStatus { get; } + + /// The materialised rows, projected onto the plan's selected columns. + public IReadOnlyList Rows { get; } + + /// Selected column name → its position within each row array. + public IReadOnlyDictionary ColumnIndex { get; } + + /// Each selected column's provider type name, for dialect type inference. + public IReadOnlyList TypeNames { get; } + + /// True when the query ran; a zero-row result set is still a success. + public bool Succeeded => FailureStatus == SqlStatusCodes.Good; + + /// A completed result set. + /// The materialised rows. + /// Selected column name → row-array position. + /// Each selected column's provider type name. + /// A successful outcome. + public static GroupResult Ok( + IReadOnlyList rows, + IReadOnlyDictionary columnIndex, + IReadOnlyList typeNames) + => new(SqlStatusCodes.Good, rows, columnIndex, typeNames); + } +} + +/// +/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read +/// them back with. +/// Why a driver-local table rather than a shared one. Every driver in this tree carries its +/// own (TwinCATStatusMapper, AbCipStatusMapper, FocasStatusMapper, +/// AbLegacyStatusMapper, Galaxy's StatusCodeMap) — there is no shared helper in +/// Core.Abstractions, because drivers deliberately do not reference the OPC UA SDK and +/// is a bare . The values below were read +/// off Opc.Ua.StatusCodes rather than copied from a sibling driver, since two of those tables +/// carry transcription errors. +/// +public static class SqlStatusCodes +{ + /// The value is good. (Good) + public const uint Good = 0x00000000u; + + /// Quality is uncertain with no more specific reason — a NULL cell under nullIsBad=false. + public const uint Uncertain = 0x40000000u; + + /// Quality is bad with no more specific reason — a NULL cell under nullIsBad=true. + public const uint Bad = 0x80000000u; + + /// + /// No row was returned for this tag — the key is absent from the result set, or the wide-row + /// selector matched nothing. Deliberately distinct from a NULL cell: the row is gone, versus + /// the row is there and the value is not. + /// + public const uint BadNoData = 0x809B0000u; + + /// The group exceeded its client-side wall-clock deadline (design §8.3). + public const uint BadTimeout = 0x800A0000u; + + /// The reference resolves to no authored tag. + public const uint BadNodeIdUnknown = 0x80340000u; + + /// The query failed after the connection opened. + public const uint BadCommunicationError = 0x80050000u; + + /// The cell could not be coerced to the tag's effective data type. + public const uint BadTypeMismatch = 0x80740000u; + + /// A tag definition the planner rejected — an authoring fault, not a database fault. + public const uint BadConfigurationError = 0x80890000u; + + /// A defect in the reader itself; never expected in the field. + public const uint BadInternalError = 0x80020000u; + + /// The quality-class mask — the top two bits of an OPC UA status code. + private const uint SeverityMask = 0xC0000000u; + + /// True when is in the Good quality class. + /// The status code to classify. + /// True when the code's severity bits are Good. + public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u; + + /// True when is in the Uncertain quality class. + /// The status code to classify. + /// True when the code's severity bits are Uncertain. + public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain; + + /// True when is in the Bad quality class. + /// The status code to classify. + /// True when the code's severity bits are Bad. + public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs new file mode 100644 index 00000000..f586bbf6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -0,0 +1,90 @@ +using System.Data.Common; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that +/// shares a reads from the same result set, so a poll pass executes one plan +/// per group rather than one query per tag. +/// Injection boundary. is built from dialect-quoted identifiers only; +/// every authored value lives in and is bound as a +/// named by the positionally-aligned . There is no +/// path by which a tag's keyValue or whereValue reaches the command text. +/// Produced only by ; consumed by the poll reader, which executes +/// once and slices the rows back to per-member snapshots. +/// +/// +/// The tag→value mapping model every member shares. Determines how the reader slices the result set: +/// indexes rows by and matches each member's +/// ; takes the single row and +/// reads each member's . +/// +/// +/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly +/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing. +/// +/// The single parameterized statement to execute for this group. +/// +/// The parameter markers appearing in , in the order they appear, aligned index-for-index +/// with . Carried explicitly so the reader never has to re-derive the naming +/// convention. +/// +/// +/// The values to bind, aligned with . Captured verbatim from the authored tags +/// — a hostile value is inert here because it is data, not text. +/// +/// +/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags +/// may legitimately read the same cell, and both must receive a value. +/// +/// +/// The bare (unquoted) column names in 's SELECT list, in order and distinct — the +/// reader's map from result-set ordinal to source column. +/// +public sealed record SqlQueryPlan( + SqlTagModel Model, + object GroupKey, + string SqlText, + IReadOnlyList ParameterNames, + IReadOnlyList Parameters, + IReadOnlyList Members, + IReadOnlyList SelectedColumns) +{ + // A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the + // planner's mutable working lists — an IReadOnlyList holding a List is downcastable, and a + // mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary. + /// The parameter markers in , aligned with . + public IReadOnlyList ParameterNames { get; } = [.. ParameterNames]; + + /// The values to bind, aligned with . + public IReadOnlyList Parameters { get; } = [.. Parameters]; + + /// The tags this plan feeds, in input order; duplicates preserved. + public IReadOnlyList Members { get; } = [.. Members]; + + /// The bare SELECT-list column names, in result-set ordinal order. + public IReadOnlyList SelectedColumns { get; } = [.. SelectedColumns]; + + /// + /// The key column all members are matched on — for any model but + /// . Uniform across by the group-key invariant, + /// so Members[0] is authoritative. + /// + public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null; + + /// + /// The column every member's value is read from — for any model but + /// . Uniform across by the group-key invariant. + /// + public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null; + + /// + /// The shared source-timestamp column, or when the group has none (the reader + /// then stamps the poll time). only — it is part of that model's + /// group key, so it is uniform. A plan deliberately allows members to + /// carry different timestamp columns (all of them are selected), so the reader must read + /// per member there. + /// + public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs new file mode 100644 index 00000000..e666856f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -0,0 +1,151 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting, +/// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map. +/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and +/// the AdminUI browse session. +/// +public sealed class SqlServerDialect : ISqlDialect +{ + /// + /// T-SQL's regular-identifier ceiling (sysname = nvarchar(128)). Enforced because a + /// longer string cannot name a real catalog object — it is either a config mistake or padding, and + /// rejecting it keeps the quoted output bounded. + /// + internal const int MaxIdentifierLength = 128; + + /// + public SqlProvider Provider => SqlProvider.SqlServer; + + /// + public DbProviderFactory Factory => SqlClientFactory.Instance; + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// T-SQL limits after the SELECT keyword, so the whole limit lives in the prefix — + /// "TOP 1 ", trailing space included so SELECT TOP 1 [col] composes with no extra + /// separator at the call site. + /// + public string SingleRowLimitPrefix => "TOP 1 "; + + /// Empty — T-SQL has no trailing row-limit clause (OFFSET/FETCH is a paging construct, not this). + public string SingleRowLimitSuffix => string.Empty; + + /// + public string ListSchemasSql => + "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; + + /// + public string ListTablesSql => + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME"; + + /// + public string ListColumnsSql => + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION"; + + /// + /// Brackets one identifier part, doubling any embedded ] — the T-SQL escape rule, and the only + /// metacharacter that could terminate a bracketed identifier early. [, quotes, semicolons and + /// comment markers are inert inside brackets and are therefore passed through unchanged. + /// + /// + /// Rejects (all as ): null / empty / all-whitespace; longer + /// than ; any control character (which covers embedded NUL, and the + /// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode + /// character — bidi overrides, zero-width + /// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the rendered + /// text of a log line or an AdminUI label while comparing byte-different from the real catalog name + /// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the + /// control-character rule. + /// The rejection messages deliberately do not echo the offending value — it is untrusted + /// input and this exception's text reaches the driver log. + /// This is currently the only defence, not a backstop. Design §8.1 specifies that an + /// identifier is validated against the live catalog before reaching here; that gate is not implemented + /// yet, so an authored TagConfig table/column arrives unfiltered. + /// + /// The bare, unquoted identifier part. + /// The bracket-quoted identifier. + /// The value cannot be a valid SQL Server identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + if (ident.Length > MaxIdentifierLength) + throw new ArgumentException( + $"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + + // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and + // zero-width character. They are inert inside brackets but spoof rendered log/UI text. + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format) + throw new ArgumentException( + "A SQL identifier may not contain Unicode format characters " + + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident)); + } + + return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]"); + } + + /// + /// Folds an INFORMATION_SCHEMA.COLUMNS.DATA_TYPE family name onto a + /// per design §3.7. + /// + /// + /// Precision caveat: decimal / numeric / money / smallmoney + /// collapse to in v1. A .NET decimal carries more + /// significant digits than a double, so a high-precision column (e.g. decimal(38,10)) + /// loses low-order digits. Authoring an explicit "type": "String" on the tag preserves the exact + /// textual value when that matters. + /// Fallback: an unrecognised family maps to and never + /// throws — a schema browse must render a table containing an exotic column (xml, + /// geography, hierarchyid, sql_variant, a CLR UDT, varbinary) rather than + /// crash on it. Null/blank input takes the same fallback. + /// Deliberate non-mappings: SQL Server's timestamp/rowversion is an opaque + /// 8-byte row version, not a date — it falls back to + /// rather than being mistaken for a . time is a + /// time-of-day span, likewise not a point in time, and takes the same fallback. + /// tinyint widens to (it is unsigned 0–255 in T-SQL, so + /// a signed 8-bit type would not hold it, and the driver type set has no Byte). + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "bit" or "boolean" => DriverDataType.Boolean, + "tinyint" or "smallint" => DriverDataType.Int16, + "int" or "integer" => DriverDataType.Int32, + "bigint" => DriverDataType.Int64, + "real" => DriverDataType.Float32, + "float" or "double" or "double precision" + or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64, + "char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext" + or "uniqueidentifier" => DriverDataType.String, + "date" or "datetime" or "datetime2" or "smalldatetime" + or "datetimeoffset" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj new file mode 100644 index 00000000..66ddb720 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Sql + + + + + + + + + + + + + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index d2140099..16402154 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -57,6 +57,9 @@ case DriverTypeNames.Galaxy: break; + case DriverTypeNames.Sql: + + break; default:
No typed config form for driver type @_driverType.
break; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor index 5bf707fc..8b90303c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor @@ -36,6 +36,7 @@ +
Cannot be changed after creation — drives the actor type that owns this instance.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor new file mode 100644 index 00000000..744aee9f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor @@ -0,0 +1,198 @@ +@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device + endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name, + resolved at Initialize), so no connection string is ever authored into the config. Hosted by + DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires + DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the + factory's string-typed deserialize accepts them. *@ +@using System.Text.Json +@using System.Text.Json.Nodes +@using System.Text.Json.Serialization +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts + +@* Connection *@ +
+
Database connection
+
+
+
+ + + @* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@ + + +
Only Microsoft SQL Server is supported in v1.
+
+
+ + +
+ Name of the Sql__ConnectionStrings__<ref> env var set on the driver + AdminUI hosts — + not a connection string. The credential is resolved at startup and never stored in the config. +
+ @if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef)) + { +
Connection string ref is required.
+ } +
+
+
+
+ +@* Polling / timeouts *@ +
+
Polling & timeouts
+
+
+
+ + +
Blank = factory default. Applied to groups without their own interval.
+
+
+ + +
Client-side per-query deadline. Should exceed the command timeout.
+
+
+ + +
ADO.NET server-side CommandTimeout backstop.
+
+
+ + +
Blank = factory default. Caps concurrent group queries (pool guard).
+
+
+
+
+ +@* Value handling *@ +
+
Value handling
+
+
+
+
+ + +
+
Unchecked = factory default (a NULL cell publishes Uncertain).
+
+
+
+ + +
+
+ Read-only v1 does not implement writes — this flag is inert and is not authored. +
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + // camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize. + // WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and + // drops the composer-owned rawTags (never authored here). + private static readonly JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private FormModel _form = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (don't clobber in-progress edits). + if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) + { + var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto(); + _form = FormModel.FromDto(dto); + _lastParsed = DriverConfigJson; + } + } + + /// Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings. + /// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only). + public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts); + + private async Task EmitAsync() + { + var json = GetConfigJson(); + _lastParsed = json; + await DriverConfigJsonChanged.InvokeAsync(json); + } + + private async Task OnResilienceChanged(string? r) + { + ResilienceConfig = r; + await ResilienceConfigChanged.InvokeAsync(r); + } + + private static SqlDriverConfigDto? TryDeserialize(string json) + { + try { return JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + /// Flat mutable mirror of the driver-level fields of . TimeSpans are + /// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not + /// mirrored — the composer owns rawTags and v1 is read-only. + public sealed class FormModel + { + public SqlProvider Provider { get; set; } = SqlProvider.SqlServer; + public string? ConnectionStringRef { get; set; } + public int? DefaultPollIntervalSeconds { get; set; } + public int? OperationTimeoutSeconds { get; set; } + public int? CommandTimeoutSeconds { get; set; } + public int? MaxConcurrentGroups { get; set; } + public bool NullIsBad { get; set; } + + public static FormModel FromDto(SqlDriverConfigDto d) => new() + { + Provider = d.Provider, + ConnectionStringRef = d.ConnectionStringRef, + DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval), + OperationTimeoutSeconds = ToSeconds(d.OperationTimeout), + CommandTimeoutSeconds = ToSeconds(d.CommandTimeout), + MaxConcurrentGroups = d.MaxConcurrentGroups, + NullIsBad = d.NullIsBad ?? false, + }; + + public SqlDriverConfigDto ToDto() => new() + { + Provider = Provider, + ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(), + DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds), + OperationTimeout = ToTimeSpan(OperationTimeoutSeconds), + CommandTimeout = ToTimeSpan(CommandTimeoutSeconds), + MaxConcurrentGroups = MaxConcurrentGroups, + // Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites + // and rawTags are deliberately left null so they are omitted from the authored blob. + NullIsBad = NullIsBad ? true : null, + }; + + private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null; + private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor new file mode 100644 index 00000000..b616571a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor @@ -0,0 +1,441 @@ +@* Sql address picker (read-only v1): + 1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the + table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a + server the AdminUI can't reach, authors a tag entirely from these fields. + 2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the + picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills + `table`, the value/column field, and the inferred `type`. + + The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the + shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/ + `type` enums are matched to the parser precisely; a mismatch would author fine and never read. + + Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the + JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed + TagConfig blob nor back into the driver config — there is no code path here that persists it. *@ +@implements IAsyncDisposable +@using System.Text.Json.Nodes +@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing +@using ZB.MOM.WW.OtOpcUa.Commons.Browsing +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Sql +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser +@inject IBrowserSessionService BrowserService +@inject AuthenticationStateProvider AuthState +@inject IAuthorizationService AuthorizationService + +
+
+ + +
+ KeyValue: one row per key. WideRow: many columns of one selected row. +
+
+
+ + +
Schema-qualified, e.g. dbo.TagValues.
+
+
+ + +
Blank ⇒ inferred from the column.
+
+
+ +@if (_model == "KeyValue") +{ +
+
+ + +
+
+ + +
Bound as a parameter — never interpolated.
+
+
+ + +
+
+} +else +{ +
+
+ + +
The column this tag reads from the selected row.
+
+
+ + +
+ @if (_selectorMode == "Where") + { +
+ + +
+
+ + +
+ } + else + { +
+ + +
+ } +
+} + +
+
+ + +
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+
+ +@if (_canOperate) +{ +
+
+
+ + +
+ Leave blank to browse via the driver's connectionStringRef (resolved from + the AdminUI host's Sql__ConnectionStrings__<ref> env var). Paste a + literal only for a one-off browse — it is used for this session and never persisted. +
+
+
+ +
+ @if (_token == Guid.Empty) + { + + } + else + { + Browser open + + } +
+ @if (_openError is not null) + { +
@_openError
+ } + + @if (_token != Guid.Empty) + { +
+
+ + +
+
+ +
+ @if (_attrsLoading) + { + + } + else if (_attrsError is not null) + { + @_attrsError + } + else if (_attrs is null) + { + Pick a column leaf. + } + else if (_attrs.Count == 0) + { + Column no longer present. + } + else + { + @foreach (var a in _attrs) + { +
+ @a.Name + @a.DriverDataType · @a.SecurityClass +
+ } + } +
+
+
+ } +} + +
+ TagConfig: + @(_built.Length == 0 ? "—" : _built) +
+ +@code { + [Parameter] public string CurrentAddress { get; set; } = ""; + [Parameter] public EventCallback CurrentAddressChanged { get; set; } + + /// Live accessor for the selected driver's DriverConfig JSON (carries + /// connectionStringRef + provider). Fed to BrowserService.OpenAsync("Sql", …). + [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; + + /// Fires with the picker's suggested tag display name (table.column) whenever a + /// column is picked from the tree, so a host editor may default the raw tag's name. Optional — + /// the composed TagConfig blob itself carries no display name (it is not a parser field). + [Parameter] public EventCallback SuggestedDisplayNameChanged { get; set; } + + // Manual/composed tag fields (all editable by hand; browse only prefills a subset). + private string _model = "KeyValue"; + private string _table = ""; + private string _keyColumn = ""; + private string _keyValue = ""; + private string _valueColumn = ""; + private string _columnName = ""; + private string _selectorMode = "Where"; + private string _whereColumn = ""; + private string _whereValue = ""; + private string _topByTimestamp = ""; + private string _timestampColumn = ""; + private string _type = ""; + + // Browse-only, session-scoped state. + private string _adhocConnectionString = ""; + private string _built = ""; + private string _selectedColumnNodeId = ""; + private Guid _token = Guid.Empty; + private bool _opening; + private bool _canOperate; + private string? _openError; + private bool _attrsLoading; + private string? _attrsError; + private IReadOnlyList? _attrs; + + protected override async Task OnInitializedAsync() + { + var auth = await AuthState.GetAuthenticationStateAsync(); + var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator); + _canOperate = authResult.Succeeded; + _built = Build(); + await CurrentAddressChanged.InvokeAsync(_built); + } + + private async Task OpenBrowseAsync() + { + _opening = true; + _openError = null; + StateHasChanged(); + try + { + // Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is + // never written to the composed TagConfig blob nor back to the driver config — this is the + // sole place it is read, and it dies with the local variable. + var json = BuildBrowseConfig(); + var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default); + if (result.Ok) { _token = result.Token; } + else { _openError = result.Message; } + } + finally { _opening = false; StateHasChanged(); } + } + + /// + /// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one, + /// a top-level connectionString literal. The literal wins over any connectionStringRef + /// server-side (SqlDriverBrowser precedence) and is session-only. + /// + private string BuildBrowseConfig() + { + var baseJson = GetConfigJson() ?? "{}"; + if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; } + + JsonObject o; + try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); } + catch (System.Text.Json.JsonException) { o = new JsonObject(); } + o["connectionString"] = _adhocConnectionString; + return o.ToJsonString(); + } + + private async Task CloseBrowseAsync() + { + var t = _token; + _token = Guid.Empty; + _attrs = null; + _selectedColumnNodeId = ""; + StateHasChanged(); + if (t != Guid.Empty) { await BrowserService.CloseAsync(t); } + } + + /// + /// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are + /// navigation, not selection. The NodeId is decoded with + /// — never split by hand, because SQL identifiers may contain '.' and '|'. + /// + private async Task OnTreeSelectAsync(BrowseNode node) + { + if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference) + || reference.Kind != SqlBrowseNodeKind.Column) + { + return; + } + + _selectedColumnNodeId = node.NodeId; + _table = $"{reference.Schema}.{reference.Table}"; + if (_model == "WideRow") { _columnName = reference.Column!; } + else { _valueColumn = reference.Column!; } + + // Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified). + await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}"); + + _attrs = null; + _attrsLoading = true; + _attrsError = null; + StateHasChanged(); + try + { + _attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default); + var picked = _attrs.Count > 0 ? _attrs[0] : null; + if (picked is not null) { PrefillType(picked); } + } + catch (Exception ex) { _attrsError = ex.Message; } + finally + { + _attrsLoading = false; + await OnChangedAsync(); + } + } + + /// Re-applies the side-panel column's inferred type (and value-column) on click. + private async Task ApplyAttributeAsync(AttributeInfo a) + { + if (_model == "WideRow") { _columnName = a.Name; } + else { _valueColumn = a.Name; } + PrefillType(a); + await OnChangedAsync(); + } + + /// Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps. + private void PrefillType(AttributeInfo a) + { + if (Enum.TryParse(a.DriverDataType, out _)) { _type = a.DriverDataType; } + } + + private async Task OnManualChangedAsync() => await OnChangedAsync(); + + private async Task OnChangedAsync() + { + _built = Build(); + await CurrentAddressChanged.InvokeAsync(_built); + } + + /// + /// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to + /// SqlEquipmentTagParser. Returns "" until the selected model's required fields are all + /// present, so the "Use this address" button never commits a half-blob the parser would reject. + /// model and type are written as NAME strings (the parser reads them strictly). + /// + private string Build() + { + var table = _table.Trim(); + if (table.Length == 0) { return ""; } + + var o = new JsonObject + { + ["driver"] = "Sql", + ["model"] = _model, + ["table"] = table, + }; + + if (_model == "KeyValue") + { + var keyColumn = _keyColumn.Trim(); + var valueColumn = _valueColumn.Trim(); + var keyValue = _keyValue.Trim(); + if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; } + o["keyColumn"] = keyColumn; + o["keyValue"] = keyValue; + o["valueColumn"] = valueColumn; + } + else // WideRow + { + var columnName = _columnName.Trim(); + if (columnName.Length == 0) { return ""; } + o["columnName"] = columnName; + + var rowSelector = new JsonObject(); + if (_selectorMode == "Where") + { + var whereColumn = _whereColumn.Trim(); + var whereValue = _whereValue.Trim(); + if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; } + rowSelector["whereColumn"] = whereColumn; + rowSelector["whereValue"] = whereValue; + } + else // Newest + { + var topByTimestamp = _topByTimestamp.Trim(); + if (topByTimestamp.Length == 0) { return ""; } + rowSelector["topByTimestamp"] = topByTimestamp; + } + + o["rowSelector"] = rowSelector; + } + + var timestampColumn = _timestampColumn.Trim(); + if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; } + + if (_type.Length > 0) { o["type"] = _type; } + + // v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side). + o["writable"] = false; + + return o.ToJsonString(); + } + + public ValueTask DisposeAsync() + { + if (_token != Guid.Empty) + { + // Fire-and-forget — don't block circuit teardown on a slow database. + _ = BrowserService.CloseAsync(_token); + } + return ValueTask.CompletedTask; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index e43dc14a..24b37fa2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -68,6 +68,7 @@ ("FOCAS", DriverTypeNames.FOCAS), ("OpcUaClient", DriverTypeNames.OpcUaClient), ("Galaxy", DriverTypeNames.Galaxy), + ("Sql", DriverTypeNames.Sql), ("Calculation", "Calculation"), ]; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor new file mode 100644 index 00000000..6a148034 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor @@ -0,0 +1,211 @@ +@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit + goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name + serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field + group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched + from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/ + GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by + TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers + +
+
+
+
+
+
+
+
+ +@if (_m.Model == SqlTagModel.KeyValue) +{ +
+
+
+
+ +
Bound as a parameter — never interpolated.
+
+
+
+} +else +{ +
+
+ +
The column this tag reads from the selected row.
+
+
+ @if (_selectorMode == "Where") + { +
+
+
+
+ } + else + { +
+
+ } +
+} + +
+
+ +
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+ +
+
+ +@if (_showPicker) +{ + + + +} + +@code { + /// The tag's current TagConfig JSON. + [Parameter] public string? ConfigJson { get; set; } + + /// Raised with the updated TagConfig JSON when the model changes. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + /// DriverType of the selected driver — accepted for dispatch uniformity. + [Parameter] public string DriverType { get; set; } = ""; + + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private SqlTagConfigModel _m = new(); + private string? _lastConfigJson; + private bool _showPicker; + private string _pickerAddress = ""; + + // Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model + // carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in + // the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state + // the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously + // says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where". + private string _selectorMode = "Where"; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server + // live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors). + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = SqlTagConfigModel.FromJson(ConfigJson); + DeriveSelectorMode(); + } + + // Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves + // the operator's current choice untouched when both selector fields are blank (an in-progress edit). + private void DeriveSelectorMode() + { + if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn)) + { + _selectorMode = "Newest"; + } + else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn)) + { + _selectorMode = "Where"; + } + } + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by + // NAME (never an ordinal cast), matching the model's strict name-string serialisation. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + // "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip + // is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string. + private static TEnum? ParseNullableEnum(object? v) where TEnum : struct, Enum + { + var s = v?.ToString(); + if (string.IsNullOrEmpty(s)) { return null; } + return Enum.TryParse(s, out var r) ? r : null; + } + + // Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the + // key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value + // as present-and-empty, distinct from absent, so those keep e.Value verbatim.) + private static string? NullIfBlank(object? v) + { + var s = v?.ToString(); + return string.IsNullOrWhiteSpace(s) ? null : s; + } + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } + + // The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of + // the mode being left so the composed blob carries exactly one selector (a stale where-pair would win + // over topByTimestamp at read time, silently overriding the operator's choice). + private async Task OnSelectorModeChanged(string? mode) + { + _selectorMode = mode == "Newest" ? "Newest" : "Where"; + await Update(() => + { + if (_selectorMode == "Newest") + { + _m.RowSelector.WhereColumn = null; + _m.RowSelector.WhereValue = null; + } + else + { + _m.RowSelector.TopByTimestamp = null; + } + }); + } + + // The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the + // other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working + // model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that + // the picker never authors. + private async Task OnAddressPicked(string blob) + { + if (string.IsNullOrWhiteSpace(blob)) { return; } + var picked = SqlTagConfigModel.FromJson(blob); + await Update(() => + { + _m.Model = picked.Model; + _m.Table = picked.Table; + _m.KeyColumn = picked.KeyColumn; + _m.KeyValue = picked.KeyValue; + _m.ValueColumn = picked.ValueColumn; + _m.ColumnName = picked.ColumnName; + _m.TimestampColumn = picked.TimestampColumn; + _m.Type = picked.Type; + _m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn; + _m.RowSelector.WhereValue = picked.RowSelector.WhereValue; + _m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp; + }); + DeriveSelectorMode(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 11d629c5..f19cbcf7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; using ZB.MOM.WW.Secrets.Ui; namespace ZB.MOM.WW.OtOpcUa.AdminUI; @@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + // Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every + // ctor param has a production default. The universal-browser DI line is deliberately NOT added + // for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction + // (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal). + services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory // → CanBrowse false → pickers gracefully stay manual-entry (design §6). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs new file mode 100644 index 00000000..4ac7c9c2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs @@ -0,0 +1,212 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column +/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the +/// authoritative runtime reader : the model and +/// type enums are written as their NAME strings (never numbers), and +/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys +/// (top-level and inside rowSelector) across a load→save so a future field survives an edit. +/// +public sealed class SqlTagConfigModel +{ + /// Tag→value mapping model. v1 accepts and + /// ; is deferred and rejected. + public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue; + + /// The table or view to read (e.g. dbo.TagValues). Required for every model. + public string? Table { get; set; } + + /// : the column matched against . + public string? KeyColumn { get; set; } + + /// : this tag's key value (bound as a parameter at read time). + public string? KeyValue { get; set; } + + /// : the column the value is read from. + public string? ValueColumn { get; set; } + + /// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models. + public string? TimestampColumn { get; set; } + + /// : the column this tag reads from the selected row. + public string? ColumnName { get; set; } + + /// : which row to read — a where-pair or newest-by-timestamp. + public SqlRowSelectorModel RowSelector { get; set; } = new(); + + /// Optional explicit OPC UA data type override (the blob's type field). null ⇒ the + /// driver infers the type from the result-set column metadata. + public DriverDataType? Type { get; set; } + + private JsonObject _bag = new(); + + // The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's + // strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate). + private string? _invalidModelRaw; + private string? _invalidTypeRaw; + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every + /// original key (so fields this editor doesn't expose survive a load→save). + /// The raw TagConfig JSON string, or null for a new/empty config. + /// The populated . + public static SqlTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + var (model, invalidModel) = ReadEnumStrict(o, "model"); + var (type, invalidType) = ReadEnumStrict(o, "type"); + + var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null; + + return new SqlTagConfigModel + { + Model = model ?? SqlTagModel.KeyValue, + Table = TagConfigJson.GetString(o, "table"), + KeyColumn = TagConfigJson.GetString(o, "keyColumn"), + KeyValue = TagConfigJson.GetString(o, "keyValue"), + ValueColumn = TagConfigJson.GetString(o, "valueColumn"), + TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"), + ColumnName = TagConfigJson.GetString(o, "columnName"), + RowSelector = SqlRowSelectorModel.FromJson(selectorObj), + Type = type, + _bag = o, + _invalidModelRaw = invalidModel, + _invalidTypeRaw = invalidType, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as + /// their name strings) over the preserved key bag. The nested rowSelector object is rebuilt from + /// (preserving its own unknown keys) and omitted entirely when empty. + /// The serialised TagConfig JSON string. + public string ToJson() + { + TagConfigJson.Set(_bag, "model", Model); + TagConfigJson.Set(_bag, "table", Table); + TagConfigJson.Set(_bag, "keyColumn", KeyColumn); + TagConfigJson.Set(_bag, "keyValue", KeyValue); + TagConfigJson.Set(_bag, "valueColumn", ValueColumn); + TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn); + TagConfigJson.Set(_bag, "columnName", ColumnName); + TagConfigJson.Set(_bag, "type", Type); + + var selector = RowSelector.ToJsonObject(); + if (selector is null) { _bag.Remove("rowSelector"); } + else { _bag["rowSelector"] = selector; } + + return TagConfigJson.Serialize(_bag); + } + + /// Validates the model against the exact accept/reject boundary of + /// . Returns an error message, or null when valid. + /// An error message describing the validation failure, or null when the model is valid. + public string? Validate() + { + // Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag. + if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; } + if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; } + + // Query is design §5.4 / P3 — the parser rejects it; the editor must too. + if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; } + + if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; } + + switch (Model) + { + case SqlTagModel.KeyValue: + if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; } + if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; } + if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; } + return null; + + case SqlTagModel.WideRow: + if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; } + // A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but + // must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`. + var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null; + if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp)) + { + return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp."; + } + return null; + + default: + return "model is not a supported Sql tag model."; + } + } + + // Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but- + // non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a + // present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it. + private static (TEnum? value, string? invalidRaw) ReadEnumStrict(JsonObject o, string name) + where TEnum : struct, Enum + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var s)) + { + return (null, null); + } + return Enum.TryParse(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s); + } +} + +/// +/// Typed working model for a tag's nested rowSelector object. +/// Holds a where-pair (/) or a newest-by-timestamp +/// selector (). Preserves unknown keys inside the selector across load→save. +/// +public sealed class SqlRowSelectorModel +{ + /// The column that picks the row (matched against ). + public string? WhereColumn { get; set; } + + /// The value is matched against (bound as a parameter at read time). + /// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring + /// the parser's scalar read. + public string? WhereValue { get; set; } + + /// Newest-row selector: the timestamp column to order by (used when no where-pair is authored). + public string? TopByTimestamp { get; set; } + + private JsonObject _bag = new(); + + /// Loads a selector model from the nested rowSelector JSON object (or a fresh empty model + /// when the object is absent), retaining any unknown keys. + /// The nested rowSelector object, or null when absent. + /// The populated . + public static SqlRowSelectorModel FromJson(JsonObject? o) + { + // Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson). + var bag = o?.DeepClone().AsObject() ?? new JsonObject(); + return new SqlRowSelectorModel + { + WhereColumn = TagConfigJson.GetString(bag, "whereColumn"), + WhereValue = ReadScalarText(bag, "whereValue"), + TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"), + _bag = bag, + }; + } + + /// Rebuilds the nested rowSelector JSON object from the exposed fields over the preserved + /// key bag, or returns null when the selector carries nothing (so the parent omits the key). + /// The rowSelector , or null when empty. + public JsonObject? ToJsonObject() + { + var o = _bag.DeepClone().AsObject(); + TagConfigJson.Set(o, "whereColumn", WhereColumn); + TagConfigJson.Set(o, "whereValue", WhereValue); + TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp); + return o.Count == 0 ? null : o; + } + + // Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw + // token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar). + private static string? ReadScalarText(JsonObject o, string name) + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; } + return v.TryGetValue(out var s) ? s : v.ToJsonString(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 0ec4501b..e10c9a50 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -21,6 +22,10 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), + // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared + // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests + // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index f73e69d4..50ee89af 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -23,6 +24,8 @@ public static class TagConfigValidator [DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(), + // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap. + [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 10a58694..8e483207 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -39,6 +39,10 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 71b5d0b7..2687366e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; using CalculationProbe = Driver.Calculation.CalculationDriverProbe; +using SqlProbe = Driver.Sql.SqlDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); + Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index dd7fbd9a..b3fc5bad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -76,6 +76,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index b89bfe91..325ef67f 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs new file mode 100644 index 00000000..4a34528a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ConcurrencyProbingDbConnection.cs @@ -0,0 +1,204 @@ +using System.Data; +using System.Data.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Records how many command executions are ever in flight at once, and how long each one is artificially +/// held open. Shared by and the commands it creates. +/// +/// +/// This exists because "the session serializes on a gate" is otherwise unfalsifiable from the outside: +/// against a real SQLite connection, overlapping calls mostly still *work*, so a test that only asserted +/// correct results would pass with the gate deleted. Measuring the overlap directly is what makes the +/// gate's removal visible. +/// +public sealed class ConcurrencyProbe +{ + private int _inFlight; + private int _peak; + + /// How long each command execution is held before it reaches the real provider. + public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120); + + /// The greatest number of executions observed in flight simultaneously. + public int Peak => Volatile.Read(ref _peak); + + /// Marks the start of one command execution. + public void Enter() + { + var current = Interlocked.Increment(ref _inFlight); + int observed; + while (current > (observed = Volatile.Read(ref _peak))) + { + if (Interlocked.CompareExchange(ref _peak, current, observed) == observed) break; + } + } + + /// Marks the end of one command execution. + public void Exit() => Interlocked.Decrement(ref _inFlight); +} + +/// +/// A pass-through that hands out commands instrumented with a +/// . Everything else delegates to the wrapped connection, so the code under +/// test runs against a real SQLite catalog. +/// +/// The real connection to delegate to. Not owned — the caller disposes it. +/// The probe that observes command overlap. +public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection +{ + /// + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString + { + get => inner.ConnectionString; + set => inner.ConnectionString = value!; + } + + /// + public override string Database => inner.Database; + + /// + public override string DataSource => inner.DataSource; + + /// + public override string ServerVersion => inner.ServerVersion; + + /// + public override ConnectionState State => inner.State; + + /// + public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); + + /// + public override void Close() => inner.Close(); + + /// + public override void Open() => inner.Open(); + + /// + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => + inner.BeginTransaction(isolationLevel); + + /// + protected override DbCommand CreateDbCommand() => + new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe); +} + +/// +/// A pass-through that reports every execution to a +/// and holds it open for the probe's delay, so overlapping executions are observable. +/// +/// The real command to delegate to. +/// The connection that created this command. +/// The probe that observes command overlap. +public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe) + : DbCommand +{ + /// + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string CommandText + { + get => inner.CommandText; + set => inner.CommandText = value!; + } + + /// + public override int CommandTimeout + { + get => inner.CommandTimeout; + set => inner.CommandTimeout = value; + } + + /// + public override CommandType CommandType + { + get => inner.CommandType; + set => inner.CommandType = value; + } + + /// + public override bool DesignTimeVisible + { + get => inner.DesignTimeVisible; + set => inner.DesignTimeVisible = value; + } + + /// + public override UpdateRowSource UpdatedRowSource + { + get => inner.UpdatedRowSource; + set => inner.UpdatedRowSource = value; + } + + /// + protected override DbConnection? DbConnection + { + get => owner; + set { /* The session never reassigns the connection; the wrapped command keeps its own. */ } + } + + /// + protected override DbParameterCollection DbParameterCollection => inner.Parameters; + + /// + protected override DbTransaction? DbTransaction + { + get => inner.Transaction; + set => inner.Transaction = value; + } + + /// + public override void Cancel() => inner.Cancel(); + + /// + public override int ExecuteNonQuery() => inner.ExecuteNonQuery(); + + /// + public override object? ExecuteScalar() => inner.ExecuteScalar(); + + /// + public override void Prepare() => inner.Prepare(); + + /// + protected override DbParameter CreateDbParameter() => inner.CreateParameter(); + + /// + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + probe.Enter(); + try + { + Thread.Sleep(probe.Delay); + return inner.ExecuteReader(behavior); + } + finally + { + probe.Exit(); + } + } + + /// + protected override async Task ExecuteDbDataReaderAsync( + CommandBehavior behavior, CancellationToken cancellationToken) + { + probe.Enter(); + try + { + await Task.Delay(probe.Delay, cancellationToken).ConfigureAwait(false); + return await inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false); + } + finally + { + probe.Exit(); + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs new file mode 100644 index 00000000..2dcc2a41 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.cs @@ -0,0 +1,128 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Pins the browse NodeId encoding. This codec is the load-bearing detail of the whole picker: a NodeId +/// that mis-parses sends the operator to a different column than the one they clicked, and the resulting +/// tag polls the wrong data forever with no error anywhere. SQL Server identifiers may legally contain +/// . and | (inside a quoted identifier), so the round-trip has to hold for those too. +/// +public sealed class SqlBrowseNodeIdTests +{ + [Fact] + public void ForSchema_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("dbo")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Schema); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBeNull(); + reference.Column.ShouldBeNull(); + } + + [Fact] + public void ForTable_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("dbo", "TagValues")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Table); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBe("TagValues"); + reference.Column.ShouldBeNull(); + } + + [Fact] + public void ForColumn_roundTrips() + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn("dbo", "TagValues", "num_value")); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Column); + reference.Schema.ShouldBe("dbo"); + reference.Table.ShouldBe("TagValues"); + reference.Column.ShouldBe("num_value"); + } + + /// + /// The exact case the plan's schema.table|column sketch cannot express: main.a.b|c reads + /// equally as schema main + table a.b and as schema main.a + table b. + /// + [Theory] + [InlineData("a.b", "c", "d")] + [InlineData("a", "b.c", "d")] + [InlineData("a", "b", "c.d")] + [InlineData("a|b", "c", "d")] + [InlineData("a", "b|c", "d")] + [InlineData("a", "b", "c|d")] + [InlineData(@"a\b", "c", "d")] + [InlineData("a", @"b\|c", "d")] + [InlineData("a", "b", @"c\\d")] + [InlineData("a.b|c", "d.e|f", "g.h|i")] + [InlineData("schema", "table", "column")] + [InlineData("x:y", "z:w", "q:r")] + public void AwkwardIdentifiers_roundTripExactly(string schema, string table, string column) + { + var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn(schema, table, column)); + + reference.Kind.ShouldBe(SqlBrowseNodeKind.Column); + reference.Schema.ShouldBe(schema); + reference.Table.ShouldBe(table); + reference.Column.ShouldBe(column); + } + + /// + /// Two genuinely different columns must never collide on one NodeId. Under the plan's literal + /// schema.table|column sketch both of these render as a.b|c. + /// + [Fact] + public void DistinctReferences_neverCollide() + { + var nested = SqlBrowseNodeId.ForColumn("a.b", "t", "c"); + var flat = SqlBrowseNodeId.ForColumn("a", "b.t", "c"); + + nested.ShouldNotBe(flat); + SqlBrowseNodeId.Parse(nested).Schema.ShouldBe("a.b"); + SqlBrowseNodeId.Parse(flat).Schema.ShouldBe("a"); + } + + /// A table NodeId and a schema NodeId are never confusable, whatever the names contain. + [Fact] + public void KindsAreDistinguishable() + { + SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("a|b")).Kind.ShouldBe(SqlBrowseNodeKind.Schema); + SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("a", "b")).Kind.ShouldBe(SqlBrowseNodeKind.Table); + SqlBrowseNodeId.ForSchema("a|b").ShouldNotBe(SqlBrowseNodeId.ForTable("a", "b")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("main")] // no kind prefix + [InlineData("bogus:main")] // unknown kind + [InlineData("table:main")] // table kind with only one part + [InlineData("schema:main|extra")] // schema kind with two parts + [InlineData("column:a|b")] // column kind with two parts + [InlineData("column:a|b|c|d")] // column kind with four parts + [InlineData("schema:")] // empty part + [InlineData("table:main|")] // empty trailing part + [InlineData(@"schema:main\")] // dangling escape + [InlineData(":main")] // empty kind + public void MalformedNodeIds_areRejected(string? nodeId) + { + SqlBrowseNodeId.TryParse(nodeId, out _).ShouldBeFalse(); + Should.Throw(() => SqlBrowseNodeId.Parse(nodeId)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyIdentifiers_areRejectedAtEncode(string? identifier) + { + Should.Throw(() => SqlBrowseNodeId.ForSchema(identifier!)); + Should.Throw(() => SqlBrowseNodeId.ForTable("main", identifier!)); + Should.Throw(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs new file mode 100644 index 00000000..d75ec6ec --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs @@ -0,0 +1,374 @@ +using System.Data; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Walks over a real, seeded SQLite catalog through the +/// — the only non-SQL-Server dialect in the tree, and therefore the control +/// that proves the session runs the dialect's catalog SQL rather than INFORMATION_SCHEMA +/// with a quoting helper bolted on. +/// +public sealed class SqlBrowseSessionTests +{ + private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId; + + [Fact] + public async Task RootAsync_yieldsSchemaFolders() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var roots = await session.RootAsync(CancellationToken.None); + + roots.Count.ShouldBe(1); + roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema); + roots[0].NodeId.ShouldBe(SchemaNodeId); + roots[0].Kind.ShouldBe(BrowseNodeKind.Folder); + roots[0].HasChildrenHint.ShouldBeTrue(); + } + + [Fact] + public async Task ExpandSchema_yieldsTablesAndViewsAsFolders() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + + children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint); + children.ShouldContain(n => + n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable)); + // The view is listed beside the tables, and is labelled so the operator can tell them apart. + var view = children + .Where(n => n.NodeId == + SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName)) + .ShouldHaveSingleItem(); + view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName); + view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName); + } + + /// The plan's headline case: table expand yields columns as committable leaves. + [Fact] + public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + // Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --", + // so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too. + var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None)) + .First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); + + var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None); + + columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn + && c.Kind == BrowseNodeKind.Leaf + && !c.HasChildrenHint); + + var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn); + var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None); + + var attribute = attributes.ShouldHaveSingleItem(); + attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn); + attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); + attribute.IsArray.ShouldBeFalse(); + attribute.SecurityClass.ShouldBe("ViewOnly"); + attribute.IsAlarm.ShouldBeFalse(); + } + + [Fact] + public async Task AttributesAsync_mapsTextColumnToString() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn); + var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); + + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); + } + + /// + /// An exotic declared type must fall back to via + /// MapColumnType rather than crash the browse — a table with one geography column must + /// still render. + /// + [Fact] + public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable); + var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None); + columns.Count.ShouldBe(2); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn); + var attributes = await session.AttributesAsync(nodeId, CancellationToken.None); + + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String)); + } + + /// + /// The end-to-end awkward-identifier walk: a table named a.b|c holding a column named + /// x|y. Both characters are the ones a naive schema.table|column NodeId splits on, so + /// this is the case that proves the encoding, not just the codec unit test. + /// + [Fact] + public async Task AwkwardIdentifiers_surviveTheFullWalk() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + var awkward = tables + .Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable) + .ShouldHaveSingleItem(); + + var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None); + columns.Select(c => c.DisplayName) + .ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 }, + ignoreOrder: true); + + var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn); + var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId); + parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable); + parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn); + + var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None); + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); + } + + /// + /// A table whose name is an injection payload. If any catalog query concatenated the name + /// instead of binding @table, expanding it would drop TagValues. + /// + [Fact] + public async Task HostileTableName_isBound_notConcatenated() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable); + var columns = await session.ExpandAsync(nodeId, CancellationToken.None); + + // Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it + // before anything about the columns, or a splice that returns no rows reds on the wrong line and the + // test stops proving the payload was inert. + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn); + + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn); + var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None); + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32)); + } + + /// A hostile schema name takes the same bound path at the table level. + [Fact] + public async Task HostileSchemaName_isBound_notConcatenated() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --"); + var tables = await session.ExpandAsync(nodeId, CancellationToken.None); + + // SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing. + tables.ShouldBeEmpty(); + db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1); + } + + /// + /// A table node whose table is gone (dropped between browse and expand) yields an empty column list. + /// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less + /// table, and an empty catalog answer is what both shapes look like from the browser's side. + /// + [Fact] + public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable); + + (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); + + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever"); + (await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// A column NodeId is terminal — expanding it is a no-op, never an error. + [Fact] + public async Task ExpandColumn_yieldsEmpty() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var nodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); + + (await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser. + [Fact] + public async Task AttributesAsync_onNonLeafNode_yieldsEmpty() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + (await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty(); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); + (await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty(); + } + + /// + /// A garbage NodeId must surface as an carrying an operator-readable + /// message — the same contract the Galaxy and OPC UA client sessions have, and what + /// DriverBrowseTree.razor renders inline as the node's error. It must not be a + /// or an from a blind split. + /// + [Theory] + [InlineData("")] + [InlineData("not-a-node-id")] + [InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format + [InlineData("column:a|b|c|d")] + public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId) + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var expand = await Should.ThrowAsync( + () => session.ExpandAsync(nodeId, CancellationToken.None)); + expand.Message.ShouldNotBeNullOrWhiteSpace(); + + await Should.ThrowAsync( + () => session.AttributesAsync(nodeId, CancellationToken.None)); + } + + /// + /// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate. + /// Asserted by measuring overlap directly through : a results-only + /// assertion would pass with the gate deleted. + /// + [Fact] + public async Task ConcurrentCalls_areSerializedByTheGate() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var probe = new ConcurrencyProbe(); + var probing = new ConcurrencyProbingDbConnection(db.Connection, probe); + await using var session = new SqlBrowseSession(probing, new SqliteDialect()); + + var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable); + var calls = new[] + { + session.RootAsync(CancellationToken.None), + session.ExpandAsync(SchemaNodeId, CancellationToken.None), + session.ExpandAsync(tableNodeId, CancellationToken.None), + session.ExpandAsync(tableNodeId, CancellationToken.None), + }; + + var results = await Task.WhenAll(calls); + + probe.Peak.ShouldBe(1); + // TagValues has three columns — proves the serialized calls still returned real catalog rows. + results[2].Count.ShouldBe(3); + } + + /// + /// Ownership: the session takes over the connection it is handed and closes it on dispose. The + /// AdminUI's BrowseSessionRegistry is the only thing holding a session, so if the session did + /// not close the connection, nothing would — every reaped picker would leak one. + /// + [Fact] + public async Task DisposeAsync_closesTheConnectionItWasGiven() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var connection = db.OpenNewConnection(); + var session = new SqlBrowseSession(connection, new SqliteDialect()); + + connection.State.ShouldBe(ConnectionState.Open); + await session.DisposeAsync(); + + connection.State.ShouldBe(ConnectionState.Closed); + } + + [Fact] + public async Task DisposeAsync_isIdempotent() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); + + await session.DisposeAsync(); + await Should.NotThrowAsync(async () => await session.DisposeAsync()); + } + + [Fact] + public async Task CallsAfterDispose_throwObjectDisposedException() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect()); + await session.DisposeAsync(); + + await Should.ThrowAsync( + () => session.RootAsync(CancellationToken.None)); + await Should.ThrowAsync( + () => session.ExpandAsync(SchemaNodeId, CancellationToken.None)); + } + + /// + /// The AdminUI bounds every call with a 20 s linked CTS + /// (BrowserSessionService.PerCallTimeout) — the session invents no timeout of its own, it just + /// has to honour the token it is handed, all the way into the ADO.NET call. + /// + [Fact] + public async Task CancelledToken_isHonoured() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync(() => session.RootAsync(cts.Token)); + await Should.ThrowAsync(() => session.ExpandAsync(SchemaNodeId, cts.Token)); + } + + /// + /// feeds the registry's idle reaper — a session that never + /// refreshed it would be evicted out from under an actively browsing operator. + /// + [Fact] + public async Task LastUsedUtc_advancesOnEverySuccessfulCall() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect()); + + var atStart = session.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + await session.RootAsync(CancellationToken.None); + var afterRoot = session.LastUsedUtc; + afterRoot.ShouldBeGreaterThan(atStart); + + await Task.Delay(15, TestContext.Current.CancellationToken); + await session.ExpandAsync(SchemaNodeId, CancellationToken.None); + var afterExpand = session.LastUsedUtc; + afterExpand.ShouldBeGreaterThan(afterRoot); + + await Task.Delay(15, TestContext.Current.CancellationToken); + var columnNodeId = SqlBrowseNodeId.ForColumn( + SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn); + await session.AttributesAsync(columnNodeId, CancellationToken.None); + session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs new file mode 100644 index 00000000..c984c7bd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// Covers 's open side: which of the two ways to name a database wins, what +/// an unresolvable reference tells the operator, which providers this build carries — and, above all, +/// that a connection string never escapes into a log line or an exception. +/// The successful-open cases run through the linked against the real +/// seeded , injected via the browser's dialectSelector +/// constructor parameter. That parameter is the production extension point for a P2 provider, not a test +/// hatch — the same conclusion the driver author reached for SqlDriver's factory +/// parameter. +/// +public sealed class SqlDriverBrowserTests +{ + /// + /// The credential every hygiene assertion hunts for. Distinctive enough that a substring match + /// anywhere in an exception or a log line is proof of a leak, not a coincidence. + /// + private const string Password = "Sup3rSecret-OtOpcUa-Probe"; + + /// + /// A host that cannot resolve, so the connect fails in DNS rather than on a timer. .invalid is + /// reserved by RFC 2606 and can never be registered. + /// + private const string DeadHost = "otopcua-no-such-host.invalid"; + + private static Func SqliteSelector => static _ => new SqliteDialect(); + + // ---- identity ---- + + [Fact] + public void DriverType_isTheSqlDriversOwnInterimConstant() + { + // Deliberately asserted against SqlDriver.DriverTypeName rather than a literal: DriverTypeNames.Sql + // does not exist yet (adding it reddens DriverTypeNamesGuardTests until a factory is registered), so + // the driver's local constant is the single definition both sides must agree on. + new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName); + } + + [Fact] + public void DefaultDialectSelector_carriesSqlServerOnly() + { + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType(); + + foreach (var provider in Enum.GetValues().Where(p => p != SqlProvider.SqlServer)) + SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); + } + + // ---- connection-string reference resolution ---- + + /// The plan's headline case: an unresolvable ref must name the exact variable to set. + [Fact] + public async Task Open_unresolvableRef_failsNamingExactEnvVar() + { + var browser = new SqlDriverBrowser(); + var ex = await Should.ThrowAsync(() => + browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default)); + ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging"); + } + + /// + /// The default (production) path: no injected reader, a real process environment variable. Uses a + /// run-unique name and restores it in a finally, so a failure cannot leave it set for the rest + /// of the assembly — environment variables are process-global and every other test shares them. + /// + [Fact] + public async Task Open_refResolvedFromTheRealEnvironment_opensSession() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var reference = "OtOpcUaBrowseTest" + Guid.NewGuid().ToString("N"); + var variable = SqlDriverBrowser.EnvironmentVariableFor(reference); + + Environment.SetEnvironmentVariable(variable, db.ConnectionString); + try + { + var browser = new SqlDriverBrowser(SqliteSelector); + + await using var session = await browser.OpenAsync( + ConfigJson(connectionStringRef: reference), CancellationToken.None); + + var roots = await session.RootAsync(CancellationToken.None); + roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Fact] + public async Task Open_neitherRefNorLiteral_failsNamingBothWays() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(), CancellationToken.None)); + + ex.Message.ShouldContain("connectionStringRef"); + ex.Message.ShouldContain("connectionString"); + } + + [Fact] + public async Task Open_blankRef_failsRatherThanResolvingAnEmptyVariableName() + { + var browser = new SqlDriverBrowser(SqliteSelector, static _ => "should never be read"); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionStringRef: " "), CancellationToken.None)); + + ex.Message.ShouldContain("connectionStringRef"); + } + + // ---- pasted literal, and its precedence ---- + + /// + /// A pasted literal opens a working session, and the session owns the connection — it is still + /// live after OpenAsync returns (the browser did not close it) and dead after the session is + /// disposed (nothing else will). + /// + [Fact] + public async Task Open_pastedLiteral_opensWorkingSessionThatOwnsTheConnection() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var browser = new SqlDriverBrowser(SqliteSelector); + + var session = await browser.OpenAsync( + ConfigJson(connectionString: db.ConnectionString), CancellationToken.None); + + var children = await session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None); + children.ShouldContain(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable); + + await session.DisposeAsync(); + + await Should.ThrowAsync(() => + session.ExpandAsync(SqliteBrowseFixture.SchemaNodeId, CancellationToken.None)); + } + + /// + /// Precedence: the pasted literal wins, and the reference is not even read. Proven by a reader + /// that records every call — an assertion on the resulting session alone would still pass if the ref + /// were consulted first and merely lost a tie-break. + /// + [Fact] + public async Task Open_refAndLiteralTogether_literalWinsAndRefIsNeverRead() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var reads = new List(); + var logger = new CapturingLogger(); + var browser = new SqlDriverBrowser( + SqliteSelector, + name => + { + reads.Add(name); + return "Data Source=/otopcua-no-such-directory/never.db"; + }, + logger); + + await using var session = await browser.OpenAsync( + ConfigJson(connectionStringRef: "MesStaging", connectionString: db.ConnectionString), + CancellationToken.None); + + reads.ShouldBeEmpty(); + var roots = await session.RootAsync(CancellationToken.None); + roots.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteDialect.MainSchema); + + // The override is announced, naming the shadowed ref — never any connection text. + logger.Entries.ShouldContain(e => e.Contains("MesStaging", StringComparison.Ordinal)); + logger.Entries.ShouldNotContain(e => e.Contains(db.ConnectionString, StringComparison.OrdinalIgnoreCase)); + } + + // ---- provider availability ---- + + [Fact] + public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild() + { + var browser = new SqlDriverBrowser(); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("not available in this build"); + } + + /// + /// Provider availability is answered before the environment is touched: "this build cannot browse + /// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other. + /// + [Fact] + public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider() + { + var reads = new List(); + var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; }); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldContain("not available in this build"); + reads.ShouldBeEmpty(); + } + + // ---- malformed configuration ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Open_emptyConfig_failsAskingForOne(string configJson) + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(configJson, CancellationToken.None)); + + ex.Message.ShouldContain("configuration"); + } + + /// + /// Malformed JSON must fail as malformed JSON — and must not echo the offending text back, because + /// the text a JSON parser chokes on may itself be a half-pasted connection string. + /// + [Fact] + public async Task Open_malformedJson_failsWithoutEchoingTheConfig() + { + var browser = new SqlDriverBrowser(SqliteSelector); + var configJson = $$"""{"provider":"SqlServer","connectionString":"Password={{Password}}" """; + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(configJson, CancellationToken.None)); + + ex.Message.ShouldContain("not valid JSON"); + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + } + + [Fact] + public async Task Open_jsonThatIsNotAnObject_fails() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync("[1,2,3]", CancellationToken.None)); + + ex.Message.ShouldContain("JSON object"); + } + + /// An unknown provider name is a bind failure, and must not echo the config either. + [Fact] + public async Task Open_unknownProviderName_failsWithoutEchoingTheConfig() + { + var browser = new SqlDriverBrowser(SqliteSelector); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson("NotARealProvider", connectionString: $"Server=x;Password={Password}"), + CancellationToken.None)); + + ex.Message.ShouldContain("could not be bound"); + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + } + + // ---- credential hygiene ---- + + /// + /// The natural case the task names: a well-formed connection string carrying a password, pointed at a + /// host that does not exist. Nothing about the string may reach the exception. + /// + [Fact] + public async Task Open_unreachableHost_neverLeaksThePastedConnectionString() + { + var connectionString = + $"Server={DeadHost};Database=Mes;User ID=sa;Password={Password};Connect Timeout=1;" + + "TrustServerCertificate=true"; + var browser = new SqlDriverBrowser(); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None)); + + ex.ToString().ShouldNotContain(Password, Case.Insensitive); + ex.ToString().ShouldNotContain(connectionString, Case.Insensitive); + // Still says something useful about *why*. + ex.Message.ShouldContain("could not open a connection"); + } + + /// + /// The leak that is real, and is the reason the parser's message is suppressed rather than trusted. + /// ADO.NET expects a value containing ; to be quoted. An unquoted one splits, and the + /// tail of the password is then parsed as a keyword — which + /// Microsoft.Data.SqlClient echoes verbatim (lower-cased) in + /// Keyword not supported: '…'. The first half of this test is the control: it proves the raw + /// provider really does leak, so the second half asserting the browser does not is not vacuous. + /// + [Fact] + public async Task Open_semicolonBearingPassword_neverLeaksTheProvidersEchoedKeyword() + { + const string tail = "OtOpcUaTailSecret"; + var connectionString = $"Server={DeadHost};Password=Sup3r;{tail};Connect Timeout=1"; + + // Control: the bare provider leaks the tail of the password into its own exception. + var raw = Should.Throw(() => + { + using var connection = new SqlServerDialect().Factory.CreateConnection()!; + connection.ConnectionString = connectionString; + }); + raw.ToString().ShouldContain(tail, Case.Insensitive); + + // The browser does not. + var browser = new SqlDriverBrowser(); + var ex = await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: connectionString), CancellationToken.None)); + + ex.ToString().ShouldNotContain(tail, Case.Insensitive); + ex.Message.ShouldContain("malformed"); + } + + /// + /// Nothing the browser logs — on the success path or the failure path — carries connection text. The + /// ref name is fair game; the string never is. + /// + [Fact] + public async Task Open_neverLogsTheConnectionString() + { + await using var db = await SqliteBrowseFixture.CreateAsync(); + var logger = new CapturingLogger(); + var browser = new SqlDriverBrowser(SqliteSelector, static _ => null, logger); + + await using (await browser.OpenAsync( + ConfigJson(connectionString: db.ConnectionString), CancellationToken.None)) + { + // Opened purely for its log output. + } + + var dead = $"Data Source=/otopcua-no-such-directory/never.db;Password={Password}"; + await Should.ThrowAsync(() => + browser.OpenAsync(ConfigJson(connectionString: dead), CancellationToken.None)); + + logger.Entries.ShouldNotBeEmpty(); + foreach (var entry in logger.Entries) + { + entry.ShouldNotContain(db.ConnectionString, Case.Insensitive); + entry.ShouldNotContain(Password, Case.Insensitive); + entry.ShouldNotContain("Data Source", Case.Insensitive); + } + } + + // ---- helpers ---- + + /// + /// Builds a driver-config blob. Serialized rather than interpolated so a connection string containing + /// JSON metacharacters (backslashes in a Data Source path, quotes in a password) escapes + /// correctly instead of producing malformed JSON the test would then misattribute. + /// + private static string ConfigJson( + string provider = "SqlServer", string? connectionStringRef = null, string? connectionString = null) + { + var map = new Dictionary { ["provider"] = provider }; + if (connectionStringRef is not null) map["connectionStringRef"] = connectionStringRef; + if (connectionString is not null) map["connectionString"] = connectionString; + return JsonSerializer.Serialize(map); + } + + /// Records every formatted log message so the hygiene assertions can read them back. + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = []; + + IDisposable? ILogger.BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add(formatter(state, exception)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs new file mode 100644 index 00000000..65c4e8e8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs @@ -0,0 +1,190 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; + +/// +/// A real, seeded SQLite database standing in for the SQL Server the schema browser walks. It exists so +/// the browse path runs against a genuine catalog — real sqlite_schema rows, real +/// pragma_table_info output, real parameter binding — with no SQL Server and no network. +/// Backed by a temporary FILE, not :memory:, for the same reason +/// SqlitePollFixture is: an in-memory database dies with its last connection, and several tests +/// here deliberately open a second connection or dispose the session's connection mid-test. +/// The seed is a contract. Every table below exists to pin one browse behaviour, and the +/// awkward ones are the point: and carry the +/// . and | characters that a naive schema.table|column NodeId would mis-parse, and +/// is a live injection probe — if any catalog query concatenated a name +/// instead of binding it, expanding that table drops and the assertions say +/// so. +/// +public sealed class SqliteBrowseFixture : IAsyncDisposable +{ + /// The ordinary key-value table, mirroring the poll fixture's shape. + public const string KeyValueTable = "TagValues"; + + /// The column declared REAL, i.e. Float64. + public const string RealColumn = "num_value"; + + /// The column declared TEXT, i.e. String. + public const string TextColumn = "tag_name"; + + /// A view over — the browser must list views beside tables. + public const string ViewName = "TagValuesView"; + + /// + /// A table whose name contains BOTH NodeId metacharacters. A schema.table|column NodeId cannot + /// represent this unambiguously — main.a.b|c reads equally as schema main.a, table + /// b. Legal in SQL Server inside a quoted identifier, so the encoding must survive it. + /// + public const string AwkwardTable = "a.b|c"; + + /// A column name carrying the separator character. + public const string AwkwardColumn = "x|y"; + + /// A column name carrying a dot and the escape character. + public const string AwkwardColumn2 = @"d.o\t"; + + /// + /// A table named as a SQL injection payload. Nothing about it is special to the browser — that is + /// exactly the assertion: it is bound as a value, never spliced into a command text. + /// + public const string HostileTable = "'); DROP TABLE TagValues; --"; + + /// The single column of . + public const string HostileColumn = "hostile_col"; + + /// A table whose columns carry types no dialect map recognises. + public const string ExoticTable = "Exotic"; + + /// An column declared with a type outside the map's vocabulary. + public const string ExoticColumn = "shape_col"; + + /// A table name that exists in no catalog — the "vanished table" / zero-column probe. + public const string NonExistentTable = "NoSuchTable"; + + private readonly string _databasePath; + + private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection) + { + _databasePath = databasePath; + ConnectionString = connectionString; + Connection = connection; + } + + /// The connection string over the temporary database file. + public string ConnectionString { get; } + + /// + /// A long-lived open connection over the seeded database — what tests hand + /// SqlBrowseSession. Independent of any other connection over the same file. + /// + public SqliteConnection Connection { get; } + + /// + /// The NodeId of SQLite's one and only schema, encoded exactly as + /// SqlBrowseSession.RootAsync emits it. + /// + public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema); + + /// Creates the temporary database, applies the schema, and seeds it. + /// The ready fixture. + public static async Task CreateAsync() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"otopcua-sql-browse-{Guid.NewGuid():N}.db"); + var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString(); + var connection = new SqliteConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await SeedAsync(connection).ConfigureAwait(false); + return new SqliteBrowseFixture(databasePath, connectionString, connection); + } + + /// Opens a brand-new connection over the same database. + /// An open connection the caller owns. + public SqliteConnection OpenNewConnection() + { + var connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + /// Counts rows in a table, on a connection of its own so a disposed session cannot affect it. + /// The (unquoted) table name to count. + /// The row count, or -1 when the table does not exist. + public long CountRows(string table) + { + using var connection = OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + try + { + return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + catch (SqliteException) + { + return -1; + } + } + + /// Closes the fixture connection, clears the pool, and deletes the temporary file. + /// A task that represents the asynchronous operation. + public async ValueTask DisposeAsync() + { + await Connection.DisposeAsync().ConfigureAwait(false); + // Microsoft.Data.Sqlite pools per connection string; without this the file stays held open and the + // delete silently fails, leaking one file per test into the temp directory. + SqliteConnection.ClearAllPools(); + try + { + if (File.Exists(_databasePath)) File.Delete(_databasePath); + } + catch (IOException) + { + // A leaked temp file must never fail a test run. + } + } + + /// + /// Applies the schema described by this type's constants. Every column is explicitly declared — + /// SQLite stores affinity rather than a type, so an undeclared column would give + /// pragma_table_info (and therefore MapColumnType) nothing real to report. + /// + private static async Task SeedAsync(SqliteConnection connection) + { + await ExecuteAsync(connection, $""" + CREATE TABLE "{KeyValueTable}" ( + "{TextColumn}" TEXT NOT NULL, + "{RealColumn}" REAL NULL, + sample_ts TEXT NOT NULL + ); + INSERT INTO "{KeyValueTable}" ("{TextColumn}", "{RealColumn}", sample_ts) + VALUES ('Line1.Speed', 42.0, '2026-07-24T10:00:00Z'); + + CREATE VIEW "{ViewName}" AS SELECT "{TextColumn}", "{RealColumn}" FROM "{KeyValueTable}"; + + CREATE TABLE "{ExoticTable}" ( + "{ExoticColumn}" GEOGRAPHY NULL, + blob_col BLOB NULL + ); + """).ConfigureAwait(false); + + // Separate statements: these names must be quoted by the seed itself, so keep them off the + // interpolation path above where a stray quote would be hard to spot. + await ExecuteAsync(connection, + $"CREATE TABLE {Quote(AwkwardTable)} ({Quote(AwkwardColumn)} REAL NULL, {Quote(AwkwardColumn2)} TEXT NULL)") + .ConfigureAwait(false); + + await ExecuteAsync(connection, + $"CREATE TABLE {Quote(HostileTable)} ({Quote(HostileColumn)} INTEGER NULL)") + .ConfigureAwait(false); + } + + private static string Quote(string identifier) => + string.Concat("\"", identifier.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + + private static async Task ExecuteAsync(SqliteConnection connection, string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj new file mode 100644 index 00000000..b3837b17 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 00000000..eb918f8d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,68 @@ +# Dedicated, DISPOSABLE SQL Server for the Sql driver's blackhole / frozen-peer live gate. +# +# ── SAFETY ───────────────────────────────────────────────────────────────────── +# This stack owns its OWN mssql container, `otopcua-sql-blackhole`, on host port +# 14333. The blackhole gate `docker pause`s THIS container by that hard-coded name. +# It is NOT — and must never be — the rig's shared always-on SQL Server, which is +# `10.100.0.35,14330` and hosts ConfigDb for the whole dev rig. Two independent +# guardrails keep them apart: +# 1. a distinct container_name (`otopcua-sql-blackhole`) — the pause target is a +# compile-time constant in the test, and the shared server is not named this; and +# 2. a distinct host port (14333, not 14330) — the test SKIPS LOUDLY if its +# endpoint resolves to port 14330. +# `restart: "no"` so a paused/killed container stays down rather than respawning. +# +# ── DEPLOY (from this VM; docker runs on the shared Linux host) ────────────────── +# lmxopcua-fix sync sql-blackhole # rsync this Docker/ dir → /opt/otopcua-sql-blackhole/ +# lmxopcua-fix up sql-blackhole # single-service stack (+ one-shot seed), no profile arg +# `lmxopcua-fix` applies the host-side `project=lmxopcua` label; the explicit label +# below keeps the stack discoverable even when brought up with plain `docker compose`. +# +# The one-shot `mssql-seed` service applies seed.sql once the server is healthy, so +# the stack is self-seeding. The blackhole test ALSO seeds defensively on connect +# (create-if-missing, same schema), so a stack that never ran the seed still works. + +name: otopcua-sql-blackhole + +services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: otopcua-sql-blackhole + labels: + project: lmxopcua + restart: "no" + environment: + ACCEPT_EULA: "Y" + # Dev-only sandbox password for a throwaway container. Override via the shell env + # (the same value the test reads from SQL_BLACKHOLE_PASSWORD). + MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}" + MSSQL_PID: "Developer" + ports: + - "14333:1433" + healthcheck: + # -C trusts the self-signed dev cert; the login succeeding is the readiness signal. + test: + - "CMD-SHELL" + - "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -Q 'SELECT 1' || exit 1" + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + + # One-shot seed: waits for the server to pass its healthcheck, applies seed.sql, exits. + mssql-seed: + image: mcr.microsoft.com/mssql/server:2022-latest + labels: + project: lmxopcua + restart: "no" + depends_on: + mssql: + condition: service_healthy + environment: + MSSQL_SA_PASSWORD: "${SQL_BLACKHOLE_SA_PASSWORD:-Blackhole_dev_pw1}" + volumes: + - ./seed.sql:/seed.sql:ro + entrypoint: + - "/bin/bash" + - "-c" + - "/opt/mssql-tools18/bin/sqlcmd -C -S mssql -U sa -P \"$${MSSQL_SA_PASSWORD}\" -i /seed.sql" diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql new file mode 100644 index 00000000..6a91d115 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql @@ -0,0 +1,43 @@ +-- Seed for the Sql driver's blackhole / frozen-peer live gate (dedicated container only). +-- +-- Applied once by the compose `mssql-seed` one-shot against the OWNED +-- `otopcua-sql-blackhole` container. The blackhole test (SqlBlackholeTimeoutTests) +-- also creates this exact shape defensively on connect, so the two are kept in +-- parity: a Good baseline poll before the pause depends on `sqlpoll.TagValues` +-- holding the one seeded row. +-- +-- Two sample tables mirror the offline SqlitePollFixture / live SqlPollServerFixture +-- shapes so the gate polls something representative, not a bespoke schema. + +IF DB_ID(N'SqlBlackholeFixture') IS NULL CREATE DATABASE [SqlBlackholeFixture]; +GO + +USE [SqlBlackholeFixture]; +GO + +IF SCHEMA_ID(N'sqlpoll') IS NULL EXEC(N'CREATE SCHEMA [sqlpoll]'); +GO + +-- Key-value (EAV) source: tag_name → num_value, with a source timestamp. +DROP TABLE IF EXISTS [sqlpoll].[TagValues]; +CREATE TABLE [sqlpoll].[TagValues] ( + [tag_name] nvarchar(128) NOT NULL, + [num_value] float NULL, + [sample_ts] datetime2(3) NOT NULL +); +INSERT INTO [sqlpoll].[TagValues] ([tag_name], [num_value], [sample_ts]) +VALUES (N'Line1.Speed', 42.0, '2026-07-24T10:00:00'), + (N'Line1.Pressure', 3.5, '2026-07-24T10:00:01'); +GO + +-- Wide-row source: one row per station, several value columns. +DROP TABLE IF EXISTS [sqlpoll].[LatestStatus]; +CREATE TABLE [sqlpoll].[LatestStatus] ( + [station_id] int NOT NULL, + [oven_temp] float NULL, + [pressure] float NULL, + [sample_ts] datetime2(3) NOT NULL +); +INSERT INTO [sqlpoll].[LatestStatus] ([station_id], [oven_temp], [pressure], [sample_ts]) +VALUES (7, 180.5, 1.2, '2026-07-24T10:00:00'); +GO diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs new file mode 100644 index 00000000..9cc26756 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs @@ -0,0 +1,440 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The single highest-value integration test for the Sql driver: the frozen-peer / +/// blackhole gate. When the database stops answering mid-poll, a reused pooled connection hangs +/// inside ExecuteReaderAsync; the driver must surface +/// within its client-side operationTimeout (a real linked-CTS cancellation) and NOT wedge the +/// poll loop. This is the exact bug class the repo shipped and fixed once in its S7 driver (async +/// reads ignored the socket read timeout → a frozen peer wedged the poll loop; see +/// S7_1500ConnectTimeoutOutageTests, which this mirrors). +/// The wall-clock bound is the whole point. operationTimeout is set well BELOW +/// commandTimeout here, deliberately inverted from the production rule, so the two backstops +/// are distinguishable: a working client-side cancellation returns at ≈ operationTimeout; if it +/// were broken and only the ADO.NET CommandTimeout server-side backstop fired, the read would +/// return at ≈ commandTimeout instead — which the upper-bound assertion fails on. A test that +/// merely asserted "eventually BadTimeout" would pass even with the client-side cancellation broken. +/// SAFETY — this gate `docker pause`s a DEDICATED container it owns, never shared infra. +/// The pause target is the compile-time constant +/// (otopcua-sql-blackhole), never anything derived from an env var, so a mis-set endpoint can +/// never aim a pause at the rig's shared always-on SQL Server (10.100.0.35,14330, which hosts +/// ConfigDb for the whole rig). As a second guardrail the gate skips loudly if its endpoint +/// resolves to the shared server's port . Bring the dedicated stack up +/// with Docker/docker-compose.yml (container otopcua-sql-blackhole, host port 14333). +/// Env-gated; offline skip is the normal outcome. With unset +/// nothing here opens a socket, shells out to docker, or waits — the suite is instant and green on the +/// macOS dev box, exactly like every other *.IntegrationTests live gate in this tree. +/// +/// Operator run recipe (docker runs on the shared Linux host; drive it from this VM): +/// +/// # 1. Deploy + start the DEDICATED disposable mssql (NOT the shared 14330 server): +/// lmxopcua-fix sync sql-blackhole +/// lmxopcua-fix up sql-blackhole # container otopcua-sql-blackhole on :14333, self-seeds +/// +/// # 2. Point the gate at the dedicated container + tell it how to reach docker: +/// export SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 # the dedicated container; MUST NOT be ,14330 +/// export SQL_BLACKHOLE_PASSWORD=Blackhole_dev_pw1 # matches the compose SA password +/// export SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 # empty ⇒ run docker locally +/// +/// # 3. Run just this gate: +/// dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \ +/// --filter "FullyQualifiedName~SqlBlackholeTimeoutTests" +/// +/// # 4. Tear the disposable stack down: +/// lmxopcua-fix down sql-blackhole +/// +/// +/// +[Trait("Category", "Integration")] +[Trait("Category", "Blackhole")] +public sealed class SqlBlackholeTimeoutTests +{ + /// Endpoint (host,port) of the DEDICATED disposable container. Absent ⇒ offline skip. + private const string EndpointEnvVar = "SQL_BLACKHOLE_ENDPOINT"; + + /// SA password for the dedicated container. Required by the run. + private const string PasswordEnvVar = "SQL_BLACKHOLE_PASSWORD"; + + /// SQL login. Defaults to sa. + private const string UserEnvVar = "SQL_BLACKHOLE_USER"; + + /// + /// Optional SSH target the docker pause/unpause runs through (e.g. + /// dohertj2@10.100.0.35), since docker -H ssh:// does not work from this VM. Empty ⇒ + /// run docker locally. + /// + private const string DockerSshEnvVar = "SQL_BLACKHOLE_DOCKER_SSH"; + + /// + /// The ONLY container this gate ever pauses — a compile-time constant, never derived from the + /// environment. The shared central SQL Server is not named this, so no env misconfiguration can + /// cause this gate to freeze shared infra. It matches Docker/docker-compose.yml's + /// container_name. + /// + private const string ContainerName = "otopcua-sql-blackhole"; + + /// + /// The rig's shared always-on SQL Server port (hosts ConfigDb for the whole rig). An endpoint on + /// this port is refused with a loud skip — the dedicated container must be on a different port. + /// + private const string SharedCentralPort = "14330"; + + /// The database the seed lives in; created if missing (never dropped). + private const string Database = "SqlBlackholeFixture"; + + /// The seeded key-value table + its columns and the one baseline key. + private const string Schema = "sqlpoll"; + private const string KeyValueTable = "TagValues"; + private const string KeyColumn = "tag_name"; + private const string ValueColumn = "num_value"; + private const string TimestampColumn = "sample_ts"; + private const string BaselineKey = "Line1.Speed"; + private const double BaselineValue = 42.0; + + /// The RawPath the driver serves the baseline key under. + private const string RawPath = "Sql/Line1/Speed"; + + // ── deadlines chosen so client-side and server-side backstops are DISTINGUISHABLE ── + + /// Client-side wall-clock ceiling on one group. A frozen peer must surface BadTimeout at ≈ this. + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(3); + + /// + /// ADO.NET server-side backstop — set an order of magnitude ABOVE so + /// that if the client-side cancellation were broken and only this fired, the read would return at + /// ≈ 30 s and the upper-bound assertion would catch it. + /// + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30); + + /// + /// Upper bound on the post-pause read. Comfortably above (docker + /// pause propagation + pooled-connection validation + thread-pool scheduling) yet comfortably below + /// — so a return here proves the CLIENT-side bound fired, not the + /// server-side backstop. + /// + private static readonly TimeSpan UpperBound = TimeSpan.FromSeconds(15); + + /// Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI. + private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60); + + /// Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI. + private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30); + + /// + /// Start reading against the dedicated mssql, docker pause it mid-poll, and assert the next + /// read surfaces within (a + /// client-side linked-CTS cancellation, not the poll thread wedging and not the server-side + /// backstop), that the driver degrades (health + host + /// ), and that after docker unpause a subsequent poll recovers + /// to / . + /// + [Fact] + public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged() + { + var ep = Environment.GetEnvironmentVariable(EndpointEnvVar); + Assert.SkipWhen( + string.IsNullOrWhiteSpace(ep), + $"{EndpointEnvVar} not set — offline skip. Bring up Docker/docker-compose.yml (dedicated " + + $"container {ContainerName} on :14333) and export {EndpointEnvVar}/{PasswordEnvVar} to run this gate."); + + var password = Environment.GetEnvironmentVariable(PasswordEnvVar); + Assert.SkipWhen( + string.IsNullOrWhiteSpace(password), + $"{PasswordEnvVar} not set — the blackhole gate needs the dedicated container's SA password."); + + var (host, port) = ParseEndpoint(ep!); + + // ── SAFETY GUARD: refuse to run against the rig's shared central SQL Server. ── + // The pause target (ContainerName) is a constant and could never be the shared container regardless, + // but refusing the shared PORT stops us even connecting-and-polling against shared infra. + Assert.SkipWhen( + string.Equals(port, SharedCentralPort, StringComparison.Ordinal), + $"{EndpointEnvVar} resolves to port {SharedCentralPort} — that is the rig's SHARED always-on SQL " + + "Server (ConfigDb for the whole rig), which this destructive gate must NEVER pause. Point it at " + + $"the dedicated {ContainerName} container (host port 14333), not the shared server."); + + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa"; + var ssh = Environment.GetEnvironmentVariable(DockerSshEnvVar); + var ct = TestContext.Current.CancellationToken; + + // Seed defensively (create-if-missing) so the baseline poll is Good even if compose never ran seed.sql. + await SeedAsync(host, port, user, password!, ct); + + await using var driver = await StartDriverAsync(host, port, user, password!, ct); + + // Good baseline — prove the endpoint answers before we freeze it. + var baseline = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem(); + baseline.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(baseline.Value).ShouldBe(BaselineValue); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + var paused = false; + try + { + // Freeze the container mid-poll: its SQL process is SIGSTOPped, so the pooled connection's next + // ExecuteReaderAsync hangs against a live-but-silent socket — the frozen-peer wedge exactly. + // Mark paused BEFORE the await: if `ct` fires mid-command the process may still complete the pause, + // so the finally must attempt an unpause regardless — never leave the container frozen. + paused = true; + await RunShellCommandAsync(DockerCommand("pause", ssh), ct); + + var clock = Stopwatch.StartNew(); + // Bounded by HardCap so a wedged implementation FAILS here instead of hanging CI. + var snapshots = await ReadOnceAsync(driver, ct).WaitAsync(HardCap, ct); + clock.Stop(); + + var frozen = snapshots.ShouldHaveSingleItem(); + frozen.StatusCode.ShouldBe( + SqlStatusCodes.BadTimeout, + "a frozen database must surface BadTimeout, not a stale Good or another Bad class"); + + // The wall-clock contract: returned at ≈ operationTimeout (client-side cancellation), NOT at + // commandTimeout (server-side backstop) and NOT never. UpperBound < CommandTimeout is what + // distinguishes a working client-side abort from a broken one that only the backstop rescued. + clock.Elapsed.ShouldBeLessThan( + UpperBound, + $"BadTimeout must arrive on the client-side {OperationTimeout.TotalSeconds:0}s bound, not the " + + $"server-side {CommandTimeout.TotalSeconds:0}s CommandTimeout backstop"); + clock.Elapsed.ShouldBeLessThan(CommandTimeout, "the server-side backstop must not be what fired"); + clock.Elapsed.ShouldBeGreaterThanOrEqualTo( + OperationTimeout - TimeSpan.FromSeconds(2), + "returning far faster than operationTimeout would mean a fast-fail misclassified as BadTimeout"); + + // Driver-level classification, not just the reader: an all-BadTimeout poll degrades health and + // stops the host (SqlDriver.ObservePollOutcome), even though the reader returned successfully. + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Stopped); + } + finally + { + if (paused) + { + // ALWAYS restore the container once a pause was attempted — never leave a frozen container + // behind. Best-effort: unpausing a container that never actually paused (pause command failed) + // errors harmlessly, and a cleanup failure must never mask the real test outcome. + try + { + await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None); + } + catch + { + // swallow — cleanup is best-effort; the container is disposable and torn down by the runbook. + } + } + } + + // Recovery: after unpause a subsequent poll must return Good and the whole stack must recover. + await PollUntilGoodAsync(driver, ct); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); + } + + // ── helpers ── + + /// Runs one poll of the single baseline reference. + private static Task> ReadOnceAsync(SqlDriver driver, CancellationToken ct) + => driver.ReadAsync([RawPath], ct); + + /// Polls until a Good snapshot returns, or the recovery deadline elapses. + private static async Task PollUntilGoodAsync(SqlDriver driver, CancellationToken ct) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(45); + while (DateTime.UtcNow < deadline) + { + var snapshot = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem(); + if (snapshot.StatusCode == SqlStatusCodes.Good) return; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + throw new Xunit.Sdk.XunitException( + "the driver never recovered to a Good poll within 45 s of docker unpause — the poll loop did not survive the outage"); + } + + /// Builds a driver over the dedicated container, warmed to hold a pooled connection. + private static async Task StartDriverAsync( + string host, string port, string user, string password, CancellationToken ct) + { + var options = new SqlDriverOptions + { + RawTags = [KeyValueTag()], + OperationTimeout = OperationTimeout, + CommandTimeout = CommandTimeout, + }; + + var driver = new SqlDriver( + options, + driverInstanceId: "sql-blackhole", + dialect: new SqlServerDialect(), + connectionString: DriverConnectionString(host, port, user, password)); + + try + { + await driver.InitializeAsync("{}", ct); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + /// The KeyValue tag over the seeded sqlpoll.TagValues baseline row. + private static RawTagEntry KeyValueTag() + { + var config = new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = $"{Schema}.{KeyValueTable}", + ["keyColumn"] = KeyColumn, + ["keyValue"] = BaselineKey, + ["valueColumn"] = ValueColumn, + ["timestampColumn"] = TimestampColumn, + }; + return new RawTagEntry(RawPath, config.ToJsonString(), WriteIdempotent: false); + } + + /// + /// The driver's connection string. Min Pool Size=1 keeps a warm pooled connection so the + /// post-pause read reuses it and hangs inside ExecuteReaderAsync — exercising the + /// query-execution wedge (the S7 bug class) rather than a fresh connect. + /// + private static string DriverConnectionString(string host, string port, string user, string password) + => new SqlConnectionStringBuilder + { + DataSource = $"{host},{port}", + InitialCatalog = Database, + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + ConnectTimeout = 5, + MinPoolSize = 1, + }.ConnectionString; + + /// Creates the database (if missing) and (re)seeds the baseline schema + row. + private static async Task SeedAsync( + string host, string port, string user, string password, CancellationToken ct) + { + var master = new SqlConnectionStringBuilder + { + DataSource = $"{host},{port}", + InitialCatalog = "master", + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + ConnectTimeout = 10, + }.ConnectionString; + + await using (var connection = new SqlConnection(master)) + { + await connection.OpenAsync(ct); + await ExecuteAsync(connection, $"IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];", ct); + } + + var db = new SqlConnectionStringBuilder(master) { InitialCatalog = Database }.ConnectionString; + await using (var connection = new SqlConnection(db)) + { + await connection.OpenAsync(ct); + foreach (var statement in SeedStatements()) + await ExecuteAsync(connection, statement, ct); + } + } + + /// The seed, one batch per statement (T-SQL requires CREATE SCHEMA to be first in its batch). + private static IEnumerable SeedStatements() + { + yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];"; + yield return $""" + CREATE TABLE [{Schema}].[{KeyValueTable}] ( + [{KeyColumn}] nvarchar(128) NOT NULL, + [{ValueColumn}] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}]) + VALUES (N'{BaselineKey}', {BaselineValue}, '2026-07-24T10:00:00'); + """); + } + + /// Runs one non-query batch. + private static async Task ExecuteAsync(SqlConnection connection, string sql, CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(ct); + } + + /// Splits a host,port endpoint. A missing port is rejected — the guard needs the port. + private static (string Host, string Port) ParseEndpoint(string endpoint) + { + var parts = endpoint.Split(',', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 || parts[1].Length == 0) + { + throw new Xunit.Sdk.XunitException( + $"{EndpointEnvVar} must be 'host,port' (e.g. 10.100.0.35,14333) so the shared-server port " + + $"guard can run — got '{endpoint}'."); + } + + return (parts[0], parts[1]); + } + + /// + /// Builds the docker command for the OWNED container only. The verb and the constant container name + /// are the sole inputs — the target is never taken from the environment. + /// + private static string DockerCommand(string verb, string? ssh) + { + var docker = $"docker {verb} {ContainerName}"; + return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\""; + } + + /// + /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded + /// by its own so a hung SSH handshake (unreachable docker host, an auth + /// prompt) fails the test rather than hanging CI indefinitely. The process is killed on timeout. + /// + private static async Task RunShellCommandAsync(string command, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ShellCommandHardCap); + + using var proc = new Process + { + StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }, + }; + proc.Start(); + try + { + await proc.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested) + { + try { proc.Kill(entireProcessTree: true); } catch { /* already gone */ } + throw new TimeoutException( + $"shell command did not complete within {ShellCommandHardCap.TotalSeconds:0}s: {command}"); + } + + proc.ExitCode.ShouldBe(0, $"docker command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); + // Give the pause/unpause a moment to take effect before the next poll tick. + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs new file mode 100644 index 00000000..16c0ed61 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// A real, seeded SQL Server database standing in for the source the Sql driver polls — +/// the live counterpart of the offline SqlitePollFixture, and the only place the shipped +/// Microsoft.Data.SqlClient path, the real INFORMATION_SCHEMA catalog SQL, and genuine +/// T-SQL column types are exercised at all. +/// Env-gated, and the gate is checked before any socket is touched. This fixture opens +/// nothing unless — or plus a +/// password — is set; absent, it records and every test calls +/// Assert.Skip. That is what keeps dotnet test genuinely green (and instant) on the +/// macOS dev box this is usually run from, which is the same contract every other +/// *.IntegrationTests fixture in this tree honours. +/// Safety against the shared central server (design §9). The always-on SQL Server on the +/// docker host hosts ConfigDb for the whole dev rig, so this fixture is deliberately +/// conservative: +/// +/// It uses its own database, — the supplied connection +/// string's catalog is used only to reach master and is otherwise ignored, so a +/// mis-set env var cannot aim the seed at somebody else's schema. +/// A supplied catalog literally named ConfigDb is rejected loudly, at +/// construction, rather than quietly retargeted — it is the signature of a copied-and-pasted +/// connection string, and the operator should know their env var is wrong. +/// It creates the database only if missing and never drops a database, so +/// re-running the suite is safe and an operator-created SqlPollFixture is never destroyed. +/// Only the objects inside its own schema are dropped and recreated, which +/// is what makes the seed deterministic across runs. +/// +/// The seed is a contract, not scaffolding. The constants below mirror +/// SqlitePollFixture's shape — a present value, a present row with a NULL cell, an +/// absent key, and a wide-row table whose newest row is deliberately not its first — so the +/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server +/// paths shows up as a failing assertion rather than as two suites that quietly test different +/// things. has no offline counterpart: it exists to pin +/// SqlServerDialect.MapColumnType against the type names SQL Server actually reports, +/// which a hand-written mapping table cannot do. +/// +public sealed class SqlPollServerFixture : IAsyncLifetime +{ + /// A full ADO.NET connection string for the fixture server. Takes precedence when set. + public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture"; + + /// + /// Server endpoint in host,port form (e.g. 10.100.0.35,14330) — the lighter-weight + /// gate. Requires ; defaults to sa. + /// + public const string EndpointEnvVar = "SQL_TEST_ENDPOINT"; + + /// SQL login for the form. Defaults to sa. + public const string UserEnvVar = "SQL_TEST_USER"; + + /// SQL password for the form. Required by that form. + public const string PasswordEnvVar = "SQL_TEST_PASSWORD"; + + /// The database this fixture creates and seeds. Never ConfigDb, never dropped. + public const string FixtureDatabase = "SqlPollFixture"; + + /// + /// The schema every seeded object lives in. Deliberately not dbo: it scopes the + /// drop-and-recreate to objects this fixture owns, and it makes every authored table a + /// two-part name, so the planner's QuoteQualifiedName ([sqlpoll].[TagValues]) is + /// exercised against a real server rather than only in a unit test. + /// + public const string Schema = "sqlpoll"; + + /// + /// The Application Name the driver's connections carry, so the pooling assertion can count + /// exactly this suite's sessions on a server shared with the whole dev rig. + /// + public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver"; + + /// The Application Name the fixture's own inspection connections carry. + private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture"; + + /// Seconds a connect attempt may take before the fixture calls the server unreachable. + private const int ProbeConnectTimeoutSeconds = 5; + + // ---- key-value (EAV) source ---- + + /// The key-value table: tag_name nvarchar(128), num_value float NULL, sample_ts datetime2. + public const string KeyValueTable = "TagValues"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by every seeded table. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for . + public const double SecondPresentValue = 3.5; + + /// A key whose row exists but whose value cell is NULL. + public const string NullValueKey = "Line1.Temp"; + + /// A key with no row at all — a different outcome from . + public const string AbsentKey = "Line1.Missing"; + + /// The UTC source timestamp seeded for . + public static readonly DateTime PresentKeyTimestampUtc = + new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); + + // ---- wide-row source ---- + + /// The wide-row table: station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2. + public const string WideRowTable = "LatestStatus"; + + /// The wide-row table's row-selector column. + public const string WideRowSelectorColumn = "station_id"; + + /// A station whose row exists, with both value columns populated. + public const string PresentStation = "7"; + + /// 's oven_temp. + public const double PresentStationOvenTemp = 180.5; + + /// 's pressure. + public const double PresentStationPressure = 1.2; + + /// + /// The station holding the newest sample_ts — what a topByTimestamp selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that loses its + /// ORDER BY … DESC or its SELECT TOP 1 picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — what a topByTimestamp plan yields. + public const double NewestStationOvenTemp = 210.25; + + /// A station whose row exists but whose oven_temp cell is NULL. + public const string NullOvenTempStation = "9"; + + /// A station with no row at all. + public const string AbsentStation = "404"; + + /// A view over — the TABLE_TYPE = 'VIEW' catalog row. + public const string WideRowView = "LatestStatusView"; + + // ---- T-SQL type-coverage source ---- + + /// + /// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no + /// authored type, each column's driver type is inferred from what + /// SqlDataReader.GetDataTypeName reports — which is the only way to find out whether + /// SqlServerDialect.MapColumnType's table matches reality rather than matching itself. + /// + public const string TypeProbeTable = "TypeProbe"; + + /// The single-row selector column of . + public const string TypeProbeSelectorColumn = "probe_id"; + + /// The seeded row's value. + public const string TypeProbeRow = "1"; + + /// Seeded bit value. + public const bool ProbeBit = true; + + /// Seeded int value — int.MaxValue, so a narrowing coercion overflows loudly. + public const int ProbeInt = int.MaxValue; + + /// Seeded bigint value — beyond exact double range, so an Int64→Float64 slip shows. + public const long ProbeBigInt = 9007199254740993L; + + /// Seeded real value; exactly representable, so equality is a fair assertion. + public const float ProbeReal = 1.5f; + + /// Seeded float value; exactly representable. + public const double ProbeFloat = 3.25d; + + /// Seeded decimal(18,4) value — the documented Float64 precision collapse. + public const decimal ProbeDecimal = 123.4567m; + + /// Seeded nvarchar(64) value. + public const string ProbeNVarChar = "hello"; + + /// Seeded datetime2(3) value, read back as UTC. + public static readonly DateTime ProbeDateTimeUtc = + new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc); + + /// + /// Why the suite is skipping, or when the fixture is seeded and usable. + /// Tests read this and call Assert.Skip — the house idiom + /// (Snap7ServerFixture / ModbusSimulatorFixture). + /// + public string? SkipReason { get; private set; } + + /// + /// The connection string to hand the code under test: the seeded , + /// tagged with . Empty while is set. + /// + public string ConnectionString { get; private set; } = string.Empty; + + /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. + public string Server { get; private set; } = string.Empty; + + /// Connection string the fixture's own inspection connections use. Empty while skipping. + private string _inspectionConnectionString = string.Empty; + + /// + /// Resolves the gate, creates if missing, and (re)seeds this + /// fixture's schema. + /// The two failure modes are handled deliberately differently: failing to connect is + /// the offline case and becomes a , while failing after the connection + /// opened (DDL, seeding) is a real fault against a reachable server and is allowed to throw — + /// swallowing that would leave the suite silently testing an unseeded database. + /// + /// A task that represents the asynchronous operation. + public async ValueTask InitializeAsync() + { + if (ResolveConnectionString() is not { } supplied) + { + SkipReason = + $"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " + + $"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " + + "the Sql driver's live SQL Server suite. No connection is attempted without it."; + return; + } + + var builder = new SqlConnectionStringBuilder(supplied); + GuardAgainstConfigDb(builder); + if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds; + Server = builder.DataSource; + + // The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase. + var master = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = "master", + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + SqlConnection connection; + try + { + connection = new SqlConnection(master); + await connection.OpenAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + SkipReason = + $"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " + + $"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " + + $"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite."; + return; + } + + await using (connection.ConfigureAwait(false)) + { + // Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever + // made it, keeps everything outside this fixture's own schema. + await ExecuteAsync( + connection, + $"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];") + .ConfigureAwait(false); + } + + ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = DriverApplicationName, + }.ConnectionString; + + _inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + await SeedAsync().ConfigureAwait(false); + } + + /// Holds no long-lived connection, so there is nothing to tear down. + /// A completed task. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// + /// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged + /// with the fixture's own application name so it is never counted by the pooling assertion. + /// + /// An open connection the caller owns and must dispose. + public async Task OpenInspectionConnectionAsync() + { + var connection = new SqlConnection(_inspectionConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + return connection; + } + + /// Two-part name of a seeded object, in the form an authored tag's table carries. + /// The bare table or view name. + /// The schema.table name. + public static string Qualified(string table) => $"{Schema}.{table}"; + + /// + /// Reads the connection-string gate. Returns — touching no socket — when + /// neither form is configured. + /// + private static string? ResolveConnectionString() + { + if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full) + return full; + + if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) + return null; + + // A password is required rather than defaulted: guessing one would turn a misconfiguration into a + // failed login against a shared server, which is how accounts get locked out. + if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) + return null; + + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa"; + return new SqlConnectionStringBuilder + { + DataSource = endpoint, + InitialCatalog = FixtureDatabase, + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + }.ConnectionString; + } + + /// + /// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared + /// ConfigDb. The catalog is ignored either way (everything lands in + /// ), so this exists purely to tell an operator their env var is + /// wrong rather than to prevent damage that could otherwise occur. + /// + /// The supplied catalog is named ConfigDb. + private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder) + { + if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return; + + throw new InvalidOperationException( + $"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " + + $"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " + + "never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog)."); + } + + /// + /// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows. + /// Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract: + /// a leftover row from an older revision of this file would make a passing test meaningless. The + /// blast radius is bounded to the schema, which nothing else creates. + /// + private async Task SeedAsync() + { + var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false); + await using (connection.ConfigureAwait(false)) + { + // Views first: a view over a dropped table blocks nothing, but dropping the table under a live + // view leaves the catalog reporting a column set that no longer resolves. + foreach (var statement in SeedStatements()) + await ExecuteAsync(connection, statement).ConfigureAwait(false); + } + } + + /// + /// The seed, one batch per statement. CREATE SCHEMA and CREATE VIEW must each be the + /// first statement of their batch in T-SQL, which is why this is a list rather than one script. + /// Every numeric literal is composed under : plain + /// interpolation would emit 180,5 on a comma-decimal machine, and T-SQL would read that as + /// two column values rather than as one wrong number — a seed failure with a baffling message. + /// + private static IEnumerable SeedStatements() + { + yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];"; + yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');"; + + yield return $""" + CREATE TABLE [{Schema}].[{KeyValueTable}] ( + [{KeyColumn}] nvarchar(128) NOT NULL, + [{ValueColumn}] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}]) + VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'), + (N'{NullValueKey}', NULL, '2026-07-24T10:00:01'), + (N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE TABLE [{Schema}].[{WideRowTable}] ( + [{WideRowSelectorColumn}] int NOT NULL, + [oven_temp] float NULL, + [pressure] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + // NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A + // topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two + // orderings are kept deliberately different. + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{WideRowTable}] + ([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]) + VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'), + ({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'), + ({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE VIEW [{Schema}].[{WideRowView}] AS + SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}] + FROM [{Schema}].[{WideRowTable}]; + """; + + yield return $""" + CREATE TABLE [{Schema}].[{TypeProbeTable}] ( + [{TypeProbeSelectorColumn}] int NOT NULL, + [bit_col] bit NULL, + [int_col] int NULL, + [bigint_col] bigint NULL, + [real_col] real NULL, + [float_col] float NULL, + [decimal_col] decimal(18,4) NULL, + [nvarchar_col] nvarchar(64) NULL, + [datetime2_col] datetime2(3) NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{TypeProbeTable}] + ([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col], + [decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}]) + VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat}, + {ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03'); + """); + } + + /// Runs one non-query batch. + private static async Task ExecuteAsync(SqlConnection connection, string sql) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } +} + +/// +/// Collection definition so the seed runs once per test session rather than once per test class — +/// the same shape as Snap7ServerCollection. +/// +[CollectionDefinition(Name)] +public sealed class SqlPollServerCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlPollServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs new file mode 100644 index 00000000..c3fe2e99 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs @@ -0,0 +1,671 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The Sql driver against a real SQL Server, over the Microsoft.Data.SqlClient +/// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which +/// means three things have never been exercised anywhere until this suite: the shipped ADO.NET +/// provider, T-SQL's own syntax (bracket-quoted two-part names, SELECT TOP 1), and the +/// INFORMATION_SCHEMA catalog SQL that SqlServerDialect carries but that no offline test +/// can reach — the SQLite dialect answers those questions with pragma_table_info instead. +/// Env-gated; skipping is the normal outcome. See for +/// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline — +/// which is the point: a live test that goes red on a laptop teaches people to ignore red. +/// Out of scope, deliberately: the frozen-database / BadTimeout gate. That needs a +/// docker pause-able container this suite must never own, because the server it points at is +/// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql +/// container. +/// +[Collection(SqlPollServerCollection.Name)] +public sealed class SqlServerReadTests +{ + private readonly SqlPollServerFixture _sql; + + /// Initializes a new instance of the class. + /// The seeded live-server fixture (collection-scoped). + public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql; + + // ---- lifecycle ---- + + /// + /// Initialize's liveness check (SELECT 1 over a real connection) succeeds and the driver + /// reports Healthy with the endpoint described credential-free. + /// + [Fact] + public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); + health.LastSuccessfulRead.ShouldNotBeNull(); + + // The endpoint description is what every log line and the Admin UI show. It must name the + // database and must not carry the password that reached the provider. + driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase); + driver.Endpoint.ShouldNotContain("Password"); + + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); + } + + // ---- key-value model ---- + + /// The plan's headline case: a seeded key-value row round-trips as a Good snapshot. + [Fact] + public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken); + + var snapshot = snapshots.ShouldHaveSingleItem(); + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue); + // datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it + // through the host's zone, which is the behaviour this asserts against a real column type. + snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc); + } + + /// + /// Two keys on the same table fold into ONE query (… WHERE [tag_name] IN (@k0, @k1)) and are + /// sliced back to the right tags. Against a real server this also proves the parameter binding and + /// the key-column stringification survive a genuine nvarchar key. + /// + [Fact] + public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(2); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue); + } + + /// + /// A present row whose value cell is a real T-SQL NULL is Uncertain, not Bad and not + /// BadNoData — the row WAS read. + /// + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain); + snapshot.Value.ShouldBeNull(); + // The row exists, so its timestamp is still readable — that is exactly what distinguishes this + // from the absent-key case below. + snapshot.SourceTimestampUtc.ShouldNotBeNull(); + } + + /// nullIsBad re-codes the same NULL cell as Bad, and only that cell. + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad); + } + + /// A key the table has no row for is BadNoData with no source timestamp. + [Fact] + public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshot.SourceTimestampUtc.ShouldBeNull(); + } + + // ---- wide-row model ---- + + /// + /// Two columns of one wide row, selected by a whereColumn/whereValue pair, come back from a + /// single query — including the bound selector value coercing from authored text to a real + /// int column server-side. + /// + [Fact] + public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation)); + + var snapshots = await driver.ReadAsync( + ["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"], + TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure); + // A selector matching no row is BadNoData, not an error — and it does not disturb its siblings. + snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + } + + /// + /// The topByTimestamp selector resolves to the newest row. + /// This is the T-SQL-specific leg. The planner emits the row limit through the + /// dialect's SingleRowLimitPrefixSELECT TOP 1 … for T-SQL, where every offline + /// test exercises SQLite's trailing LIMIT 1 instead. The seed makes the newest row not the + /// first-inserted one, so losing either the TOP 1 or the ORDER BY … DESC yields a + /// different, wrong value rather than a coincidentally-right one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp")); + + var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp); + } + + /// A view reads exactly like the table it wraps — the shape operators are told to author. + [Fact] + public async Task ReadAsync_realSqlServer_readsThroughAView() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere( + "Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation, + table: SqlPollServerFixture.WideRowView)); + + var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + } + + // ---- type mapping against real T-SQL column types ---- + + /// + /// With no authored type, each tag's driver type is inferred from the provider's own + /// GetDataTypeName through SqlServerDialect.MapColumnType. This is the only test in + /// the estate where that map is checked against the strings SQL Server actually returns — + /// everywhere else it is fed a hand-written list, i.e. checked against itself. + /// + [Fact] + public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/Bit", "bit_col"), + TypeProbe("Sql/Probe/Int", "int_col"), + TypeProbe("Sql/Probe/BigInt", "bigint_col"), + TypeProbe("Sql/Probe/Real", "real_col"), + TypeProbe("Sql/Probe/Float", "float_col"), + TypeProbe("Sql/Probe/Decimal", "decimal_col"), + TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"), + TypeProbe("Sql/Probe/DateTime2", "datetime2_col")); + + var snapshots = await driver.ReadAsync( + [ + "Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real", + "Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2", + ], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + + // bit → Boolean, not "1"; the CLR type is what the address space declared for the node. + snapshots[0].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBit); + snapshots[1].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeInt); + // bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to + // Float64 would come back as a different number rather than as a type-only difference. + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBigInt); + snapshots[3].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + snapshots[4].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeFloat); + // decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future + // change to that policy cannot land silently. + snapshots[5].Value.ShouldBeOfType() + .ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9); + snapshots[6].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeNVarChar); + snapshots[7].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc); + } + + /// + /// An authored type wins over the inferred column type, and the coercion runs over the + /// provider's real CLR cell rather than over a test double. + /// + [Fact] + public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String), + TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32), + TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64)); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + snapshots[0].Value.ShouldBeOfType() + .ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture)); + snapshots[1].Value.ShouldBeOfType().ShouldBe(1); + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + } + + /// + /// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone, + /// never a thrown read. int.MaxValue into an Int16 is a genuine provider-level + /// OverflowException, not a synthesised one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16), + TypeProbe("Sql/Probe/Float", "float_col")); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch); + snapshots[0].Value.ShouldBeNull(); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + // ---- cancellation + connection lifecycle ---- + + /// + /// A cancelled poll propagates rather than publishing + /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The + /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate. + /// What this proves, precisely. The token is already cancelled before + /// ReadAsync is called, so the cancellation is observed at the reader's first checkpoint — + /// SemaphoreSlim.WaitAsync(_, token)before any SqlConnection opens. It + /// therefore does not exercise Microsoft.Data.SqlClient's in-flight cancellation of a + /// running command; that is left to the blackhole gate's deadline path + /// (SqlBlackholeTimeoutTests). What it pins is the driver-shell contract that a cancelled + /// token surfaces as an and is never swallowed into + /// a Bad snapshot or misread as an unreachable-database verdict — an offline-equivalent guarantee, + /// run here over the real provider only to keep it beside its siblings. + /// + [Fact] + public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and + // which of the two arrives is not a contract this driver makes. + await Assert.ThrowsAnyAsync( + async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token)); + + // Cancellation is teardown, not a database verdict: health must be untouched. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET + /// pools them. After many polls the server should still hold a small, non-zero number of + /// sessions for this driver's Application Name: non-zero proves the disposed connections + /// went back to a pool instead of being torn down, and small proves the poll loop is not leaking + /// one per pass. Counting per application name is what makes this safe on a server shared with the + /// whole dev rig. + /// + [Fact] + public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections() + { + SkipUnlessLive(); + + const int polls = 25; + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + for (var i = 0; i < polls; i++) + { + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + var sessions = await CountDriverSessionsAsync(); + sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side"); + sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25"); + } + + // ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ---- + + /// The dialect's schema list — real INFORMATION_SCHEMA.TABLES — sees the seeded schema. + [Fact] + public async Task Catalog_listSchemasSql_returnsTheSeededSchema() + { + SkipUnlessLive(); + + var schemas = await QueryCatalogAsync( + new SqlServerDialect().ListSchemasSql, + _ => { }, + reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA"))); + + schemas.ShouldContain(SqlPollServerFixture.Schema); + } + + /// + /// The dialect's table list returns the seeded relations for the bound schema, and flags the view + /// as VIEW — the TABLE_TYPE the browse session decorates its label from. + /// + [Fact] + public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView() + { + SkipUnlessLive(); + + var rows = await QueryCatalogAsync( + new SqlServerDialect().ListTablesSql, + command => Bind(command, "@schema", SqlPollServerFixture.Schema), + reader => ( + Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")), + Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE")))); + + var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW"); + } + + /// + /// The dialect's column list returns the key-value table's columns in ordinal order (the + /// order the browse tree renders), with DATA_TYPE values the dialect's map recognises. + /// + [Fact] + public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable); + + columns.Select(c => c.Name).ToArray().ShouldBe(new[] + { + SqlPollServerFixture.KeyColumn, + SqlPollServerFixture.ValueColumn, + SqlPollServerFixture.TimestampColumn, + }); + + var dialect = new SqlServerDialect(); + dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar + dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float + dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2 + + // IS_NULLABLE is the third projected column and the browse reads it; prove it is really there. + columns[0].IsNullable.ShouldBe("NO"); + columns[1].IsNullable.ShouldBe("YES"); + } + + /// + /// Every T-SQL family the type-probe table declares maps to the driver type + /// SqlServerDialect.MapColumnType promises — read from the catalog's own + /// DATA_TYPE strings, which is the exact input the browse side-panel feeds it. + /// + [Fact] + public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable); + var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal); + var dialect = new SqlServerDialect(); + + dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean); + dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32); + dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64); + dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32); + dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String); + dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime); + } + + // ---- helpers ---- + + /// Skips the calling test when the live-server gate is not configured or not reachable. + private void SkipUnlessLive() + { + if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason); + } + + /// + /// Builds and initializes a driver over the seeded database. + /// There is no ForProduction / ForTest hatch on + /// — its single public constructor takes the resolved connection string, the dialect and an + /// optional factory, and leaving the factory unset is precisely what makes this suite exercise + /// SqlClientFactory.Instance, the provider the driver ships. + /// + private Task StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags); + + /// + private async Task StartAsync(bool nullIsBad, params RawTagEntry[] tags) + { + var options = new SqlDriverOptions + { + RawTags = tags, + NullIsBad = nullIsBad, + CommandTimeout = TimeSpan.FromSeconds(10), + OperationTimeout = TimeSpan.FromSeconds(15), + }; + + var driver = new SqlDriver( + options, + driverInstanceId: "sql-integration", + dialect: new SqlServerDialect(), + connectionString: _sql.ConnectionString); + + try + { + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + /// A key-value tag over the seeded EAV table. + private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable), + ["keyColumn"] = SqlPollServerFixture.KeyColumn, + ["keyValue"] = key, + ["valueColumn"] = SqlPollServerFixture.ValueColumn, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + }); + + /// A wide-row tag selected by a whereColumn/whereValue pair. + private static RawTagEntry WideRowWhere( + string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn, + ["whereValue"] = station, + }, + }); + + /// A wide-row tag selected by newest timestamp. + private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["topByTimestamp"] = SqlPollServerFixture.TimestampColumn, + }, + }); + + /// A wide-row tag over the single-row type-probe table, optionally with a declared type. + private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null) + { + var config = new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn, + ["whereValue"] = SqlPollServerFixture.TypeProbeRow, + }, + }; + + // Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole + // point of the inference test; present ⇒ it overrides. + if (declared is not null) config["type"] = declared.Value.ToString(); + return Tag(rawPath, config); + } + + /// + /// Wraps an authored TagConfig object as a raw tag entry. + /// Composed as a rather than as an interpolated string literal on + /// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace + /// produces a config the parser silently rejects — which surfaces as BadNodeIdUnknown, i.e. + /// as a plausible-looking test failure about the driver rather than about the test. + /// + private static RawTagEntry Tag(string rawPath, JsonObject config) => + new(rawPath, config.ToJsonString(), WriteIdempotent: false); + + /// Runs one of the dialect's catalog statements against the seeded database. + private async Task> QueryCatalogAsync( + string sql, Action bind, Func project) + { + var connection = await _sql.OpenInspectionConnectionAsync(); + await using (connection.ConfigureAwait(false)) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + bind(command); + + var results = new List(); + var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); + await using (reader.ConfigureAwait(false)) + { + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + results.Add(project(reader)); + } + + return results; + } + } + } + + /// Reads one seeded table's catalog columns through the dialect's ListColumnsSql. + private Task> ReadCatalogColumnsAsync(string table) => + QueryCatalogAsync( + new SqlServerDialect().ListColumnsSql, + command => + { + Bind(command, "@schema", SqlPollServerFixture.Schema); + Bind(command, "@table", table); + }, + reader => ( + Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")), + DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")), + IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE")))); + + /// + /// Counts the server-side sessions carrying the driver's application name. Skips — rather than + /// failing — when the configured login cannot read the DMV, since VIEW SERVER STATE is a + /// property of the credentials the operator supplied and not of the code under test. + /// + private async Task CountDriverSessionsAsync() + { + try + { + var counts = await QueryCatalogAsync( + "SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app", + command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName), + reader => reader.GetInt32(reader.GetOrdinal("n"))); + return counts[0]; + } + catch (SqlException ex) + { + Assert.Skip( + "The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " + + $"pooling cannot be observed from here: {ex.Message}"); + throw; // unreachable; Assert.Skip throws. + } + } + + /// Binds one string catalog parameter, exactly as the browse session does. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj new file mode 100644 index 00000000..2a1e1c4c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj @@ -0,0 +1,39 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs new file mode 100644 index 00000000..0fb8fdb0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -0,0 +1,452 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves the composition layer: (config blob → live driver) and +/// (a connectionStringRef name → the secret it stands for). +/// Three things here are behaviour rather than plumbing, and each has its own test below. +/// (1) The string-enum guard — the driver-page enum-serialization defect that bit the AdminUI shipped +/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the +/// driver at deploy time. The factory's options therefore both accept a numeric provider and write the +/// NAME. (2) Config validationoperationTimeout > commandTimeout is deliberately +/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a +/// deployment silently masks its own server-side backstop. (3) Credential hygiene — the resolved +/// connection string is a secret; it must not reach a log line or an exception message, and the tests below +/// assert that rather than trusting the reviewer. +/// +public sealed class SqlDriverFactoryTests +{ + // ---- the plan's headline legs ---- + + [Fact] + public void CreateInstance_missingConnectionStringRef_throwsActionable() + => Should.Throw(() => + SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null)) + .Message.ShouldContain("connectionStringRef"); + + [Fact] + public void ConnectionStringRef_resolvesFromEnv() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;"); + + SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;"); + } + + [Fact] + public void Provider_serializesAsNameNotNumber() + { + var json = JsonSerializer.Serialize( + new SqlDriverConfigDto { Provider = SqlProvider.SqlServer }, + SqlDriverFactoryExtensions.JsonOptionsForTest); + + json.ShouldContain("\"SqlServer\""); + json.ShouldNotContain("\"provider\":0"); + } + + // ---- the other half of the enum guard: a NUMERIC provider must still parse ---- + + [Fact] + public void CreateInstance_providerWrittenAsANumber_stillParses() + { + // The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that + // would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the + // options accept both spellings on the way IN, and only ever emit the name on the way OUT. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null); + + driver.DriverType.ShouldBe(SqlDriver.DriverTypeName); + } + + [Fact] + public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal() + { + // A config blob authored against a newer (or older) schema must not brick the driver. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null); + + driver.DriverInstanceId.ShouldBe("d1"); + } + + // ---- connection-string resolution ---- + + [Fact] + public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable() + { + var name = NewRef(); + + var ex = Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); + + // The operator's next action is "set this variable", so the message must name it exactly. + ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); + } + + [Fact] + public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent() + { + // An empty value is a half-provisioned deployment, not a connection string — failing here beats + // handing "" to the provider and reporting an unintelligible ADO.NET error. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), " "); + + Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); + } + + [Fact] + public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef() + { + var name = NewRef(); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}"}""", null)); + + ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); + } + + // ---- provider gate ---- + + [Fact] + public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null)); + + ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("not available in this build"); + } + + // ---- config validation (the reader and the shell deliberately do NOT do this) ---- + + [Fact] + public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected() + { + // Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout + // backstop can never be reached — the deployment silently loses one of its two independent bounds. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} + """, + null)); + + ex.Message.ShouldContain("operationTimeout"); + ex.Message.ShouldContain("commandTimeout"); + } + + [Fact] + public void CreateInstance_equalTimeouts_areAlsoRejected() + { + // "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect + // with a coin toss on top. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"} + """, + null)); + } + + [Theory] + [InlineData("\"commandTimeout\":\"00:00:00\"")] + [InlineData("\"operationTimeout\":\"-00:00:05\"")] + [InlineData("\"defaultPollInterval\":\"00:00:00\"")] + [InlineData("\"maxConcurrentGroups\":0")] + public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson) + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null)); + } + + [Fact] + public void CreateInstance_theDocumentedDefaults_passValidation() + { + // The falsifiability control for the four rejection legs above: an all-defaults config must build. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + + var options = SqlDriverFactoryExtensions.BuildOptions( + new SqlDriverConfigDto { ConnectionStringRef = name }, "d1"); + + options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10)); + options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15)); + options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5)); + options.MaxConcurrentGroups.ShouldBe(4); + options.NullIsBad.ShouldBeFalse(); + options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout); + } + + // ---- rawTags: how the deploy artifact actually delivers a driver's tags ---- + + [Fact] + public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions() + { + var dto = JsonSerializer.Deserialize( + """ + { + "connectionStringRef":"anything", + "rawTags":[ + {"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}, + {"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false} + ] + } + """, + SqlDriverFactoryExtensions.JsonOptionsForTest)!; + + var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1"); + + options.RawTags.Count.ShouldBe(2); + options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed"); + options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp"); + } + + [Fact] + public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull() + { + var options = SqlDriverFactoryExtensions.BuildOptions( + new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1"); + + options.RawTags.ShouldBeEmpty(); + } + + // ---- v1 is read-only, structurally ---- + + [Fact] + public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly() + { + // allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible + // and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure + // mode is an operator who believes writes are enabled — which only a loud warning closes. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var loggerFactory = new RecordingLoggerFactory(); + + var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); + + driver.ShouldNotBeAssignableTo(); + var warning = loggerFactory.Entries + .Where(e => e.Level >= LogLevel.Warning) + .ShouldHaveSingleItem(); + warning.Message.ShouldContain("allowWrites"); + warning.Message.ShouldContain("read-only"); + } + + [Fact] + public void CreateInstance_withoutAllowWrites_logsNoWarning() + { + // The falsifiability control for the warning above — a warning that always fires proves nothing. + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var loggerFactory = new RecordingLoggerFactory(); + + SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory); + + loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty(); + // ...and the factory DID log — so "no warning" is a statement about severity, not about silence. + loggerFactory.Entries.ShouldNotBeEmpty(); + } + + // ---- credential hygiene ---- + + [Fact] + public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint() + { + const string secret = "SuperSecret-PleaseDoNotLogMe"; + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), + $"Server=srv;Database=db;User ID=sa;Password={secret}"); + var loggerFactory = new RecordingLoggerFactory(); + + var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); + + // The credential-free rendering IS emitted — the factory logs where the driver points, which is what + // makes "no connection string" a real constraint rather than "log nothing and call it safe". + driver.Endpoint.ShouldBe("srv/db"); + foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret); + loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into. + } + + [Fact] + public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString() + { + const string secret = "SuperSecret-PleaseDoNotThrowMe"; + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), + $"Server=srv;Database=db;Password={secret}"); + + // Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the + // secret in hand — the case where a careless $"...{connectionString}" would actually leak. + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", + $$""" + {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} + """, + null)); + + ex.ToString().ShouldNotContain(secret); + } + + // ---- registry wiring ---- + + [Fact] + public void Register_registersTheFactoryUnderTheDriversTypeName() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); + var registry = new DriverFactoryRegistry(); + + SqlDriverFactoryExtensions.Register(registry); + + var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull(); + var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}"""); + driver.ShouldBeOfType(); + driver.DriverType.ShouldBe("Sql"); + } + + [Fact] + public void DriverTypeName_matchesTheDriversOwnConstant() + // Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart. + => SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName); + + // ---- argument guards ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CreateInstance_blankInputs_areRejected(string blank) + { + Should.Throw(() => + SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null)); + Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null)); + } + + [Fact] + public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance() + { + var ex = Should.Throw( + () => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null)); + + ex.Message.ShouldContain("d1"); + } + + /// A per-test connection-string ref, so no two tests can collide over one process-wide env var. + private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}"; +} + +/// +/// Sets one environment variable for the life of the scope and restores its previous value — including +/// restoring it to . Environment variables are process-global, so a test that sets +/// one without restoring it leaks into every test that runs after it in the same process. +/// +internal sealed class EnvironmentVariableScope : IDisposable +{ + private readonly string _name; + private readonly string? _previous; + + /// Sets to , remembering what was there. + /// The environment variable to set. + /// The value to set it to. + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + /// Restores the previous value. + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); +} + +/// +/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted +/// rather than assumed. +/// +internal sealed class RecordingLoggerFactory : ILoggerFactory +{ + private readonly List _entries = []; + + /// The log entries recorded so far, in order. + public IReadOnlyList Entries + { + get { lock (_entries) return [.. _entries]; } + } + + /// + public ILogger CreateLogger(string categoryName) => new Recorder(this); + + /// + public void AddProvider(ILoggerProvider provider) { } + + /// + public void Dispose() { } + + private void Record(LogLevel level, string message) + { + lock (_entries) _entries.Add(new LogEntry(level, message)); + } + + /// One captured log entry. + /// The level it was logged at. + /// The formatted message. + internal sealed record LogEntry(LogLevel Level, string Message); + + private sealed class Recorder(RecordingLoggerFactory owner) : ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => owner.Record(logLevel, formatter(state, exception)); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() { } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs new file mode 100644 index 00000000..7092695d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -0,0 +1,198 @@ +using System.Data; +using System.Data.Common; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves — the AdminUI "Test Connect" liveness check — against the real +/// SQLite fixture. A probe opens a connection, runs the dialect's SELECT 1, and reports green with +/// latency or red with a reason; it never throws (the contract). +/// The probe parses the SAME factory DTO shape as (R2-11 +/// factory parity, mirroring ModbusDriverProbe), so a config that Tests-green and a config that +/// Deploys are the same config. It resolves the connectionStringRef the same way too — and the +/// tests below assert the resolved connection string never reaches the red-result message. +/// +public sealed class SqlDriverProbeTests +{ + [Fact] + public async Task Probe_liveConnection_returnsGreen() + { + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeTrue(); + result.Latency.ShouldNotBeNull(); + result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); + } + + [Fact] + public async Task Probe_declaresTheSqlDriverType() + => SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()) + .DriverType.ShouldBe(SqlDriver.DriverTypeName); + + [Fact] + public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing() + { + // A connection string that resolves fine but points at nothing openable. The probe must catch the + // provider's failure and hand back a red result, never propagate it. + var probe = SqlDriverProbe.ForTest( + new FailingFactory("simulated open failure"), new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(connectionString: "Data Source=/no/such/place.db"), + TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNullOrWhiteSpace(); + result.Latency.ShouldBeNull(); + } + + [Fact] + public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow() + { + var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + """{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("connectionStringRef"); + } + + [Fact] + public async Task Probe_configThatIsNotJson_returnsRed_notAThrow() + { + var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + "definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy() + { + // R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically + // serialised provider Test-Connects exactly as it Deploys. + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeTrue(); + } + + [Fact] + public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage() + { + const string secret = "Password=DoNotLeakMeIntoAProbeMessage"; + var connectionString = $"Data Source=/no/such.db;{secret}"; + // The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its + // OWN exception message. This fake reproduces that — it throws with the connection string in the + // message — so a probe that surfaced ex.Message would leak, and this test would then go red. + var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect()); + + var result = await probe.ProbeAsync( + ConfigJson(connectionString: connectionString), + TimeSpan.FromSeconds(5), CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage"); + } + + [Fact] + public async Task Probe_honoursCancellation_asRed_notAThrow() + { + // A cancelled probe is still a probe result, not an exception the AdminUI has to catch. + using var fixture = new SqlitePollFixture(); + var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token); + + result.Ok.ShouldBeFalse(); + } + + // ---- helpers ---- + + /// A config blob whose connectionStringRef resolves (for the life of the returned scope) to the + /// fixture's real database, so the probe's SELECT 1 actually runs. + private static string ConfigJson(SqlitePollFixture fixture) + => ConfigJson(connectionString: fixture.ConnectionString); + + private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture) + { + var name = SetRef(fixture.ConnectionString); + return $$"""{"provider":0,"connectionStringRef":"{{name}}"}"""; + } + + /// + /// Builds a config JSON whose connectionStringRef is provisioned to + /// via the process environment — the exact resolution path the + /// factory uses. The env var is set for the test process; each call mints a unique ref so parallel + /// tests cannot collide, and the value is never unset (a harmless per-run leak of a random name). + /// + private static string ConfigJson(string connectionString) + { + var name = SetRef(connectionString); + return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}"""; + } + + private static string SetRef(string connectionString) + { + var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable( + SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString); + return name; + } + + /// A whose connections throw on open — a database that cannot be + /// reached, without needing a real unreachable endpoint. + private sealed class FailingFactory(string message) : DbProviderFactory + { + public override DbConnection CreateConnection() => new FailingConnection(message); + } + + /// Like , but its open failure embeds the connection string in the + /// thrown exception's message — the realistic provider shape that a naive ex.Message would leak. + private sealed class LeakyFailingFactory : DbProviderFactory + { + public override DbConnection CreateConnection() => new FailingConnection(message: null); + } + + private sealed class FailingConnection(string? message) : DbConnection + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + public override string Database => string.Empty; + public override string DataSource => string.Empty; + public override string ServerVersion => string.Empty; + public override ConnectionState State => ConnectionState.Closed; + + // A null ctor message means "embed the connection string" — the leaky provider shape. + private string FailureMessage => message ?? $"connect failed for {ConnectionString}"; + + public override void Open() => throw new InvalidOperationException(FailureMessage); + + public override Task OpenAsync(CancellationToken cancellationToken) + => throw new InvalidOperationException(FailureMessage); + + public override void Close() { } + public override void ChangeDatabase(string databaseName) { } + protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) + => throw new NotSupportedException(); + protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); + } +} 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..9533b8bf --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs @@ -0,0 +1,696 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// 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); + } + } + + // ---- C1a: the credential guarantee, proven load-bearing ---- + + [Fact] + public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface() + { + // The vacuous sibling test drives SQLite's "unable to open database file" path, whose message + // structurally never contains the connection string — so it passes with or without any defensive + // code. This one fabricates a DbException whose OWN .Message carries a credential-like token and + // drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED + // against the pre-fix code that interpolated ex.Message into LastError and the thrown message. + const string leakyMessage = "login failed for user; Password=" + SecretToken; + var driver = new SqlDriver( + new SqlDriverOptions + { + RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], + OperationTimeout = TimeSpan.FromSeconds(15), + CommandTimeout = TimeSpan.FromSeconds(10), + }, + DriverInstanceId, + new SqliteDialect(), + // A credential-free connection string: the ONLY place the token can come from is the exception. + "Server=sqlsrv01;Initial Catalog=Mes", + factory: new ThrowingConnectionFactory(leakyMessage), + logger: CapturingLogger.Null); + await using var _ = driver; + + var thrown = await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Faulted); + health.LastError.ShouldNotBeNullOrWhiteSpace(); + // The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status. + health.LastError!.ShouldNotContain(SecretToken); + thrown.Message.ShouldNotContain(SecretToken); + driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken); + driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); + // …and it is still actionable: it names the endpoint the operator has to go look at. + health.LastError.ShouldContain("sqlsrv01"); + } + + // ---- I1: no double health-classification on a subscribed-read outage ---- + + [Fact] + public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce() + { + // On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a + // good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll + // engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync + // owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a + // credential-leak path) and logged the outage a second time. + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + + driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken)); + + var health = driver.GetHealth(); + // Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked… + health.State.ShouldBe(DriverState.Healthy); + health.LastError.ShouldBeNull(); + // …and it logged nothing (ReadAsync owns the single outage warning). + logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0); + } + + [Fact] + public void HandlePollError_forAnEngineContractViolation_stillDegrades() + { + // The falsifiability control for the guard above: a non-DbException (the poll engine's own + // reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health + // surface. The guard skips only the DbException connection class. + using var fixture = new SqlitePollFixture(); + var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots")); + + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + [Fact] + public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem() + { + // The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction — + // canonically the ArgumentException a malformed connection string's keyword parser throws, echoing + // credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is + // not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never + // ex.Message. Pre-fix this degraded with the raw message and leaked the token. + using var fixture = new SqlitePollFixture(); + var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); + + driver.HandlePollError(new ArgumentException( + "Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastError.ShouldNotBeNull(); + health.LastError.ShouldNotContain(SecretToken); // never the message text + health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type) + } + + // ---- I2: a late poll cannot un-fault a Faulted driver ---- + + [Fact] + public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver() + { + // The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to + // the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has + // recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only + // a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write. + using var fixture = new SqlitePollFixture(); + await using var driver = NewDriver( + fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); + await Should.ThrowAsync( + async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + + // The stale in-flight poll completes with an all-Good batch. + driver.ObservePollOutcome( + [new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]); + + driver.GetHealth().State.ShouldBe(DriverState.Faulted); // stayed Faulted + } + + // ---- I3: an omitted-type tag warns at discovery ---- + + [Fact] + public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne() + { + using var fixture = new SqlitePollFixture(); + var logger = new CapturingLogger(); + await using var driver = NewDriver( + fixture, logger, + KvEntry("Speed", SqlitePollFixture.PresentKey), // no "type" → discovered String + TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32")); // explicit "type" + await driver.InitializeAsync(ConfigJson, CancellationToken.None); + + var capture = new CapturingBuilder(); + await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); + + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored")); + logger.Entries.ShouldNotContain(e => + e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored")); + } + + // ---- helpers ---- + + /// A distinctive password token: any assertion that finds it has found a credential leak. + 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); + + /// A key-value entry carrying an explicit "type" — so DeclaredType is non-null. + private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type) + => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" + { + "driver": "Sql", + "model": "KeyValue", + "table": "{{SqlitePollFixture.KeyValueTable}}", + "keyColumn": "{{SqlitePollFixture.KeyColumn}}", + "keyValue": "{{keyValue}}", + "valueColumn": "{{SqlitePollFixture.ValueColumn}}", + "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}", + "type": "{{type}}" + } + """), WriteIdempotent: false); + + /// The same tag as , already typed — for the reference reader. + private static SqlTagDefinition KvDefinition(string rawPath, string keyValue) + => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + 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() { } + } + } + + /// + /// A whose message is under the test's control — stands in for the + /// credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene + /// guarantee can be exercised without a live database. + /// + private sealed class FakeDbException(string message) : DbException(message); + + /// + /// A whose connections always fail to open, with a + /// caller-supplied (credential-bearing) message — the seam that drives the driver's + /// unreachable-database path with a fabricated provider exception. + /// + private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory + { + public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage); + } + + /// A connection that throws on open; every other member is inert. + private sealed class ThrowingConnection(string openFailureMessage) : DbConnection + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => string.Empty; + + public override string DataSource => string.Empty; + + public override string ServerVersion => string.Empty; + + public override ConnectionState State => ConnectionState.Closed; + + public override void Open() => throw new FakeDbException(openFailureMessage); + + public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + + public override void Close() { } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + => throw new NotSupportedException(); + + protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs new file mode 100644 index 00000000..bec219f7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs @@ -0,0 +1,199 @@ +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; + +/// +/// Pins against its documented contract: what it accepts, what +/// it captures verbatim, and — the larger half — everything it must reject. Rejection is the whole +/// safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for +/// a typo'd enum) a node fed from the wrong model while publishing a confident Good (R2-11). +/// +public class SqlEquipmentTagParserTests +{ + [Fact] + public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim() + { + var json = """ + {"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name", + "keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"} + """; + SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.KeyValue); + def.Table.ShouldBe("dbo.TagValues"); + def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates + def.DeclaredType.ShouldBe(DriverDataType.Float64); + def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key) + } + + [Fact] + public void TryParse_invalidModelEnum_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", + "raw", out _).ShouldBeFalse(); + + // ---- WideRow: the where-pair selector ---- + + [Fact] + public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp", + "timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}} + """; + + SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.Table.ShouldBe("dbo.LatestStatus"); + def.ColumnName.ShouldBe("oven_temp"); + def.TimestampColumn.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); + // Wide-row carries none of the key-value fields. + def.KeyColumn.ShouldBeNull(); + def.KeyValue.ShouldBeNull(); + def.ValueColumn.ShouldBeNull(); + def.DeclaredType.ShouldBeNull(); // no "type" authored ⇒ infer from column metadata + } + + [Theory] + [InlineData("7", "7")] // JSON number → its raw token, so the reader binds one string either way + [InlineData("\"7\"", "7")] // JSON string → its contents + [InlineData("true", "true")] // JSON bool → its raw token (lower-case, as written) + public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar( + string authored, string expected) + { + var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":""" + + authored + "}}"; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorValue.ShouldBe(expected); + } + + [Fact] + public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values. + def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --"); + } + + // ---- WideRow: the topByTimestamp selector ---- + + [Fact] + public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp", + "rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + def.RowSelectorTopByTimestamp.ShouldBe("sample_ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + def.DeclaredType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair() + { + var json = """ + {"driver":"Sql","model":"WideRow","table":"T","columnName":"c", + "rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}} + """; + + SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue(); + // SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair + // first and would otherwise silently drop the topByTimestamp ordering from a group's key. + def.RowSelectorColumn.ShouldBe("station_id"); + def.RowSelectorValue.ShouldBe("7"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + // ---- WideRow rejections ---- + + [Theory] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":" ","rowSelector":{"topByTimestamp":"ts"}}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Theory] + // No rowSelector object at all. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")] + // Present but empty. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")] + // Half a where-pair is not a selector: a whereColumn with no value… + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")] + // …and a whereValue with no column. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")] + // rowSelector authored as the wrong JSON kind is ignored, leaving no selector. + [InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")] + public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json) + { + // Accepting one would author a node whose query cannot say WHICH row it reads. + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + // ---- recognition + shared rejections ---- + + [Theory] + [InlineData("""["not","an","object"]""")] + [InlineData(""""just a string"""")] + [InlineData("42")] + [InlineData("null")] + [InlineData("not json at all")] + [InlineData("")] + [InlineData(" ")] + public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference) + => SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag() + { + // Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag. + const string json = + """{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Fact] + public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""", + "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_theDeferredQueryModel_rejectsTag() + { + // Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read. + const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}"""; + SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + } + + [Theory] + [InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"KeyValue","table":" ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")] + [InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")] + public void TryParse_withoutATable_rejectsTag(string json) + => SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); + + [Fact] + public void TryParse_invalidTypeEnum_rejectsTag() + => SqlEquipmentTagParser.TryParse( + """{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""", + "raw", out _).ShouldBeFalse(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs new file mode 100644 index 00000000..85fe8f34 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs @@ -0,0 +1,386 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Pins the query-mapping core: which tags share a query (the GroupKey), the exact SQL emitted for each +/// group, and — the thing this type exists to guarantee — that every authored value is a bound +/// parameter and never text. +/// GroupKey correctness governs read correctness: too coarse and tags silently read each other's +/// values; too fine and the batching collapses to one query per tag. Both failure modes are asserted +/// here. +/// +public class SqlGroupPlannerTests +{ + private static SqlTagDefinition KvTag( + string name, string table, string keyColumn, string keyValue, string valueColumn, string? timestampColumn) + => new(name, SqlTagModel.KeyValue, table, + KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn, + TimestampColumn: timestampColumn); + + private static SqlTagDefinition WideTag( + string name, string table, string columnName, + string? selectorColumn = null, string? selectorValue = null, + string? topByTimestamp = null, string? timestampColumn = null) + => new(name, SqlTagModel.WideRow, table, + TimestampColumn: timestampColumn, ColumnName: columnName, + RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + // ---- the plan's golden case ---- + + [Fact] + public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList() + { + var dialect = new SqlServerDialect(); + var tags = new[] + { + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"), + }; + + var plans = SqlGroupPlanner.Plan(tags, dialect).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SqlText.ShouldContain("IN (@k0, @k1)"); + plans[0].SqlText.ShouldContain("[tag_name]"); + plans[0].Members.Count.ShouldBe(2); + plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text + } + + [Fact] + public void KeyValuePlan_emitsTheWholeStatement_keyValueTimestampFromTheQuotedTable() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts")], + new SqlServerDialect()); + + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)"); + plans[0].ParameterNames.ShouldBe(new[] { "@k0" }); + plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" }); + plans[0].Model.ShouldBe(SqlTagModel.KeyValue); + plans[0].KeyColumn.ShouldBe("tag_name"); + plans[0].ValueColumn.ShouldBe("num_value"); + plans[0].TimestampColumn.ShouldBe("sample_ts"); + } + + [Fact] + public void KeyValuePlan_withoutATimestampColumn_omitsItFromTheSelectList() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)], + new SqlServerDialect()); + + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)"); + plans[0].TimestampColumn.ShouldBeNull(); + } + + // ---- GroupKey: what must NOT fold together ---- + + [Fact] + public void KeyValueTags_onDifferentTables_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.OtherValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].GroupKey.ShouldNotBe(plans[1].GroupKey); + plans[0].SqlText.ShouldContain("[dbo].[TagValues]"); + plans[1].SqlText.ShouldContain("[dbo].[OtherValues]"); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentValueColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "str_value", "sample_ts"), + ], new SqlServerDialect()).ToList(); + + // Folding these would make B read A's column — the silent cross-read this GroupKey prevents. + plans.Count.ShouldBe(2); + plans[0].SqlText.ShouldContain("[num_value]"); + plans[1].SqlText.ShouldContain("[str_value]"); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentTimestampColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "recorded_at"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + [Fact] + public void KeyValueTags_onTheSameTableButDifferentKeyColumn_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("B", "dbo.TagValues", "alt_name", "Line1.Temp", "num_value", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + [Fact] + public void TagsOfDifferentModels_neverShareAPlan_evenOnTheSameTable() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.Latest", "tag_name", "Line1.Speed", "num_value", null), + WideTag("B", "dbo.Latest", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].Model.ShouldBe(SqlTagModel.KeyValue); + plans[1].Model.ShouldBe(SqlTagModel.WideRow); + } + + // ---- distinct parameter binding ---- + + [Fact] + public void KeyValueTags_sharingAKeyValue_bindTheKeyOnce_butKeepBothMembers() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("B", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null), + KvTag("C", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); + plans[0].ParameterNames.ShouldBe(new[] { "@k0", "@k1" }); + plans[0].SqlText.ShouldContain("IN (@k0, @k1)"); + // Both tags stay members: two OPC UA nodes are fed from the one row. + plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" }); + } + + [Fact] + public void ParameterNamesAndParameters_arePositionallyAligned_andMatchTheMarkersInTheSql() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null), + KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null), + KvTag("C", "dbo.TagValues", "tag_name", "k2", "num_value", null), + ], new SqlServerDialect()).ToList(); + + var plan = plans[0]; + plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count); + plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); + plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" }); + } + + // ---- wide row ---- + + [Fact] + public void WideRowTags_onTheSameRowSelector_foldIntoOnePlan_listingEachColumnOnce() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + WideTag("B", "dbo.LatestStatus", "pressure", selectorColumn: "station_id", selectorValue: "7"), + WideTag("C", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SqlText.ShouldBe( + "SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w"); + plans[0].ParameterNames.ShouldBe(new[] { "@w" }); + plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined + plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" }); + plans[0].Members.Count.ShouldBe(3); + } + + [Fact] + public void WideRowTags_onADifferentRowSelectorValue_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + WideTag("B", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "8"), + ], new SqlServerDialect()).ToList(); + + // Folding these would publish station 7's temperature on station 8's node. + plans.Count.ShouldBe(2); + plans[0].Parameters.ShouldBe(new object[] { "7" }); + plans[1].Parameters.ShouldBe(new object[] { "8" }); + } + + [Fact] + public void WideRowPlan_appendsEachDistinctMemberTimestampColumnAfterTheValueColumns() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.LatestStatus", "oven_temp", + selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"), + WideTag("B", "dbo.LatestStatus", "pressure", + selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" }); + plans[0].SqlText.ShouldBe( + "SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w"); + } + + [Fact] + public void WideRowTags_topByTimestamp_emitTheTop1OrderByDescForm_withNoParameters() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"), + WideTag("B", "dbo.Readings", "pressure", topByTimestamp: "sample_ts"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(1); + plans[0].SqlText.ShouldBe( + "SELECT TOP 1 [oven_temp], [pressure] FROM [dbo].[Readings] ORDER BY [sample_ts] DESC"); + plans[0].Parameters.ShouldBeEmpty(); + plans[0].ParameterNames.ShouldBeEmpty(); + } + + [Fact] + public void WideRowTags_topByTimestamp_andAWherePair_onTheSameTable_produceTwoPlans() + { + var plans = SqlGroupPlanner.Plan( + [ + WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"), + WideTag("B", "dbo.Readings", "oven_temp", selectorColumn: "station_id", selectorValue: "7"), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + } + + // ---- identifier quoting ---- + + [Fact] + public void DottedTableName_isQuotedPerPart_notAsOneIdentifier() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("[dbo].[TagValues]"); + plans[0].SqlText.ShouldNotContain("[dbo.TagValues]"); // would name a nonexistent object + } + + [Fact] + public void UndottedTableName_isQuotedAsASinglePart() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("FROM [TagValues] "); + } + + [Fact] + public void ThreePartTableName_isQuotedPerPart() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "Plant.dbo.TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].SqlText.ShouldContain("[Plant].[dbo].[TagValues]"); + } + + [Fact] + public void AnEmptyTableNamePart_isRejected_ratherThanEmitted() + => Should.Throw(() => SqlGroupPlanner.Plan( + [KvTag("A", "dbo..TagValues", "tag_name", "k", "num_value", null)], + new SqlServerDialect())); + + // ---- the injection boundary ---- + + [Fact] + public void AHostileKeyValue_isBoundAsAParameter_andNeverReachesTheSqlText() + { + const string payload = "'; DROP TABLE x --"; + + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.TagValues", "tag_name", payload, "num_value", null)], + new SqlServerDialect()).ToList(); + + plans[0].Parameters.ShouldBe(new object[] { payload }); + plans[0].SqlText.ShouldNotContain("DROP"); + plans[0].SqlText.ShouldNotContain("--"); + plans[0].SqlText.ShouldNotContain("'"); + } + + [Fact] + public void AHostileWideRowSelectorValue_isBoundAsAParameter_andNeverReachesTheSqlText() + { + const string payload = "7'); DROP TABLE x --"; + + var plans = SqlGroupPlanner.Plan( + [WideTag("A", "dbo.LatestStatus", "oven_temp", + selectorColumn: "station_id", selectorValue: payload)], + new SqlServerDialect()).ToList(); + + plans[0].Parameters.ShouldBe(new object[] { payload }); + plans[0].SqlText.ShouldNotContain("DROP"); + } + + [Fact] + public void AHostileColumnName_goesThroughQuoteIdentifier_ratherThanBeingConcatenatedRaw() + { + var plans = SqlGroupPlanner.Plan( + [KvTag("A", "dbo.T", "tag_name", "k", "v] ; DROP TABLE Users --", null)], + new SqlServerDialect()).ToList(); + + // A column name is an identifier — it cannot be parameterized, so the guarantee is that it is + // bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL. + plans[0].SqlText.ShouldBe( + "SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)"); + } + + // ---- contract edges ---- + + [Fact] + public void NoTags_yieldNoPlans() + => SqlGroupPlanner.Plan([], new SqlServerDialect()).ShouldBeEmpty(); + + [Fact] + public void PlanOrder_followsTheInputOrderOfTheFirstMemberOfEachGroup() + { + var plans = SqlGroupPlanner.Plan( + [ + KvTag("A", "dbo.Third", "k", "1", "v", null), + KvTag("B", "dbo.First", "k", "2", "v", null), + KvTag("C", "dbo.Third", "k", "3", "v", null), + ], new SqlServerDialect()).ToList(); + + plans.Count.ShouldBe(2); + plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "C" }); + plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "B" }); + } + + [Fact] + public void TheDeferredQueryModel_throws_ratherThanBeingSilentlyDropped() + { + var tag = new SqlTagDefinition("A", SqlTagModel.Query, "dbo.T"); + Should.Throw(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect())); + } + + [Fact] + public void AWideRowTagWithNoRowSelectorAtAll_throws() + { + var tag = WideTag("A", "dbo.T", "oven_temp"); + Should.Throw(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect())); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs new file mode 100644 index 00000000..784b3dd7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs @@ -0,0 +1,168 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Locks the driver's SQL-injection guarantee against a real database (): +/// an authored value is bound as a parameter and can never execute, and an authored +/// identifier — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes +/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag +/// cannot alter the database; the seed table is intact after every hostile poll. +/// Scope note — what this suite does NOT assume. Design §8.1 also specifies a catalog gate: +/// validate every authored table/column against INFORMATION_SCHEMA before quoting it, so an unknown +/// identifier rejects the tag. No such gate exists in the driver yet (see the "Not yet +/// implemented" note on ), and no task in this workstream builds one. So a hostile +/// identifier here is not rejected as BadNodeIdUnknown — it is bracket-/double-quoted into a +/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes +/// as a query failure (). This suite proves the payload +/// is inert — it does not execute and the table survives — which is the guarantee the code +/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be +/// asserting fiction. +/// +public sealed class SqlInjectionRegressionTests +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15); + + /// The classic payload: were the key concatenated into text, this would drop the table. + private const string DropTablePayload = "'; DROP TABLE TagValues; --"; + + [Fact] + public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives() + { + using var fixture = new SqlitePollFixture(); + var reader = NewReader(fixture, KvTag("Speed", DropTablePayload)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + // Bound, not executed: it is simply a key that matches no row. + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + // The table is still there, with its seeded rows — the DROP never ran. + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); + } + + [Fact] + public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll() + { + // A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own + // parameter and matches nothing; the real key still reads. + using var fixture = new SqlitePollFixture(); + var reader = NewReader(fixture, + KvTag("Evil", DropTablePayload), + KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); + } + + [Fact] + public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives() + { + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + // A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not + // rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table), + // the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs. + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: "TagValues\"; DROP TABLE TagValues; --", + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + // Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate). + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError); + // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched. + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + [Fact] + public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives() + { + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + // The same attack via the value column: quoted, so it becomes a nonexistent column reference; the + // SELECT fails and the tag Bad-codes. Nothing executes. + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: "num_value\"; DROP TABLE TagValues; --", + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + [Fact] + public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase() + { + // QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object). + // The planner throws ArgumentException, which the reader classes as an authoring fault — + // BadConfigurationError — rather than a database failure. Still inert: the table survives. + using var fixture = new SqlitePollFixture(); + var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); + + var reader = NewReader(fixture, + new SqlTagDefinition( + Name: "Evil", + Model: SqlTagModel.KeyValue, + Table: "Tag\0Values", + KeyColumn: SqlitePollFixture.KeyColumn, + KeyValue: SqlitePollFixture.PresentKey, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError); + (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); + } + + // ---- helpers ---- + + private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags) + { + var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal); + return new SqlPollReader( + fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: rawPath => table.GetValueOrDefault(rawPath)); + } + + private static SqlTagDefinition KvTag(string name, string keyValue) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn); + + /// Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not + /// run. The table name is a test constant, never authored input, so interpolating it here is safe. + private static async Task RowCountAsync(SqlitePollFixture fixture, string table) + { + await using var command = fixture.Connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table}\""; + return Convert.ToInt64(await command.ExecuteScalarAsync()); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs new file mode 100644 index 00000000..cc203d9d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs @@ -0,0 +1,651 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using System.Globalization; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves against a real database (): the +/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct +/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven +/// by reading the code — that a query which refuses to return does not wedge the caller. +/// Each test owns its own fixture. The fixture is a temp file, so a fresh one per test is +/// cheap and buys total isolation — which matters here because two tests deliberately mutate the +/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into +/// every other test in the class. +/// +public sealed class SqlPollReaderTests +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15); + + // ---- the plan's headline contract: N in, N out, in input order ---- + + [Fact] + public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("Speed", SqlitePollFixture.PresentKey), + KvTag("Missing", SqlitePollFixture.AbsentKey), + KvTag("Temp", SqlitePollFixture.NullValueKey)); + + var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None); + + snapshots.Count.ShouldBe(3); + + // [0] present row, present cell — REAL infers Float64, so the published CLR type is double. + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + snapshots[0].SourceTimestampUtc.ShouldBe( + new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock + + // [1] no row at all — NOT the same thing as a NULL cell. + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshots[1].Value.ShouldBeNull(); + + // [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives). + snapshots[2].Value.ShouldBeNull(); + SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue(); + snapshots[2].SourceTimestampUtc.ShouldBe( + new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc)); + } + + [Fact] + public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, nullIsBad: true, + tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]); + + var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad); + SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); + // The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code. + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + } + + [Fact] + public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("Speed", SqlitePollFixture.PresentKey), + KvTag("Pressure", SqlitePollFixture.SecondPresentKey)); + + var snapshots = await reader.ReadAsync( + ["Speed", "NotAuthored", "Pressure"], CancellationToken.None); + + snapshots.Count.ShouldBe(3); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); + // The hole must not shift the tail — this is the ordering contract's real failure mode. + snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue); + } + + [Fact] + public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue() + { + // SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members, + // and a reader that indexed members by key into a 1:1 map would feed only one of them. + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("SpeedA", SqlitePollFixture.PresentKey), + KvTag("SpeedB", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + [Fact] + public async Task ReadAsync_theSameRefTwice_feedsBothSlots() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None); + + snapshots.Count.ShouldBe(2); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + [Fact] + public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection() + { + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null); + + (await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty(); + + factory.Created.ShouldBe(0); + } + + // ---- wide row ---- + + [Fact] + public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation)); + + var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None); + + // One row, two tags — the wrong-slice defect would cross these two values over. + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp); + snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure); + snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation), + WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation), + WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation)); + + var snapshots = await reader.ReadAsync( + ["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector + SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL + snapshots[1].Value.ShouldBeNull(); + snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected + } + + [Fact] + public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)); + + var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None); + + // Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here. + snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp); + snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp); + } + + [Fact] + public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne() + { + using var fixture = new SqlitePollFixture(); + // A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so + // which of the two the reader publishes is decided by physical storage order — an authoring mistake + // (a selector column that is not actually unique) that must not pass silently. This is the WideRow + // counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning. + AddSecondRowForPresentStation(fixture); + var logger = new RecordingLogger(); + var reader = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + + var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None); + + // Still a value — the reader degrades to "last row wins", exactly as the key-value model does. + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + + var warning = logger.Entries.ShouldHaveSingleItem(); + warning.Level.ShouldBe(LogLevel.Warning); + // Names the table AND the selector, so an operator can find the offending tag from the log alone. + warning.Message.ShouldContain(SqlitePollFixture.WideRowTable); + warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn); + warning.Message.ShouldContain(SqlitePollFixture.PresentStation); + } + + [Fact] + public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous() + { + using var fixture = new SqlitePollFixture(); + var logger = new RecordingLogger(); + + // A selector that matches exactly one row is the normal case and must log nothing at all — + // without this leg a warning that always fires would still pass the test above. + var clean = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + await clean.ReadAsync(["Oven"], CancellationToken.None); + logger.Entries.ShouldBeEmpty(); + + AddSecondRowForPresentStation(fixture); + var ambiguous = Reader(fixture, logger, + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)); + await ambiguous.ReadAsync(["Oven"], CancellationToken.None); + await ambiguous.ReadAsync(["Oven"], CancellationToken.None); + + // A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding + // the log. Two polls, one warning. + logger.Entries.Count.ShouldBe(1); + } + + // ---- type + timestamp mapping ---- + + [Fact] + public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType() + { + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, + KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32), + KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String), + KvTag("Inferred", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins + snapshots[1].Value.ShouldBe("42"); // string + snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL + } + + [Fact] + public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock() + { + using var fixture = new SqlitePollFixture(); + var before = DateTime.UtcNow; + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + snapshots[0].SourceTimestampUtc.ShouldNotBeNull(); + snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1)); + snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc); + } + + [Fact] + public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically() + { + using var fixture = new SqlitePollFixture(); + fixture.Execute( + $"INSERT INTO {SqlitePollFixture.KeyValueTable} " + + $"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " + + $"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')"); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + + // Design §3.6: the contract is one row per key; when a source breaks it the reader is at least + // deterministic — last occurrence in reader order — rather than silently arbitrary. + snapshots[0].Value.ShouldBe(99.0); + } + + // ---- the frozen-peer contract ---- + + [Fact] + public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline() + { + using var fixture = new SqlitePollFixture(); + + // A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and + // Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a + // cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async + // API that ignores its deadline), so only a real wall-clock bound can end this call early. + using var locker = fixture.OpenNewConnection(); + Execute(locker, "BEGIN EXCLUSIVE"); + + try + { + // Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the + // point is to prove the CLIENT-side bound fires while the server-side backstop would still wait. + var reader = new SqlPollReader( + fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500), + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + var clock = Stopwatch.StartNew(); + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + clock.Stop(); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + snapshots[0].Value.ShouldBeNull(); + // Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s. + clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + finally + { + Execute(locker, "ROLLBACK"); + } + } + + [Fact] + public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes() + { + // The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group + // is still holding a connection, so handing its slot back at the deadline would let the next poll + // open another one — and a database that stays frozen would accumulate one connection per poll pass + // forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test + // above never times a group out, and the BadTimeout test above never counts connections. + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + // commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay + // wedged for as long as the lock is held, so nothing but the test releases the slot. + commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500), + maxConcurrentGroups: 1, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + var locker = fixture.OpenNewConnection(); + Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws + try + { + // Poll 1 wedges against the lock and times out, abandoning a still-running query. + var first = await reader.ReadAsync(["Speed"], CancellationToken.None); + first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + factory.Created.ShouldBe(1); + factory.Live.ShouldBe(1); // the zombie still owns its connection + + // (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the + // factory: it times out waiting on the gate. Created staying at 1 is the load-bearing + // assertion — release-from-the-waiter would make this 2. + var second = await reader.ReadAsync(["Speed"], CancellationToken.None); + second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); + factory.Created.ShouldBe(1); + factory.Live.ShouldBe(1); + } + finally + { + Execute(locker, "ROLLBACK"); + locker.Dispose(); + } + + // (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline), + // closes its connection, and only then hands the slot back. + (await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30))) + .ShouldBeTrue("the abandoned group never released its connection"); + + var third = await reader.ReadAsync(["Speed"], CancellationToken.None); + third[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished + factory.Live.ShouldBe(0); + } + + [Fact] + public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation() + { + // Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so + // clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must + // propagate rather than be laundered into a snapshot nobody will consume. + using var fixture = new SqlitePollFixture(); + var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey)); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + await Should.ThrowAsync( + async () => await reader.ReadAsync(["Speed"], cancelled.Token)); + } + + [Fact] + public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff() + { + // IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws. + using var fixture = new SqlitePollFixture(); + var unreachable = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine( + Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"), + }.ToString(); + var reader = new SqlPollReader( + fixture.Factory, unreachable, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + await Should.ThrowAsync( + async () => await reader.ReadAsync(["Speed"], CancellationToken.None)); + } + + // ---- connection lifecycle ---- + + [Theory] + [InlineData(1)] + [InlineData(3)] + public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups) + { + using var fixture = new SqlitePollFixture(); + // Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp. + var tags = new[] + { + KvTag("Speed", SqlitePollFixture.PresentKey), + WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn), + }; + // The delay makes the groups genuinely overlap; without it a SQLite query is too fast for + // concurrency to be observable at all and the cap assertion below would pass vacuously. + var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150)); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30), + maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags)); + + var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None); + + snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good); + factory.Created.ShouldBe(3); + factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real + factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop + } + + [Fact] + public async Task ReadAsync_repeatedPolls_leakNoConnections() + { + using var fixture = new SqlitePollFixture(); + var factory = new TrackingFactory(); + var reader = new SqlPollReader( + factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, + resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey))); + + for (var poll = 0; poll < 5; poll++) + { + var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); + snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue); + } + + factory.Created.ShouldBe(5); + factory.Live.ShouldBe(0); + } + + // ---- argument guards ---- + + [Fact] + public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap() + { + var dialect = new SqliteDialect(); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null)); + Should.Throw(() => new SqlPollReader( + dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null)); + } + + [Fact] + public async Task ReadAsync_rejectsANullReferenceList() + => await Should.ThrowAsync(async () => + { + using var fixture = new SqlitePollFixture(); + await Reader(fixture).ReadAsync(null!, CancellationToken.None); + }); + + // ---- helpers ---- + + private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags) + => Reader(fixture, nullIsBad: false, tags: tags); + + private static SqlPollReader Reader( + SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags) + => new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags)); + + private static SqlPollReader Reader( + SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags) + => new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(), + commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, + maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger); + + /// + /// Adds a second row for the station the wide-row tests + /// select, breaking that model's one-row-per-selector contract. + /// + private static void AddSecondRowForPresentStation(SqlitePollFixture fixture) + => fixture.Execute(string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO {SqlitePollFixture.WideRowTable} + ({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure, + {SqlitePollFixture.TimestampColumn}) + VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z') + """)); + + /// Runs one statement on a caller-owned connection. + private static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// + /// Polls until it holds or elapses. Bounded on + /// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it + /// does not. + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan limit) + { + var clock = Stopwatch.StartNew(); + while (clock.Elapsed < limit) + { + if (condition()) return true; + await Task.Delay(25); + } + + return condition(); + } + + /// The driver's RawPath→definition table, standing in for EquipmentTagRefResolver. + private static Func Resolver(params SqlTagDefinition[] tags) + { + var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal); + return rawPath => table.GetValueOrDefault(rawPath); + } + + private static SqlTagDefinition KvTag( + string name, + string keyValue, + DriverDataType? declaredType = null, + string? timestampColumn = SqlitePollFixture.TimestampColumn) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: timestampColumn, + DeclaredType: declaredType); + + private static SqlTagDefinition WideTag( + string name, + string columnName, + string? selectorValue = null, + string? topByTimestamp = null, + string? timestampColumn = SqlitePollFixture.TimestampColumn) + => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable, + ColumnName: columnName, + TimestampColumn: timestampColumn, + RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn, + RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + /// + /// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the + /// contract-violation warnings are the reader's only signal that a source is misauthored, so they are + /// behaviour, not diagnostics. + /// + private sealed class RecordingLogger : ILogger + { + /// Every record written, level + rendered message. + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => Scope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class Scope : IDisposable + { + public static Scope Instance { get; } = new(); + + public void Dispose() { } + } + } + + /// + /// A over SQLite that counts how many connections the reader has + /// created and how many are alive at once — the only way to observe the connection lifecycle from + /// outside, since deliberately owns its connections end to end. + /// The optional createDelay widens the window each connection is alive so that + /// concurrent group execution is actually observable against a database whose queries would + /// otherwise finish in microseconds. + /// + private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory + { + private readonly object _sync = new(); + private int _live; + private int _peak; + private int _created; + + /// How many connections the reader has asked the factory for. + public int Created { get { lock (_sync) { return _created; } } } + + /// How many created connections have not yet closed. + public int Live { get { lock (_sync) { return _live; } } } + + /// The high-water mark of . + public int Peak { get { lock (_sync) { return _peak; } } } + + public override DbConnection CreateConnection() + { + var connection = SqliteFactory.Instance.CreateConnection()!; + connection.StateChange += OnStateChange; + lock (_sync) + { + _created++; + _live++; + if (_live > _peak) _peak = _live; + } + + if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay); + return connection; + } + + private void OnStateChange(object sender, StateChangeEventArgs e) + { + if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return; + lock (_sync) { _live--; } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs new file mode 100644 index 00000000..b76ddd8a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs @@ -0,0 +1,34 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +public class SqlQueryPlanImmutabilityTests +{ + // A plan is documented as cacheable across polls. If it stayed aliased to the planner's working + // List, a consumer downcast could corrupt every later poll that reused it. + [Fact] + public void Plan_doesNotAliasTheCallersLists() + { + var names = new List { "@k0" }; + var values = new List { "v" }; + var members = new List + { + new("raw", SqlTagModel.KeyValue, "t") { KeyColumn = "k", KeyValue = "v", ValueColumn = "c" }, + }; + var selected = new List { "k", "c" }; + + var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected); + + names.Add("@k1"); + values.Add("other"); + members.Add(members[0]); + selected.Add("extra"); + + plan.ParameterNames.Count.ShouldBe(1); + plan.Parameters.Count.ShouldBe(1); + plan.Members.Count.ShouldBe(1); + plan.SelectedColumns.Count.ShouldBe(2); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs new file mode 100644 index 00000000..e10995bc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -0,0 +1,143 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Golden tests for the driver's SQL-injection boundary. Values are always bound as +/// DbParameters; identifiers cannot be, so is the +/// only thing standing between a catalog-sourced name and a command text. These tests pin the escape +/// rule, the rejection rules, and the exact catalog SQL. +/// +public class SqlServerDialectTests +{ + [Fact] + public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets() + { + var d = new SqlServerDialect(); + d.QuoteIdentifier("Speed").ShouldBe("[Speed]"); + d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules + } + + [Fact] + public void QuoteIdentifier_rejectsControlCharsAndNul() + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier("x\0y")); + + // NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. + [Theory] + [InlineData("x\u0001y")] // C0 SOH + [InlineData("x\u001By")] // C0 ESC + [InlineData("x\u007Fy")] // DEL + [InlineData("x\u0085y")] // C1 NEL + [InlineData("x\ty")] + [InlineData("x\ny")] + public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident)); + + // Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all + // of these. They cannot escape the brackets, but they spoof rendered log/AdminUI text while comparing + // byte-different from the real catalog name (Trojan Source, CVE-2021-42574). + [Theory] + [InlineData("Sp\u200Beed")] // zero-width space + [InlineData("\u202Ediop.selbat")] // right-to-left override + [InlineData("x\u2066y\u2069")] // bidi isolates + [InlineData("x\uFEFFy")] // BOM / zero-width no-break space + [InlineData("x\u00ADy")] // soft hyphen + public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident)); + + [Theory] + [InlineData("bit", DriverDataType.Boolean)] + [InlineData("int", DriverDataType.Int32)] + [InlineData("bigint", DriverDataType.Int64)] + [InlineData("real", DriverDataType.Float32)] + [InlineData("float", DriverDataType.Float64)] + [InlineData("decimal", DriverDataType.Float64)] + [InlineData("nvarchar", DriverDataType.String)] + [InlineData("datetime2", DriverDataType.DateTime)] + [InlineData("uniqueidentifier", DriverDataType.String)] + public void MapColumnType_mapsFamilies(string sql, DriverDataType expected) + => new SqlServerDialect().MapColumnType(sql).ShouldBe(expected); + + [Fact] + public void CatalogSql_isInformationSchemaAndParameterized() + { + var d = new SqlServerDialect(); + d.LivenessSql.ShouldBe("SELECT 1"); + d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES"); + d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated + d.ListColumnsSql.ShouldContain("@table"); + } + + [Fact] + public void SingleRowLimit_isTSqlsTop1_inThePrefixPosition_withItsOwnTrailingSpace() + { + var d = new SqlServerDialect(); + + // The planner concatenates "SELECT " + prefix + columns with no separator of its own, so the + // trailing space belongs to the fragment. T-SQL has no end-of-statement limit clause. + d.SingleRowLimitPrefix.ShouldBe("TOP 1 "); + d.SingleRowLimitSuffix.ShouldBe(""); + string.Concat("SELECT ", d.SingleRowLimitPrefix, "[a]", d.SingleRowLimitSuffix) + .ShouldBe("SELECT TOP 1 [a]"); + } + + // ---- extra guards on the injection boundary (beyond the plan's golden set) ---- + + [Fact] + public void Dialect_identifiesItselfAsSqlServer_andExposesTheAbstractFactory() + { + var d = new SqlServerDialect(); + d.Provider.ShouldBe(SqlProvider.SqlServer); + d.Factory.ShouldNotBeNull(); + // The seam must not leak Microsoft.Data.SqlClient into its signature. + typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))! + .PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident) + => Should.Throw(() => new SqlServerDialect().QuoteIdentifier(ident!)); + + [Fact] + public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButABracket() + => new SqlServerDialect().QuoteIdentifier("]").ShouldBe("[]]]"); // T-SQL for the 1-char name "]" + + [Fact] + public void QuoteIdentifier_acceptsExactly128Chars_andRejects129() + { + var d = new SqlServerDialect(); + d.QuoteIdentifier(new string('a', 128)).ShouldBe("[" + new string('a', 128) + "]"); + Should.Throw(() => d.QuoteIdentifier(new string('a', 129))); + } + + [Fact] + public void QuoteIdentifier_neutralisesAClassicInjectionPayload() + { + // A hostile "column name" cannot escape the brackets: the only metacharacter that could is ], + // and it is doubled. The result is a single (nonexistent) identifier, never executable SQL. + new SqlServerDialect().QuoteIdentifier("x] ; DROP TABLE Users --") + .ShouldBe("[x]] ; DROP TABLE Users --]"); + } + + [Theory] + [InlineData("NVARCHAR", DriverDataType.String)] + [InlineData("BiT", DriverDataType.Boolean)] + public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected) + => new SqlServerDialect().MapColumnType(sql).ShouldBe(expected); + + [Theory] + [InlineData("geography")] + [InlineData("sql_variant")] + [InlineData("timestamp")] // SQL Server rowversion — binary, deliberately NOT a DateTime + [InlineData("")] + [InlineData(null)] + public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql) + => new SqlServerDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs new file mode 100644 index 00000000..8a7a7527 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -0,0 +1,154 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// A test-only over SQLite, so the poll reader and the schema browser can +/// be exercised against a real — real parameter binding, real type coercion, +/// real NULLs — with no SQL Server and no network. No product project references SQLite. +/// It is also the seam's falsifiability control: it is the only non-SQL-Server +/// in the tree, so it is what proves the planner emits dialect SQL +/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's +/// TOP 1 does not parse, and the fixture tests fail loudly if that regresses. +/// Kept public and self-contained on purpose: Driver.Sql.Browser.Tests links this file +/// (<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />) +/// rather than referencing this project, so it must not depend on anything else defined here. +/// +public sealed class SqliteDialect : ISqlDialect +{ + /// + /// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the + /// catalog queries accept this one value so the browser's schema level has something real to expand. + /// + public const string MainSchema = "main"; + + /// + /// Reports . + /// Why not a Sqlite member: is a shipped product + /// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain + /// a case that can never occur in production. + /// Why of the existing members: it is the enum's generic, + /// not-yet-constructed member, so nothing branches on it today. Crucially it is not + /// — any future T-SQL-only code path that keys off + /// stays switched off against this dialect and fails visibly here rather than + /// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp + /// for exactly the assumptions it exists to catch. + /// + public SqlProvider Provider => SqlProvider.Odbc; + + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// SQLite spells the row limit at the end of the statement, so the after-SELECT + /// position is empty — the mirror image of SqlServerDialect, which is the point of having both + /// ends on the seam. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// " LIMIT 1", leading space included so it appends straight onto the finished statement + /// (… ORDER BY "sample_ts" DESC LIMIT 1). + /// + public string SingleRowLimitSuffix => " LIMIT 1"; + + /// + /// SQLite has no schema namespace, so the schema level is the single literal + /// — enough to give the browser a real root to expand. + /// + public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; + + /// + /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the + /// internal sqlite_* objects filtered out and the type folded onto the + /// INFORMATION_SCHEMA.TABLES vocabulary the browser already speaks. @schema is bound and + /// honoured — anything but legitimately lists nothing. + /// + public string ListTablesSql => + "SELECT name AS TABLE_NAME, " + + "CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " + + "FROM sqlite_schema " + + "WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " + + $"AND @schema = '{MainSchema}' ORDER BY name"; + + /// + /// Columns via the pragma_table_info table-valued function rather than the bare + /// PRAGMA table_info(x) statement, because only the function form lets the table name be a + /// bound parameter — a bare PRAGMA takes its argument as text, which would put an authored name + /// back into a command string and reopen the injection boundary this seam exists to close. + /// DATA_TYPE is the column's declared type (SQLite stores affinity, not a type). + /// + public string ListColumnsSql => + "SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " + + "CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " + + "FROM pragma_table_info(@table) " + + $"WHERE @schema = '{MainSchema}' ORDER BY cid"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — SQLite's escape rule, and the + /// only metacharacter that could close a quoted identifier early. + /// + /// + /// Mirrors SqlServerDialect.QuoteIdentifier's rejections (null / empty / all-whitespace / + /// control characters) so a test written against one dialect fails the same way against the other. + /// SQLite imposes no identifier length ceiling, so there is no length rule here. + /// + /// The bare, unquoted identifier part. + /// The double-quote-quoted identifier. + /// The value cannot be a valid SQLite identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + } + + return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + } + + /// + /// Folds a SQLite declared column type onto a . SQLite is + /// dynamically typed and a column's declared type is advisory, which is exactly why the fixture + /// declares every seeded column explicitly. + /// + /// + /// Never throws, mirroring SqlServerDialect: an unrecognised (or blank, or + /// parameterised like VARCHAR(50)) declaration falls back to + /// so a browse over an oddly-declared table still renders. + /// Deliberate divergence from SQL Server: REAL maps to + /// here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's + /// real is 4-byte — mapping it to Float32 would narrow every value the fixture returns. + /// + /// The declared type as pragma_table_info reports it; case-insensitive. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "boolean" or "bool" or "bit" => DriverDataType.Boolean, + "tinyint" or "smallint" or "int2" => DriverDataType.Int16, + "int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32, + "bigint" or "int8" => DriverDataType.Int64, + // SQLite's REAL is a double — deliberately NOT Float32 (see remarks). + "real" or "double" or "double precision" or "float" + or "numeric" or "decimal" => DriverDataType.Float64, + "text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String, + "date" or "datetime" or "timestamp" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs new file mode 100644 index 00000000..604b5521 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs @@ -0,0 +1,212 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate +/// for every reader test. It exists so the poll path is exercised against a genuine +/// : real parameter binding, real provider type coercion, real +/// , real "no such row". +/// Backed by a temporary FILE, not :memory:. The reader opens a fresh +/// connection per poll (Factory.CreateConnection() → set → +/// OpenAsync) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment +/// its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate +/// between polls and every test would fail for a reason that has nothing to do with the code under test. +/// The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an +/// invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to +/// exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no +/// keep-alive, and its connection string is an ordinary path the code under test takes verbatim. +/// The fixture is a contract, not scaffolding. The seeded shapes below are what reader tests +/// assert against, so the constants are public and the seed deliberately covers the three cases that +/// behave differently: a present value, a present row whose value cell is NULL, and a key that is +/// absent entirely. Change a constant and you are changing what the reader is proven to do. +/// +public sealed class SqlitePollFixture : IDisposable +{ + // ---- key-value (EAV) source ---- + + /// The key-value table: TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT). + public const string KeyValueTable = "TagValues"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by both seeded tables. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for . + public const double SecondPresentValue = 3.5; + + /// + /// A key whose row exists but whose value cell is NULL — the case that must not be + /// confused with : the row was read, the value is simply not there. + /// + public const string NullValueKey = "Line1.Temp"; + + /// + /// A key with no row at all. Distinct from on purpose: a missing row + /// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell. + /// + public const string AbsentKey = "Line1.Missing"; + + // ---- wide-row source ---- + + /// + /// The wide-row table: + /// LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT). + /// + public const string WideRowTable = "LatestStatus"; + + /// The wide-row table's row-selector column. + public const string WideRowSelectorColumn = "station_id"; + + /// A station whose row exists, with both value columns populated. + public const string PresentStation = "7"; + + /// 's oven_temp. + public const double PresentStationOvenTemp = 180.5; + + /// 's pressure. + public const double PresentStationPressure = 1.2; + + /// + /// The station holding the newest sample_ts — what a topByTimestamp row selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the + /// ORDER BY … DESC (or the row limit) picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — the value a topByTimestamp plan yields. + public const double NewestStationOvenTemp = 210.25; + + /// A station whose row exists but whose oven_temp cell is NULL. + public const string NullOvenTempStation = "9"; + + /// A station with no row at all. + public const string AbsentStation = "404"; + + private readonly string _databasePath; + + /// Creates the temporary database, applies the schema, and seeds it. + public SqlitePollFixture() + { + _databasePath = Path.Combine( + Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db"); + ConnectionString = new SqliteConnectionStringBuilder + { + DataSource = _databasePath, + }.ToString(); + + Connection = new SqliteConnection(ConnectionString); + Connection.Open(); + Seed(Connection); + } + + /// + /// The connection string to hand the code under test. An ordinary file path — safe to open and close + /// any number of times, from any number of connections, in any order. + /// + public string ConnectionString { get; } + + /// + /// The provider factory to hand the code under test, matching the reader's + /// (DbProviderFactory factory, string connectionString, …) seam. + /// This is the real , not a shim that hands back a + /// pre-opened connection: because the database is a file, the reader's genuine + /// create → open → query → close cycle works unmodified, so what the tests exercise is the production + /// connection lifecycle rather than a test-only shortcut around it. + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + /// A long-lived open connection over the same database, for arranging extra state or inspecting + /// results directly. Independent of the connections the code under test opens — closing one has no + /// effect on the others, and none of them destroy the data. + /// + public SqliteConnection Connection { get; } + + /// Opens a brand-new connection, exactly as the reader does on each poll. + /// An open connection the caller owns and must dispose. + public SqliteConnection OpenNewConnection() + { + var connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + /// Runs one non-query statement against , for test-local arrangement. + /// The statement to execute. + public void Execute(string sql) + { + using var command = Connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Closes the fixture's connection, clears the pool, and deletes the temporary database file. + public void Dispose() + { + Connection.Close(); + Connection.Dispose(); + // Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be + // held open and the delete silently fails, leaking a file per test class into the temp directory. + SqliteConnection.ClearAllPools(); + try + { + if (File.Exists(_databasePath)) File.Delete(_databasePath); + } + catch (IOException) + { + // A leaked temp file must never fail a test run. + } + } + + /// + /// Applies the schema and rows described by this type's constants. + /// Every column is explicitly declared. SQLite is dynamically typed and would happily + /// store a string in an undeclared column, which would make the reader's type-coercion tests prove + /// nothing; declared types give pragma_table_info (and therefore + /// ) something real to report. + /// + private static void Seed(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = string.Create(CultureInfo.InvariantCulture, $""" + CREATE TABLE {KeyValueTable} ( + {KeyColumn} TEXT NOT NULL, + {ValueColumn} REAL NULL, + {TimestampColumn} TEXT NOT NULL + ); + INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES + ('{PresentKey}', {PresentValue}, '2026-07-24T10:00:00Z'), + ('{NullValueKey}', NULL, '2026-07-24T10:00:01Z'), + ('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z'); + + CREATE TABLE {WideRowTable} ( + {WideRowSelectorColumn} INTEGER NOT NULL, + oven_temp REAL NULL, + pressure REAL NULL, + {TimestampColumn} TEXT NOT NULL + ); + INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES + ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'), + ({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01Z'), + ({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02Z'); + """); + command.ExecuteNonQuery(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs new file mode 100644 index 00000000..390a1c08 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs @@ -0,0 +1,263 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Proves the offline substrate the reader tests will stand on: that +/// survives the reader's real per-poll connection lifecycle, and that a +/// -produced plan executes — parses, binds, and returns the seeded +/// rows — rather than merely looking right as a string. +/// The planner's own tests assert emitted SQL text. Nothing there can catch a statement that is +/// well-formed but unexecutable, or a parameter marker the provider will not bind. These tests can, and +/// they are the reason the next task starts from a known-good query path. +/// +public sealed class SqlitePollFixtureTests : IClassFixture +{ + private readonly SqlitePollFixture _fixture; + + public SqlitePollFixtureTests(SqlitePollFixture fixture) => _fixture = fixture; + + // ---- the fixture itself ---- + + [Fact] + public void AConnectionFromTheFactory_roundTripsASeededRow() + { + // Exactly the reader's seam: create from the abstract factory, assign the connection string, open. + var connection = _fixture.Factory.CreateConnection(); + connection.ShouldNotBeNull(); + connection.ConnectionString = _fixture.ConnectionString; + connection.Open(); + using var owned = connection; + + using var command = owned.CreateCommand(); + command.CommandText = + $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " + + $"WHERE {SqlitePollFixture.KeyColumn} = @k"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@k"; + parameter.Value = SqlitePollFixture.PresentKey; + command.Parameters.Add(parameter); + + Convert.ToDouble(command.ExecuteScalar()).ShouldBe(SqlitePollFixture.PresentValue); + } + + [Fact] + public void TheDatabase_survivesTheReadersOpenAndCloseCyclePerPoll() + { + // The whole reason the fixture is file-backed: an in-memory database would be gone by "poll" 2. + for (var poll = 0; poll < 3; poll++) + { + using var connection = _fixture.OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {SqlitePollFixture.KeyValueTable}"; + Convert.ToInt64(command.ExecuteScalar()).ShouldBe(3L); + } + } + + [Fact] + public void TheSeed_distinguishesAPresentValue_aNullCell_andAnAbsentKey() + { + using var connection = _fixture.OpenNewConnection(); + + ReadValue(connection, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue); + ReadValue(connection, SqlitePollFixture.NullValueKey).ShouldBe(DBNull.Value); // row present, cell NULL + ReadValue(connection, SqlitePollFixture.AbsentKey).ShouldBeNull(); // no row at all + + static object? ReadValue(SqliteConnection connection, string key) + { + using var command = connection.CreateCommand(); + command.CommandText = + $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " + + $"WHERE {SqlitePollFixture.KeyColumn} = @k"; + command.Parameters.AddWithValue("@k", key); + return command.ExecuteScalar(); + } + } + + // ---- planner-produced plans, actually executed ---- + + [Fact] + public void AKeyValuePlan_executesAgainstTheFixture_andSlicesBackPerMember() + { + var plan = SqlGroupPlanner.Plan( + [ + KvTag("A", SqlitePollFixture.PresentKey), + KvTag("B", SqlitePollFixture.NullValueKey), + KvTag("C", SqlitePollFixture.AbsentKey), + ], new SqliteDialect()).ShouldHaveSingleItem(); + + var rows = ExecutePlan(plan); + + // The result set is indexed by the key column, exactly as the reader will slice it. + var byKey = rows.ToDictionary( + r => (string)r[SqlitePollFixture.KeyColumn]!, StringComparer.Ordinal); + + byKey.Count.ShouldBe(2); // the absent key contributes no row — it is not an error, just no data + byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.ValueColumn] + .ShouldBe(SqlitePollFixture.PresentValue); + byKey[SqlitePollFixture.NullValueKey][SqlitePollFixture.ValueColumn] + .ShouldBeNull(); // present row, NULL cell — distinct from the absent key above + byKey.ShouldNotContainKey(SqlitePollFixture.AbsentKey); + byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.TimestampColumn] + .ShouldBe("2026-07-24T10:00:00Z"); + } + + [Fact] + public void AWideRowWherePairPlan_executesAgainstTheFixture_andReturnsTheSelectedRow() + { + var plan = SqlGroupPlanner.Plan( + [ + WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.PresentStation), + WideTag("B", "pressure", selectorValue: SqlitePollFixture.PresentStation), + ], new SqliteDialect()).ShouldHaveSingleItem(); + + // The station id is bound, not inlined — and SQLite coerces the bound string against an INTEGER column. + plan.Parameters.ShouldBe(new object[] { SqlitePollFixture.PresentStation }); + + var row = ExecutePlan(plan).ShouldHaveSingleItem(); + row["oven_temp"].ShouldBe(SqlitePollFixture.PresentStationOvenTemp); + row["pressure"].ShouldBe(SqlitePollFixture.PresentStationPressure); + } + + [Fact] + public void AWideRowWherePairPlan_forAnAbsentSelectorValue_returnsNoRows() + { + var plan = SqlGroupPlanner.Plan( + [WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation)], + new SqliteDialect()).ShouldHaveSingleItem(); + + ExecutePlan(plan).ShouldBeEmpty(); + } + + [Fact] + public void ATopByTimestampPlan_emitsSqlitesLimitForm_andExecutesToTheNewestRow() + { + var plan = SqlGroupPlanner.Plan( + [WideTag("A", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)], + new SqliteDialect()).ShouldHaveSingleItem(); + + // The payoff of putting the row limit on ISqlDialect: T-SQL's "TOP 1" does not parse in SQLite, so + // this statement would throw at ExecuteReader if the planner had kept emitting it inline. + plan.SqlText.ShouldBe( + "SELECT \"oven_temp\" FROM \"LatestStatus\" ORDER BY \"sample_ts\" DESC LIMIT 1"); + plan.SqlText.ShouldNotContain("TOP 1"); + + var row = ExecutePlan(plan).ShouldHaveSingleItem(); + row["oven_temp"].ShouldBe(SqlitePollFixture.NewestStationOvenTemp); // station 8, not the first row + } + + // ---- the dialect ---- + + [Fact] + public void TheDialect_quotesWithDoubleQuotes_andDoublesAnEmbeddedOne() + { + var dialect = new SqliteDialect(); + dialect.QuoteIdentifier("oven_temp").ShouldBe("\"oven_temp\""); + dialect.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); + Should.Throw(() => dialect.QuoteIdentifier("x\0y")); + Should.Throw(() => dialect.QuoteIdentifier(" ")); + } + + [Fact] + public void TheDialectsCatalogSql_answersOverTheRealSeededDatabase() + { + var dialect = new SqliteDialect(); + using var connection = _fixture.OpenNewConnection(); + + Query(dialect.ListSchemasSql).ShouldBe(new[] { SqliteDialect.MainSchema }); + + var tables = Query(dialect.ListTablesSql, ("@schema", SqliteDialect.MainSchema)); + tables.ShouldContain(SqlitePollFixture.KeyValueTable); + tables.ShouldContain(SqlitePollFixture.WideRowTable); + tables.ShouldAllBe(t => !t.StartsWith("sqlite_", StringComparison.Ordinal)); + + var columns = Query( + dialect.ListColumnsSql, + ("@schema", SqliteDialect.MainSchema), ("@table", SqlitePollFixture.WideRowTable)); + columns.ShouldBe(new[] + { + SqlitePollFixture.WideRowSelectorColumn, "oven_temp", "pressure", SqlitePollFixture.TimestampColumn, + }); + + List Query(string sql, params (string Name, string Value)[] parameters) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value); + using var reader = command.ExecuteReader(); + var results = new List(); + while (reader.Read()) results.Add(reader.GetString(0)); + return results; + } + } + + [Fact] + public void TheDialect_mapsTheSeededDeclaredTypes_andNeverThrowsOnAnUnknownAffinity() + { + var dialect = new SqliteDialect(); + dialect.MapColumnType("TEXT").ShouldBe(DriverDataType.String); + dialect.MapColumnType("INTEGER").ShouldBe(DriverDataType.Int32); + // SQLite's REAL is a double — NOT Float32, which is what the same word means in T-SQL. + dialect.MapColumnType("REAL").ShouldBe(DriverDataType.Float64); + dialect.MapColumnType("real").ShouldBe(DriverDataType.Float64); + dialect.MapColumnType("VARCHAR(50)").ShouldBe(DriverDataType.String); // unparsed ⇒ fallback + dialect.MapColumnType("some_udt").ShouldBe(DriverDataType.String); + dialect.MapColumnType("").ShouldBe(DriverDataType.String); + dialect.MapColumnType(null!).ShouldBe(DriverDataType.String); + } + + [Fact] + public void TheDialect_doesNotClaimToBeSqlServer() + // A test dialect that reported SqlServer would rubber-stamp the T-SQL assumptions it exists to catch. + => new SqliteDialect().Provider.ShouldNotBe(SqlProvider.SqlServer); + + // ---- helpers ---- + + private static SqlTagDefinition KvTag(string name, string keyValue) + => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, + KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, + ValueColumn: SqlitePollFixture.ValueColumn, + TimestampColumn: SqlitePollFixture.TimestampColumn); + + private static SqlTagDefinition WideTag( + string name, string columnName, string? selectorValue = null, string? topByTimestamp = null) + => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable, + ColumnName: columnName, + RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn, + RowSelectorValue: selectorValue, + RowSelectorTopByTimestamp: topByTimestamp); + + /// + /// Executes a plan the way the reader will: one fresh connection, the plan's SQL verbatim, its + /// parameters bound positionally by name, and the rows projected by + /// . + /// + private List> ExecutePlan(SqlQueryPlan plan) + { + using var connection = _fixture.OpenNewConnection(); + using var command = connection.CreateCommand(); + command.CommandText = plan.SqlText; + for (var i = 0; i < plan.ParameterNames.Count; i++) + command.Parameters.AddWithValue(plan.ParameterNames[i], plan.Parameters[i] ?? DBNull.Value); + + using var reader = command.ExecuteReader(); + var rows = new List>(); + while (reader.Read()) + { + var row = new Dictionary(StringComparer.Ordinal); + foreach (var column in plan.SelectedColumns) + { + var ordinal = reader.GetOrdinal(column); + row[column] = reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal); + } + + rows.Add(row); + } + + return rows; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj new file mode 100644 index 00000000..6b5c15d4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -0,0 +1,44 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs new file mode 100644 index 00000000..8bc28e88 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Guards the AdminUI SqlDriverForm's driver-config output against the runtime factory that consumes +/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms: +/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative +/// reader, ; and (2) the +/// serialization contract — provider as a NAME string (never an ordinal), camelCase keys, and no +/// composer-owned rawTags / inert allowWrites leaking into the authored blob. A numeric enum +/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards. +/// +public sealed class SqlDriverFormContractTests +{ + // The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls). + private static readonly JsonSerializerOptions FormOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + // ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ---- + + [Theory] + // Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory). + [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")] + // Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout). + [InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")] + public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson) + { + var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest"); + var previous = Environment.GetEnvironmentVariable(envVar); + Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;"); + try + { + var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson); + driver.ShouldNotBeNull(); + } + finally + { + Environment.SetEnvironmentVariable(envVar, previous); + } + } + + [Fact] + public void Factory_rejects_a_blob_missing_connectionStringRef() + { + // The form marks connectionStringRef required; the factory is the authoritative enforcement. + var ex = Should.Throw( + () => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}""")); + ex.Message.ShouldContain("connectionStringRef"); + } + + // ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals -------- + + [Fact] + public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields() + { + // Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set). + var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" }; + + var json = JsonSerializer.Serialize(dto, FormOptions); + + json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}"""); + json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read + json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form + json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored + json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies + } + + [Fact] + public void Provider_round_trips_as_the_SqlServer_name() + { + var json = JsonSerializer.Serialize( + new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions); + + json.ShouldContain("\"SqlServer\""); + JsonSerializer.Deserialize(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs new file mode 100644 index 00000000..bfd0ad86 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs @@ -0,0 +1,277 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔ +/// parser-input agreement: what writes MUST parse cleanly through +/// (the runtime factory's authoritative reader), and the +/// model/type enums MUST serialise as NAME strings — a numeric enum makes the parser reject +/// the tag (the "authors fine, never reads" defect class this guards). +/// +public sealed class SqlTagConfigModelTests +{ + // ---- the plan's required assertion: string enums + unknown-key survival -------------------- + + [Fact] + public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys() + { + var m = SqlTagConfigModel.FromJson( + """{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}"""); + var json = m.ToJson(); + json.ShouldContain("\"KeyValue\""); + json.ShouldContain("\"Float64\""); + json.ShouldContain("customX"); // unknown key survives load→save + json.ShouldNotContain("\"model\":0"); + json.ShouldNotContain("\"type\":8"); // no numeric enum leaks + } + + // ---- defaults ------------------------------------------------------------------------------ + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = SqlTagConfigModel.FromJson(json); + + m.Model.ShouldBe(SqlTagModel.KeyValue); + m.Table.ShouldBeNull(); + m.KeyColumn.ShouldBeNull(); + m.KeyValue.ShouldBeNull(); + m.ValueColumn.ShouldBeNull(); + m.TimestampColumn.ShouldBeNull(); + m.ColumnName.ShouldBeNull(); + m.Type.ShouldBeNull(); + m.RowSelector.WhereColumn.ShouldBeNull(); + m.RowSelector.WhereValue.ShouldBeNull(); + m.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_of_default_model_omits_optional_keys() + { + var json = new SqlTagConfigModel { Table = "t" }.ToJson(); + + json.ShouldContain("\"model\":\"KeyValue\""); + json.ShouldContain("\"table\":\"t\""); + json.ShouldNotContain("type"); // no type set ⇒ key omitted + json.ShouldNotContain("rowSelector"); // no selector ⇒ key omitted + json.ShouldNotContain("timestampColumn"); + } + + // ---- round-trip fidelity through FromJson→ToJson→FromJson ---------------------------------- + + [Fact] + public void Round_trip_preserves_keyValue_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.KeyValue, + Table = "dbo.TagValues", + KeyColumn = "TagName", + KeyValue = "Line1.Speed", + ValueColumn = "Val", + TimestampColumn = "Ts", + Type = DriverDataType.Float64, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.KeyValue); + m2.Table.ShouldBe("dbo.TagValues"); + m2.KeyColumn.ShouldBe("TagName"); + m2.KeyValue.ShouldBe("Line1.Speed"); + m2.ValueColumn.ShouldBe("Val"); + m2.TimestampColumn.ShouldBe("Ts"); + m2.Type.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void Round_trip_preserves_wideRow_wherePair_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { WhereColumn = "DeviceId", WhereValue = "42" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.Table.ShouldBe("dbo.Wide"); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.WhereColumn.ShouldBe("DeviceId"); + m2.RowSelector.WhereValue.ShouldBe("42"); + m2.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void Round_trip_preserves_wideRow_topByTimestamp_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { TopByTimestamp = "Ts" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.TopByTimestamp.ShouldBe("Ts"); + m2.RowSelector.WhereColumn.ShouldBeNull(); + m2.RowSelector.WhereValue.ShouldBeNull(); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys_top_level_and_nested() + { + var json = SqlTagConfigModel + .FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts","futureKey":"keep"},"scaling":2.5}""") + .ToJson(); + + json.ShouldContain("scaling"); // unknown top-level key survives + json.ShouldContain("2.5"); + json.ShouldContain("futureKey"); // unknown nested (rowSelector) key survives + json.ShouldContain("keep"); + json.ShouldContain("\"topByTimestamp\":\"Ts\""); + } + + // ---- Validate() mirrors the parser's accept/reject boundary -------------------------------- + + [Fact] + public void Validate_returns_null_for_valid_keyValue() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_wherePair() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"whereColumn":"id","whereValue":"1"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_topByTimestamp() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_rejects_missing_table() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_valueColumn() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_keyValue_field() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_without_a_selector() + => SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_missing_columnName() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_model_enum() + => SqlTagConfigModel.FromJson("""{"model":"Bogus","table":"t"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_type_enum() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_deferred_query_model() + => SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""") + .Validate().ShouldNotBeNull(); + + // ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader -------------- + + [Theory] + [InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")] + public void ToJson_output_parses_cleanly_through_SqlEquipmentTagParser(string authored) + { + // Round-trip the authored blob through the editor model, exactly as the TagModal save path does. + var roundTripped = SqlTagConfigModel.FromJson(authored).ToJson(); + + // The authoritative runtime reader must accept it — this is the editor-output ⇔ parser-input contract. + SqlEquipmentTagParser.TryParse(roundTripped, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue(); + def.Name.ShouldBe("Plant/Sql/dev1/Speed"); + def.Table.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void ToJson_keyValue_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.KeyValue); + def.Table.ShouldBe("dbo.TagValues"); + def.KeyColumn.ShouldBe("TagName"); + def.KeyValue.ShouldBe("Line1.Speed"); + def.ValueColumn.ShouldBe("Val"); + def.TimestampColumn.ShouldBe("Ts"); + def.DeclaredType.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void ToJson_wideRow_wherePair_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorColumn.ShouldBe("DeviceId"); + def.RowSelectorValue.ShouldBe("42"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_wideRow_topByTimestamp_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorTopByTimestamp.ShouldBe("Ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + } +}