feat(sql): SqlBrowseSession schema-walk over dialect catalog

Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with
@schema/@table bound as parameters at every level. Column leaves carry the
dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only
v1).

The NodeId encoding deliberately departs from the design sketch's literal
`schema.table|column`: SQL Server permits `.` and `|` inside a quoted
identifier, so that form mis-parses (main.a.b|c reads equally as schema
`main`+table `a.b` and schema `main.a`+table `b`) and silently binds an
operator's tag to the wrong column. SqlBrowseNodeId encodes
`<kind>:<part>[|<part>...]` with `\`/`|` escaped and the kind prefix carrying
the arity; it is public because the picker body decodes it back.

The session owns the connection it is handed and closes it on dispose -- the
registry-held session is the only lifetime hook, so a non-owning session would
leak one pooled connection per reaped picker. Per-call work stays bounded by
the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout);
no second deadline is invented here.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:49:36 -04:00
parent 9b30bdeb7a
commit 861a1d1df0
8 changed files with 1452 additions and 0 deletions
@@ -0,0 +1,204 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// Records how many command executions are ever in flight at once, and how long each one is artificially
/// held open. Shared by <see cref="ConcurrencyProbingDbConnection"/> and the commands it creates.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class ConcurrencyProbe
{
private int _inFlight;
private int _peak;
/// <summary>How long each command execution is held before it reaches the real provider.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120);
/// <summary>The greatest number of executions observed in flight simultaneously.</summary>
public int Peak => Volatile.Read(ref _peak);
/// <summary>Marks the start of one command execution.</summary>
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;
}
}
/// <summary>Marks the end of one command execution.</summary>
public void Exit() => Interlocked.Decrement(ref _inFlight);
}
/// <summary>
/// A pass-through <see cref="DbConnection"/> that hands out commands instrumented with a
/// <see cref="ConcurrencyProbe"/>. Everything else delegates to the wrapped connection, so the code under
/// test runs against a real SQLite catalog.
/// </summary>
/// <param name="inner">The real connection to delegate to. Not owned — the caller disposes it.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString
{
get => inner.ConnectionString;
set => inner.ConnectionString = value!;
}
/// <inheritdoc />
public override string Database => inner.Database;
/// <inheritdoc />
public override string DataSource => inner.DataSource;
/// <inheritdoc />
public override string ServerVersion => inner.ServerVersion;
/// <inheritdoc />
public override ConnectionState State => inner.State;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
/// <inheritdoc />
public override void Close() => inner.Close();
/// <inheritdoc />
public override void Open() => inner.Open();
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
inner.BeginTransaction(isolationLevel);
/// <inheritdoc />
protected override DbCommand CreateDbCommand() =>
new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe);
}
/// <summary>
/// A pass-through <see cref="DbCommand"/> that reports every execution to a <see cref="ConcurrencyProbe"/>
/// and holds it open for the probe's delay, so overlapping executions are observable.
/// </summary>
/// <param name="inner">The real command to delegate to.</param>
/// <param name="owner">The connection that created this command.</param>
/// <param name="probe">The probe that observes command overlap.</param>
public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe)
: DbCommand
{
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string CommandText
{
get => inner.CommandText;
set => inner.CommandText = value!;
}
/// <inheritdoc />
public override int CommandTimeout
{
get => inner.CommandTimeout;
set => inner.CommandTimeout = value;
}
/// <inheritdoc />
public override CommandType CommandType
{
get => inner.CommandType;
set => inner.CommandType = value;
}
/// <inheritdoc />
public override bool DesignTimeVisible
{
get => inner.DesignTimeVisible;
set => inner.DesignTimeVisible = value;
}
/// <inheritdoc />
public override UpdateRowSource UpdatedRowSource
{
get => inner.UpdatedRowSource;
set => inner.UpdatedRowSource = value;
}
/// <inheritdoc />
protected override DbConnection? DbConnection
{
get => owner;
set { /* The session never reassigns the connection; the wrapped command keeps its own. */ }
}
/// <inheritdoc />
protected override DbParameterCollection DbParameterCollection => inner.Parameters;
/// <inheritdoc />
protected override DbTransaction? DbTransaction
{
get => inner.Transaction;
set => inner.Transaction = value;
}
/// <inheritdoc />
public override void Cancel() => inner.Cancel();
/// <inheritdoc />
public override int ExecuteNonQuery() => inner.ExecuteNonQuery();
/// <inheritdoc />
public override object? ExecuteScalar() => inner.ExecuteScalar();
/// <inheritdoc />
public override void Prepare() => inner.Prepare();
/// <inheritdoc />
protected override DbParameter CreateDbParameter() => inner.CreateParameter();
/// <inheritdoc />
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
probe.Enter();
try
{
Thread.Sleep(probe.Delay);
return inner.ExecuteReader(behavior);
}
finally
{
probe.Exit();
}
}
/// <inheritdoc />
protected override async Task<DbDataReader> 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();
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) inner.Dispose();
base.Dispose(disposing);
}
}
@@ -0,0 +1,128 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
/// <summary>
/// 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
/// <c>.</c> and <c>|</c> (inside a quoted identifier), so the round-trip has to hold for those too.
/// </summary>
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");
}
/// <summary>
/// The exact case the plan's <c>schema.table|column</c> sketch cannot express: <c>main.a.b|c</c> reads
/// equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c> + table <c>b</c>.
/// </summary>
[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);
}
/// <summary>
/// Two genuinely different columns must never collide on one NodeId. Under the plan's literal
/// <c>schema.table|column</c> sketch both of these render as <c>a.b|c</c>.
/// </summary>
[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");
}
/// <summary>A table NodeId and a schema NodeId are never confusable, whatever the names contain.</summary>
[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<ArgumentException>(() => SqlBrowseNodeId.Parse(nodeId));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void EmptyIdentifiers_areRejectedAtEncode(string? identifier)
{
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForSchema(identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForTable("main", identifier!));
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!));
}
}
@@ -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;
/// <summary>
/// Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
/// <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
/// that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
/// with a quoting helper bolted on.
/// </summary>
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);
}
/// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
[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));
}
/// <summary>
/// An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
/// <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
/// still render.
/// </summary>
[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));
}
/// <summary>
/// The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
/// <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
/// this is the case that proves the encoding, not just the codec unit test.
/// </summary>
[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));
}
/// <summary>
/// A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
/// instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
/// </summary>
[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));
}
/// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
[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();
}
/// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
[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();
}
/// <summary>
/// A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
/// message — the same contract the Galaxy and OPC UA client sessions have, and what
/// <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
/// <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
/// </summary>
[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<ArgumentException>(
() => session.ExpandAsync(nodeId, CancellationToken.None));
expand.Message.ShouldNotBeNullOrWhiteSpace();
await Should.ThrowAsync<ArgumentException>(
() => session.AttributesAsync(nodeId, CancellationToken.None));
}
/// <summary>
/// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
/// Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
/// assertion would pass with the gate deleted.
/// </summary>
[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);
}
/// <summary>
/// Ownership: the session takes over the connection it is handed and closes it on dispose. The
/// AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
/// not close the connection, nothing would — every reaped picker would leak one.
/// </summary>
[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<ObjectDisposedException>(
() => session.RootAsync(CancellationToken.None));
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// The AdminUI bounds every call with a 20 s linked CTS
/// (<c>BrowserSessionService.PerCallTimeout</c>) — 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.
/// </summary>
[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<OperationCanceledException>(() => session.RootAsync(cts.Token));
await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
}
/// <summary>
/// <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
/// refreshed it would be evicted out from under an actively browsing operator.
/// </summary>
[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);
}
}
@@ -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;
/// <summary>
/// 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 <c>sqlite_schema</c> rows, real
/// <c>pragma_table_info</c> output, real parameter binding — with no SQL Server and no network.
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c></b>, for the same reason
/// <c>SqlitePollFixture</c> 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.</para>
/// <para><b>The seed is a contract.</b> Every table below exists to pin one browse behaviour, and the
/// awkward ones are the point: <see cref="AwkwardTable"/> and <see cref="AwkwardColumn"/> carry the
/// <c>.</c> and <c>|</c> characters that a naive <c>schema.table|column</c> NodeId would mis-parse, and
/// <see cref="HostileTable"/> is a live injection probe — if any catalog query concatenated a name
/// instead of binding it, expanding that table drops <see cref="KeyValueTable"/> and the assertions say
/// so.</para>
/// </summary>
public sealed class SqliteBrowseFixture : IAsyncDisposable
{
/// <summary>The ordinary key-value table, mirroring the poll fixture's shape.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>REAL</c>, i.e. <c>Float64</c>.</summary>
public const string RealColumn = "num_value";
/// <summary>The <see cref="KeyValueTable"/> column declared <c>TEXT</c>, i.e. <c>String</c>.</summary>
public const string TextColumn = "tag_name";
/// <summary>A view over <see cref="KeyValueTable"/> — the browser must list views beside tables.</summary>
public const string ViewName = "TagValuesView";
/// <summary>
/// A table whose name contains BOTH NodeId metacharacters. A <c>schema.table|column</c> NodeId cannot
/// represent this unambiguously — <c>main.a.b|c</c> reads equally as schema <c>main.a</c>, table
/// <c>b</c>. Legal in SQL Server inside a quoted identifier, so the encoding must survive it.
/// </summary>
public const string AwkwardTable = "a.b|c";
/// <summary>A column name carrying the separator character.</summary>
public const string AwkwardColumn = "x|y";
/// <summary>A column name carrying a dot and the escape character.</summary>
public const string AwkwardColumn2 = @"d.o\t";
/// <summary>
/// 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.
/// </summary>
public const string HostileTable = "'); DROP TABLE TagValues; --";
/// <summary>The single column of <see cref="HostileTable"/>.</summary>
public const string HostileColumn = "hostile_col";
/// <summary>A table whose columns carry types no dialect map recognises.</summary>
public const string ExoticTable = "Exotic";
/// <summary>An <see cref="ExoticTable"/> column declared with a type outside the map's vocabulary.</summary>
public const string ExoticColumn = "shape_col";
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
public const string NonExistentTable = "NoSuchTable";
private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
{
_databasePath = databasePath;
ConnectionString = connectionString;
Connection = connection;
}
/// <summary>The connection string over the temporary database file.</summary>
public string ConnectionString { get; }
/// <summary>
/// A long-lived open connection over the seeded database — what tests hand
/// <c>SqlBrowseSession</c>. Independent of any other connection over the same file.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>
/// The NodeId of SQLite's one and only schema, encoded exactly as
/// <c>SqlBrowseSession.RootAsync</c> emits it.
/// </summary>
public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema);
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
/// <returns>The ready fixture.</returns>
public static async Task<SqliteBrowseFixture> 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);
}
/// <summary>Opens a brand-new connection over the same database.</summary>
/// <returns>An open connection the caller owns.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Counts rows in a table, on a connection of its own so a disposed session cannot affect it.</summary>
/// <param name="table">The (unquoted) table name to count.</param>
/// <returns>The row count, or <c>-1</c> when the table does not exist.</returns>
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;
}
}
/// <summary>Closes the fixture connection, clears the pool, and deletes the temporary file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
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.
}
}
/// <summary>
/// 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
/// <c>pragma_table_info</c> (and therefore <c>MapColumnType</c>) nothing real to report.
/// </summary>
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);
}
}
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<!--
TEST-ONLY, same rationale as Driver.Sql.Tests: SQLite is the offline substrate that lets the
schema browser run against a real DbConnection (real parameter binding, real catalog rows) with
no SQL Server and no network. No product project references SQLite. The bundle_e_sqlite3 line is
the surgical direct pin that promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
<ItemGroup>
<!--
LINKED, not duplicated. SqliteDialect is the only non-SQL-Server ISqlDialect in the tree and is
therefore the falsifiability control for the whole seam: it proves the browser runs the *dialect's*
catalog SQL rather than INFORMATION_SCHEMA with a quoting function attached. Linking the one file
(rather than referencing Driver.Sql.Tests) keeps a second copy from drifting while avoiding a
test-project-to-test-project reference. The file is public + self-contained for exactly this reason
— see its class docs.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
</ItemGroup>
</Project>