using System.Data.Common; using System.Globalization; using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// 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 /// : real parameter binding, real provider type coercion, real /// , real "no such row". /// Backed by a temporary FILE, not :memory:. The reader opens a fresh /// connection per poll (Factory.CreateConnection() → set → /// OpenAsync) 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. /// The fixture is a contract, not scaffolding. 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 NULL, and a key that is /// absent entirely. Change a constant and you are changing what the reader is proven to do. /// public sealed class SqlitePollFixture : IDisposable { // ---- key-value (EAV) source ---- /// The key-value table: TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT). public const string KeyValueTable = "TagValues"; /// The key-value table's key column. public const string KeyColumn = "tag_name"; /// The key-value table's value column. public const string ValueColumn = "num_value"; /// The source-timestamp column carried by both seeded tables. public const string TimestampColumn = "sample_ts"; /// A key whose row exists and whose value cell holds . public const string PresentKey = "Line1.Speed"; /// The value seeded for . public const double PresentValue = 42.0; /// A second present key, so a group can legitimately fold more than one member. public const string SecondPresentKey = "Line1.Pressure"; /// The value seeded for . public const double SecondPresentValue = 3.5; /// /// A key whose row exists but whose value cell is NULL — the case that must not be /// confused with : the row was read, the value is simply not there. /// public const string NullValueKey = "Line1.Temp"; /// /// A key with no row at all. Distinct from on purpose: a missing row /// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell. /// public const string AbsentKey = "Line1.Missing"; // ---- wide-row source ---- /// /// The wide-row table: /// LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT). /// public const string WideRowTable = "LatestStatus"; /// The wide-row table's row-selector column. public const string WideRowSelectorColumn = "station_id"; /// A station whose row exists, with both value columns populated. public const string PresentStation = "7"; /// 's oven_temp. public const double PresentStationOvenTemp = 180.5; /// 's pressure. public const double PresentStationPressure = 1.2; /// /// The station holding the newest sample_ts — what a topByTimestamp row selector /// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the /// ORDER BY … DESC (or the row limit) picks the wrong one and the test says so. /// public const string NewestStation = "8"; /// 's oven_temp — the value a topByTimestamp plan yields. public const double NewestStationOvenTemp = 210.25; /// A station whose row exists but whose oven_temp cell is NULL. public const string NullOvenTempStation = "9"; /// A station with no row at all. public const string AbsentStation = "404"; private readonly string _databasePath; /// Creates the temporary database, applies the schema, and seeds it. 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); } /// /// 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. /// public string ConnectionString { get; } /// /// The provider factory to hand the code under test, matching the reader's /// (DbProviderFactory factory, string connectionString, …) seam. /// This is the real , 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. /// public DbProviderFactory Factory => SqliteFactory.Instance; /// /// 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. /// public SqliteConnection Connection { get; } /// Opens a brand-new connection, exactly as the reader does on each poll. /// An open connection the caller owns and must dispose. public SqliteConnection OpenNewConnection() { var connection = new SqliteConnection(ConnectionString); connection.Open(); return connection; } /// Runs one non-query statement against , for test-local arrangement. /// The statement to execute. public void Execute(string sql) { using var command = Connection.CreateCommand(); command.CommandText = sql; command.ExecuteNonQuery(); } /// Closes the fixture's connection, clears the pool, and deletes the temporary database file. 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. } } /// /// Applies the schema and rows described by this type's constants. /// Every column is explicitly declared. 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 pragma_table_info (and therefore /// ) something real to report. /// 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(); } }