merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003)
This commit is contained in:
+64
@@ -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 <date> 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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user