feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
allowWrites is no longer inert: SqlDriver implements IWritable, default off. - Gates (outermost first): node Writable + WriteOperate (server), driver allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean; anything else refuses the tag, and the deploy gate says why), and a model that names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and Query are BadNotWritable. - SqlWritePlanner: the only statement a write can become is UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the catalog-gate-canonical identifiers (dialect-quoted) with value and key bound; value-before-key marker order so positional ODBC binds correctly. No authored write SQL, INSERT, UPSERT or procedure. - Executed in a transaction that commits only when exactly one row was touched: 0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError, unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout; provider errors -> BadCommunicationError; health untouched. - Discovery advertises Operate only where a write could succeed. - AdminUI: driver form's Allow writes switch is live; tag editor gains a per-tag write switch; model validates writable strictly (parity corpus). - Factory no longer warns "allowWrites ignored"; it logs whether writes are on. - Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate, readback, absent key, two-row rollback, gate-rejected tag, discovery); MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback, server-side grant refusal, default-off). Seed adds a setpoints table and the one UPDATE grant. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+48
-10
@@ -3,8 +3,9 @@
|
||||
> **Status lives in Gitea.** Open follow-ups and known gaps noted in this doc are tracked as issues on
|
||||
> gitea.dohertylan.com/dohertj2/lmxopcua (label `migrated`, 2026-11-06). This doc describes the system — do not track status here; file new work as an issue.
|
||||
|
||||
Getting-started guide for the `Sql` driver — a **read-only** Equipment-kind driver that polls rows
|
||||
out of a relational database and publishes them as OPC UA variables. For the design rationale read
|
||||
Getting-started guide for the `Sql` driver — an Equipment-kind driver that polls rows out of a
|
||||
relational database and publishes them as OPC UA variables. It is **read-only unless writes are
|
||||
explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753)). For the design rationale read
|
||||
[`docs/plans/2026-07-15-sql-poll-driver-design.md`](../plans/2026-07-15-sql-poll-driver-design.md);
|
||||
for the build-vs-plan record read
|
||||
[`docs/plans/2026-07-24-sql-poll-driver.md`](../plans/2026-07-24-sql-poll-driver.md).
|
||||
@@ -54,10 +55,8 @@ Two provider-specific things are worth knowing before you author against them:
|
||||
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
|
||||
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
|
||||
|
||||
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
|
||||
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
|
||||
is authored `true` rather than failing, on the grounds that the flag cannot do harm but an operator
|
||||
who believes writes are enabled can. → #753
|
||||
**Read-only by default.** `allowWrites` is off unless authored `true`, and even then only a tag whose
|
||||
own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753).
|
||||
|
||||
## Tag models
|
||||
|
||||
@@ -186,7 +185,8 @@ row:
|
||||
| Field | Effect |
|
||||
|---|---|
|
||||
| `timestampColumn` | Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
|
||||
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. |
|
||||
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. A write is coerced to it. |
|
||||
| `writable` | The tag's write opt-in — a JSON **boolean**; absent ⇒ read-only. Anything else (`"true"`, `1`) refuses the tag. Effective only with the driver's `allowWrites`. |
|
||||
|
||||
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
|
||||
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
|
||||
@@ -216,7 +216,7 @@ existed in the parser — nothing reads it.)
|
||||
| `commandTimeout` | `00:00:10` | ADO.NET server-side backstop. **Must be less than `operationTimeout`** — it is the backstop, not the deadline. |
|
||||
| `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. |
|
||||
| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753). |
|
||||
| `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
|
||||
@@ -329,6 +329,43 @@ catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses
|
||||
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
|
||||
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
|
||||
|
||||
## Writes (opt-in, #753)
|
||||
|
||||
A write reaches the database only when **every** gate holds, outermost first:
|
||||
|
||||
1. the OPC UA node is Writable (the Tag's own flag) and the caller holds `WriteOperate` — the server;
|
||||
2. the driver config says `"allowWrites": true` — otherwise `BadNotWritable`;
|
||||
3. the tag's TagConfig says `"writable": true` — otherwise `BadNotWritable`;
|
||||
4. the model names exactly one row: **KeyValue**, or **WideRow with a `whereColumn`/`whereValue`
|
||||
pair**. `topByTimestamp` ("the newest row" moves under a concurrent insert) and `Query` (a projection
|
||||
names no table) are `BadNotWritable`.
|
||||
|
||||
The write is the **only** statement shape the driver can produce — there is no authored write SQL,
|
||||
INSERT, UPSERT or procedure call:
|
||||
|
||||
```sql
|
||||
UPDATE <table> SET <valueColumn | columnName> = @v WHERE <keyColumn | whereColumn> = @k
|
||||
```
|
||||
|
||||
- **Identifiers are the catalog gate's**: a write resolves against the gate-accepted definition, so
|
||||
every name in that statement was read back out of the catalog and dialect-quoted. A gate-rejected tag
|
||||
answers `BadNodeIdUnknown`. The value and the key are always **bound parameters**.
|
||||
- **Exactly one row or nothing.** The UPDATE runs in a transaction that commits only when it touched
|
||||
one row. Zero rows ⇒ `BadNoData` (the driver never inserts); more than one ⇒ rolled back,
|
||||
`BadConfigurationError` (the selector is ambiguous — the read path's rule); a provider that cannot
|
||||
report a row count ⇒ rolled back, `BadNotSupported`.
|
||||
- The value is coerced to the tag's `type` when it has one (`BadTypeMismatch` when it will not
|
||||
coerce), else bound as the scalar the client sent. A null writes SQL NULL.
|
||||
- Bounded by `operationTimeout` (`BadTimeout`). A provider error after the connection opened — a
|
||||
missing `UPDATE` grant, a constraint — is `BadCommunicationError`, nothing committed. Writes never
|
||||
change driver health.
|
||||
- Discovery advertises `Operate` only for a tag where all of 2–4 hold, `ViewOnly` otherwise.
|
||||
- **Grant the login `UPDATE` on just the tables that carry writable tags.** The live gate
|
||||
(`MySqlReadTests`) proves the server refuses a write to a SELECT-only table even with every driver
|
||||
gate open.
|
||||
- A source timestamp column is **not** touched by a write, so the next read keeps the row's old
|
||||
timestamp unless the database maintains it (a trigger or `ON UPDATE CURRENT_TIMESTAMP`).
|
||||
|
||||
## Authoring in the AdminUI
|
||||
|
||||
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
|
||||
@@ -338,9 +375,10 @@ selector-less rather than dropping to a generic `{"address": …}` the typed edi
|
||||
|
||||
## Capabilities
|
||||
|
||||
`IDriver`, `ITagDiscovery`, `IReadable`, `ISubscribable`, `IHostConnectivityProbe`.
|
||||
`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable` (opt-in, above), `ISubscribable`,
|
||||
`IHostConnectivityProbe`.
|
||||
|
||||
No `IWritable` (structural, above). No `IRediscoverable` — a SQL schema change is not signalled, and
|
||||
No `IRediscoverable` — a SQL schema change is not signalled, and
|
||||
`DiscoverAsync` echoes authored config rather than enumerating the database, so this driver is
|
||||
deliberately excluded from any discovery-diff machinery.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user