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,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;
/// <summary>
/// A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
/// be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
/// real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
/// <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
/// <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
/// <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
/// <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
/// (<c>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</c>)
/// rather than referencing this project, so it must not depend on anything else defined here.</para>
/// </summary>
public sealed class SqliteDialect : ISqlDialect
{
/// <summary>
/// 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.
/// </summary>
public const string MainSchema = "main";
/// <summary>
/// Reports <see cref="SqlProvider.Odbc"/>.
/// <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> 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.</para>
/// <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
/// not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
/// <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
/// <see cref="Provider"/> 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.</para>
/// </summary>
public SqlProvider Provider => SqlProvider.Odbc;
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
/// position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
/// ends on the seam.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// SQLite has no schema namespace, so the schema level is the single literal
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
/// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
/// <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
/// honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
/// </summary>
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";
/// <summary>
/// Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
/// <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
/// <b>bound parameter</b> — 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.
/// <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
/// </summary>
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";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
/// only metacharacter that could close a quoted identifier early.
/// </summary>
/// <remarks>
/// Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'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.
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quote-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid SQLite identifier.</exception>
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), "\"");
}
/// <summary>
/// Folds a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
/// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
/// declares every seeded column explicitly.
/// </summary>
/// <remarks>
/// <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
/// parameterised like <c>VARCHAR(50)</c>) declaration falls back to
/// <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
/// <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
/// <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
/// <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
/// </remarks>
/// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
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,
};
}
}
@@ -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();
}
}
@@ -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;
/// <summary>
/// Proves the offline substrate the reader tests will stand on: that <see cref="SqlitePollFixture"/>
/// survives the reader's real per-poll connection lifecycle, and that a
/// <see cref="SqlGroupPlanner"/>-produced plan <b>executes</b> — parses, binds, and returns the seeded
/// rows — rather than merely looking right as a string.
/// <para>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.</para>
/// </summary>
public sealed class SqlitePollFixtureTests : IClassFixture<SqlitePollFixture>
{
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<ArgumentException>(() => dialect.QuoteIdentifier("x\0y"));
Should.Throw<ArgumentException>(() => 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<string> 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<string>();
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);
/// <summary>
/// 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
/// <see cref="SqlQueryPlan.SelectedColumns"/>.
/// </summary>
private List<Dictionary<string, object?>> 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<Dictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(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;
}
}
@@ -12,6 +12,16 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3"/> <PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/> <PackageReference Include="Shouldly"/>
<!--
TEST-ONLY. SQLite backs SqlitePollFixture so the poll reader can be exercised against a real
DbConnection offline; no product project takes a dependency on it (Driver.Sql ships
Microsoft.Data.SqlClient only). The bundle_e_sqlite3 line is the same surgical direct pin
Core.AlarmHistorian carries — it promotes the native bundle to 2.1.12, outside the
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range that Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
See Directory.Packages.props.
-->
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/> <PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio"> <PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>