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