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