diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs new file mode 100644 index 00000000..8a7a7527 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -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; + +/// +/// A test-only over SQLite, so the poll reader and the schema browser can +/// be exercised against a real — real parameter binding, real type coercion, +/// real NULLs — with no SQL Server and no network. No product project references SQLite. +/// It is also the seam's falsifiability control: it is the only non-SQL-Server +/// in the tree, so it is what proves the planner emits dialect SQL +/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's +/// TOP 1 does not parse, and the fixture tests fail loudly if that regresses. +/// Kept public and self-contained on purpose: Driver.Sql.Browser.Tests links this file +/// (<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />) +/// rather than referencing this project, so it must not depend on anything else defined here. +/// +public sealed class SqliteDialect : ISqlDialect +{ + /// + /// 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. + /// + public const string MainSchema = "main"; + + /// + /// Reports . + /// Why not a Sqlite member: 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. + /// Why of the existing members: it is the enum's generic, + /// not-yet-constructed member, so nothing branches on it today. Crucially it is not + /// — any future T-SQL-only code path that keys off + /// 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. + /// + public SqlProvider Provider => SqlProvider.Odbc; + + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// SQLite spells the row limit at the end of the statement, so the after-SELECT + /// position is empty — the mirror image of SqlServerDialect, which is the point of having both + /// ends on the seam. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// " LIMIT 1", leading space included so it appends straight onto the finished statement + /// (… ORDER BY "sample_ts" DESC LIMIT 1). + /// + public string SingleRowLimitSuffix => " LIMIT 1"; + + /// + /// SQLite has no schema namespace, so the schema level is the single literal + /// — enough to give the browser a real root to expand. + /// + public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; + + /// + /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the + /// internal sqlite_* objects filtered out and the type folded onto the + /// INFORMATION_SCHEMA.TABLES vocabulary the browser already speaks. @schema is bound and + /// honoured — anything but legitimately lists nothing. + /// + 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"; + + /// + /// Columns via the pragma_table_info table-valued function rather than the bare + /// PRAGMA table_info(x) statement, because only the function form lets the table name be a + /// bound parameter — 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. + /// DATA_TYPE is the column's declared type (SQLite stores affinity, not a type). + /// + 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"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — SQLite's escape rule, and the + /// only metacharacter that could close a quoted identifier early. + /// + /// + /// Mirrors SqlServerDialect.QuoteIdentifier'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. + /// + /// The bare, unquoted identifier part. + /// The double-quote-quoted identifier. + /// The value cannot be a valid SQLite identifier. + 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), "\""); + } + + /// + /// Folds a SQLite declared column type onto a . SQLite is + /// dynamically typed and a column's declared type is advisory, which is exactly why the fixture + /// declares every seeded column explicitly. + /// + /// + /// Never throws, mirroring SqlServerDialect: an unrecognised (or blank, or + /// parameterised like VARCHAR(50)) declaration falls back to + /// so a browse over an oddly-declared table still renders. + /// Deliberate divergence from SQL Server: REAL maps to + /// here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's + /// real is 4-byte — mapping it to Float32 would narrow every value the fixture returns. + /// + /// The declared type as pragma_table_info reports it; case-insensitive. + /// The mapped driver data type, or when unrecognised. + 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, + }; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs new file mode 100644 index 00000000..604b5521 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs @@ -0,0 +1,212 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// 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 +/// : real parameter binding, real provider type coercion, real +/// , real "no such row". +/// Backed by a temporary FILE, not :memory:. The reader opens a fresh +/// connection per poll (Factory.CreateConnection() → set → +/// OpenAsync) 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. +/// The fixture is a contract, not scaffolding. 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 NULL, and a key that is +/// absent entirely. Change a constant and you are changing what the reader is proven to do. +/// +public sealed class SqlitePollFixture : IDisposable +{ + // ---- key-value (EAV) source ---- + + /// The key-value table: TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT). + public const string KeyValueTable = "TagValues"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by both seeded tables. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for . + public const double SecondPresentValue = 3.5; + + /// + /// A key whose row exists but whose value cell is NULL — the case that must not be + /// confused with : the row was read, the value is simply not there. + /// + public const string NullValueKey = "Line1.Temp"; + + /// + /// A key with no row at all. Distinct from on purpose: a missing row + /// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell. + /// + public const string AbsentKey = "Line1.Missing"; + + // ---- wide-row source ---- + + /// + /// The wide-row table: + /// LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT). + /// + public const string WideRowTable = "LatestStatus"; + + /// The wide-row table's row-selector column. + public const string WideRowSelectorColumn = "station_id"; + + /// A station whose row exists, with both value columns populated. + public const string PresentStation = "7"; + + /// 's oven_temp. + public const double PresentStationOvenTemp = 180.5; + + /// 's pressure. + public const double PresentStationPressure = 1.2; + + /// + /// The station holding the newest sample_ts — what a topByTimestamp row selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the + /// ORDER BY … DESC (or the row limit) picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — the value a topByTimestamp plan yields. + public const double NewestStationOvenTemp = 210.25; + + /// A station whose row exists but whose oven_temp cell is NULL. + public const string NullOvenTempStation = "9"; + + /// A station with no row at all. + public const string AbsentStation = "404"; + + private readonly string _databasePath; + + /// Creates the temporary database, applies the schema, and seeds it. + 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); + } + + /// + /// 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. + /// + public string ConnectionString { get; } + + /// + /// The provider factory to hand the code under test, matching the reader's + /// (DbProviderFactory factory, string connectionString, …) seam. + /// This is the real , 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. + /// + public DbProviderFactory Factory => SqliteFactory.Instance; + + /// + /// 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. + /// + public SqliteConnection Connection { get; } + + /// Opens a brand-new connection, exactly as the reader does on each poll. + /// An open connection the caller owns and must dispose. + public SqliteConnection OpenNewConnection() + { + var connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + /// Runs one non-query statement against , for test-local arrangement. + /// The statement to execute. + public void Execute(string sql) + { + using var command = Connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Closes the fixture's connection, clears the pool, and deletes the temporary database file. + 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. + } + } + + /// + /// Applies the schema and rows described by this type's constants. + /// Every column is explicitly declared. 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 pragma_table_info (and therefore + /// ) something real to report. + /// + 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(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs new file mode 100644 index 00000000..390a1c08 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixtureTests.cs @@ -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; + +/// +/// 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; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj index 74a7aae6..47c8bf1f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj @@ -12,6 +12,16 @@ + + + all