diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 1bff1974..033ff3d3 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -94,6 +94,7 @@ + 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/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/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 + + + + + + + + + + + + + + + +