Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs
T
Joseph Doherty 886ce1b87c fix(sql): chunk the key-value IN list at the dialect's bind ceiling (Driver.Sql-002, Driver.Sql.Dialect-002)
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was
missing while modelling everything else that differs per backend: 2098 usable
of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list
limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres
Bind message.

SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when
its distinct keys exceed that ceiling, instead of one statement the provider
refuses after the connection opened — which left every tag in an EAV group
publishing BadCommunicationError on every poll, permanently, and reported the
database endpoint as down. Chunks share the group key; members follow their
own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs
one command per plan and slices each plan's own Members back, proven by a
fixture read at a ceiling of 1.
2026-08-10 08:02:44 -04:00

454 lines
20 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
/// of rejecting every tag.</para>
/// </summary>
public sealed class SqlCatalogGateDriverTests
{
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
/// <summary>
/// Gitea #541. Sql drops a tag at TWO points — a blob that does not map, and a rejection against
/// the live catalog — and both leave the tag unpolled and publishing <c>BadNodeIdUnknown</c> with
/// the driver <c>Healthy</c>. The skip report must be the union.
/// <para>This is the case that makes Sql different from the other eleven drivers, and the one worth
/// a live-catalog test rather than a unit one: a gate rejection is precisely what a schema drift
/// (renamed column, dropped view) produces months after a deployment sealed green. Reporting only
/// the blob-level skips would have this driver claim "0 skipped" while polling half its tags —
/// worse than reporting nothing, because the column would be actively reassuring.</para>
/// </summary>
[Fact]
public async Task A_catalog_gate_rejection_is_reported_as_a_skipped_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The reading an operator gets from every other column on /hosts.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastError.ShouldBeNull();
// …and the one that contradicts it.
var report = ((ISkippedTagReporter)driver).GetSkippedTags();
report.AuthoredCount.ShouldBe(2);
report.SkippedCount.ShouldBe(1, "the gate rejected one tag; the blob mapped both");
report.Sample.Single().RawPath.ShouldBe("Bogus");
report.Sample.Single().Reason.ShouldContain("catalog gate");
}
/// <summary>A driver whose tags all map and all pass the gate reports zero skips.</summary>
[Fact]
public async Task A_driver_whose_tags_all_pass_the_gate_reports_no_skips()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var report = ((ISkippedTagReporter)driver).GetSkippedTags();
report.AuthoredCount.ShouldBe(1);
report.HasSkips.ShouldBeFalse();
}
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
/// turn a diagnosable Bad quality into a missing address-space entry.
/// </summary>
[Fact]
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The driver is healthy: an authoring typo is not a database fault.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// The node is still materialized...
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
/// query that failed only once the connection was open; now it never reaches a query at all.
/// </summary>
[Fact]
public async Task A_hostile_table_name_never_reaches_a_query()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Evil"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The fixture's table is untouched — proven by a second tag still reading through it.
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
}
/// <summary>
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
/// field and the reason.
/// </summary>
[Fact]
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var warning = logger.Entries
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ShouldHaveSingleItem();
warning.ShouldContain("Bogus");
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
warning.ShouldContain("num_valeu");
}
/// <summary>
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
/// catalog strings rather than operator input.
/// </summary>
[Fact]
public async Task A_case_variant_identifier_is_accepted_and_polls()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry(
"Speed", SqlitePollFixture.PresentKey,
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
/// be a round-trip that can only fail.
/// </summary>
[Fact]
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
/// </summary>
[Fact]
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT this is not valid sql"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
var thrown = await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
// The operator surface names the stage that actually failed — "reached it but could not read the
// catalog" and "could not reach it" send an operator to different systems.
thrown.Message.ShouldContain("catalog");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
/// every tag for an authoring fault the operator does not have.
/// </summary>
[Fact]
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
/// carries.
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
/// </summary>
[Fact]
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags =
[
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
dialect,
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The catalog queries bound the bare names, and every catalog command went through the hook.
dialect.BoundParameterNames.ShouldContain("schema");
dialect.BoundParameterNames.ShouldContain("table");
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
}
// ---- helpers ----
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
/// catalog-load failure modes can be driven against an otherwise-real dialect.
/// </summary>
/// <remarks>
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
/// would silently not happen and both tests below would pass for the wrong reason.
/// </remarks>
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public SqlProvider Provider => Inner.Provider;
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
public int MaxBindParameters => Inner.MaxBindParameters;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string ListSchemasSql { get; } = listSchemasSql;
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, new CapturingLogger(), rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
fixture.ConnectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
private static RawTagEntry KvEntry(
string rawPath,
string keyValue,
string? table = null,
string? valueColumn = null)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Records every log record, level + rendered message.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}