merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003)

This commit is contained in:
Joseph Doherty
2026-08-10 08:06:02 -04:00
11 changed files with 454 additions and 33 deletions
@@ -0,0 +1,64 @@
using System.Data.Common;
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;
/// <summary>
/// <see cref="SqliteDialect"/> in every respect except that it reports <see cref="SqlProvider.Oracle"/>.
/// It exists for exactly one assertion: <b>Oracle's <c>DATE</c> carries a time-of-day component</b>
/// (<c>OracleDialect.MapColumnType</c> says so in as many words), so it is NOT the date-only type the
/// row-selector ranking demotes on the other dialects, and an Oracle table whose <c>DATE</c> column is
/// declared before its <c>TIMESTAMP</c> must still pick the <c>DATE</c> — the ordinary
/// first-in-catalog-order tie-break.
/// <para>Without this control, implementing the ranking provider-blind ("any type family spelled
/// <c>date</c> is date-only") passes every other test in the suite while silently changing the pick on
/// the one backend where the spelling means something else.</para>
/// </summary>
public sealed class OracleFlavouredSqliteDialect : ISqlDialect
{
private readonly SqliteDialect _inner = new();
/// <summary>The whole point of the type: an Oracle-shaped provider over the SQLite catalog.</summary>
public SqlProvider Provider => SqlProvider.Oracle;
/// <inheritdoc/>
public DbProviderFactory Factory => _inner.Factory;
/// <inheritdoc/>
public string LivenessSql => _inner.LivenessSql;
/// <inheritdoc/>
public string SingleRowLimitPrefix => _inner.SingleRowLimitPrefix;
/// <inheritdoc/>
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
/// <inheritdoc/>
public string ListSchemasSql => _inner.ListSchemasSql;
/// <inheritdoc/>
public string DefaultSchemaSql => _inner.DefaultSchemaSql;
/// <inheritdoc/>
public string ListTablesSql => _inner.ListTablesSql;
/// <inheritdoc/>
public string ListColumnsSql => _inner.ListColumnsSql;
/// <inheritdoc/>
public string QuoteIdentifier(string ident) => _inner.QuoteIdentifier(ident);
/// <inheritdoc/>
public string ParameterMarker(string baseName) => _inner.ParameterMarker(baseName);
/// <inheritdoc/>
public string ParameterBindName(string baseName) => _inner.ParameterBindName(baseName);
/// <inheritdoc/>
public void ConfigureCommand(DbCommand command) => _inner.ConfigureCommand(command);
/// <inheritdoc/>
public DriverDataType MapColumnType(string sqlDataType) => _inner.MapColumnType(sqlDataType);
}
@@ -328,6 +328,75 @@ public sealed class SqlBrowseSessionTests
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// A dispose racing an in-flight call must not <b>strand</b> the caller queued behind it. The registry's
/// TTL reaper disposes on its own thread while a picker may have two clicks outstanding, so this is the
/// ordinary shape, not a corner: the second call is parked in <c>SemaphoreSlim.WaitAsync</c>.
/// <para>Disposing the gate does not complete its pending async waiters, and the in-flight call's
/// <c>Release()</c> then throws <see cref="ObjectDisposedException"/> from inside the semaphore — so
/// the waiter is never released and the call never returns at all. The session must instead let it
/// through to the guarded <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member
/// raises.</para>
/// </summary>
[Fact]
public async Task DisposeAsync_doesNotStrandACallerQueuedOnTheGate()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var probe = new ConcurrencyProbe { Delay = TimeSpan.FromMilliseconds(400) };
var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe);
var session = new SqlBrowseSession(probing, new SqliteDialect());
// First call takes the gate and holds it for the probe's delay; second parks in WaitAsync.
var inFlight = session.RootAsync(CancellationToken.None);
await Task.Delay(100, TestContext.Current.CancellationToken);
var queued = session.ExpandAsync(SchemaNodeId, CancellationToken.None);
await session.DisposeAsync();
// The in-flight call's own outcome is not the assertion — it may fail on the closed connection.
try { await inFlight; }
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException) { }
// A stranded caller surfaces as TimeoutException here, which reads as "expected
// ObjectDisposedException, got TimeoutException" — the honest description of the defect.
var disposed = await Should.ThrowAsync<ObjectDisposedException>(
() => queued.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
disposed.ObjectName.ShouldBe(
typeof(SqlBrowseSession).FullName,
"the failure must be the session's own guarded throw, not the semaphore's");
}
/// <summary>
/// <see cref="IBrowseSession.LastUsedUtc"/> must advance when a call <b>starts</b>, not only when it
/// finishes. The AdminUI allows a catalog call 20 s; stamping only on completion leaves a slow expand
/// invisible to the registry's TTL reaper for its whole duration, so a session that idled up to the
/// edge of the TTL and was then given one long expand can be disposed mid-call — tearing the
/// connection down under an open reader.
/// </summary>
[Fact]
public async Task LastUsedUtc_advancesWhenACallSTARTS_notOnlyWhenItCompletes()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var probe = new ConcurrencyProbe { Delay = TimeSpan.FromSeconds(2) };
var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe);
await using var session = new SqlBrowseSession(probing, new SqliteDialect());
var atConstruction = session.LastUsedUtc;
// DateTime.UtcNow ticks at ~15.6 ms on Windows; without this the entry stamp could equal the
// construction stamp and the assertion would be about clock resolution rather than the stamp.
await Task.Delay(50, TestContext.Current.CancellationToken);
var call = session.RootAsync(CancellationToken.None);
// Observed WHILE the call is still running: the completion stamp cannot account for it.
await Task.Delay(300, TestContext.Current.CancellationToken);
call.IsCompleted.ShouldBeFalse("the probe's delay must still be holding the call open");
session.LastUsedUtc.ShouldBeGreaterThan(
atConstruction, "the stamp must advance on entry, not only on completion");
await call;
}
/// <summary>
/// The AdminUI bounds every call with a 20 s linked CTS
/// (<c>BrowserSessionService.PerCallTimeout</c>) — the session invents no timeout of its own, it just
@@ -458,6 +527,83 @@ public sealed class SqlBrowseSessionTests
.ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one");
}
/// <summary>
/// A <b>date-only</b> column is a candidate of last resort, not the first pick. <c>date</c> maps to
/// <see cref="DriverDataType.DateTime"/> on every dialect, so a candidacy rule that only asked the
/// mapped type would commit <c>ORDER BY &lt;date&gt; DESC LIMIT 1</c> — which ties for every row
/// written on the same day, making "the newest row" storage order among the ties. The reader cannot
/// warn about it either: the server has already discarded the other rows.
/// <para>The fixture's table declares the date-only column FIRST, so this fails against the plain
/// first-in-catalog-order rule rather than passing by luck.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_prefersATimeBearingColumn_overAnEarlierDateOnlyOne()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.DateOnlyFirstTable,
SqliteBrowseFixture.DateOnlyFirstValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
SqliteBrowseFixture.DateOnlyFirstTimeColumn,
"a time-bearing column outranks a date-only one even when the date-only one is declared first");
}
/// <summary>
/// The fallback half of the ranking: when date-only is ALL the table has, it is still stated. Dropping
/// date-only types from candidacy outright would instead make such a table unbindable by browse, which
/// is a bigger behaviour change than the defect being fixed.
/// </summary>
[Fact]
public async Task AttributesAsync_stillStatesADateOnlyColumn_whenTheTableHasNothingBetter()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.DateOnlySoleTable,
SqliteBrowseFixture.DateOnlySoleValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.DateOnlySoleColumn);
}
/// <summary>
/// The falsification control for the ranking: <b>Oracle's <c>DATE</c> carries a time-of-day
/// component</b>, so it is not date-only there and must not be demoted. Same catalog, same columns,
/// only the dialect's <see cref="ISqlDialect.Provider"/> differs — and the pick reverts to the
/// ordinary first-in-catalog-order one.
/// <para>A ranking implemented provider-blind ("any family spelled <c>date</c>") passes every other
/// test in this file and fails only here.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_doesNotDemoteOraclesDATE_whichCarriesATimeOfDay()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new OracleFlavouredSqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.DateOnlyFirstTable,
SqliteBrowseFixture.DateOnlyFirstValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
SqliteBrowseFixture.DateOnlyFirstColumn,
"Oracle's DATE is time-bearing, so the first-in-catalog-order tie-break applies unchanged");
}
/// <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".
@@ -96,6 +96,40 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
public const string MultiTimeValueColumn = "level";
/// <summary>
/// A table whose FIRST date/time-typed column is <b>date-only</b>, with a genuinely time-bearing one
/// declared after it. A date-only column maps to
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.DateTime"/> like any other, so a
/// candidacy rule that only asked the mapped type would order the wide-row read by
/// <see cref="DateOnlyFirstColumn"/> — one row per DAY, ties broken by storage order.
/// </summary>
public const string DateOnlyFirstTable = "DailyTotals";
/// <summary>The date-only column of <see cref="DateOnlyFirstTable"/>, declared first.</summary>
public const string DateOnlyFirstColumn = "shift_day";
/// <summary>A <see cref="DateOnlyFirstTable"/> value column, sitting between the two candidates.</summary>
public const string DateOnlyFirstValueColumn = "units";
/// <summary>
/// The time-bearing column of <see cref="DateOnlyFirstTable"/>, declared LAST — the expected pick,
/// even though the date-only one comes first in catalog order.
/// </summary>
public const string DateOnlyFirstTimeColumn = "logged_at";
/// <summary>
/// A table whose ONLY date/time-typed column is date-only — the fallback control. Dropping date-only
/// types from candidacy outright would make this table unbindable by browse; ranking keeps it bindable
/// while still preferring a better column wherever one exists.
/// </summary>
public const string DateOnlySoleTable = "DayBook";
/// <summary>The sole (date-only) candidate of <see cref="DateOnlySoleTable"/>.</summary>
public const string DateOnlySoleColumn = "book_day";
/// <summary>A <see cref="DateOnlySoleTable"/> value column.</summary>
public const string DateOnlySoleValueColumn = "opening_balance";
private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
@@ -210,6 +244,17 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
"{MultiTimeValueColumn}" REAL NULL,
"{MultiTimeSecondColumn}" TIMESTAMP NULL
);
CREATE TABLE "{DateOnlyFirstTable}" (
"{DateOnlyFirstColumn}" DATE NOT NULL,
"{DateOnlyFirstValueColumn}" REAL NULL,
"{DateOnlyFirstTimeColumn}" DATETIME NOT NULL
);
CREATE TABLE "{DateOnlySoleTable}" (
"{DateOnlySoleColumn}" DATE NOT NULL,
"{DateOnlySoleValueColumn}" REAL NULL
);
""").ConfigureAwait(false);
// Separate statements: these names must be quoted by the seed itself, so keep them off the
@@ -79,6 +79,38 @@ public sealed class SqlDriverProbeTests
result.Message.ShouldNotBeNullOrWhiteSpace();
}
/// <summary>
/// A malformed config is reported as a failure CLASS, never as the parser's own message — the same
/// discipline <c>SqlDriverBrowser.ParseDocument</c> keeps, and for the same reason: the blob the two
/// read is the one the AdminUI Sql picker composes, which carries a top-level <c>connectionString</c>
/// literal, so a malformed paste is exactly the text that reaches here.
/// <para><see cref="System.Text.Json"/>'s message quotes the offending token and states the JSON path
/// and the byte position. None of it may leave the probe: at this point nothing has been extracted,
/// so there is no known secret to redact against.</para>
/// </summary>
[Fact]
public async Task Probe_malformedConfig_withholdsTheParserMessage()
{
using var fixture = new SqlitePollFixture();
var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());
// A pasted literal whose password carries an unquoted ';' — mis-quoted exactly the way an operator's
// paste is, so the parse fails partway THROUGH the credential.
const string malformed =
"""{"provider":"SqlServer","connectionString":Password=Sup3r;SecretTail}""";
var result = await probe.ProbeAsync(malformed, TimeSpan.FromSeconds(5), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldNotContain("LineNumber");
result.Message.ShouldNotContain("BytePositionInLine");
result.Message.ShouldNotContain("Sup3r");
result.Message.ShouldNotContain("SecretTail");
// Still actionable: it names what is wrong and why the detail is missing.
result.Message.ShouldContain("not valid JSON");
}
[Fact]
public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy()
{