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
@@ -97,6 +97,25 @@
</div>
</section>
@* Named queries (design §5.4) — read by Query-model tags *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Named queries</div>
<div style="padding:1rem">
<textarea class="form-control form-control-sm mono" rows="6" value="@_form.QueriesJson"
placeholder='{ "activeBatch": { "sql": "SELECT line, speed, ts FROM dbo.LineStatus WHERE active = @@active", "params": { "active": true } } }'
@onchange="@(async e => { _form.SetQueriesJson(e.Value?.ToString()); await EmitAsync(); })"></textarea>
<div class="form-text">
JSON object: name → <code>{ "sql", "params", "maxRows" }</code>. Each statement must be a single read-only
<code>SELECT</code>/<code>WITH</code>; parameters are always bound (write the provider's marker — <code>@@name</code>,
Oracle <code>:p_name</code>, Odbc <code>?</code> in order). Queries run inside a rolled-back transaction.
</div>
@if (_form.QueriesError is { } queriesError)
{
<div class="text-danger small mt-1">@queriesError — the last valid queries are kept until this is fixed.</div>
}
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
@@ -171,6 +190,57 @@
public int? MaxConcurrentGroups { get; set; }
public bool NullIsBad { get; set; }
/// <summary>The named queries the form emits — the last text that parsed and validated.</summary>
public Dictionary<string, SqlNamedQueryDto>? Queries { get; set; }
/// <summary>The operator's queries text, as typed.</summary>
public string QueriesJson { get; set; } = "";
/// <summary>Why <see cref="QueriesJson"/> was refused, or null when it is in force.</summary>
public string? QueriesError { get; private set; }
private static readonly JsonSerializerOptions QueryJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
/// <summary>Adopts edited queries text when it parses and every entry passes the runtime's own rules
/// (<see cref="SqlNamedQueryRules.Describe"/>); otherwise records why and keeps the last valid set, so a
/// typo never silently drops queries the deployed tags read.</summary>
public void SetQueriesJson(string? text)
{
QueriesJson = text ?? "";
QueriesError = TryParseQueries(QueriesJson, out var parsed);
if (QueriesError is null) { Queries = parsed; }
}
/// <summary>Parses + validates queries text. Blank ⇒ no queries. Returns the problem, or null.</summary>
public static string? TryParseQueries(string? text, out Dictionary<string, SqlNamedQueryDto>? queries)
{
queries = null;
if (string.IsNullOrWhiteSpace(text)) { return null; }
try
{
queries = JsonSerializer.Deserialize<Dictionary<string, SqlNamedQueryDto>>(text, QueryJsonOptions);
}
catch (JsonException ex)
{
return $"named queries are not valid JSON (line {(ex.LineNumber ?? 0) + 1})";
}
if (queries is null) { return "named queries must be a JSON object"; }
foreach (var (name, query) in queries)
{
if (SqlNamedQueryRules.Describe(name, query) is { } problem) { queries = null; return problem; }
}
if (queries.Count == 0) { queries = null; }
return null;
}
public static FormModel FromDto(SqlDriverConfigDto d) => new()
{
Provider = d.Provider,
@@ -180,6 +250,8 @@
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
MaxConcurrentGroups = d.MaxConcurrentGroups,
NullIsBad = d.NullIsBad ?? false,
Queries = d.Queries,
QueriesJson = d.Queries is { Count: > 0 } q ? JsonSerializer.Serialize(q, QueryJsonOptions) : "",
};
public SqlDriverConfigDto ToDto() => new()
@@ -193,6 +265,7 @@
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
// and rawTags are deliberately left null so they are omitted from the authored blob.
NullIsBad = NullIsBad ? true : null,
Queries = Queries is { Count: > 0 } ? Queries : null,
};
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
@@ -1,4 +1,4 @@
@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
@* Typed TagConfig editor for the Sql driver. Thin shell over SqlTagConfigModel: every field edit
goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
@@ -14,12 +14,21 @@
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Model</label>
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
<option value="@SqlTagModel.KeyValue">KeyValue</option>
<option value="@SqlTagModel.WideRow">WideRow</option>
<option value="@SqlTagModel.Query">Query (named query)</option>
</select></div>
<div class="col-md-5"><label class="form-label">Table or view</label>
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
@if (_m.Model != SqlTagModel.Query)
{
<div class="col-md-5"><label class="form-label">Table or view</label>
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
}
else
{
<div class="col-md-5"><label class="form-label">Named query</label>
<input class="form-control form-control-sm mono" value="@_m.QueryName" placeholder="activeBatch" @onchange="@(e => Update(() => _m.QueryName = NullIfBlank(e.Value)))" />
<div class="form-text">A key of the driver config's <code>queries</code>.</div></div>
}
<div class="col-md-3"><label class="form-label">Type override</label>
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(infer from column)</option>
@@ -27,7 +36,20 @@
</select></div>
</div>
@if (_m.Model == SqlTagModel.KeyValue)
@if (_m.Model == SqlTagModel.Query)
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Result column</label>
<input class="form-control form-control-sm mono" value="@_m.Column" placeholder="speed" @onchange="@(e => Update(() => _m.Column = NullIfBlank(e.Value)))" />
<div class="form-text">The column of the query's result this tag reads.</div></div>
<div class="col-md-4"><label class="form-label">Row key column <span class="text-muted small">(optional)</span></label>
<input class="form-control form-control-sm mono" value="@_m.RowKey.Column" placeholder="line" @onchange="@(e => Update(() => SetRowKey(NullIfBlank(e.Value), _m.RowKey.Value)))" /></div>
<div class="col-md-4"><label class="form-label">Row key value</label>
<input class="form-control form-control-sm mono" value="@_m.RowKey.Value" placeholder="Line1" @onchange="@(e => Update(() => SetRowKey(_m.RowKey.Column, e.Value?.ToString())))" />
<div class="form-text">Both blank ⇒ the query must return exactly one row.</div></div>
</div>
}
else if (_m.Model == SqlTagModel.KeyValue)
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Key column</label>
@@ -158,6 +180,14 @@ else
return string.IsNullOrWhiteSpace(s) ? null : s;
}
// Both row-key fields blank clears the rowKey entirely, so the blob carries no half-authored selector.
private void SetRowKey(string? column, string? value)
{
if (string.IsNullOrWhiteSpace(column) && string.IsNullOrEmpty(value)) { _m.RowKey.Clear(); return; }
_m.RowKey.Column = column;
_m.RowKey.Value = value;
}
private async Task Update(Action apply)
{
apply();
@@ -6,7 +6,7 @@ using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column
/// Typed working model for a Sql tag's TagConfig JSON (the driver-specific table/column
/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the
/// authoritative runtime reader <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
@@ -15,11 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// </summary>
public sealed class SqlTagConfigModel
{
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
/// <summary>Tag→value mapping model: <see cref="SqlTagModel.KeyValue"/>, <see cref="SqlTagModel.WideRow"/>
/// or <see cref="SqlTagModel.Query"/> (a driver-config named query).</summary>
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for KeyValue and WideRow; a
/// Query tag reads its named query instead.</summary>
public string? Table { get; set; }
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
@@ -42,6 +43,17 @@ public sealed class SqlTagConfigModel
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
public SqlRowSelectorModel RowSelector { get; set; } = new();
/// <summary><see cref="SqlTagModel.Query"/>: the driver-config <c>queries</c> entry this tag reads (the
/// blob's <c>query</c> key).</summary>
public string? QueryName { get; set; }
/// <summary><see cref="SqlTagModel.Query"/>: the result-set column this tag reads (the blob's <c>column</c>).</summary>
public string? Column { get; set; }
/// <summary><see cref="SqlTagModel.Query"/>: which result row this tag reads. Empty ⇒ the query must return
/// exactly one row.</summary>
public SqlRowKeyModel RowKey { get; set; } = new();
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
/// driver infers the type from the result-set column metadata.</summary>
public DriverDataType? Type { get; set; }
@@ -79,6 +91,9 @@ public sealed class SqlTagConfigModel
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
QueryName = TagConfigJson.GetString(o, "query"),
Column = TagConfigJson.GetString(o, "column"),
RowKey = SqlRowKeyModel.FromJson(o.TryGetPropertyValue("rowKey", out var rk) ? rk : null),
Type = type,
_bag = o,
_source = json ?? "",
@@ -101,6 +116,12 @@ public sealed class SqlTagConfigModel
TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn);
TagConfigJson.Set(_bag, "columnName", ColumnName);
TagConfigJson.Set(_bag, "type", Type);
TagConfigJson.Set(_bag, "query", QueryName);
TagConfigJson.Set(_bag, "column", Column);
var rowKey = RowKey.ToJsonNode();
if (rowKey is null) { _bag.Remove("rowKey"); }
else { _bag["rowKey"] = rowKey; }
var selector = RowSelector.ToJsonObject();
if (selector is null) { _bag.Remove("rowSelector"); }
@@ -118,8 +139,23 @@ public sealed class SqlTagConfigModel
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
// Query is design §5.4 / P3 — the parser rejects it; the editor must too.
if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; }
if (Model == SqlTagModel.Query)
{
// Mirrors the parser's Query arm: query + column are required, and a rowKey — when present at all —
// needs both its column and a scalar value. Table is not read (the named query is the source).
if (string.IsNullOrWhiteSpace(QueryName)) { return "query is required for a Query tag (the driver-config named query)."; }
if (string.IsNullOrWhiteSpace(Column)) { return "column is required for a Query tag (the result-set column)."; }
if (RowKey.Present && (string.IsNullOrWhiteSpace(RowKey.Column) || RowKey.Value is null))
{
return "rowKey needs both a column and a value (clear it when the query returns exactly one row).";
}
return TagConfigParseGate.Describe(
_source,
j => SqlEquipmentTagParser.TryParse(j, TagConfigParseGate.AuthoringRawPath, out _),
SqlTagDefinitionFactory.Inspect,
"Sql");
}
if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; }
@@ -224,3 +260,60 @@ public sealed class SqlRowSelectorModel
return o.Count == 0 ? null : o;
}
}
/// <summary>
/// Typed working model for a <see cref="SqlTagModel.Query"/> tag's optional nested <c>rowKey</c> object
/// (<c>{"column": …, "value": …}</c>). <see cref="Present"/> records whether the blob carried the key at all, so a
/// half-authored rowKey is reported rather than silently read as "no rowKey". Preserves unknown keys.
/// </summary>
public sealed class SqlRowKeyModel
{
/// <summary>The result-set column that picks the row.</summary>
public string? Column { get; set; }
/// <summary>The value <see cref="Column"/> must hold (matched as text). Captured as text from a JSON string,
/// number or boolean, mirroring the parser's scalar read.</summary>
public string? Value { get; set; }
/// <summary>True when the blob carried a <c>rowKey</c> (object or not) or a field here has been set.</summary>
public bool Present => _authored || Column is not null || Value is not null;
private bool _authored;
private JsonObject _bag = new();
/// <summary>Loads from the blob's <c>rowKey</c> node, or an empty model when absent.</summary>
/// <param name="node">The <c>rowKey</c> node, or <c>null</c> when absent.</param>
/// <returns>The populated model.</returns>
public static SqlRowKeyModel FromJson(JsonNode? node)
{
if (node is null) { return new SqlRowKeyModel(); }
var bag = node is JsonObject o ? o.DeepClone().AsObject() : new JsonObject();
return new SqlRowKeyModel
{
Column = TagConfigJson.GetString(bag, "column"),
Value = TagConfigJson.GetScalarText(bag, "value"),
_bag = bag,
// A non-object rowKey is still "present" — the parser refuses it, and so must Validate.
_authored = true,
};
}
/// <summary>Clears the row key, so <see cref="ToJsonNode"/> omits it.</summary>
public void Clear()
{
Column = null;
Value = null;
_authored = false;
_bag = new JsonObject();
}
/// <summary>Rebuilds the <c>rowKey</c> object over the preserved bag, or <c>null</c> when empty.</summary>
/// <returns>The node, or null.</returns>
public JsonObject? ToJsonNode()
{
var o = _bag.DeepClone().AsObject();
TagConfigJson.Set(o, "column", Column);
TagConfigJson.Set(o, "value", Value);
return o.Count == 0 && !_authored ? null : o;
}
}