Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseNodeIdTests.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

129 lines
5.1 KiB
C#

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