test(sql): SqliteDialect + poll fixture
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user