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;
}
}