diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs
new file mode 100644
index 00000000..8a7a7527
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs
@@ -0,0 +1,154 @@
+using System.Data.Common;
+using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
+
+///
+/// A test-only over SQLite, so the poll reader and the schema browser can
+/// be exercised against a real — real parameter binding, real type coercion,
+/// real NULLs — with no SQL Server and no network. No product project references SQLite.
+/// It is also the seam's falsifiability control: it is the only non-SQL-Server
+/// in the tree, so it is what proves the planner emits dialect SQL
+/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
+/// TOP 1 does not parse, and the fixture tests fail loudly if that regresses.
+/// Kept public and self-contained on purpose: Driver.Sql.Browser.Tests links this file
+/// (<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />)
+/// rather than referencing this project, so it must not depend on anything else defined here.
+///
+public sealed class SqliteDialect : ISqlDialect
+{
+ ///
+ /// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the
+ /// catalog queries accept this one value so the browser's schema level has something real to expand.
+ ///
+ public const string MainSchema = "main";
+
+ ///
+ /// Reports .
+ /// Why not a Sqlite member: is a shipped product
+ /// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain
+ /// a case that can never occur in production.
+ /// Why of the existing members: it is the enum's generic,
+ /// not-yet-constructed member, so nothing branches on it today. Crucially it is not
+ /// — any future T-SQL-only code path that keys off
+ /// stays switched off against this dialect and fails visibly here rather than
+ /// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp
+ /// for exactly the assumptions it exists to catch.
+ ///
+ public SqlProvider Provider => SqlProvider.Odbc;
+
+ ///
+ public DbProviderFactory Factory => SqliteFactory.Instance;
+
+ ///
+ public string LivenessSql => "SELECT 1";
+
+ ///
+ /// SQLite spells the row limit at the end of the statement, so the after-SELECT
+ /// position is empty — the mirror image of SqlServerDialect, which is the point of having both
+ /// ends on the seam.
+ ///
+ public string SingleRowLimitPrefix => string.Empty;
+
+ ///
+ /// " LIMIT 1", leading space included so it appends straight onto the finished statement
+ /// (… ORDER BY "sample_ts" DESC LIMIT 1).
+ ///
+ public string SingleRowLimitSuffix => " LIMIT 1";
+
+ ///
+ /// SQLite has no schema namespace, so the schema level is the single literal
+ /// — enough to give the browser a real root to expand.
+ ///
+ public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
+
+ ///
+ /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the
+ /// internal sqlite_* objects filtered out and the type folded onto the
+ /// INFORMATION_SCHEMA.TABLES vocabulary the browser already speaks. @schema is bound and
+ /// honoured — anything but legitimately lists nothing.
+ ///
+ public string ListTablesSql =>
+ "SELECT name AS TABLE_NAME, " +
+ "CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " +
+ "FROM sqlite_schema " +
+ "WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " +
+ $"AND @schema = '{MainSchema}' ORDER BY name";
+
+ ///
+ /// Columns via the pragma_table_info table-valued function rather than the bare
+ /// PRAGMA table_info(x) statement, because only the function form lets the table name be a
+ /// bound parameter — a bare PRAGMA takes its argument as text, which would put an authored name
+ /// back into a command string and reopen the injection boundary this seam exists to close.
+ /// DATA_TYPE is the column's declared type (SQLite stores affinity, not a type).
+ ///
+ public string ListColumnsSql =>
+ "SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " +
+ "CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " +
+ "FROM pragma_table_info(@table) " +
+ $"WHERE @schema = '{MainSchema}' ORDER BY cid";
+
+ ///
+ /// Double-quotes one identifier part, doubling any embedded " — SQLite's escape rule, and the
+ /// only metacharacter that could close a quoted identifier early.
+ ///
+ ///
+ /// Mirrors SqlServerDialect.QuoteIdentifier's rejections (null / empty / all-whitespace /
+ /// control characters) so a test written against one dialect fails the same way against the other.
+ /// SQLite imposes no identifier length ceiling, so there is no length rule here.
+ ///
+ /// The bare, unquoted identifier part.
+ /// The double-quote-quoted identifier.
+ /// The value cannot be a valid SQLite identifier.
+ public string QuoteIdentifier(string ident)
+ {
+ if (string.IsNullOrWhiteSpace(ident))
+ throw new ArgumentException(
+ "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
+
+ foreach (var ch in ident)
+ {
+ if (char.IsControl(ch))
+ throw new ArgumentException(
+ "A SQL identifier may not contain control characters (including NUL).", nameof(ident));
+ }
+
+ return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
+ }
+
+ ///
+ /// Folds a SQLite declared column type onto a . SQLite is
+ /// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
+ /// declares every seeded column explicitly.
+ ///
+ ///
+ /// Never throws, mirroring SqlServerDialect: an unrecognised (or blank, or
+ /// parameterised like VARCHAR(50)) declaration falls back to
+ /// so a browse over an oddly-declared table still renders.
+ /// Deliberate divergence from SQL Server: REAL maps to
+ /// here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
+ /// real is 4-byte — mapping it to Float32 would narrow every value the fixture returns.
+ ///
+ /// The declared type as pragma_table_info reports it; case-insensitive.
+ /// The mapped driver data type, or when unrecognised.
+ public DriverDataType MapColumnType(string sqlDataType)
+ {
+ if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
+
+ return sqlDataType.Trim().ToLowerInvariant() switch
+ {
+ "boolean" or "bool" or "bit" => DriverDataType.Boolean,
+ "tinyint" or "smallint" or "int2" => DriverDataType.Int16,
+ "int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32,
+ "bigint" or "int8" => DriverDataType.Int64,
+ // SQLite's REAL is a double — deliberately NOT Float32 (see remarks).
+ "real" or "double" or "double precision" or "float"
+ or "numeric" or "decimal" => DriverDataType.Float64,
+ "text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String,
+ "date" or "datetime" or "timestamp" => DriverDataType.DateTime,
+ _ => DriverDataType.String,
+ };
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs
new file mode 100644
index 00000000..604b5521
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs
@@ -0,0 +1,212 @@
+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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs
new file mode 100644
index 00000000..390a1c08
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs
@@ -0,0 +1,263 @@
+using Microsoft.Data.Sqlite;
+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;
+
+///
+/// Proves the offline substrate the reader tests will stand on: that
+/// survives the reader's real per-poll connection lifecycle, and that a
+/// -produced plan executes — parses, binds, and returns the seeded
+/// rows — rather than merely looking right as a string.
+/// The planner's own tests assert emitted SQL text. Nothing there can catch a statement that is
+/// well-formed but unexecutable, or a parameter marker the provider will not bind. These tests can, and
+/// they are the reason the next task starts from a known-good query path.
+///
+public sealed class SqlitePollFixtureTests : IClassFixture
+{
+ private readonly SqlitePollFixture _fixture;
+
+ public SqlitePollFixtureTests(SqlitePollFixture fixture) => _fixture = fixture;
+
+ // ---- the fixture itself ----
+
+ [Fact]
+ public void AConnectionFromTheFactory_roundTripsASeededRow()
+ {
+ // Exactly the reader's seam: create from the abstract factory, assign the connection string, open.
+ var connection = _fixture.Factory.CreateConnection();
+ connection.ShouldNotBeNull();
+ connection.ConnectionString = _fixture.ConnectionString;
+ connection.Open();
+ using var owned = connection;
+
+ using var command = owned.CreateCommand();
+ command.CommandText =
+ $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
+ $"WHERE {SqlitePollFixture.KeyColumn} = @k";
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = "@k";
+ parameter.Value = SqlitePollFixture.PresentKey;
+ command.Parameters.Add(parameter);
+
+ Convert.ToDouble(command.ExecuteScalar()).ShouldBe(SqlitePollFixture.PresentValue);
+ }
+
+ [Fact]
+ public void TheDatabase_survivesTheReadersOpenAndCloseCyclePerPoll()
+ {
+ // The whole reason the fixture is file-backed: an in-memory database would be gone by "poll" 2.
+ for (var poll = 0; poll < 3; poll++)
+ {
+ using var connection = _fixture.OpenNewConnection();
+ using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT COUNT(*) FROM {SqlitePollFixture.KeyValueTable}";
+ Convert.ToInt64(command.ExecuteScalar()).ShouldBe(3L);
+ }
+ }
+
+ [Fact]
+ public void TheSeed_distinguishesAPresentValue_aNullCell_andAnAbsentKey()
+ {
+ using var connection = _fixture.OpenNewConnection();
+
+ ReadValue(connection, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
+ ReadValue(connection, SqlitePollFixture.NullValueKey).ShouldBe(DBNull.Value); // row present, cell NULL
+ ReadValue(connection, SqlitePollFixture.AbsentKey).ShouldBeNull(); // no row at all
+
+ static object? ReadValue(SqliteConnection connection, string key)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText =
+ $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
+ $"WHERE {SqlitePollFixture.KeyColumn} = @k";
+ command.Parameters.AddWithValue("@k", key);
+ return command.ExecuteScalar();
+ }
+ }
+
+ // ---- planner-produced plans, actually executed ----
+
+ [Fact]
+ public void AKeyValuePlan_executesAgainstTheFixture_andSlicesBackPerMember()
+ {
+ var plan = SqlGroupPlanner.Plan(
+ [
+ KvTag("A", SqlitePollFixture.PresentKey),
+ KvTag("B", SqlitePollFixture.NullValueKey),
+ KvTag("C", SqlitePollFixture.AbsentKey),
+ ], new SqliteDialect()).ShouldHaveSingleItem();
+
+ var rows = ExecutePlan(plan);
+
+ // The result set is indexed by the key column, exactly as the reader will slice it.
+ var byKey = rows.ToDictionary(
+ r => (string)r[SqlitePollFixture.KeyColumn]!, StringComparer.Ordinal);
+
+ byKey.Count.ShouldBe(2); // the absent key contributes no row — it is not an error, just no data
+ byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.ValueColumn]
+ .ShouldBe(SqlitePollFixture.PresentValue);
+ byKey[SqlitePollFixture.NullValueKey][SqlitePollFixture.ValueColumn]
+ .ShouldBeNull(); // present row, NULL cell — distinct from the absent key above
+ byKey.ShouldNotContainKey(SqlitePollFixture.AbsentKey);
+ byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.TimestampColumn]
+ .ShouldBe("2026-07-24T10:00:00Z");
+ }
+
+ [Fact]
+ public void AWideRowWherePairPlan_executesAgainstTheFixture_andReturnsTheSelectedRow()
+ {
+ var plan = SqlGroupPlanner.Plan(
+ [
+ WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
+ WideTag("B", "pressure", selectorValue: SqlitePollFixture.PresentStation),
+ ], new SqliteDialect()).ShouldHaveSingleItem();
+
+ // The station id is bound, not inlined — and SQLite coerces the bound string against an INTEGER column.
+ plan.Parameters.ShouldBe(new object[] { SqlitePollFixture.PresentStation });
+
+ var row = ExecutePlan(plan).ShouldHaveSingleItem();
+ row["oven_temp"].ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
+ row["pressure"].ShouldBe(SqlitePollFixture.PresentStationPressure);
+ }
+
+ [Fact]
+ public void AWideRowWherePairPlan_forAnAbsentSelectorValue_returnsNoRows()
+ {
+ var plan = SqlGroupPlanner.Plan(
+ [WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation)],
+ new SqliteDialect()).ShouldHaveSingleItem();
+
+ ExecutePlan(plan).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ATopByTimestampPlan_emitsSqlitesLimitForm_andExecutesToTheNewestRow()
+ {
+ var plan = SqlGroupPlanner.Plan(
+ [WideTag("A", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)],
+ new SqliteDialect()).ShouldHaveSingleItem();
+
+ // The payoff of putting the row limit on ISqlDialect: T-SQL's "TOP 1" does not parse in SQLite, so
+ // this statement would throw at ExecuteReader if the planner had kept emitting it inline.
+ plan.SqlText.ShouldBe(
+ "SELECT \"oven_temp\" FROM \"LatestStatus\" ORDER BY \"sample_ts\" DESC LIMIT 1");
+ plan.SqlText.ShouldNotContain("TOP 1");
+
+ var row = ExecutePlan(plan).ShouldHaveSingleItem();
+ row["oven_temp"].ShouldBe(SqlitePollFixture.NewestStationOvenTemp); // station 8, not the first row
+ }
+
+ // ---- the dialect ----
+
+ [Fact]
+ public void TheDialect_quotesWithDoubleQuotes_andDoublesAnEmbeddedOne()
+ {
+ var dialect = new SqliteDialect();
+ dialect.QuoteIdentifier("oven_temp").ShouldBe("\"oven_temp\"");
+ dialect.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
+ Should.Throw(() => dialect.QuoteIdentifier("x\0y"));
+ Should.Throw(() => dialect.QuoteIdentifier(" "));
+ }
+
+ [Fact]
+ public void TheDialectsCatalogSql_answersOverTheRealSeededDatabase()
+ {
+ var dialect = new SqliteDialect();
+ using var connection = _fixture.OpenNewConnection();
+
+ Query(dialect.ListSchemasSql).ShouldBe(new[] { SqliteDialect.MainSchema });
+
+ var tables = Query(dialect.ListTablesSql, ("@schema", SqliteDialect.MainSchema));
+ tables.ShouldContain(SqlitePollFixture.KeyValueTable);
+ tables.ShouldContain(SqlitePollFixture.WideRowTable);
+ tables.ShouldAllBe(t => !t.StartsWith("sqlite_", StringComparison.Ordinal));
+
+ var columns = Query(
+ dialect.ListColumnsSql,
+ ("@schema", SqliteDialect.MainSchema), ("@table", SqlitePollFixture.WideRowTable));
+ columns.ShouldBe(new[]
+ {
+ SqlitePollFixture.WideRowSelectorColumn, "oven_temp", "pressure", SqlitePollFixture.TimestampColumn,
+ });
+
+ List Query(string sql, params (string Name, string Value)[] parameters)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value);
+ using var reader = command.ExecuteReader();
+ var results = new List();
+ while (reader.Read()) results.Add(reader.GetString(0));
+ return results;
+ }
+ }
+
+ [Fact]
+ public void TheDialect_mapsTheSeededDeclaredTypes_andNeverThrowsOnAnUnknownAffinity()
+ {
+ var dialect = new SqliteDialect();
+ dialect.MapColumnType("TEXT").ShouldBe(DriverDataType.String);
+ dialect.MapColumnType("INTEGER").ShouldBe(DriverDataType.Int32);
+ // SQLite's REAL is a double — NOT Float32, which is what the same word means in T-SQL.
+ dialect.MapColumnType("REAL").ShouldBe(DriverDataType.Float64);
+ dialect.MapColumnType("real").ShouldBe(DriverDataType.Float64);
+ dialect.MapColumnType("VARCHAR(50)").ShouldBe(DriverDataType.String); // unparsed ⇒ fallback
+ dialect.MapColumnType("some_udt").ShouldBe(DriverDataType.String);
+ dialect.MapColumnType("").ShouldBe(DriverDataType.String);
+ dialect.MapColumnType(null!).ShouldBe(DriverDataType.String);
+ }
+
+ [Fact]
+ public void TheDialect_doesNotClaimToBeSqlServer()
+ // A test dialect that reported SqlServer would rubber-stamp the T-SQL assumptions it exists to catch.
+ => new SqliteDialect().Provider.ShouldNotBe(SqlProvider.SqlServer);
+
+ // ---- helpers ----
+
+ private static SqlTagDefinition KvTag(string name, string keyValue)
+ => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
+ KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
+ ValueColumn: SqlitePollFixture.ValueColumn,
+ TimestampColumn: SqlitePollFixture.TimestampColumn);
+
+ private static SqlTagDefinition WideTag(
+ string name, string columnName, string? selectorValue = null, string? topByTimestamp = null)
+ => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
+ ColumnName: columnName,
+ RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
+ RowSelectorValue: selectorValue,
+ RowSelectorTopByTimestamp: topByTimestamp);
+
+ ///
+ /// Executes a plan the way the reader will: one fresh connection, the plan's SQL verbatim, its
+ /// parameters bound positionally by name, and the rows projected by
+ /// .
+ ///
+ private List> ExecutePlan(SqlQueryPlan plan)
+ {
+ using var connection = _fixture.OpenNewConnection();
+ using var command = connection.CreateCommand();
+ command.CommandText = plan.SqlText;
+ for (var i = 0; i < plan.ParameterNames.Count; i++)
+ command.Parameters.AddWithValue(plan.ParameterNames[i], plan.Parameters[i] ?? DBNull.Value);
+
+ using var reader = command.ExecuteReader();
+ var rows = new List>();
+ while (reader.Read())
+ {
+ var row = new Dictionary(StringComparer.Ordinal);
+ foreach (var column in plan.SelectedColumns)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ row[column] = reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal);
+ }
+
+ rows.Add(row);
+ }
+
+ return rows;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
index 74a7aae6..47c8bf1f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj
@@ -12,6 +12,16 @@
+
+
+
all