merge: #552 — Sql browse-commit states a topByTimestamp row selector from column-type metadata (catalog-order tie-break); no-candidate leaves refused in words at the modal
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
|
||||
|
||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
@@ -671,4 +672,214 @@ public sealed class RawBrowseCommitMapperTests
|
||||
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temperature", null, "Double",
|
||||
null, null, false),
|
||||
}).ShouldBeEmpty();
|
||||
|
||||
// ---- BuildTagConfig: Sql (the address is a TUPLE, and the third part is WHICH ROW) — Gitea #552 --
|
||||
|
||||
/// <summary>The address a <c>SqlBrowseSession</c> states for a column of a table that has a timestamp.</summary>
|
||||
/// <param name="schema">The owning schema.</param>
|
||||
/// <param name="table">The owning table.</param>
|
||||
/// <param name="column">The browsed column.</param>
|
||||
/// <param name="timestampColumn">The table's preferred date/time column, or null to state none.</param>
|
||||
/// <returns>The stated address fields.</returns>
|
||||
private static Dictionary<string, string> SqlFields(
|
||||
string schema = "dbo",
|
||||
string table = "Readings",
|
||||
string column = "oven_temp",
|
||||
string? timestampColumn = "recorded")
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SqlBrowseAddressFields.Schema] = schema,
|
||||
[SqlBrowseAddressFields.Table] = table,
|
||||
[SqlBrowseAddressFields.ColumnName] = column,
|
||||
};
|
||||
if (timestampColumn is not null) fields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn;
|
||||
return fields;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_writes_the_row_selector_the_session_stated()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["model"]!.GetValue<string>().ShouldBe(nameof(SqlTagModel.WideRow));
|
||||
o["table"]!.GetValue<string>().ShouldBe("Readings");
|
||||
o["columnName"]!.GetValue<string>().ShouldBe("oven_temp");
|
||||
o["rowSelector"]!.AsObject()["topByTimestamp"]!.GetValue<string>().ShouldBe("recorded");
|
||||
o.ContainsKey("address").ShouldBeFalse(); // never the generic fallback key
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The blob must satisfy BOTH gates, and they are two different implementations of one rule: the
|
||||
/// AdminUI model (what the authoring gate and the typed editor run) and
|
||||
/// <see cref="SqlEquipmentTagParser"/> (what the deployed driver runs). Before #552 the emitted blob
|
||||
/// failed both — so a browse-committed Sql tag was never readable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_blob_is_accepted_by_the_model_AND_by_the_REAL_driver_parser()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
|
||||
|
||||
SqlTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||
TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldBeNull();
|
||||
|
||||
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue();
|
||||
def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); // v3: identity is the RawPath
|
||||
def.Model.ShouldBe(SqlTagModel.WideRow);
|
||||
def.Table.ShouldBe("Readings");
|
||||
def.ColumnName.ShouldBe("oven_temp");
|
||||
def.RowSelectorTopByTimestamp.ShouldBe("recorded");
|
||||
def.RowSelectorColumn.ShouldBeNull();
|
||||
def.RowSelectorValue.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsification control for the pair above: the blob the mapper emitted BEFORE #552 — the same
|
||||
/// bytes minus the selector — must be refused by both. Without it, the acceptance assertions could
|
||||
/// pass against gates that accept anything.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_Sql_double_acceptance_is_falsifiable_a_selector_less_blob_is_refused_by_both()
|
||||
{
|
||||
var legacy = new JsonObject
|
||||
{
|
||||
["model"] = nameof(SqlTagModel.WideRow),
|
||||
["table"] = "Readings",
|
||||
["columnName"] = "oven_temp",
|
||||
}.ToJsonString();
|
||||
|
||||
SqlTagConfigModel.FromJson(legacy).Validate().ShouldNotBeNullOrEmpty();
|
||||
TagConfigValidator.Validate(DriverTypeNames.Sql, legacy).ShouldNotBeNullOrEmpty();
|
||||
SqlEquipmentTagParser.TryParse(legacy, "Plant/Sql/dev1/OvenTemp", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selector is a visible, editable DEFAULT: it survives a load→save through the typed editor's
|
||||
/// model unchanged, and the operator can replace it with a where-pair, which must also still parse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_blob_round_trips_the_typed_editor()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
|
||||
|
||||
var loaded = SqlTagConfigModel.FromJson(json);
|
||||
loaded.Model.ShouldBe(SqlTagModel.WideRow);
|
||||
loaded.Table.ShouldBe("Readings");
|
||||
loaded.ColumnName.ShouldBe("oven_temp");
|
||||
loaded.RowSelector.TopByTimestamp.ShouldBe("recorded");
|
||||
|
||||
var resaved = loaded.ToJson();
|
||||
SqlEquipmentTagParser.TryParse(resaved, "p", out var def).ShouldBeTrue();
|
||||
def.RowSelectorTopByTimestamp.ShouldBe("recorded");
|
||||
|
||||
// …and the operator's own edit — swapping the browse default for a where-pair — is equally readable.
|
||||
loaded.RowSelector.TopByTimestamp = null;
|
||||
loaded.RowSelector.WhereColumn = "machine_id";
|
||||
loaded.RowSelector.WhereValue = "7";
|
||||
var edited = loaded.ToJson();
|
||||
SqlTagConfigModel.FromJson(edited).Validate().ShouldBeNull();
|
||||
SqlEquipmentTagParser.TryParse(edited, "p", out var editedDef).ShouldBeTrue();
|
||||
editedDef.RowSelectorColumn.ShouldBe("machine_id");
|
||||
editedDef.RowSelectorValue.ShouldBe("7");
|
||||
editedDef.RowSelectorTopByTimestamp.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A non-default schema qualifies the table, and the selector column is a column of THAT table — the
|
||||
/// two facts travel together from one session call, so neither can be attributed to the wrong table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_qualifies_a_non_default_schema_and_keeps_its_own_selector()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
DriverTypeNames.Sql,
|
||||
"level",
|
||||
SqlFields(schema: "sales", table: "Readings", column: "level", timestampColumn: "sold_at"));
|
||||
|
||||
SqlEquipmentTagParser.TryParse(json, "p", out var def).ShouldBeTrue();
|
||||
def.Table.ShouldBe("sales.Readings");
|
||||
def.RowSelectorTopByTimestamp.ShouldBe("sold_at");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No stated candidate ⇒ NO invented selector. The blob keeps the wide-row shape (so it opens in the
|
||||
/// typed editor with the table and column already filled in) and is refused by the gate — the commit
|
||||
/// itself never gets this far, because <c>DescribeUncommittableLeaf</c> refuses the leaf first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_invents_no_selector_when_the_session_stated_no_candidate()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", column: "setpoint", timestampColumn: null));
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["table"]!.GetValue<string>().ShouldBe("Settings");
|
||||
o["columnName"]!.GetValue<string>().ShouldBe("setpoint");
|
||||
o.ContainsKey("rowSelector").ShouldBeFalse("an empty or fabricated selector is worse than none");
|
||||
|
||||
TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldNotBeNullOrEmpty();
|
||||
SqlEquipmentTagParser.TryParse(json, "p", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A blank stated candidate is treated as none, not written through as a blank selector.</summary>
|
||||
[Fact]
|
||||
public void BuildTagConfig_Sql_treats_a_blank_stated_candidate_as_none()
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(
|
||||
DriverTypeNames.Sql, "setpoint", SqlFields(timestampColumn: " "));
|
||||
|
||||
JsonNode.Parse(json)!.AsObject().ContainsKey("rowSelector").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_table_has_no_date_time_column()
|
||||
{
|
||||
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf(
|
||||
DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", timestampColumn: null));
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error!.ShouldContain("setpoint");
|
||||
error.ShouldContain("Settings"); // names the table, not just the leaf
|
||||
error.ShouldContain("whereColumn"); // and names the fix
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_address_was_never_stated()
|
||||
{
|
||||
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf(
|
||||
DriverTypeNames.Sql, "oven_temp", addressFields: null);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error!.ShouldContain("oven_temp");
|
||||
}
|
||||
|
||||
/// <summary>The acceptance control — a stated candidate makes the leaf committable.</summary>
|
||||
[Fact]
|
||||
public void DescribeUncommittableLeaf_accepts_a_sql_leaf_carrying_a_stated_candidate()
|
||||
=> RawBrowseCommitMapper
|
||||
.DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", SqlFields())
|
||||
.ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The two refusals are one seam and must stay in agreement: a leaf the mapper can build a
|
||||
/// gate-passing blob for must be committable, and one it cannot must be refused. Getting this
|
||||
/// backwards either blocks a working commit or lets an unreadable tag through.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("recorded", false)]
|
||||
[InlineData(null, true)]
|
||||
public void Sql_refusal_and_the_emitted_blob_agree(string? timestampColumn, bool expectRefused)
|
||||
{
|
||||
var fields = SqlFields(timestampColumn: timestampColumn);
|
||||
|
||||
var refused = RawBrowseCommitMapper
|
||||
.DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", fields) is not null;
|
||||
var blobRejected = TagConfigValidator.Validate(
|
||||
DriverTypeNames.Sql,
|
||||
RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", fields)) is not null;
|
||||
|
||||
refused.ShouldBe(expectRefused);
|
||||
blobRejected.ShouldBe(expectRefused);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
@@ -322,13 +323,13 @@ public sealed class RawTagConfigGateTests
|
||||
/// <see cref="RawBrowseCommitMapper.BuildTagConfig"/> emits must survive the gate — otherwise the
|
||||
/// commit would author rows the import refuses.
|
||||
/// <para>
|
||||
/// <b><c>Sql</c> is deliberately absent, and that is a FINDING, not an oversight.</b> Its branch
|
||||
/// emits <c>{"model":"WideRow","table":…,"columnName":…}</c> with no <c>rowSelector</c>, which
|
||||
/// <c>SqlTagConfigModel.Validate</c> refuses and <c>SqlEquipmentTagParser</c> refuses too — so a
|
||||
/// browse-committed Sql tag has never been readable, it merely failed later (at the deploy gate,
|
||||
/// or silently at the driver) instead of at commit. Pinning today's behaviour either way would
|
||||
/// encode a defect; it is reported for its own fix (the browse session should state a row
|
||||
/// selector, or Sql should commit through the typed editor).
|
||||
/// <b><c>Sql</c> is here now (Gitea #552), and it is the one driver whose leaf carries stated
|
||||
/// address fields it cannot be built without</b> — a wide-row tag needs the table, the column
|
||||
/// AND which row, so the theory supplies the fields its browse session states rather than the
|
||||
/// bare node id every single-reference driver binds by. The no-candidate half of that branch —
|
||||
/// a table with no date/time column, where the row selector genuinely cannot be derived from
|
||||
/// schema — is pinned separately by
|
||||
/// <see cref="A_browse_committed_Sql_config_is_refused_when_the_table_has_no_timestamp"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="driverType">The browsable driver type.</param>
|
||||
@@ -341,14 +342,60 @@ public sealed class RawTagConfigGateTests
|
||||
[InlineData(DriverTypeNames.MTConnect)]
|
||||
[InlineData(DriverTypeNames.Mqtt)]
|
||||
[InlineData(DriverTypeNames.Galaxy)]
|
||||
[InlineData(DriverTypeNames.Sql)]
|
||||
public void A_browse_committed_config_passes_the_gate(string driverType)
|
||||
{
|
||||
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(driverType, "SomeAddress");
|
||||
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
|
||||
driverType, "SomeAddress", BrowseFieldsFor(driverType));
|
||||
|
||||
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
|
||||
$"browse-commit for {driverType} must not author a config the import refuses");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stated address fields a browse session supplies for this driver's leaf, or <c>null</c> for
|
||||
/// every driver whose address IS the node id.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The browsable driver type.</param>
|
||||
/// <returns>The stated fields, or null.</returns>
|
||||
private static IReadOnlyDictionary<string, string>? BrowseFieldsFor(string driverType) =>
|
||||
string.Equals(driverType, DriverTypeNames.Sql, StringComparison.OrdinalIgnoreCase)
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SqlBrowseAddressFields.Schema] = "dbo",
|
||||
[SqlBrowseAddressFields.Table] = "Readings",
|
||||
[SqlBrowseAddressFields.ColumnName] = "SomeAddress",
|
||||
[SqlBrowseAddressFields.TopByTimestamp] = "recorded",
|
||||
}
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// The other half of #552, kept explicit rather than papered over: a wide-row tag must say WHICH row
|
||||
/// it reads, and on a table with no date/time column the only remaining selector is a
|
||||
/// <c>whereColumn</c>/<c>whereValue</c> pair whose value is <em>data</em>. No browse can know it, so
|
||||
/// the mapper invents nothing and the gate refuses the blob.
|
||||
/// <para>The operator never meets this as a bare gate error: the browse modal calls
|
||||
/// <see cref="RawBrowseCommitMapper.DescribeUncommittableLeaf"/> first, which names the table and the
|
||||
/// fix. Both are asserted here so deleting either leaves this red.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_browse_committed_Sql_config_is_refused_when_the_table_has_no_timestamp()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SqlBrowseAddressFields.Schema] = "dbo",
|
||||
[SqlBrowseAddressFields.Table] = "Settings",
|
||||
[SqlBrowseAddressFields.ColumnName] = "setpoint",
|
||||
};
|
||||
|
||||
RawBrowseCommitMapper.DescribeUncommittableLeaf(DriverTypeNames.Sql, "setpoint", fields)
|
||||
.ShouldNotBeNullOrEmpty();
|
||||
|
||||
TagConfigValidator
|
||||
.Validate(DriverTypeNames.Sql, RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "setpoint", fields))
|
||||
.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Sanity: the enums the three new rules read are the driver's own, not a local copy.</summary>
|
||||
[Fact]
|
||||
public void The_new_rules_read_the_drivers_own_enums()
|
||||
|
||||
Reference in New Issue
Block a user