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