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:
+48
-5
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user