test(sql): SqliteDialect + poll fixture
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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><Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /></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;
|
||||
}
|
||||
}
|
||||
+10
@@ -12,6 +12,16 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<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="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user