fix(sql): browse-commit states a topByTimestamp row selector (#552)
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only table + columnName. A wide-row tag must also say WHICH row it reads, so both `SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate` (the authoring gate) refused it — a browse-committed Sql tag was never readable, first silently as BadNodeIdUnknown and, since #551 put the gate on the import, loudly at commit. `SqlBrowseSession` now states a fourth address field: the table's preferred date/time-typed column, computed from the columns its attributes call ALREADY read (no extra catalog round-trip). Candidacy is decided by `ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column name, which would order a wide-row read by a string column that merely looks like a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position), so the pick is stable across calls and servers; it is a visible, editable default the typed editor round-trips. No candidate (a table with no date/time column) is an operator-actionable refusal, not an invented selector: the only remaining selector is a whereColumn/whereValue pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that leaf by name, names the table, and points at the tag editor. The session and the mapper now agree via `SqlBrowseAddressFields` constants rather than two independently spelled string literals — a disagreement there is silent, and is exactly the shape this defect had. `RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit output passes the gate", with the no-candidate case pinned as its own test.
This commit is contained in:
@@ -3,6 +3,7 @@ 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.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
||||
@@ -398,4 +399,116 @@ public sealed class SqlBrowseSessionTests
|
||||
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
|
||||
dialect.ConfiguredCommands.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- the wide-row ROW SELECTOR candidate (Gitea #552) -----------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The session states the three parts of a Sql binding it can know — the table, the column, and the
|
||||
/// table's own date/time column, which the commit turns into <c>rowSelector.topByTimestamp</c>.
|
||||
/// Without the third the committed blob is a wide-row tag that never says which ROW it reads, which
|
||||
/// the driver's parser and the authoring gate both refuse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_statesTheTablesSoleDateTimeColumn_asTheRowSelectorCandidate()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.TimestampedTable,
|
||||
SqliteBrowseFixture.TimestampedTableValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.Schema].ShouldBe(SqliteDialect.MainSchema);
|
||||
fields[SqlBrowseAddressFields.Table].ShouldBe(SqliteBrowseFixture.TimestampedTable);
|
||||
fields[SqlBrowseAddressFields.ColumnName].ShouldBe(SqliteBrowseFixture.TimestampedTableValueColumn);
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tie-break is FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position),
|
||||
/// so two operators browsing the same table commit the same binding.
|
||||
/// <para>The fixture's two candidates are named so catalog order and alphabetical order DISAGREE —
|
||||
/// <c>zulu_time</c> is declared first and sorts last. A tie-break that quietly became "any candidate"
|
||||
/// or "sorted by name" fails here rather than passing by luck.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_breaksAMultiCandidateTie_onCatalogOrderNotName()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.MultiTimeTable,
|
||||
SqliteBrowseFixture.MultiTimeValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
|
||||
SqliteBrowseFixture.MultiTimeFirstColumn,
|
||||
"the first date/time column in catalog order wins, not the first alphabetically");
|
||||
// Guards the fixture itself: the assertion above is only a tie-break test while the two disagree.
|
||||
string.CompareOrdinal(
|
||||
SqliteBrowseFixture.MultiTimeFirstColumn, SqliteBrowseFixture.MultiTimeSecondColumn)
|
||||
.ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table with no date/time column states NO candidate — the key is absent, never blank — so the
|
||||
/// commit can tell "this table cannot be bound by browse" from "the session said nothing".
|
||||
/// <para>The control that makes this sharp: <c>TagValues</c> holds <c>sample_ts</c>, a column that
|
||||
/// LOOKS like a timestamp and is declared <c>TEXT</c>. Candidacy is decided by the mapped type, so a
|
||||
/// name heuristic — which would order the wide-row read by a string column, binding the tag to
|
||||
/// whichever row sorts last lexically — fails this test.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_statesNoCandidate_whenNoColumnIsDateTimeTyped()
|
||||
{
|
||||
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);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields.ShouldContainKey(SqlBrowseAddressFields.Table);
|
||||
fields.ShouldNotContainKey(SqlBrowseAddressFields.TopByTimestamp);
|
||||
|
||||
// The name-heuristic control, stated explicitly: the tempting column IS present and IS a string.
|
||||
var stringTyped = await session.AttributesAsync(
|
||||
SqlBrowseNodeId.ForColumn(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, "sample_ts"),
|
||||
CancellationToken.None);
|
||||
stringTyped.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The candidate costs no extra catalog round-trip: it is computed from the columns the attributes
|
||||
/// call already read. Counting the dialect's configured commands is the cheapest honest proxy —
|
||||
/// one <c>ListColumnsSql</c> execution, exactly as before #552.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_computesTheCandidate_withoutASecondCatalogQuery()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
await using var session = new SqlBrowseSession(db.Connection, dialect);
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.TimestampedTable,
|
||||
SqliteBrowseFixture.TimestampedTableValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
|
||||
dialect.ConfiguredCommands.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,40 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
|
||||
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
|
||||
public const string NonExistentTable = "NoSuchTable";
|
||||
|
||||
/// <summary>
|
||||
/// A wide-row table with exactly ONE date/time-typed column — the sole-candidate case for the
|
||||
/// browse-commit row selector (Gitea #552).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note <see cref="KeyValueTable"/> is the no-candidate control and needs no table of its own: its
|
||||
/// <c>sample_ts</c> column is declared <c>TEXT</c>, so it is a timestamp by NAME and a
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String"/> by type — exactly the
|
||||
/// column a name heuristic would order a wide-row read by, and exactly the one the type test must
|
||||
/// refuse.
|
||||
/// </remarks>
|
||||
public const string TimestampedTable = "Readings";
|
||||
|
||||
/// <summary>The one <c>DATETIME</c> column of <see cref="TimestampedTable"/>.</summary>
|
||||
public const string TimestampedTableTimeColumn = "recorded";
|
||||
|
||||
/// <summary>A <see cref="TimestampedTable"/> value column — an ordinary wide-row signal.</summary>
|
||||
public const string TimestampedTableValueColumn = "oven_temp";
|
||||
|
||||
/// <summary>
|
||||
/// A table carrying TWO date/time columns, deliberately named so that catalog order and alphabetical
|
||||
/// order disagree — <see cref="MultiTimeFirstColumn"/> is declared first but sorts last.
|
||||
/// </summary>
|
||||
public const string MultiTimeTable = "MultiStamped";
|
||||
|
||||
/// <summary>The FIRST-declared date/time column of <see cref="MultiTimeTable"/> — the expected pick.</summary>
|
||||
public const string MultiTimeFirstColumn = "zulu_time";
|
||||
|
||||
/// <summary>The second-declared date/time column of <see cref="MultiTimeTable"/> — alphabetically first.</summary>
|
||||
public const string MultiTimeSecondColumn = "alpha_time";
|
||||
|
||||
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
|
||||
public const string MultiTimeValueColumn = "level";
|
||||
|
||||
private readonly string _databasePath;
|
||||
|
||||
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
|
||||
@@ -165,6 +199,17 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
|
||||
"{ExoticColumn}" GEOGRAPHY NULL,
|
||||
blob_col BLOB NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "{TimestampedTable}" (
|
||||
"{TimestampedTableValueColumn}" REAL NULL,
|
||||
"{TimestampedTableTimeColumn}" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "{MultiTimeTable}" (
|
||||
"{MultiTimeFirstColumn}" DATETIME NOT NULL,
|
||||
"{MultiTimeValueColumn}" REAL NULL,
|
||||
"{MultiTimeSecondColumn}" TIMESTAMP NULL
|
||||
);
|
||||
""").ConfigureAwait(false);
|
||||
|
||||
// Separate statements: these names must be quoted by the seed itself, so keep them off the
|
||||
|
||||
Reference in New Issue
Block a user