feat(sql): named-query tag model — read-only SELECTs declared on the driver (lmxopcua#753)

SqlTagModel.Query (design §5.4) is now served end to end instead of refused.
The driver config declares `queries` (name -> sql, params, maxRows); a tag names
the query, the result `column` it reads and optionally a `rowKey` picking its row.

- Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect
  explains each refusal. Table is not read for this model.
- SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword
  outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by
  the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the
  form refuses what the driver refuses. One bad entry fails the driver.
- Parameters are always bound; the factory refuses a declared parameter whose
  dialect marker the statement never uses.
- Reader: one execution per query per poll, inside a transaction that is always
  rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData,
  ambiguous row / missing column / over maxRows -> BadConfigurationError.
- Catalog gate passes Query tags through (nothing is quoted into SQL); a
  Query-only driver skips the catalog load. A tag naming an undeclared query is
  skipped with the reason on /hosts.
- AdminUI: typed editor offers Query (query/column/rowKey); driver form edits
  `queries` as JSON and keeps the last valid set on a typo.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:59:05 -05:00
parent 9e4a26f888
commit a68593e714
26 changed files with 1523 additions and 83 deletions
@@ -130,4 +130,58 @@ public sealed class SqlDriverFormContractTests
json.ShouldContain($"\"provider\":\"{reserved}\"");
}
// ---- named queries (lmxopcua#753): the form refuses exactly what the factory refuses ----
/// <summary>
/// The driver form's queries editor consults the runtime's own rules
/// (<see cref="SqlNamedQueryRules.Describe"/>), so what the form keeps is what the factory constructs and
/// what it refuses the factory refuses too. Driven through the real factory entry point, so a rule added
/// on one side only breaks this test.
/// </summary>
[Theory]
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":1}}}""", true)]
[InlineData("""{"q":{"sql":"WITH l AS (SELECT a FROM t) SELECT a FROM l;","maxRows":10}}""", true)]
[InlineData("""{"q":{"sql":"UPDATE t SET a = 1"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a INTO t2 FROM t"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT 1; SELECT 2"}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a FROM t","maxRows":0}}""", false)]
[InlineData("""{"q":{"sql":"SELECT a FROM t WHERE x = @x","params":{"x":{"nested":1}}}}""", false)]
[InlineData("""{"q":{"sql":""}}""", false)]
public void Form_queries_editor_and_factory_agree(string queriesJson, bool accepted)
{
var formError = SqlDriverForm.FormModel.TryParseQueries(queriesJson, out _);
(formError is null).ShouldBe(accepted, formError);
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
var blob = $$"""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","queries":{{queriesJson}}}""";
if (accepted) { SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob).ShouldNotBeNull(); }
else
{
Should.Throw<InvalidOperationException>(
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", blob));
}
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Queries_survive_the_form_model_round_trip_and_a_typo_keeps_the_last_valid_set()
{
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
"""{"provider":"SqlServer","connectionStringRef":"R","queries":{"q":{"sql":"SELECT a FROM t"}}}""", FormOptions)!;
var form = SqlDriverForm.FormModel.FromDto(dto);
form.SetQueriesJson("{ not json");
form.QueriesError.ShouldNotBeNull();
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
json.ShouldContain("\"queries\":{\"q\":{\"sql\":\"SELECT a FROM t\"}}");
}
}
@@ -232,10 +232,42 @@ public sealed class SqlTagConfigModelTests
"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""")
.Validate().ShouldNotBeNull();
[Theory]
[InlineData("""{"model":"Query","table":"t"}""")] // no query, no column
[InlineData("""{"model":"Query","query":"q"}""")] // no column
[InlineData("""{"model":"Query","query":"q","column":"c","rowKey":{"column":"k"}}""")] // half a rowKey
[InlineData("""{"model":"Query","query":"q","column":"c","rowKey":"k"}""")] // not an object
public void Validate_rejects_an_incomplete_query_tag(string json)
=> SqlTagConfigModel.FromJson(json).Validate().ShouldNotBeNull();
[Fact]
public void Validate_rejects_deferred_query_model()
=> SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""")
.Validate().ShouldNotBeNull();
public void Query_tag_round_trips_and_parses_to_the_expected_definition()
{
var json = SqlTagConfigModel.FromJson(
"""{"model":"Query","query":"lineStatus","column":"speed","rowKey":{"column":"line","value":7,"note":"keep"},"timestampColumn":"ts","type":"Float64"}""")
.ToJson();
SqlTagConfigModel.FromJson(json).Validate().ShouldBeNull();
json.ShouldContain("\"note\":\"keep\""); // unknown rowKey keys survive an edit
SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.Query);
def.QueryName.ShouldBe("lineStatus");
def.ColumnName.ShouldBe("speed");
def.RowKeyColumn.ShouldBe("line");
def.RowKeyValue.ShouldBe("7");
def.TimestampColumn.ShouldBe("ts");
}
[Fact]
public void Clearing_the_row_key_omits_it_from_the_blob()
{
var model = SqlTagConfigModel.FromJson("""{"model":"Query","query":"q","column":"c","rowKey":{"column":"k","value":"v"}}""");
model.RowKey.Clear();
var json = model.ToJson();
json.ShouldNotContain("rowKey");
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
def.RowKeyColumn.ShouldBeNull();
}
// ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader --------------
@@ -68,6 +68,20 @@ public sealed class TagConfigAuthoringDeployParityTests
AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson());
}
/// <summary>A named-query Sql tag (lmxopcua#753) as the typed editor saves it.</summary>
[Fact]
public void Sql_query_config_the_editor_accepts_draws_no_deploy_warning()
{
var model = new SqlTagConfigModel
{
Model = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.SqlTagModel.Query,
QueryName = "lineStatus",
Column = "speed",
RowKey = new SqlRowKeyModel { Column = "line", Value = "Line1" },
};
AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson());
}
[Fact]
public void MTConnect_config_the_editor_accepts_draws_no_deploy_warning()
{
@@ -403,6 +403,13 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x"}"""),
(DriverTypeNames.Sql, """{"table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x"}"""),
(DriverTypeNames.Sql, """{"model":"Query","table":"dbo.T"}"""),
// lmxopcua#753 — the named-query model: query + column required, rowKey all-or-nothing.
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus","column":"speed"}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus","column":"speed","rowKey":{"column":"line","value":7}}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus","column":"speed","rowKey":{"column":"line"}}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus","column":"speed","rowKey":"line"}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus"}"""),
(DriverTypeNames.Sql, """{"model":"Query","column":"speed","query":" "}"""),
(DriverTypeNames.Sql, """{"model":"WideRow","table":"dbo.T","columnName":"Speed","rowSelector":{"whereColumn":"LineId","whereValue":"L1"}}"""),
(DriverTypeNames.Sql, """{"model":"WideRow","table":"dbo.T","columnName":"Speed"}"""),
@@ -611,6 +618,13 @@ public sealed class TagConfigNonEnumParityTests
ColumnName = "Speed",
RowSelector = new SqlRowSelectorModel { WhereColumn = "LineId", WhereValue = "L1" },
}.ToJson());
data.Add(DriverTypeNames.Sql, new SqlTagConfigModel
{
Model = SqlTagModel.Query,
QueryName = "lineStatus",
Column = "speed",
RowKey = new SqlRowKeyModel { Column = "line", Value = "Line1" },
}.ToJson());
return data;
}