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:
@@ -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 --------------
|
||||
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user