test(sql): SqliteDialect + poll fixture

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:04:06 -04:00
parent 6de782e0fd
commit a05cff794c
4 changed files with 639 additions and 0 deletions
@@ -0,0 +1,212 @@
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate
/// for every reader test. It exists so the poll path is exercised against a genuine
/// <see cref="DbConnection"/>: real parameter binding, real provider type coercion, real
/// <see cref="DBNull"/>, real "no such row".
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c>.</b> The reader opens a <em>fresh</em>
/// connection per poll (<c>Factory.CreateConnection()</c> → set <see cref="ConnectionString"/> →
/// <c>OpenAsync</c>) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment
/// its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate
/// between polls and every test would fail for a reason that has nothing to do with the code under test.
/// The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an
/// invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to
/// exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no
/// keep-alive, and its connection string is an ordinary path the code under test takes verbatim.</para>
/// <para><b>The fixture is a contract, not scaffolding.</b> The seeded shapes below are what reader tests
/// assert against, so the constants are public and the seed deliberately covers the three cases that
/// behave differently: a present value, a present row whose value cell is <b>NULL</b>, and a key that is
/// <b>absent</b> entirely. Change a constant and you are changing what the reader is proven to do.</para>
/// </summary>
public sealed class SqlitePollFixture : IDisposable
{
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by both seeded tables.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>
/// A key whose row <b>exists</b> but whose value cell is <c>NULL</c> — the case that must not be
/// confused with <see cref="AbsentKey"/>: the row was read, the value is simply not there.
/// </summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>
/// A key with <b>no row at all</b>. Distinct from <see cref="NullValueKey"/> on purpose: a missing row
/// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
/// </summary>
public const string AbsentKey = "Line1.Missing";
// ---- wide-row source ----
/// <summary>
/// The wide-row table:
/// <c>LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT)</c>.
/// </summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> row selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
/// <c>ORDER BY … DESC</c> (or the row limit) picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — the value a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
private readonly string _databasePath;
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
public SqlitePollFixture()
{
_databasePath = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db");
ConnectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
}.ToString();
Connection = new SqliteConnection(ConnectionString);
Connection.Open();
Seed(Connection);
}
/// <summary>
/// The connection string to hand the code under test. An ordinary file path — safe to open and close
/// any number of times, from any number of connections, in any order.
/// </summary>
public string ConnectionString { get; }
/// <summary>
/// The provider factory to hand the code under test, matching the reader's
/// <c>(DbProviderFactory factory, string connectionString, …)</c> seam.
/// <para>This is the <em>real</em> <see cref="SqliteFactory"/>, not a shim that hands back a
/// pre-opened connection: because the database is a file, the reader's genuine
/// create → open → query → close cycle works unmodified, so what the tests exercise is the production
/// connection lifecycle rather than a test-only shortcut around it.</para>
/// </summary>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <summary>
/// A long-lived open connection over the same database, for arranging extra state or inspecting
/// results directly. Independent of the connections the code under test opens — closing one has no
/// effect on the others, and none of them destroy the data.
/// </summary>
public SqliteConnection Connection { get; }
/// <summary>Opens a brand-new connection, exactly as the reader does on each poll.</summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// <summary>Runs one non-query statement against <see cref="Connection"/>, for test-local arrangement.</summary>
/// <param name="sql">The statement to execute.</param>
public void Execute(string sql)
{
using var command = Connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// <summary>Closes the fixture's connection, clears the pool, and deletes the temporary database file.</summary>
public void Dispose()
{
Connection.Close();
Connection.Dispose();
// Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be
// held open and the delete silently fails, leaking a file per test class into the temp directory.
SqliteConnection.ClearAllPools();
try
{
if (File.Exists(_databasePath)) File.Delete(_databasePath);
}
catch (IOException)
{
// A leaked temp file must never fail a test run.
}
}
/// <summary>
/// Applies the schema and rows described by this type's constants.
/// <para>Every column is <b>explicitly declared</b>. SQLite is dynamically typed and would happily
/// store a string in an undeclared column, which would make the reader's type-coercion tests prove
/// nothing; declared types give <c>pragma_table_info</c> (and therefore
/// <see cref="SqliteDialect.MapColumnType"/>) something real to report.</para>
/// </summary>
private static void Seed(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
CREATE TABLE {KeyValueTable} (
{KeyColumn} TEXT NOT NULL,
{ValueColumn} REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES
('{PresentKey}', {PresentValue}, '2026-07-24T10:00:00Z'),
('{NullValueKey}', NULL, '2026-07-24T10:00:01Z'),
('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z');
CREATE TABLE {WideRowTable} (
{WideRowSelectorColumn} INTEGER NOT NULL,
oven_temp REAL NULL,
pressure REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES
({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01Z'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02Z');
""");
command.ExecuteNonQuery();
}
}