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
@@ -53,13 +53,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// </param>
/// <param name="SelectedColumns">
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// reader's map from result-set ordinal to source column. Empty for a <see cref="SqlTagModel.Query"/> plan,
/// whose projection is the named query's own: the reader reads every result column by its reported name.
/// </param>
/// <param name="RowCap">
/// The most rows the reader will attribute before refusing the whole group — twice the distinct key count
/// (key-value: headroom so stray duplicates are attributed per key) or 1 (a where-pair wide row: one
/// selector, so any second row is the group's ambiguity); <see langword="null"/> when the statement
/// already limits itself (topByTimestamp). <see cref="SqlText"/> asks for <c>RowCap + 1</c>, and the
/// already limits itself (topByTimestamp). For a <see cref="SqlTagModel.Query"/> plan it is the query's
/// <c>maxRows</c>, enforced client-side only. <see cref="SqlText"/> asks for <c>RowCap + 1</c>, and the
/// reader stops there: receiving the extra row means a history/events table or a non-unique where column,
/// and the group publishes <c>BadConfigurationError</c> instead of materialising every matching row on
/// every poll (R06:S-1).