using System.Data; using System.Data.Common; using System.Globalization; using System.Text.Json.Nodes; 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; /// /// The named-query model (, design §5.4, lmxopcua#753): the tag blob parse, /// the read-only statement guard, the factory's query validation, the planner, and the end-to-end read /// through over SQLite — including the rolled-back transaction every named query /// runs inside. /// public sealed class SqlNamedQueryTests { private const string DriverInstanceId = "sql-query"; // ---- tag blob parse + deploy-gate explanation ---- [Fact] public void TryParse_aQueryTag_mapsQueryColumnRowKeyAndType() { const string json = """ {"driver":"Sql","model":"Query","query":"lineStatus","column":"speed", "rowKey":{"column":"line","value":7},"timestampColumn":"ts","type":"Float64"} """; SqlEquipmentTagParser.TryParse(json, "raw/1", out var def).ShouldBeTrue(); def.Model.ShouldBe(SqlTagModel.Query); def.Name.ShouldBe("raw/1"); def.QueryName.ShouldBe("lineStatus"); def.ColumnName.ShouldBe("speed"); def.RowKeyColumn.ShouldBe("line"); def.RowKeyValue.ShouldBe("7"); // a JSON number is captured as text, like keyValue def.TimestampColumn.ShouldBe("ts"); def.DeclaredType.ShouldBe(DriverDataType.Float64); def.Table.ShouldBeEmpty(); // a Query tag reads no table of its own SqlTagDefinitionFactory.Inspect(json).ShouldBeEmpty(); } [Fact] public void TryParse_aQueryTagWithoutRowKey_isAccepted() => SqlEquipmentTagParser.TryParse( """{"model":"Query","query":"q","column":"c"}""", "raw", out _).ShouldBeTrue(); [Theory] [InlineData("""{"model":"Query","column":"c"}""", "'query'")] [InlineData("""{"model":"Query","query":"q"}""", "'column'")] [InlineData("""{"model":"Query","query":"q","column":"c","rowKey":{"column":"k"}}""", "rowKey")] [InlineData("""{"model":"Query","query":"q","column":"c","rowKey":{"value":"v"}}""", "rowKey")] [InlineData("""{"model":"Query","query":"q","column":"c","rowKey":"k"}""", "rowKey")] public void TryParse_anIncompleteQueryTag_isRejected_andTheGateSaysWhy(string json, string named) { SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse(); SqlTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains(named, StringComparison.Ordinal)); } // ---- the read-only statement guard ---- [Theory] [InlineData("SELECT a, b FROM t WHERE x = @x")] [InlineData(" select a from t; ")] [InlineData("WITH latest AS (SELECT a FROM t) SELECT a FROM latest")] [InlineData("SELECT 'DELETE FROM t; DROP TABLE t' AS note FROM t")] // keywords inside a literal [InlineData("SELECT a /* UPDATE t */ FROM t -- INSERT INTO t\n WHERE 1 = 1")] // …and inside comments [InlineData("SELECT [update] FROM t")] // a quoted identifier [InlineData("SELECT \"delete\" FROM t")] public void ReadOnlyStatement_acceptsASingleRead(string sql) => SqlReadOnlyStatement.Check(sql, out _).ShouldBeNull(); [Theory] [InlineData("UPDATE t SET a = 1")] [InlineData("DELETE FROM t")] [InlineData("INSERT INTO t VALUES (1)")] [InlineData("EXEC dbo.DoThings")] [InlineData("SELECT a INTO t2 FROM t")] // SELECT … INTO creates a table [InlineData("SELECT a FROM t FOR UPDATE")] // takes write locks [InlineData("WITH d AS (DELETE FROM t RETURNING a) SELECT a FROM d")] // data-modifying CTE [InlineData("SELECT 1; DROP TABLE t")] // a second statement [InlineData("SELECT 1; SELECT 2")] [InlineData("SELECT 'unterminated FROM t")] [InlineData("SELECT 1 /* never closed")] [InlineData(" ")] public void ReadOnlyStatement_refusesAnythingElse(string sql) => SqlReadOnlyStatement.Check(sql, out _).ShouldNotBeNull(); [Fact] public void ReadOnlyStatement_stripsOneTrailingTerminator() { SqlReadOnlyStatement.Check(" SELECT a FROM t ; ", out var normalised).ShouldBeNull(); normalised.ShouldBe("SELECT a FROM t"); } [Theory] [InlineData("SELECT a FROM t WHERE x = @line", "@line", true)] [InlineData("SELECT a FROM t WHERE x = @lineId", "@line", false)] // a longer name is not the marker [InlineData("SELECT '@line' FROM t", "@line", false)] // inside a literal [InlineData("SELECT a FROM t WHERE x = :p_line", ":p_line", true)] public void UsesMarker_findsOnlyARealParameterToken(string sql, string marker, bool expected) => SqlReadOnlyStatement.UsesMarker(sql, marker).ShouldBe(expected); // ---- factory: named queries are validated, and one bad entry fails the driver ---- [Fact] public void BuildOptions_typesAValidNamedQuery() { var options = SqlDriverFactoryExtensions.BuildOptions( Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = @x AND y = @y AND z = @z;","params":{"@x":7,"y":"s","z":1.5}}}"""), "d1", new SqlServerDialect()); var query = options.Queries["q"]; query.Sql.ShouldBe("SELECT a FROM t WHERE x = @x AND y = @y AND z = @z"); query.ParameterNames.ShouldBe(["x", "y", "z"]); query.ParameterValues.ShouldBe([7L, "s", 1.5]); query.MaxRows.ShouldBe(SqlNamedQueryRules.DefaultMaxRows); } [Theory] [InlineData("""{"q":{"sql":"DELETE FROM t"}}""", "single SELECT")] [InlineData("""{"q":{"sql":"SELECT a FROM t","params":{"x":1}}}""", "'@x'")] // declared, never used [InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x y":1}}}""", "plain identifier")] [InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":[1]}}}""", "string, number")] [InlineData("""{"q":{"sql":"SELECT a FROM t","maxRows":0}}""", "maxRows")] public void BuildOptions_anUnusableNamedQuery_failsTheDriver(string queries, string named) { var ex = Should.Throw(() => SqlDriverFactoryExtensions.BuildOptions(Dto(queries), "d1", new SqlServerDialect())); ex.Message.ShouldContain("d1"); ex.Message.ShouldContain(named); } [Fact] public void BuildOptions_anOracleQueryMustUseTheOracleMarker() { Should.Throw(() => SqlDriverFactoryExtensions.BuildOptions( Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":1}}}"""), "d1", new OracleDialect())); SqlDriverFactoryExtensions.BuildOptions( Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = :p_x","params":{"x":1}}}"""), "d1", new OracleDialect()) .Queries["q"].ParameterNames.ShouldBe(["x"]); } // ---- planner ---- [Fact] public void Plan_foldsEveryTagOfOneQueryIntoOnePlanRunningTheQueryVerbatim() { var queries = Queries(new SqlNamedQuery("q", "SELECT a, b FROM t WHERE x = @x", ["x"], [7L], 50)); var plan = SqlGroupPlanner.Plan( [ QueryTag("A", "q", "a"), QueryTag("B", "q", "b", rowKeyColumn: "a", rowKeyValue: "1"), ], new SqlServerDialect(), queries).ShouldHaveSingleItem(); plan.Model.ShouldBe(SqlTagModel.Query); plan.SqlText.ShouldBe("SELECT a, b FROM t WHERE x = @x"); plan.ParameterNames.ShouldBe(["@x"]); plan.ParameterBindNames.ShouldBe(["@x"]); plan.Parameters.ShouldBe([7L]); plan.Members.Count.ShouldBe(2); plan.RowCap.ShouldBe(50); plan.SelectedColumns.ShouldBeEmpty(); } [Fact] public void Plan_aQueryTagNamingAnUndeclaredQuery_throws() => Should.Throw(() => SqlGroupPlanner.Plan([QueryTag("A", "missing", "a")], new SqlServerDialect(), Queries())); [Fact] public void CatalogGate_passesQueryTagsThroughUntouched() { var tag = QueryTag("A", "q", "a"); var result = SqlCatalogGate.Apply([tag], SqlCatalog.Empty, new SqlServerDialect()); result.Accepted.ShouldHaveSingleItem().ShouldBeSameAs(tag); result.Rejected.ShouldBeEmpty(); } // ---- end to end over SQLite ---- [Fact] public async Task Read_aRowKeyedQueryTag_readsItsRow_withBoundParameters() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, """ {"stations":{"sql":"SELECT station_id AS Station, oven_temp AS Temp, sample_ts AS Ts FROM LatestStatus WHERE pressure > @minPressure ORDER BY station_id", "params":{"minPressure":1.3}}} """, Tag("Q/Newest", """{"model":"Query","query":"stations","column":"temp","rowKey":{"column":"station","value":8},"timestampColumn":"ts","type":"Float64"}"""), Tag("Q/Present", """{"model":"Query","query":"stations","column":"Temp","rowKey":{"column":"Station","value":"7"},"type":"Float64"}""")); var snapshots = await driver.ReadAsync(["Q/Newest", "Q/Present"], CancellationToken.None); // The parameter filters station 7 out (pressure 1.2): proof @minPressure was bound, not ignored. snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture) .ShouldBe(SqlitePollFixture.NewestStationOvenTemp); snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 2, DateTimeKind.Utc)); snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); } [Fact] public async Task Read_aSingleRowQuery_withoutRowKey_readsTheRow_andMoreRowsAreAmbiguous() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, """ {"one":{"sql":"SELECT MAX(num_value) AS top FROM TagValues"}, "many":{"sql":"SELECT num_value FROM TagValues"}} """, Tag("Q/One", """{"model":"Query","query":"one","column":"top","type":"Float64"}"""), Tag("Q/Many", """{"model":"Query","query":"many","column":"num_value","type":"Float64"}""")); var snapshots = await driver.ReadAsync(["Q/One", "Q/Many"], CancellationToken.None); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture).ShouldBe(SqlitePollFixture.PresentValue); // Three rows and no rowKey: never "last row wins" — the selector is ambiguous. snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError); // …and that refusal is the database answering, so the driver stays Healthy. driver.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Read_aColumnTheQueryDoesNotReturn_isAConfigurationError_notSomeoneElsesCell() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, """{"one":{"sql":"SELECT MAX(num_value) AS top FROM TagValues"}}""", Tag("Q/Wrong", """{"model":"Query","query":"one","column":"nope"}""")); (await driver.ReadAsync(["Q/Wrong"], CancellationToken.None))[0].StatusCode .ShouldBe(SqlStatusCodes.BadConfigurationError); } [Fact] public async Task Read_moreRowsThanMaxRows_refusesTheWholeQuery() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, """{"all":{"sql":"SELECT tag_name, num_value FROM TagValues","maxRows":2}}""", Tag("Q/Speed", $$$"""{"model":"Query","query":"all","column":"num_value","rowKey":{"column":"tag_name","value":"{{{SqlitePollFixture.PresentKey}}}"}}""")); (await driver.ReadAsync(["Q/Speed"], CancellationToken.None))[0].StatusCode .ShouldBe(SqlStatusCodes.BadConfigurationError); } [Fact] public async Task Initialize_aQueryTagNamingAnUndeclaredQuery_isSkippedWithTheReason() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, """{"one":{"sql":"SELECT 1 AS v"}}""", Tag("Q/Ok", """{"model":"Query","query":"one","column":"v"}"""), Tag("Q/Missing", """{"model":"Query","query":"nope","column":"v"}""")); var skipped = ((ISkippedTagReporter)driver).GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem(); skipped.RawPath.ShouldBe("Q/Missing"); skipped.Reason.ShouldContain("'nope'"); (await driver.ReadAsync(["Q/Missing", "Q/Ok"], CancellationToken.None)) .Select(s => s.StatusCode).ShouldBe([SqlStatusCodes.BadNodeIdUnknown, SqlStatusCodes.Good]); } /// /// The backstop behind the authoring guard: a named query runs inside a transaction that is always /// rolled back. The probe connection performs a WRITE inside every transaction the driver opens — /// standing in for a side-effecting function the lexical guard cannot see — so the table staying empty /// after a read proves the work was rolled back, and the begin counter (the positive control) proves a /// transaction was opened at all. A reader that committed would leave one row per poll. /// [Fact] public async Task Read_aNamedQuery_runsInsideATransactionThatIsRolledBack() { using var fixture = new SqlitePollFixture(); fixture.Execute("CREATE TABLE SideEffects (n INTEGER)"); var factory = new SideEffectFactory(); await using var driver = await StartAsync(fixture, factory, """{"one":{"sql":"SELECT MAX(num_value) AS top FROM TagValues"}}""", Tag("Q/One", """{"model":"Query","query":"one","column":"top"}""")); (await driver.ReadAsync(["Q/One"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good); (await driver.ReadAsync(["Q/One"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good); factory.TransactionsBegun.ShouldBe(2); using var count = fixture.Connection.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM SideEffects"; Convert.ToInt64(count.ExecuteScalar(), CultureInfo.InvariantCulture).ShouldBe(0L); } [Fact] public async Task Read_keyValueTags_doNotOpenATransaction() { using var fixture = new SqlitePollFixture(); fixture.Execute("CREATE TABLE SideEffects (n INTEGER)"); var factory = new SideEffectFactory(); await using var driver = await StartAsync(fixture, factory, queriesJson: null, Tag("Kv", $$"""{"model":"KeyValue","table":"TagValues","keyColumn":"tag_name","keyValue":"{{SqlitePollFixture.PresentKey}}","valueColumn":"num_value"}""")); (await driver.ReadAsync(["Kv"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good); factory.TransactionsBegun.ShouldBe(0); } // ---- helpers ---- private static SqlDriverConfigDto Dto(string queriesJson) => System.Text.Json.JsonSerializer.Deserialize( new JsonObject { ["connectionStringRef"] = "x", ["queries"] = JsonNode.Parse(queriesJson) }.ToJsonString(), SqlDriverFactoryExtensions.JsonOptionsForTest)!; private static Dictionary Queries(params SqlNamedQuery[] queries) => queries.ToDictionary(q => q.Name, StringComparer.Ordinal); private static SqlTagDefinition QueryTag( string name, string query, string column, string? rowKeyColumn = null, string? rowKeyValue = null) => new(name, SqlTagModel.Query, string.Empty, ColumnName: column, QueryName: query, RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue); private static RawTagEntry Tag(string rawPath, string json) => new(rawPath, json, WriteIdempotent: false); private static Task StartAsync(SqlitePollFixture fixture, string? queriesJson, params RawTagEntry[] tags) => StartAsync(fixture, fixture.Factory, queriesJson, tags); private static async Task StartAsync( SqlitePollFixture fixture, DbProviderFactory factory, string? queriesJson, params RawTagEntry[] tags) { var dto = queriesJson is null ? new SqlDriverConfigDto() : Dto(queriesJson); var built = SqlDriverFactoryExtensions.BuildOptions(dto, DriverInstanceId, new SqliteDialect()); var options = new SqlDriverOptions { RawTags = tags, OperationTimeout = TimeSpan.FromSeconds(15), CommandTimeout = TimeSpan.FromSeconds(10), Queries = built.Queries, }; var driver = new SqlDriver(options, DriverInstanceId, new SqliteDialect(), fixture.ConnectionString, factory); await driver.InitializeAsync("{}", CancellationToken.None); return driver; } /// A SQLite factory whose connections write a row inside every transaction they begin. private sealed class SideEffectFactory : DbProviderFactory { private int _begun; public int TransactionsBegun => Volatile.Read(ref _begun); public override DbConnection CreateConnection() => new SideEffectConnection(this); private sealed class SideEffectConnection(SideEffectFactory owner) : SqliteConnection { protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) { Interlocked.Increment(ref owner._begun); var transaction = BeginTransaction(isolationLevel); using var write = CreateCommand(); write.Transaction = transaction; write.CommandText = "INSERT INTO SideEffects (n) VALUES (1)"; write.ExecuteNonQuery(); return transaction; } } } }