861a1d1df0
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
129 lines
5.1 KiB
C#
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!));
|
|
}
|
|
}
|