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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+