Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs
T
Joseph Doherty 861a1d1df0 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
2026-07-24 14:49:36 -04:00

375 lines
18 KiB
C#

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);
}
}