1da770253e
ISqlDialect gains ParameterMarker (the token in the SQL text), ParameterBindName
(the DbParameter.ParameterName) and ConfigureCommand (per-command provider setup,
where Oracle will set BindByName). SQL Server and the test SQLite dialect answer
"@"+name for both spellings and no-op the hook, so SQL Server behaviour is
byte-identical — every existing expected-SQL assertion passes untouched.
SqlGroupPlanner drops its hardcoded @k/@w consts and mints base names (k0..kN, w)
that the dialect spells; SqlQueryPlan carries the emitted markers AND the matching
bind names, so SqlPollReader keeps copying the plan's pairing rather than
re-deriving a provider convention (the two diverge on ODP.NET: ":k0" in the text,
bare "k0" on the parameter). SqlCatalogLoader and SqlBrowseSession bind
schema/table through the dialect, so their bind names cannot drift from the
markers in the dialect-owned catalog SQL. Every parameter-carrying command site
calls ConfigureCommand before its first parameter.
Controlled by a new test-only ColonMarkerDialect (":name" markers, bare bind
names) that really executes against the SQLite fixture. Each leg was falsified
against the pre-change behaviour: the planner emitted "@k0", the reader bound
":k0", and a hardcoded "@schema" in the loader failed catalog load outright.
693 lines
33 KiB
C#
693 lines
33 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging;
|
|
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>
|
|
/// Proves <see cref="SqlPollReader"/> against a real database (<see cref="SqlitePollFixture"/>): the
|
|
/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct
|
|
/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven
|
|
/// by reading the code — that a query which refuses to return <b>does not wedge the caller</b>.
|
|
/// <para><b>Each test owns its own fixture.</b> The fixture is a temp file, so a fresh one per test is
|
|
/// cheap and buys total isolation — which matters here because two tests deliberately mutate the
|
|
/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into
|
|
/// every other test in the class.</para>
|
|
/// </summary>
|
|
public sealed class SqlPollReaderTests
|
|
{
|
|
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
|
|
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
|
|
|
|
// ---- the plan's headline contract: N in, N out, in input order ----
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
KvTag("Speed", SqlitePollFixture.PresentKey),
|
|
KvTag("Missing", SqlitePollFixture.AbsentKey),
|
|
KvTag("Temp", SqlitePollFixture.NullValueKey));
|
|
|
|
var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None);
|
|
|
|
snapshots.Count.ShouldBe(3);
|
|
|
|
// [0] present row, present cell — REAL infers Float64, so the published CLR type is double.
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
|
snapshots[0].SourceTimestampUtc.ShouldBe(
|
|
new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock
|
|
|
|
// [1] no row at all — NOT the same thing as a NULL cell.
|
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
|
snapshots[1].Value.ShouldBeNull();
|
|
|
|
// [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives).
|
|
snapshots[2].Value.ShouldBeNull();
|
|
SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue();
|
|
snapshots[2].SourceTimestampUtc.ShouldBe(
|
|
new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture, nullIsBad: true,
|
|
tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]);
|
|
|
|
var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None);
|
|
|
|
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad);
|
|
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
|
|
// The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code.
|
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
KvTag("Speed", SqlitePollFixture.PresentKey),
|
|
KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
|
|
|
var snapshots = await reader.ReadAsync(
|
|
["Speed", "NotAuthored", "Pressure"], CancellationToken.None);
|
|
|
|
snapshots.Count.ShouldBe(3);
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
|
// The hole must not shift the tail — this is the ordering contract's real failure mode.
|
|
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue()
|
|
{
|
|
// SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members,
|
|
// and a reader that indexed members by key into a 1:1 map would feed only one of them.
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
KvTag("SpeedA", SqlitePollFixture.PresentKey),
|
|
KvTag("SpeedB", SqlitePollFixture.PresentKey));
|
|
|
|
var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None);
|
|
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_theSameRefTwice_feedsBothSlots()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
|
|
|
var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None);
|
|
|
|
snapshots.Count.ShouldBe(2);
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var factory = new TrackingFactory();
|
|
var reader = new SqlPollReader(
|
|
factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null);
|
|
|
|
(await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty();
|
|
|
|
factory.Created.ShouldBe(0);
|
|
}
|
|
|
|
// ---- wide row ----
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
|
|
WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation));
|
|
|
|
var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None);
|
|
|
|
// One row, two tags — the wrong-slice defect would cross these two values over.
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
|
|
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure);
|
|
snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation),
|
|
WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation),
|
|
WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation));
|
|
|
|
var snapshots = await reader.ReadAsync(
|
|
["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None);
|
|
|
|
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector
|
|
SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL
|
|
snapshots[1].Value.ShouldBeNull();
|
|
snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn));
|
|
|
|
var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None);
|
|
|
|
// Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here.
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
|
|
snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
|
|
// which of the two the reader publishes is decided by physical storage order — an authoring mistake
|
|
// (a selector column that is not actually unique) that must not pass silently. This is the WideRow
|
|
// counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning.
|
|
AddSecondRowForPresentStation(fixture);
|
|
var logger = new RecordingLogger();
|
|
var reader = Reader(fixture, logger,
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
|
|
|
|
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
|
|
|
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
|
|
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
|
|
|
var warning = logger.Entries.ShouldHaveSingleItem();
|
|
warning.Level.ShouldBe(LogLevel.Warning);
|
|
// Names the table AND the selector, so an operator can find the offending tag from the log alone.
|
|
warning.Message.ShouldContain(SqlitePollFixture.WideRowTable);
|
|
warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn);
|
|
warning.Message.ShouldContain(SqlitePollFixture.PresentStation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var logger = new RecordingLogger();
|
|
|
|
// A selector that matches exactly one row is the normal case and must log nothing at all —
|
|
// without this leg a warning that always fires would still pass the test above.
|
|
var clean = Reader(fixture, logger,
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
|
|
await clean.ReadAsync(["Oven"], CancellationToken.None);
|
|
logger.Entries.ShouldBeEmpty();
|
|
|
|
AddSecondRowForPresentStation(fixture);
|
|
var ambiguous = Reader(fixture, logger,
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
|
|
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
|
|
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
|
|
|
|
// A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding
|
|
// the log. Two polls, one warning.
|
|
logger.Entries.Count.ShouldBe(1);
|
|
}
|
|
|
|
// ---- type + timestamp mapping ----
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture,
|
|
KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32),
|
|
KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String),
|
|
KvTag("Inferred", SqlitePollFixture.PresentKey));
|
|
|
|
var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None);
|
|
|
|
snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins
|
|
snapshots[1].Value.ShouldBe("42"); // string
|
|
snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var before = DateTime.UtcNow;
|
|
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null));
|
|
|
|
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[0].SourceTimestampUtc.ShouldNotBeNull();
|
|
snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
|
|
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
fixture.Execute(
|
|
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
|
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
|
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
|
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
|
|
|
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
|
|
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
|
|
// deterministic — last occurrence in reader order — rather than silently arbitrary.
|
|
snapshots[0].Value.ShouldBe(99.0);
|
|
}
|
|
|
|
// ---- the frozen-peer contract ----
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
|
|
// A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and
|
|
// Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a
|
|
// cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async
|
|
// API that ignores its deadline), so only a real wall-clock bound can end this call early.
|
|
using var locker = fixture.OpenNewConnection();
|
|
Execute(locker, "BEGIN EXCLUSIVE");
|
|
|
|
try
|
|
{
|
|
// Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the
|
|
// point is to prove the CLIENT-side bound fires while the server-side backstop would still wait.
|
|
var reader = new SqlPollReader(
|
|
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
|
maxConcurrentGroups: 4, nullIsBad: false,
|
|
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
|
|
|
var clock = Stopwatch.StartNew();
|
|
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
clock.Stop();
|
|
|
|
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
|
snapshots[0].Value.ShouldBeNull();
|
|
// Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s.
|
|
clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
|
}
|
|
finally
|
|
{
|
|
Execute(locker, "ROLLBACK");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes()
|
|
{
|
|
// The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group
|
|
// is still holding a connection, so handing its slot back at the deadline would let the next poll
|
|
// open another one — and a database that stays frozen would accumulate one connection per poll pass
|
|
// forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test
|
|
// above never times a group out, and the BadTimeout test above never counts connections.
|
|
using var fixture = new SqlitePollFixture();
|
|
var factory = new TrackingFactory();
|
|
var reader = new SqlPollReader(
|
|
factory, fixture.ConnectionString, new SqliteDialect(),
|
|
// commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay
|
|
// wedged for as long as the lock is held, so nothing but the test releases the slot.
|
|
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
|
maxConcurrentGroups: 1, nullIsBad: false,
|
|
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
|
|
|
var locker = fixture.OpenNewConnection();
|
|
Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws
|
|
try
|
|
{
|
|
// Poll 1 wedges against the lock and times out, abandoning a still-running query.
|
|
var first = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
|
factory.Created.ShouldBe(1);
|
|
factory.Live.ShouldBe(1); // the zombie still owns its connection
|
|
|
|
// (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the
|
|
// factory: it times out waiting on the gate. Created staying at 1 is the load-bearing
|
|
// assertion — release-from-the-waiter would make this 2.
|
|
var second = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
|
factory.Created.ShouldBe(1);
|
|
factory.Live.ShouldBe(1);
|
|
}
|
|
finally
|
|
{
|
|
Execute(locker, "ROLLBACK");
|
|
locker.Dispose();
|
|
}
|
|
|
|
// (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline),
|
|
// closes its connection, and only then hands the slot back.
|
|
(await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30)))
|
|
.ShouldBeTrue("the abandoned group never released its connection");
|
|
|
|
var third = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
third[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished
|
|
factory.Live.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation()
|
|
{
|
|
// Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so
|
|
// clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must
|
|
// propagate rather than be laundered into a snapshot nobody will consume.
|
|
using var fixture = new SqlitePollFixture();
|
|
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
|
using var cancelled = new CancellationTokenSource();
|
|
await cancelled.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
async () => await reader.ReadAsync(["Speed"], cancelled.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff()
|
|
{
|
|
// IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws.
|
|
using var fixture = new SqlitePollFixture();
|
|
var unreachable = new SqliteConnectionStringBuilder
|
|
{
|
|
DataSource = Path.Combine(
|
|
Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"),
|
|
}.ToString();
|
|
var reader = new SqlPollReader(
|
|
fixture.Factory, unreachable, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: false,
|
|
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
|
|
|
await Should.ThrowAsync<DbException>(
|
|
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
|
|
}
|
|
|
|
// ---- dialect-owned parameter naming ----
|
|
|
|
/// <summary>
|
|
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
|
|
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
|
|
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
|
|
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
|
|
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
|
|
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var dialect = new ColonMarkerDialect();
|
|
var reader = new SqlPollReader(
|
|
fixture.Factory, fixture.ConnectionString, dialect,
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: false,
|
|
resolve: Resolver(
|
|
KvTag("Speed", SqlitePollFixture.PresentKey),
|
|
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
|
|
|
var snapshots = await reader.ReadAsync(
|
|
["Speed", "Pressure", "Oven"], CancellationToken.None);
|
|
|
|
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
|
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
|
|
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
|
|
|
|
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
|
|
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
|
|
|
|
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
|
|
// absence mis-binds an IN list by position instead of failing.
|
|
dialect.ConfiguredCommands.ShouldBe(2);
|
|
}
|
|
|
|
// ---- connection lifecycle ----
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(3)]
|
|
public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups)
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
// Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp.
|
|
var tags = new[]
|
|
{
|
|
KvTag("Speed", SqlitePollFixture.PresentKey),
|
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
|
|
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn),
|
|
};
|
|
// The delay makes the groups genuinely overlap; without it a SQLite query is too fast for
|
|
// concurrency to be observable at all and the cap assertion below would pass vacuously.
|
|
var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150));
|
|
var reader = new SqlPollReader(
|
|
factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30),
|
|
maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags));
|
|
|
|
var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None);
|
|
|
|
snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good);
|
|
factory.Created.ShouldBe(3);
|
|
factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real
|
|
factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_repeatedPolls_leakNoConnections()
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
var factory = new TrackingFactory();
|
|
var reader = new SqlPollReader(
|
|
factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: false,
|
|
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
|
|
|
for (var poll = 0; poll < 5; poll++)
|
|
{
|
|
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
|
}
|
|
|
|
factory.Created.ShouldBe(5);
|
|
factory.Live.ShouldBe(0);
|
|
}
|
|
|
|
// ---- argument guards ----
|
|
|
|
[Fact]
|
|
public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap()
|
|
{
|
|
var dialect = new SqliteDialect();
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
|
|
dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null));
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
|
|
dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null));
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new SqlPollReader(
|
|
dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null));
|
|
Should.Throw<ArgumentException>(() => new SqlPollReader(
|
|
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_rejectsANullReferenceList()
|
|
=> await Should.ThrowAsync<ArgumentNullException>(async () =>
|
|
{
|
|
using var fixture = new SqlitePollFixture();
|
|
await Reader(fixture).ReadAsync(null!, CancellationToken.None);
|
|
});
|
|
|
|
// ---- helpers ----
|
|
|
|
private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
|
|
=> Reader(fixture, nullIsBad: false, tags: tags);
|
|
|
|
private static SqlPollReader Reader(
|
|
SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags)
|
|
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags));
|
|
|
|
private static SqlPollReader Reader(
|
|
SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags)
|
|
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
|
maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger);
|
|
|
|
/// <summary>
|
|
/// Adds a second <see cref="SqlitePollFixture.WideRowTable"/> row for the station the wide-row tests
|
|
/// select, breaking that model's one-row-per-selector contract.
|
|
/// </summary>
|
|
private static void AddSecondRowForPresentStation(SqlitePollFixture fixture)
|
|
=> fixture.Execute(string.Create(CultureInfo.InvariantCulture, $"""
|
|
INSERT INTO {SqlitePollFixture.WideRowTable}
|
|
({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure,
|
|
{SqlitePollFixture.TimestampColumn})
|
|
VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z')
|
|
"""));
|
|
|
|
/// <summary>Runs one statement on a caller-owned connection.</summary>
|
|
private static void Execute(SqliteConnection connection, string sql)
|
|
{
|
|
using var command = connection.CreateCommand();
|
|
command.CommandText = sql;
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls <paramref name="condition"/> until it holds or <paramref name="limit"/> elapses. Bounded on
|
|
/// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it
|
|
/// does not.
|
|
/// </summary>
|
|
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan limit)
|
|
{
|
|
var clock = Stopwatch.StartNew();
|
|
while (clock.Elapsed < limit)
|
|
{
|
|
if (condition()) return true;
|
|
await Task.Delay(25);
|
|
}
|
|
|
|
return condition();
|
|
}
|
|
|
|
/// <summary>The driver's RawPath→definition table, standing in for <c>EquipmentTagRefResolver</c>.</summary>
|
|
private static Func<string, SqlTagDefinition?> Resolver(params SqlTagDefinition[] tags)
|
|
{
|
|
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
|
|
return rawPath => table.GetValueOrDefault(rawPath);
|
|
}
|
|
|
|
private static SqlTagDefinition KvTag(
|
|
string name,
|
|
string keyValue,
|
|
DriverDataType? declaredType = null,
|
|
string? timestampColumn = SqlitePollFixture.TimestampColumn)
|
|
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
|
|
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
|
|
ValueColumn: SqlitePollFixture.ValueColumn,
|
|
TimestampColumn: timestampColumn,
|
|
DeclaredType: declaredType);
|
|
|
|
private static SqlTagDefinition WideTag(
|
|
string name,
|
|
string columnName,
|
|
string? selectorValue = null,
|
|
string? topByTimestamp = null,
|
|
string? timestampColumn = SqlitePollFixture.TimestampColumn)
|
|
=> new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
|
|
ColumnName: columnName,
|
|
TimestampColumn: timestampColumn,
|
|
RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
|
|
RowSelectorValue: selectorValue,
|
|
RowSelectorTopByTimestamp: topByTimestamp);
|
|
|
|
/// <summary>
|
|
/// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the
|
|
/// contract-violation warnings are the reader's only signal that a source is misauthored, so they are
|
|
/// behaviour, not diagnostics.
|
|
/// </summary>
|
|
private sealed class RecordingLogger : ILogger
|
|
{
|
|
/// <summary>Every record written, level + rendered message.</summary>
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => Scope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
private sealed class Scope : IDisposable
|
|
{
|
|
public static Scope Instance { get; } = new();
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <see cref="DbProviderFactory"/> over SQLite that counts how many connections the reader has
|
|
/// created and how many are alive at once — the only way to observe the connection lifecycle from
|
|
/// outside, since <see cref="SqlPollReader"/> deliberately owns its connections end to end.
|
|
/// <para>The optional <c>createDelay</c> widens the window each connection is alive so that
|
|
/// concurrent group execution is actually observable against a database whose queries would
|
|
/// otherwise finish in microseconds.</para>
|
|
/// </summary>
|
|
private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory
|
|
{
|
|
private readonly object _sync = new();
|
|
private int _live;
|
|
private int _peak;
|
|
private int _created;
|
|
|
|
/// <summary>How many connections the reader has asked the factory for.</summary>
|
|
public int Created { get { lock (_sync) { return _created; } } }
|
|
|
|
/// <summary>How many created connections have not yet closed.</summary>
|
|
public int Live { get { lock (_sync) { return _live; } } }
|
|
|
|
/// <summary>The high-water mark of <see cref="Live"/>.</summary>
|
|
public int Peak { get { lock (_sync) { return _peak; } } }
|
|
|
|
public override DbConnection CreateConnection()
|
|
{
|
|
var connection = SqliteFactory.Instance.CreateConnection()!;
|
|
connection.StateChange += OnStateChange;
|
|
lock (_sync)
|
|
{
|
|
_created++;
|
|
_live++;
|
|
if (_live > _peak) _peak = _live;
|
|
}
|
|
|
|
if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay);
|
|
return connection;
|
|
}
|
|
|
|
private void OnStateChange(object sender, StateChangeEventArgs e)
|
|
{
|
|
if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return;
|
|
lock (_sync) { _live--; }
|
|
}
|
|
}
|
|
}
|