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
+48 -5
View File
@@ -37,7 +37,7 @@ who believes writes are enabled can. → #753
## Tag models
A tag says how to find its value. Two models ship; a third is deferred.
A tag says how to find its value. Three models ship: `KeyValue`, `WideRow` and the named-query `Query`.
### `KeyValue` — the EAV shape
@@ -109,11 +109,53 @@ canonical text form. SQL Server behaves the same way.
authored `keyValue` refused the tag while the identically-bound `whereValue` was served — and the
deploy gate reported the key as *missing* when it was right there in the blob.)
### `Query` — deferred to P3 (→ #753)
### `Query` — a named read-only query (#753)
Named arbitrary-`SELECT`. **The v1 parser rejects it**, and that refusal is enforced end-to-end —
parser, validator, planner and the AdminUI editor all decline it rather than accepting the value and
ignoring it later.
For a join, a view you cannot create, or a computed projection. The **driver config** declares the
statement under `queries`; the **tag** names the query, the result column it reads, and optionally the
row:
```json
{ "queries": {
"activeBatch": {
"sql": "SELECT line, speed, temp, ts FROM dbo.LineStatus WHERE active = @active",
"params": { "active": true },
"maxRows": 100
} } }
```
```json
{ "model": "Query", "query": "activeBatch", "column": "speed",
"rowKey": { "column": "line", "value": "Line1" }, "timestampColumn": "ts", "type": "Float64" }
```
- **One execution per query per poll**, however many tags read it.
- **`rowKey` picks the row** (matched as text against that result column, like `keyValue`). Without a
`rowKey` the query must return exactly one row. No matching row ⇒ `BadNoData`; two matching rows (or
two rows with no `rowKey`) ⇒ `BadConfigurationError` — never "last row wins".
- **Result column names** (`column`, `rowKey.column`, `timestampColumn`) match exactly first, then by a
unique case-insensitive match, so Postgres' lower-cased and Oracle's upper-cased aliases still bind. A
column the query does not return ⇒ `BadConfigurationError` plus a rate-limited warning listing what it
did return.
- **`maxRows`** (default 1000, at most 100 000) is enforced client-side: the reader stops one row past
it and the whole query publishes `BadConfigurationError`. A statement cannot be wrapped in a provider
row limit portably, so narrow the `WHERE` rather than relying on the cap.
- **Parameters are always bound.** Keys are bare names (`active`; a leading `@`/`:` is tolerated);
values are JSON scalars or `null`. Write the provider's own marker in the SQL: `@active` on SqlServer,
Postgres and MySql, `:p_active` on Oracle, a positional `?` on Odbc (bound in authored order). The
factory refuses a declared parameter whose marker the statement never uses.
- **Read-only, twice over.** `SqlReadOnlyStatement` refuses at construction (and in the AdminUI form)
anything but a single `SELECT`/`WITH` statement, and any of `INSERT UPDATE DELETE MERGE UPSERT DROP
ALTER CREATE TRUNCATE EXEC EXECUTE CALL GRANT REVOKE DENY INTO COPY LOCK RENAME` outside a literal or
comment — so `SELECT … INTO`, `FOR UPDATE` and a data-modifying CTE are refused too. Behind that guard
the statement runs **inside a transaction the driver always rolls back**, so a side effect the guard
cannot see (a function that writes) is never committed. A non-transactional side effect (an e-mail
procedure, a sequence) is outside both, so give the connecting principal `SELECT` only.
- A tag naming a query the driver config does not declare is **skipped** at Initialize (it reads
`BadNodeIdUnknown`, and `/hosts` names the missing query). One unusable `queries` entry **fails the
driver** instead, because every tag that names it would otherwise go dark unexplained.
- Query tags read no table, so they need **no catalog gate** — a driver serving only named queries skips
the catalog load entirely. Nothing from a Query tag is ever quoted into SQL.
## Optional per-tag fields
@@ -151,6 +193,7 @@ existed in the parser — nothing reads it.)
| `maxConcurrentGroups` | `4` | Connection-pool guard: cap on group queries in flight. |
| `nullIsBad` | `false` | `false` ⇒ a NULL cell publishes **Uncertain**; `true` ⇒ **Bad**. |
| `allowWrites` | — | **Inert.** v1 has no write path; authoring `true` logs a warning. |
| `queries` | — | Named read-only queries for `Query` tags — see [`Query`](#query--a-named-read-only-query-753). Edited as JSON in the driver form, which applies the factory's own rules. |
### Credentials never enter the deployment artifact