diff --git a/docs/drivers/README.md b/docs/drivers/README.md
index 124bbcc2..3dd5309f 100644
--- a/docs/drivers/README.md
+++ b/docs/drivers/README.md
@@ -73,7 +73,7 @@ Three properties worth knowing:
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHostConnectivityProbe | Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
-| [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
+| [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`, `.Dialect`) | A | ADO.NET behind an `ISqlDialect` seam — `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres` (Npgsql), `Oracle` (ODP.NET), `MySql` (MySqlConnector), `Odbc` (ANSI SQL:2008 profile only) | IDriver, ITagDiscovery, IReadable, IWritable (opt-in), ISubscribable, IHostConnectivityProbe | DB-poll driver (KeyValue / WideRow / named read-only `Query` tags) with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). Writes are off unless `allowWrites` + the tag's `writable`; a tag's `write` block picks Update / Insert / Upsert / stored Procedure (#781; Odbc: Update/Insert). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). |
| [Calculation](Calculation.md) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). `runTimeoutMs` is authorable through `CalculationDriverForm` (the "no typed config form" gap — G-1 — was closed 2026-07-27) |
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the default historian backend (the other is `Historian.SqlServer`, below). Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
| [Historian.SqlServer](../Historian.md#sql-server-backend) | `Driver.Historian.SqlServer` | — | `Microsoft.Data.SqlClient` (a plain SQL Server database, schema `hist` by default) | `IHistorianDataSource` + `IHistorianValueWriter` + `IAlarmHistorianWriter` + `IHistorianProvisioning` + `IHistorianDataUpdater` | Not a tag driver — a pluggable historian backend selected with `ServerHistorian:Backend=SqlServer`. Stores typed values with their real status, serves Raw / AtTime / Events / 13 push-down aggregates, and is the only backend that supports OPC UA **HistoryUpdate**. No `IDriver`/`ITagDiscovery` surface. |
diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index 59ae77cf..8546d037 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -5,7 +5,7 @@
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
+explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753-781)). 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).
@@ -32,19 +32,40 @@ boolean under MySqlConnector's default `TreatTinyAsBoolean`; `BIT(n)`, `TIME`, `
types infer as `String`. Live gate: `Docker/mysql/docker-compose.yml` + `MySqlReadTests`
(`SQL_MYSQL_ENDPOINT` / `SQL_MYSQL_PASSWORD`).
-ODBC specifics: `OdbcDialect` is the **ANSI SQL:2008 profile** — double-quoted identifiers,
-positional `?` parameters, the standard `INFORMATION_SCHEMA` views and `FETCH FIRST n ROWS ONLY`.
-ODBC abstracts the wire, not the SQL, so the backend behind the DSN must accept all four:
-PostgreSQL (psqlODBC — the live gate), Snowflake and similar do; **SQL Server** (needs `OFFSET …
-FETCH` after an `ORDER BY`), **MySQL** (no `FETCH FIRST`) and **DB2** (no `INFORMATION_SCHEMA`) do
-not and should use their native provider. It is also the one provider that is **not fully
-managed**: every host running an Odbc instance needs the platform driver manager (unixODBC / iODBC
-/ Windows' own) and the vendor's ODBC driver — on the driver nodes and, for browse, the AdminUI
-nodes. Parameters bind in order, so the planner, the catalog loader and the browse session all add
-them in marker order, and a named query's `params` bind in authored order (the factory refuses a
-statement whose `?` count differs). Live gate: `Docker/odbc/run-odbc-gate.sh` runs `OdbcReadTests`
-in a throwaway Linux container with unixODBC + psqlODBC against a throwaway PostgreSQL; on macOS
-the suite is unit-tested only (`OdbcDialectTests`) because no vendor ODBC driver is installed.
+ODBC specifics: `OdbcDialect` is the **ANSI SQL:2008 profile, and only that** — double-quoted
+identifiers, positional `?` parameters, the standard `INFORMATION_SCHEMA` views and `FETCH FIRST n
+ROWS ONLY`. The scope is deliberately narrow (owner decision 2026-11-09, lmxopcua#781): there are no
+per-backend ODBC dialect variants and no `SQLTables`/`SQLColumns` catalog path. ODBC abstracts the
+wire, not the SQL, so the backend behind the connection string must accept all four: PostgreSQL
+(psqlODBC — the live gate), Snowflake and similar do. **SQL Server** (needs `OFFSET … FETCH` after an
+`ORDER BY`) and **MySQL / MariaDB** (no `FETCH FIRST`) do not — use the native `SqlServer` / `MySql`
+provider. **DB2** (no `INFORMATION_SCHEMA`) is not supported through Odbc at all. Writes over Odbc
+are **Update and Insert only** — see [Writes](#writes-opt-in-753-781).
+
+Parameters bind in order, so the planner, the catalog loader and the browse session all add them in
+marker order, and a named query's `params` bind in authored order (the factory refuses a statement
+whose `?` count differs). Live gate: `Docker/odbc/run-odbc-gate.sh` runs `OdbcReadTests` (reads,
+named queries, browse, and Update/Insert writes) in a throwaway Linux container with unixODBC +
+psqlODBC against a throwaway PostgreSQL; on macOS the suite is unit-tested only (`OdbcDialectTests`)
+because no vendor ODBC driver is installed.
+
+> **Ops note — Odbc is the one provider that is not fully managed.** `System.Data.Odbc` is only the
+> ADO.NET shim; the work is done by native code that must be installed on **every host that runs an
+> Odbc driver instance** (every node of its cluster, since either can become Primary) **and every
+> AdminUI host** (the browse picker and Test Connect open the connection from the AdminUI process):
+>
+> - **Linux:** the driver manager (`unixodbc`) plus the vendor's driver package (e.g.
+> `odbc-postgresql`), registered in `odbcinst.ini`. Check with `odbcinst -q -d` — the name it prints
+> is the `Driver={…}` value the connection string needs.
+> - **Windows:** the driver manager ships with the OS; install the vendor's **64-bit** ODBC driver
+> (the server is x64) and confirm it in *ODBC Data Sources (64-bit)* → *Drivers*.
+> - Prefer a **DSN-less** connection string (`Driver={PostgreSQL Unicode};Server=…;Database=…`) in
+> the secret store: a DSN is per-host configuration that every node and AdminUI host would also
+> need, identically.
+>
+> A host missing either piece fails the driver's Initialize (and the AdminUI browse) with the driver
+> manager's "data source name not found / no default driver" error — a host-provisioning fault, not a
+> tag fault.
Two provider-specific things are worth knowing before you author against them:
@@ -56,7 +77,7 @@ Two provider-specific things are worth knowing before you author against them:
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
**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).
+own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753-781).
## Tag models
@@ -173,7 +194,8 @@ row:
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.
+ procedure, a sequence, a call out to another system) is outside both — which is why a read-only
+ driver **must** connect with a `SELECT`-only login (see [Database login](#database-login-required)).
- 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.
@@ -187,6 +209,7 @@ row:
| `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. 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`. |
+| `write` | Optional: how a write is executed — `Update` (default), `Insert`, `Upsert` or `Procedure`, plus the timestamp stamp. See [Writes](#writes-opt-in-753-781). A malformed block refuses the whole tag and blocks deploy. |
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 +239,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` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753). |
+| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753-781). |
| `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,42 +352,122 @@ 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)
+## Writes (opt-in, #753, #781)
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`.
+4. the tag's write mode has a target on its model (table below) — otherwise `BadNotWritable`;
+5. the provider serves the mode — otherwise deploy refuses it (`SqlWriteModeUnsupported`) and, at
+ runtime, `BadNotSupported`.
-The write is the **only** statement shape the driver can produce — there is no authored write SQL,
-INSERT, UPSERT or procedure call:
+There is **no authored write SQL.** A tag's optional `write` block picks one of four statement
+shapes the driver builds itself from the tag's own catalog-validated identifiers:
-```sql
-UPDATE
SET = @v WHERE = @k
+```json
+{ "model": "KeyValue", "table": "plant.setpoints", "keyColumn": "tag_name", "keyValue": "Line1.Speed",
+ "valueColumn": "sp_value", "timestampColumn": "updated_at", "type": "Float64", "writable": true,
+ "write": { "mode": "Upsert", "stampTimestamp": true } }
```
-- **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
+| `mode` | Statement | Models | Rows that count as success |
+|---|---|---|---|
+| `Update` *(default — also what a writable tag with no `write` block does)* | `UPDATE t SET value [, stamp] WHERE key = @k` | KeyValue; WideRow with `whereColumn`/`whereValue` | exactly 1 |
+| `Insert` | `INSERT INTO t (key, value [, stamp]) VALUES (…)` — no key column for a WideRow `topByTimestamp` tag (an append) | KeyValue; WideRow (either selector) | exactly 1 |
+| `Upsert` | SQL Server `MERGE … WITH (HOLDLOCK)`; PostgreSQL `INSERT … ON CONFLICT (key) DO UPDATE`; MySQL `INSERT … ON DUPLICATE KEY UPDATE`; Oracle `MERGE … USING (SELECT 1 FROM dual)` | KeyValue; WideRow with a where-pair | 1 (MySQL: 1 or 2) |
+| `Procedure` | SQL Server `EXEC p @value = @v, …`; PostgreSQL `CALL p(value => @v, …)`; MySQL `CALL p(@v, …)` (**positional**); Oracle `BEGIN p(value => :p_v, …); END;` | any, including `Query` | the call did not throw |
+
+`write` keys — anything else refuses the tag:
+
+| Key | Modes | Meaning |
+|---|---|---|
+| `mode` | all | `Update` \| `Insert` \| `Upsert` \| `Procedure`; absent ⇒ `Update`. |
+| `stampTimestamp` | Update / Insert / Upsert | `true` ⇒ also set the tag's `timestampColumn` (a WideRow `topByTimestamp` tag: that column) to the write time, **UTC, from the driver host's clock**. Requires such a column. Default `false` — the timestamp column is then untouched and the next read keeps the row's old timestamp unless the database maintains it (a trigger, `ON UPDATE CURRENT_TIMESTAMP`). |
+| `procedure` | Procedure (required) | `procedure` or `schema.procedure` — a **stored procedure**, resolved against the routine catalog like a table (case folds; an unknown, non-executable or package-member name rejects the tag). |
+| `valueParam` | Procedure | The procedure parameter the value binds to. Default `value`. |
+| `keyParam` | Procedure | Optional: the parameter the tag's key binds to (`keyValue`, `whereValue` or a Query tag's `rowKey.value`). |
+| `timestampParam` | Procedure | Optional: the parameter the write time (UTC) binds to. |
+
+Parameter names are plain identifiers (letter/underscore, then letters/digits/underscores; a leading
+`@`/`:` is stripped) — they reach the statement unquoted, in named notation. **MySQL has no named
+notation**: its arguments are passed positionally in the fixed order *value, key, timestamp* (those
+configured), so declare the procedure's parameters in that order. **PostgreSQL** resolves procedure
+arguments by implicit cast, so declare a numeric value parameter `double precision` (a `Float64`
+binds as that) and a timestamp parameter `timestamptz`.
+
+**Provider support** (one table, `SqlWriteSupport`, read by the driver and the deploy gate):
+
+| | Update | Insert | Upsert | Procedure |
+|---|---|---|---|---|
+| SqlServer, Postgres, MySql, Oracle | ✓ | ✓ | ✓ | ✓ |
+| Odbc | ✓ | ✓ | — | — |
+
+Odbc has no upsert every ODBC backend accepts (and positional `?` markers cannot repeat, which every
+single-statement upsert needs) and no portable routine catalog or calling convention; use the
+backend's native provider for those modes.
+
+**Upsert needs a unique key on the key column.** PostgreSQL refuses `ON CONFLICT (key)` without a
+unique constraint or index on exactly that column; MySQL's `ON DUPLICATE KEY` fires on *any* unique
+key of the table, so the key column should be the primary key and no other unique key should be able
+to collide; SQL Server and Oracle `MERGE` work without one, but then a key matching two rows updates
+both and is rolled back (`BadConfigurationError`).
+
+### Outcomes
+
+| Outcome | Status |
+|---|---|
+| Committed | `Good` |
+| `Update` (or a non-MySQL `Upsert`) touched no row — the key is absent; nothing is inserted | `BadNoData` |
+| More rows than the mode allows — the key is not unique; rolled back | `BadConfigurationError` |
+| The provider could not report a row count; rolled back | `BadNotSupported` |
+| Unique / primary-key violation — e.g. an `Insert` of a key that exists (SQLSTATE 23505, SQL Server 2627/2601, MySQL 1062, ORA-00001) | `BadEntryExists` |
+| Another integrity rule — NOT NULL, foreign key, CHECK (SQLSTATE class 23, SQL Server 515/547, ORA-01400/02290/02291) — or a data exception: overflow, truncation, an unconvertible value (SQLSTATE class 22, SQL Server 8115/8152/245, ORA-01438/12899/01722) | `BadOutOfRange` |
+| Any other provider error after the connection opened — **a missing grant included**, a missing object, a broken link | `BadCommunicationError` |
+| The value will not coerce to the tag's `type` | `BadTypeMismatch` |
+| `operationTimeout` elapsed | `BadTimeout` |
+
+Nothing is committed on any non-`Good` outcome. Writes are **never retried**
+(`WriteIdempotent = false` — an `Insert` or a procedure is not idempotent, and an `Update` is only
+idempotent until something else writes the row), and a failed write reverts the node to its prior
+value. Writes never change driver health. Discovery advertises `Operate` only for a tag where gates
+2–5 hold, `ViewOnly` otherwise.
+
+### Safety
+
+- **Identifiers are the catalog gate's.** A write resolves against the gate-accepted definition, so
+ every table, column and procedure name in the statement was read back out of the catalog
+ (`INFORMATION_SCHEMA.ROUTINES` / Oracle `ALL_PROCEDURES` for procedures) and dialect-quoted. A
+ gate-rejected tag answers `BadNodeIdUnknown`.
+- **Values are always bound** — the written value, the key and the stamp. Nothing an OPC UA client
+ sends reaches command text.
+- **Deploy checks the block with the driver's own parser** (`SqlTagDefinitionFactory.DescribeWriteBlock`):
+ a malformed block is the blocking error `SqlWriteBlockInvalid`, a mode the instance's provider does
+ not serve is `SqlWriteModeUnsupported`. The AdminUI tag editor shows the same parser message.
+- Live gate: `SqlWriteModesLiveTests` runs the same suite (Update+stamp, absent key, Insert and its
+ duplicate, NOT NULL, Upsert insert-then-update, Procedure, unknown procedure) against SQL Server,
+ PostgreSQL, MySQL and Oracle; `OdbcReadTests` covers Odbc's Update/Insert.
+
+## Database login (required)
+
+The driver's own guards are not a substitute for the database's grants:
+
+- **A read-only driver (`allowWrites` off) must connect with a `SELECT`-only login** — `SELECT` on
+ the tables and views its tags and named queries read, and nothing else. The named-query guard is a
+ keyword check plus an always-rolled-back transaction; neither can stop a **non-transactional** side
+ effect — a sequence `nextval`, a function that sends mail or calls out, autonomous transactions,
+ DDL that auto-commits on some backends. Only the grant can.
+- **A writable driver must connect with a login limited to its write targets** — `SELECT` as above,
+ plus exactly what its modes need on exactly the tables / procedures its writable tags name:
+ `UPDATE` (Update), `INSERT` (Insert), `INSERT` + `UPDATE` (Upsert; SQL Server's `MERGE` also needs
+ `SELECT` on the target), `EXECUTE` on the named procedures (Procedure). Never `DELETE`, DDL or
+ ownership. The live gates provision exactly this (`Docker/*/seed-*.sql`) and prove a write to a
+ table outside the grant is refused by the server (`BadCommunicationError`) 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`).
+- A procedure runs with *its* rights (SQL Server `EXECUTE AS`, PostgreSQL `SECURITY DEFINER`, MySQL
+ `SQL SECURITY DEFINER`, Oracle definer's rights by default) — review what it can touch, since the
+ login's narrow grant does not bound it.
## Authoring in the AdminUI
diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md
index 62a13701..6d6f5e89 100644
--- a/docs/plans/2026-07-15-sql-poll-driver-design.md
+++ b/docs/plans/2026-07-15-sql-poll-driver-design.md
@@ -219,6 +219,12 @@ becomes an `IAddressSpaceBuilder.Variable(browseName, displayName, DriverAttribu
### 3.4 `IWritable` — verdict: NOT in v1
+> **Built since (2026-11-09).** #753 shipped the opt-in single-row UPDATE; lmxopcua#781 item 2 built the
+> `write` block — but as **driver-built statements** (`mode` Update / Insert / Upsert / Procedure +
+> `stampTimestamp`, procedure parameter names), not the authored `sql` text sketched below: every
+> identifier goes through the catalog gate and every value is bound. The shipped contract is
+> [`docs/drivers/Sql.md` → Writes](../drivers/Sql.md#writes-opt-in-753-781).
+
**v1 does not implement `IWritable`.** Rationale in §1. The P4 opt-in design: a tag may carry a
`write` block describing a **parameterized** statement (never string-concatenated):
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index c01c6675..a4ac7452 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -43,6 +43,7 @@ public static class DraftValidator
ValidateVirtualTagTriggers(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft));
+ errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/SqlTagDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/SqlTagDeployChecks.cs
index bdabcc4a..a0a1ad2f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/SqlTagDeployChecks.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/SqlTagDeployChecks.cs
@@ -51,6 +51,90 @@ public static class SqlTagDeployChecks
return errors;
}
+ /// Error code for a Sql tag whose write block the driver's parser refuses (lmxopcua#781 item 2).
+ public const string WriteBlockInvalidCode = "SqlWriteBlockInvalid";
+
+ /// Error code for a write mode the Sql driver instance's provider does not serve (lmxopcua#781 item 2).
+ public const string WriteModeUnsupportedCode = "SqlWriteModeUnsupported";
+
+ ///
+ /// Item 2. Always blocking: (a) a tag whose write block the driver's own parser refuses — the driver
+ /// would skip the whole tag (reads too) — explained by ;
+ /// (b) a valid block whose mode the instance's provider does not serve
+ /// ( — Upsert / Procedure on Odbc), which the driver would publish
+ /// read-only. The provider is read from the same merged config the driver binds; an unreadable provider is
+ /// left to the driver's own construction to refuse.
+ ///
+ /// The snapshot being validated.
+ /// One error per offending tag.
+ public static IReadOnlyList WriteBlockErrors(DraftSnapshot draft)
+ {
+ var errors = new List();
+ foreach (var (driver, devices) in SqlInstances(draft))
+ {
+ SqlProvider? provider = null;
+ var providerRead = false;
+ foreach (var tag in draft.Tags)
+ {
+ if (!devices.Any(d => string.Equals(d.DeviceId, tag.DeviceId, StringComparison.Ordinal))) continue;
+
+ if (SqlTagDefinitionFactory.DescribeWriteBlock(tag.TagConfig) is { } invalid)
+ {
+ errors.Add(new(WriteBlockInvalidCode, $"Sql tag '{tag.TagId}': {invalid}", tag.TagId));
+ continue;
+ }
+
+ if (!SqlEquipmentTagParser.TryParse(tag.TagConfig, tag.Name, out var def) || def.Write is not { } write)
+ continue;
+ if (!providerRead)
+ {
+ provider = ReadProvider(driver, devices);
+ providerRead = true;
+ }
+
+ if (provider is { } p && !SqlWriteSupport.IsSupported(p, write.Mode))
+ {
+ errors.Add(new(WriteModeUnsupportedCode,
+ $"Sql tag '{tag.TagId}' writes in mode {write.Mode}, which Sql driver instance " +
+ $"'{driver.DriverInstanceId}' (provider {p}) does not serve — Odbc writes are Update or Insert " +
+ "only. Use the backend's native provider, or change the tag's 'write.mode'.",
+ tag.TagId));
+ }
+ }
+ }
+
+ return errors;
+ }
+
+ /// The merged config's provider (default SqlServer), or null when it is not a provider name.
+ private static SqlProvider? ReadProvider(DriverInstance driver, List devices)
+ {
+ try
+ {
+ var merged = DriverDeviceConfigMerger.Merge(
+ driver.DriverConfig, devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(), []);
+ using var doc = JsonDocument.Parse(merged);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ foreach (var p in doc.RootElement.EnumerateObject())
+ {
+ if (!string.Equals(p.Name, "provider", StringComparison.OrdinalIgnoreCase)) continue;
+ return p.Value.ValueKind switch
+ {
+ JsonValueKind.String when Enum.TryParse(p.Value.GetString(), ignoreCase: true, out var v)
+ && Enum.IsDefined(v) => v,
+ JsonValueKind.Number when p.Value.TryGetInt32(out var n) && Enum.IsDefined((SqlProvider)n) => (SqlProvider)n,
+ _ => null,
+ };
+ }
+
+ return SqlProvider.SqlServer;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
///
/// Item 3. The node's write permission needs BOTH the Tag entity's AccessLevel (ReadWrite) and the
/// Sql blob's "writable": true; when they disagree the tag is silently read-only. A warning, never
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
index bd3ef307..b6265d38 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
@@ -30,8 +30,39 @@ public static class SqlEquipmentTagParser
/// The mapped definition when this returns .
/// when is a valid Sql tag blob.
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
+ => TryParseDetailed(reference, rawPath, out def, out _);
+
+ ///
+ /// , also reporting why an otherwise-valid tag's
+ /// write block refused it (lmxopcua#781). is null when the refusal — if
+ /// any — came from the tag's own fields, or when the tag parsed.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath.
+ /// The mapped definition when this returns .
+ /// The write block's rejection reason, when that is what refused the tag.
+ /// when is a valid Sql tag blob.
+ public static bool TryParseDetailed(string reference, string rawPath, out SqlTagDefinition def, out string? writeError)
+ {
+ writeError = null;
+ if (!TryParseFields(reference, rawPath, out def, out var writeBlock)) return false;
+ if (writeBlock is not { } block) return true;
+
+ if (!SqlWriteSpecParser.TryParse(block, def, out var spec, out writeError))
+ {
+ def = null!;
+ return false;
+ }
+
+ def = def with { Write = spec };
+ return true;
+ }
+
+ /// The tag's own fields; the write block, when present, is handed back (cloned) unparsed.
+ private static bool TryParseFields(string reference, string rawPath, out SqlTagDefinition def, out JsonElement? writeBlock)
{
def = null!;
+ writeBlock = null;
if (string.IsNullOrWhiteSpace(reference)) return false;
if (reference.TrimStart().FirstOrDefault() != '{') return false;
try
@@ -55,6 +86,7 @@ public static class SqlEquipmentTagParser
: null;
var timestampColumn = ReadString(root, "timestampColumn");
+ if (root.TryGetProperty("write", out var writeEl)) writeBlock = writeEl.Clone();
// The per-tag write opt-in (design §3.4). Strict like the enums: absent ⇒ read-only, a JSON boolean ⇒
// that, anything else (a "true" string, a number) ⇒ the tag is refused rather than guessed at — a
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
index 7144cf7f..d987c8f7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
@@ -43,6 +43,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// Optional explicit type override from the blob's type field. ⇒ the
/// driver infers the type from the result set's column metadata.
///
+///
+/// The tag's optional write block (lmxopcua#781 item 2): the statement shape a write becomes. Null ⇒ the
+/// default single-row UPDATE (). Meaningful only when .
+///
public sealed record SqlTagDefinition(
string Name,
SqlTagModel Model,
@@ -59,4 +63,9 @@ public sealed record SqlTagDefinition(
string? QueryName = null,
string? RowKeyColumn = null,
string? RowKeyValue = null,
- bool Writable = false);
+ bool Writable = false,
+ SqlWriteSpec? Write = null)
+{
+ /// The effective write spec — the authored block, or the default UPDATE.
+ public SqlWriteSpec EffectiveWrite => Write ?? SqlWriteSpec.Default;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
index 2eb8b9c5..a5db79cf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
@@ -51,7 +51,14 @@ public static class SqlTagDefinitionFactory
return warnings;
// The accept gate: whatever the driver serves draws nothing. See the class remarks.
- if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings;
+ if (SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError)) return warnings;
+
+ // The tag's own fields parsed and its write block is what refused it: the parser said why.
+ if (writeError is not null)
+ {
+ warnings.Add(FormatWriteError(writeError));
+ return warnings;
+ }
try
{
@@ -83,6 +90,25 @@ public static class SqlTagDefinitionFactory
return warnings;
}
+ ///
+ /// Why the blob's write block (lmxopcua#781 item 2) refuses an otherwise-valid tag, or null when the
+ /// block is valid, absent, or the tag's own fields already refuse it (which reports).
+ /// The verdict is the runtime parser's own ();
+ /// the deploy gate turns it into a blocking error. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The operator-facing message, or null.
+ public static string? DescribeWriteBlock(string reference)
+ {
+ if (string.IsNullOrWhiteSpace(reference)) return null;
+ return SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError) || writeError is null
+ ? null
+ : FormatWriteError(writeError);
+ }
+
+ private static string FormatWriteError(string writeError)
+ => $"Sql TagConfig 'write' block is invalid: {writeError} — the driver will skip this tag (BadNodeIdUnknown).";
+
///
/// Explains why refused an object-rooted blob, walking
/// its checks in the same order. Every branch here mirrors a return false in the parser — this
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlWriteSpec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlWriteSpec.cs
new file mode 100644
index 00000000..cac094a5
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlWriteSpec.cs
@@ -0,0 +1,275 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+/// The statement shape a Sql tag's write becomes (design §3.4, lmxopcua#781 item 2).
+public enum SqlWriteMode
+{
+ ///
+ /// UPDATE … SET value WHERE key on exactly one existing row — the default, and the only shape before
+ /// #781. Zero rows is BadNoData; the driver never inserts in this mode.
+ ///
+ Update,
+
+ /// INSERT of one new row — the key (when the model has one), the value and, optionally, the stamp.
+ Insert,
+
+ ///
+ /// Update the keyed row, or insert it when absent, in one statement (SQL Server / Oracle MERGE,
+ /// PostgreSQL INSERT … ON CONFLICT, MySQL INSERT … ON DUPLICATE KEY UPDATE). Not on Odbc.
+ ///
+ Upsert,
+
+ /// A call of one named, catalog-validated stored procedure with the value (and optionally key / stamp) bound.
+ Procedure,
+}
+
+///
+/// The parsed form of a tag's optional write block. Every string is authored and NEVER reaches SQL text
+/// unvalidated: is resolved against the live routine catalog by the catalog gate and
+/// dialect-quoted, and the parameter names are restricted to a plain identifier charset by the parser
+/// (). Values are always bound.
+///
+/// The statement shape.
+///
+/// Update / Insert / Upsert: also set the tag's timestamp column (timestampColumn, or a WideRow's
+/// topByTimestamp) to the write time, in UTC.
+///
+/// Procedure: the procedure or schema.procedure to call.
+/// Procedure: the procedure parameter the written value binds to (default value).
+/// Procedure: the parameter the tag's key binds to; null ⇒ the key is not passed.
+/// Procedure: the parameter the write time (UTC) binds to; null ⇒ not passed.
+public sealed record SqlWriteSpec(
+ SqlWriteMode Mode,
+ bool StampTimestamp = false,
+ string? Procedure = null,
+ string? ValueParameter = null,
+ string? KeyParameter = null,
+ string? TimestampParameter = null)
+{
+ /// The default when a writable tag carries no write block: today's single-row UPDATE.
+ public static SqlWriteSpec Default { get; } = new(SqlWriteMode.Update);
+}
+
+///
+/// Which s each provider serves. One table, read by the dialects (runtime), the deploy
+/// gate (SqlTagDeployChecks) and the docs, so what deploy accepts is what the driver serves.
+/// Odbc is Update / Insert only. The ANSI profile has no upsert every ODBC backend accepts
+/// (MERGE is missing or partial on several), and positional ? markers cannot be reused, which every
+/// single-statement upsert needs; a stored-procedure call would need a routine catalog and calling convention per
+/// backend. Use the backend's native provider for those modes.
+///
+public static class SqlWriteSupport
+{
+ /// True when serves .
+ /// The driver's provider.
+ /// The tag's write mode.
+ /// Whether the driver will execute the mode on that provider.
+ public static bool IsSupported(SqlProvider provider, SqlWriteMode mode) => mode switch
+ {
+ SqlWriteMode.Update or SqlWriteMode.Insert => true,
+ SqlWriteMode.Upsert or SqlWriteMode.Procedure => provider != SqlProvider.Odbc,
+ _ => false,
+ };
+}
+
+///
+/// Parses and validates a tag's write block against the tag it belongs to. Used by
+/// (a failure refuses the whole tag) and — through
+/// — by the deploy gate and the AdminUI, so all three
+/// share one verdict and one explanation.
+///
+public static partial class SqlWriteSpecParser
+{
+ /// The keys a write block may carry. Anything else is refused — a typo in a write permission is
+ /// the last place to guess.
+ private static readonly string[] KnownKeys =
+ ["mode", "stampTimestamp", "procedure", "valueParam", "keyParam", "timestampParam"];
+
+ private static readonly string[] ProcedureOnlyKeys = ["procedure", "valueParam", "keyParam", "timestampParam"];
+
+ /// The default procedure parameter the value binds to.
+ public const string DefaultValueParameter = "value";
+
+ ///
+ /// Parses for (whose own fields are already parsed).
+ ///
+ /// The write property's value.
+ /// The tag definition the block belongs to.
+ /// The spec, when this returns .
+ /// An operator-facing reason, when this returns .
+ /// Whether the block is valid for the tag.
+ public static bool TryParse(JsonElement write, SqlTagDefinition tag, out SqlWriteSpec? spec, out string? error)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ spec = null;
+ error = null;
+
+ if (write.ValueKind != JsonValueKind.Object)
+ return Fail("'write' must be a JSON object", out error);
+
+ foreach (var property in write.EnumerateObject())
+ {
+ if (!KnownKeys.Contains(property.Name, StringComparer.Ordinal))
+ {
+ return Fail(
+ $"'write' has an unknown key '{property.Name}' (valid: {string.Join(", ", KnownKeys)})", out error);
+ }
+ }
+
+ var mode = SqlWriteMode.Update;
+ if (write.TryGetProperty("mode", out var modeEl))
+ {
+ if (modeEl.ValueKind != JsonValueKind.String
+ || TagConfigJson.ClassifyEnumText(modeEl.GetString(), out mode) != JsonEnumRead.Valid)
+ {
+ return Fail(
+ $"'write.mode' must be one of {string.Join(", ", Enum.GetNames())}", out error);
+ }
+ }
+
+ var stamp = false;
+ if (write.TryGetProperty("stampTimestamp", out var stampEl))
+ {
+ if (stampEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ return Fail("'write.stampTimestamp' must be a JSON boolean", out error);
+ stamp = stampEl.GetBoolean();
+ }
+
+ if (mode != SqlWriteMode.Procedure)
+ {
+ foreach (var key in ProcedureOnlyKeys)
+ {
+ if (write.TryGetProperty(key, out _))
+ return Fail($"'write.{key}' applies only to mode Procedure (this block's mode is {mode})", out error);
+ }
+
+ if (tag.Model == SqlTagModel.Query)
+ {
+ return Fail(
+ $"a Query tag reads a named query, which names no table to {mode.ToString().ToLowerInvariant()}; " +
+ "only mode Procedure can write a Query tag", out error);
+ }
+
+ var hasWherePair = tag.Model == SqlTagModel.KeyValue || tag.RowSelectorColumn is not null;
+ if (mode is SqlWriteMode.Update or SqlWriteMode.Upsert && !hasWherePair)
+ {
+ return Fail(
+ $"mode {mode} needs a row key — a KeyValue tag, or a WideRow 'whereColumn'/'whereValue' pair; " +
+ "a 'topByTimestamp' row moves under a concurrent insert (use Insert or Procedure)", out error);
+ }
+
+ if (stamp && StampColumn(tag) is null)
+ {
+ return Fail(
+ "'write.stampTimestamp' is true but the tag has no timestamp column to stamp — author " +
+ "'timestampColumn' (or a WideRow 'topByTimestamp')", out error);
+ }
+
+ spec = new SqlWriteSpec(mode, stamp);
+ return true;
+ }
+
+ // ---- Procedure ----
+ if (stamp)
+ return Fail("'write.stampTimestamp' does not apply to mode Procedure — bind the write time with 'timestampParam'", out error);
+
+ if (!write.TryGetProperty("procedure", out var procEl) || procEl.ValueKind != JsonValueKind.String
+ || string.IsNullOrWhiteSpace(procEl.GetString()))
+ {
+ return Fail("mode Procedure needs 'write.procedure' — the procedure or schema.procedure to call", out error);
+ }
+
+ var procedure = procEl.GetString()!.Trim();
+ if (procedure.Split('.').Length > 2)
+ {
+ return Fail(
+ "'write.procedure' may be 'procedure' or 'schema.procedure' only — a package, cross-database or " +
+ "linked-server name cannot be validated against this connection's catalog", out error);
+ }
+
+ if (!TryReadParameter(write, "valueParam", DefaultValueParameter, out var valueParam, out error)) return false;
+ if (!TryReadParameter(write, "keyParam", null, out var keyParam, out error)) return false;
+ if (!TryReadParameter(write, "timestampParam", null, out var timestampParam, out error)) return false;
+
+ if (keyParam is not null && KeyValue(tag) is null)
+ {
+ return Fail(
+ "'write.keyParam' is set but the tag has no key to bind — a KeyValue 'keyValue', a WideRow " +
+ "'whereValue' or a Query 'rowKey.value'", out error);
+ }
+
+ var names = new[] { valueParam, keyParam, timestampParam }.Where(n => n is not null).ToArray();
+ if (names.Distinct(StringComparer.OrdinalIgnoreCase).Count() != names.Length)
+ return Fail("'write' binds two values to the same procedure parameter", out error);
+
+ spec = new SqlWriteSpec(SqlWriteMode.Procedure, false, procedure, valueParam, keyParam, timestampParam);
+ return true;
+ }
+
+ /// The column a stampTimestamp write sets: timestampColumn, else a WideRow's topByTimestamp.
+ /// The tag.
+ /// The column, or null when the tag has none.
+ public static string? StampColumn(SqlTagDefinition tag)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ if (tag.Model == SqlTagModel.Query) return null;
+ if (!string.IsNullOrWhiteSpace(tag.TimestampColumn)) return tag.TimestampColumn;
+ return tag.Model == SqlTagModel.WideRow && !string.IsNullOrWhiteSpace(tag.RowSelectorTopByTimestamp)
+ ? tag.RowSelectorTopByTimestamp
+ : null;
+ }
+
+ /// The tag's key value — what a procedure's keyParam binds: keyValue, whereValue or rowKey.value.
+ /// The tag.
+ /// The key, or null when the model names none.
+ public static string? KeyValue(SqlTagDefinition tag)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ return tag.Model switch
+ {
+ SqlTagModel.KeyValue => tag.KeyValue,
+ SqlTagModel.WideRow => tag.RowSelectorValue,
+ SqlTagModel.Query => tag.RowKeyColumn is null ? null : tag.RowKeyValue,
+ _ => null,
+ };
+ }
+
+ ///
+ /// Reads a procedure parameter name: absent ⇒ ; otherwise a plain identifier
+ /// (letter or underscore, then letters, digits, underscores; at most 128 characters), one leading @ or
+ /// : tolerated and stripped. The name reaches SQL text unquoted (named notation), so this charset is
+ /// the injection boundary for it.
+ ///
+ private static bool TryReadParameter(
+ JsonElement write, string key, string? fallback, out string? name, out string? error)
+ {
+ name = fallback;
+ error = null;
+ if (!write.TryGetProperty(key, out var el)) return true;
+ if (el.ValueKind != JsonValueKind.String) return Fail($"'write.{key}' must be a string", out error);
+
+ var text = el.GetString()!.Trim();
+ if (text.Length > 0 && text[0] is '@' or ':') text = text[1..];
+ if (!ParameterName().IsMatch(text))
+ {
+ return Fail(
+ $"'write.{key}' must be a plain parameter name — a letter or underscore, then letters, digits or " +
+ "underscores (at most 128)", out error);
+ }
+
+ name = text;
+ return true;
+ }
+
+ private static bool Fail(string message, out string? error)
+ {
+ error = message;
+ return false;
+ }
+
+ [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.CultureInvariant)]
+ private static partial Regex ParameterName();
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index e1069372..0fbe6471 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -243,4 +243,65 @@ public interface ISqlDialect
/// The bare SQL type family name; matched case-insensitively.
/// The mapped driver data type, or when unrecognised.
DriverDataType MapColumnType(string sqlDataType);
+
+ // ---- writes (lmxopcua#781 item 2) ----
+
+ ///
+ /// Whether this dialect executes . Defaults to the shared
+ /// table for — the same table the deploy gate checks, so
+ /// a mode deploy accepts is a mode the driver serves.
+ ///
+ /// The tag's write mode.
+ /// Whether a write in that mode is executed.
+ bool SupportsWriteMode(SqlWriteMode mode) => SqlWriteSupport.IsSupported(Provider, mode);
+
+ ///
+ /// The single-statement upsert of one keyed row, or null when this dialect has none. Every argument is
+ /// already dialect-quoted (identifiers) or a parameter marker — the dialect only arranges them, so nothing
+ /// authored and nothing a client sent reaches the text through here. A marker may appear more than once in
+ /// the result, so a positional-marker dialect must return null.
+ ///
+ /// The quoted, catalog-canonical target table.
+ /// The quoted key column.
+ /// The key's parameter marker.
+ /// The quoted columns to set and their markers — the value first, then any stamp.
+ /// The statement, or null when the dialect cannot upsert.
+ string? UpsertSql(
+ string quotedTable, string quotedKeyColumn, string keyMarker,
+ IReadOnlyList<(string QuotedColumn, string Marker)> assignments) => null;
+
+ ///
+ /// The most rows an upsert may report touching for one key. 1 everywhere except MySQL, whose
+ /// ON DUPLICATE KEY UPDATE reports 2 for "updated an existing row".
+ ///
+ int UpsertMaxRowsAffected => 1;
+
+ ///
+ /// The statement that calls one stored procedure, or null when this dialect cannot. Arguments are passed by
+ /// name (EXEC p @a = @v, CALL p(a => @v), BEGIN p(a => :p_v); END;) except where the
+ /// backend has no named notation (MySQL: positional, in list order). Parameter names reach the text
+ /// unquoted; the parser restricts them to a plain identifier charset.
+ ///
+ /// The quoted, catalog-canonical procedure name.
+ /// Procedure parameter name → marker, in order.
+ /// The statement, or null.
+ string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
+ => null;
+
+ ///
+ /// Catalog query listing the stored procedures in one schema (column ROUTINE_NAME, bound like
+ /// under the base name schema), or null when this dialect has no routine
+ /// catalog — the catalog gate then refuses every Procedure-mode tag rather than quote an unvalidated name.
+ ///
+ string? ListRoutinesSql => null;
+
+ ///
+ /// Classifies a write's provider exception so a constraint the database enforced reads as an authoring /
+ /// data fault rather than "the database is unreachable". Must never throw. The default reads the ANSI
+ /// when the provider reports one.
+ ///
+ /// What the write threw.
+ /// The failure class.
+ SqlWriteFailure ClassifyWriteFailure(Exception exception)
+ => SqlWriteFailures.FromSqlState((exception as DbException)?.SqlState);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/MySqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/MySqlDialect.cs
index 81e777b7..f1b08618 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/MySqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/MySqlDialect.cs
@@ -177,4 +177,48 @@ public sealed class MySqlDialect : ISqlDialect
_ => DriverDataType.String,
};
}
+
+ // ---- writes (lmxopcua#781 item 2) ----
+
+ ///
+ // INSERT … ON DUPLICATE KEY UPDATE — fires on ANY unique key of the table, so the key column must be the table's
+ // primary key or a unique key (and the table should carry no OTHER unique key a write could collide with).
+ // MySqlConnector substitutes parameters client-side, so a marker may repeat.
+ public string? UpsertSql(
+ string quotedTable, string quotedKeyColumn, string keyMarker,
+ IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
+ => string.Concat(
+ "INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
+ ") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
+ ") ON DUPLICATE KEY UPDATE ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)));
+
+ ///
+ // 1 for an inserted row, 2 for an updated one (MySQL's documented ON DUPLICATE KEY UPDATE count). MySqlConnector
+ // defaults to found-rows (UseAffectedRows=false), so an update to the same value still reports a row, not 0.
+ public int UpsertMaxRowsAffected => 2;
+
+ ///
+ // MySQL has no named-argument notation: arguments are POSITIONAL, in the order the driver lists them — the value,
+ // then the key (keyParam), then the write time (timestampParam). The parameter names are not sent.
+ public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
+ => "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Marker)) + ")";
+
+ ///
+ public string? ListRoutinesSql =>
+ "SELECT ROUTINE_NAME AS ROUTINE_NAME FROM information_schema.ROUTINES " +
+ "WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
+
+ ///
+ // 1062 duplicate entry; 3819 CHECK violated (reported as SQLSTATE HY000, so it needs the number); the rest by
+ // SQLSTATE — 23000 covers NOT NULL (1048) and foreign keys (1451/1452), class 22 overflow/truncation.
+ public SqlWriteFailure ClassifyWriteFailure(Exception exception)
+ {
+ if (exception is not MySqlException my) return SqlWriteFailure.Other;
+ return my.Number switch
+ {
+ 1062 => SqlWriteFailure.UniqueViolation,
+ 3819 => SqlWriteFailure.IntegrityViolation,
+ _ => SqlWriteFailures.FromSqlState(my.SqlState),
+ };
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs
index 5f381c2f..6c00b582 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs
@@ -170,4 +170,15 @@ public sealed class OdbcDialect : ISqlDialect
_ => DriverDataType.String,
};
}
+
+ // ---- writes (lmxopcua#781 item 2) ----
+ // Update and Insert only (SqlWriteSupport): no Upsert (positional markers cannot repeat, and MERGE is missing or
+ // partial on several ODBC backends) and no Procedure (no portable routine catalog or calling convention).
+
+ ///
+ // The driver manager reports the backend's SQLSTATE on each OdbcError.
+ public SqlWriteFailure ClassifyWriteFailure(Exception exception)
+ => exception is OdbcException odbc && odbc.Errors.Count > 0
+ ? SqlWriteFailures.FromSqlState(odbc.Errors[0].SQLState)
+ : SqlWriteFailure.Other;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
index 1789959f..f298989d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
@@ -325,4 +325,50 @@ public sealed class OracleDialect : ISqlDialect
_ => DriverDataType.String,
};
}
+
+ // ---- writes (lmxopcua#781 item 2) ----
+
+ ///
+ // MERGE from a constant one-row DUAL source, matching the target's key to the bound key; BindByName (set in
+ // ConfigureCommand) is what lets the key and value markers appear twice. No trailing semicolon — ODP.NET refuses
+ // one on a SQL statement.
+ public string? UpsertSql(
+ string quotedTable, string quotedKeyColumn, string keyMarker,
+ IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
+ => string.Concat(
+ "MERGE INTO ", quotedTable, " tgt USING (SELECT 1 AS one FROM dual) src ON (tgt.", quotedKeyColumn, " = ",
+ keyMarker, ") WHEN MATCHED THEN UPDATE SET ",
+ string.Join(", ", assignments.Select(a => "tgt." + a.QuotedColumn + " = " + a.Marker)),
+ " WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
+ ") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ")");
+
+ ///
+ // An anonymous PL/SQL block with named notation. Unquoted parameter names fold to upper case, as Oracle's own do.
+ // A standalone procedure only — a package member (pkg.proc) is a three-part name the gate cannot validate.
+ public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
+ => "BEGIN " + quotedProcedure
+ + (arguments.Count == 0 ? string.Empty : "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")")
+ + "; END;";
+
+ ///
+ // ALL_PROCEDURES lists a standalone procedure as OBJECT_TYPE 'PROCEDURE' with OBJECT_NAME its name; it shows only
+ // what the principal may execute or owns.
+ public string? ListRoutinesSql =>
+ "SELECT object_name AS ROUTINE_NAME FROM ALL_PROCEDURES " +
+ "WHERE owner = " + ParameterMarker("schema") + " AND object_type = 'PROCEDURE' ORDER BY object_name";
+
+ ///
+ // ODP.NET reports no SQLSTATE; the ORA- number decides. 1 unique; 1400/1407 NULL, 2290 CHECK, 2291/2292 FK;
+ // 1438 precision, 12899 too large, 1722 invalid number, 18xx date conversion.
+ public SqlWriteFailure ClassifyWriteFailure(Exception exception)
+ {
+ if (exception is not OracleException ora) return SqlWriteFailure.Other;
+ return ora.Number switch
+ {
+ 1 => SqlWriteFailure.UniqueViolation,
+ 1400 or 1407 or 2290 or 2291 or 2292 => SqlWriteFailure.IntegrityViolation,
+ 1438 or 12899 or 1722 or 1830 or 1840 or 1841 or 1843 or 1847 or 1858 or 1861 => SqlWriteFailure.DataException,
+ _ => SqlWriteFailure.Other,
+ };
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
index 6809b328..2de193c8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
@@ -267,4 +267,35 @@ public sealed class PostgresDialect : ISqlDialect
_ => DriverDataType.String,
};
}
+
+ // ---- writes (lmxopcua#781 item 2) ----
+
+ ///
+ // INSERT … ON CONFLICT (key) DO UPDATE — atomic under concurrency, but it REQUIRES a unique constraint or unique
+ // index on exactly the key column (the server refuses the statement otherwise: SQLSTATE 42P10, which surfaces as
+ // BadCommunicationError). EXCLUDED is the row the insert proposed, so each value marker appears once in VALUES.
+ // Because a conflict target is unique, the statement touches exactly one row.
+ public string? UpsertSql(
+ string quotedTable, string quotedKeyColumn, string keyMarker,
+ IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
+ => string.Concat(
+ "INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
+ ") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
+ ") ON CONFLICT (", quotedKeyColumn, ") DO UPDATE SET ",
+ string.Join(", ", assignments.Select(a => a.QuotedColumn + " = EXCLUDED." + a.QuotedColumn)));
+
+ ///
+ // CALL with named notation (PostgreSQL 11+ procedures). Argument types must resolve by IMPLICIT cast: a Float64
+ // binds as double precision, so declare a numeric parameter as double precision (or cast inside the procedure),
+ // and a write time binds as timestamptz.
+ public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
+ => "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")";
+
+ ///
+ // information_schema.routines shows only routines the current role may execute or owns — the gate's question.
+ public string? ListRoutinesSql =>
+ "SELECT routine_name AS \"ROUTINE_NAME\" FROM information_schema.routines " +
+ "WHERE routine_schema = @schema AND routine_type = 'PROCEDURE' ORDER BY \"ROUTINE_NAME\"";
+
+ // ClassifyWriteFailure: the interface default reads PostgresException.SqlState, which Npgsql always reports.
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index 61057407..43fee530 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -222,4 +222,52 @@ public sealed class SqlServerDialect : ISqlDialect
_ => DriverDataType.String,
};
}
+
+ // ---- writes (lmxopcua#781 item 2) ----
+
+ ///
+ // MERGE … WITH (HOLDLOCK): the key-range lock HOLDLOCK takes is what makes the matched/not-matched decision and
+ // the insert one atomic step under concurrency — a bare MERGE (or UPDATE-then-INSERT) can race two writers into a
+ // duplicate insert. The source is a constant one-row derived table and the ON clause compares the target's key to
+ // the bound key directly, so a key matching several rows updates them all and the driver's row-count rule rolls it
+ // back. The trailing semicolon is mandatory for MERGE.
+ public string? UpsertSql(
+ string quotedTable, string quotedKeyColumn, string keyMarker,
+ IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
+ => string.Concat(
+ "MERGE INTO ", quotedTable, " WITH (HOLDLOCK) AS tgt USING (SELECT 1 AS one) AS src ON tgt.",
+ quotedKeyColumn, " = ", keyMarker,
+ " WHEN MATCHED THEN UPDATE SET ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)),
+ " WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
+ ") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ");");
+
+ ///
+ // EXEC with named arguments: the left-hand @name is the procedure's own parameter, the right-hand marker the bind.
+ public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
+ => arguments.Count == 0
+ ? "EXEC " + quotedProcedure
+ : "EXEC " + quotedProcedure + " " + string.Join(", ", arguments.Select(a => "@" + a.Parameter + " = " + a.Marker));
+
+ ///
+ // INFORMATION_SCHEMA.ROUTINES lists only routines the principal may see (it holds a permission on), which is the
+ // question the gate asks. Procedures only — a function is not callable with EXEC semantics here.
+ public string? ListRoutinesSql =>
+ "SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES " +
+ "WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
+
+ ///
+ // SqlClient reports no SQLSTATE, so the engine error number decides. 2627/2601 duplicate key (constraint /
+ // unique index); 547 FK or CHECK, 515 NULL into NOT NULL; 8115/220/232 arithmetic overflow, 8152/2628 truncation,
+ // 245/8114 conversion, 241/242 date conversion or out of range.
+ public SqlWriteFailure ClassifyWriteFailure(Exception exception)
+ {
+ if (exception is not SqlException sql) return SqlWriteFailure.Other;
+ return sql.Number switch
+ {
+ 2627 or 2601 => SqlWriteFailure.UniqueViolation,
+ 547 or 515 => SqlWriteFailure.IntegrityViolation,
+ 8115 or 220 or 232 or 8152 or 2628 or 245 or 8114 or 241 or 242 => SqlWriteFailure.DataException,
+ _ => SqlWriteFailure.Other,
+ };
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlWriteFailure.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlWriteFailure.cs
new file mode 100644
index 00000000..fe629ab6
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlWriteFailure.cs
@@ -0,0 +1,33 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+/// Why a write's statement failed, as far as the provider's error says (lmxopcua#781 item 2).
+public enum SqlWriteFailure
+{
+ /// Anything else — permission denied, a missing object, a broken connection. BadCommunicationError.
+ Other,
+
+ /// A unique / primary-key violation (SQLSTATE 23505, SQL Server 2627/2601, MySQL 1062, ORA-00001). BadEntryExists.
+ UniqueViolation,
+
+ /// Another integrity constraint — NOT NULL, foreign key, CHECK (SQLSTATE class 23). BadOutOfRange.
+ IntegrityViolation,
+
+ /// A data exception — overflow, truncation, an unconvertible value (SQLSTATE class 22). BadOutOfRange.
+ DataException,
+}
+
+/// SQLSTATE-based classification shared by the dialects.
+public static class SqlWriteFailures
+{
+ /// Classifies an ANSI SQLSTATE: 23505 unique, class 23 integrity, class 22 data; anything else (or null) other.
+ /// The five-character SQLSTATE, or null.
+ /// The failure class.
+ public static SqlWriteFailure FromSqlState(string? sqlState)
+ {
+ if (string.IsNullOrEmpty(sqlState) || sqlState.Length < 2) return SqlWriteFailure.Other;
+ if (sqlState == "23505") return SqlWriteFailure.UniqueViolation;
+ if (sqlState.StartsWith("23", StringComparison.Ordinal)) return SqlWriteFailure.IntegrityViolation;
+ if (sqlState.StartsWith("22", StringComparison.Ordinal)) return SqlWriteFailure.DataException;
+ return SqlWriteFailure.Other;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
index f69efa4d..63742636 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
@@ -23,17 +23,23 @@ public sealed class SqlCatalog
/// Canonical schema.table → that relation's canonical column names.
private readonly IReadOnlyDictionary> _columnsByTable;
+ /// Stored procedures per canonical schema — loaded only for schemas a Procedure-mode tag names (lmxopcua#781).
+ private readonly IReadOnlyDictionary> _routinesBySchema;
+
/// Constructs a catalog snapshot.
/// The schema an unqualified object name resolves to for this connection.
/// Every schema the connection can see.
/// Canonical schema → its tables/views.
/// Canonical schema.table → its columns.
+ /// Canonical schema → its stored procedures; null ⇒ none loaded.
internal SqlCatalog(
string defaultSchema,
IReadOnlyList schemas,
IReadOnlyDictionary> tablesBySchema,
- IReadOnlyDictionary> columnsByTable)
+ IReadOnlyDictionary> columnsByTable,
+ IReadOnlyDictionary>? routinesBySchema = null)
{
+ _routinesBySchema = routinesBySchema ?? new Dictionary>(StringComparer.Ordinal);
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
@@ -93,6 +99,21 @@ public sealed class SqlCatalog
&& TryMatch(columns, authored, out canonical);
}
+ ///
+ /// Resolves an authored procedure name in a canonical schema to the catalog's spelling, by the same
+ /// exact-then-unique-case-insensitive rule as tables and columns.
+ ///
+ /// The schema, as returned it.
+ /// The authored procedure name.
+ /// The catalog spelling, when this returns true.
+ /// True when exactly one procedure matches.
+ public bool TryResolveRoutine(string canonicalSchema, string authored, out string canonical)
+ {
+ canonical = string.Empty;
+ return _routinesBySchema.TryGetValue(canonicalSchema, out var routines)
+ && TryMatch(routines, authored, out canonical);
+ }
+
/// The dictionary key for a canonical relation.
/// The canonical schema.
/// The canonical table.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
index ec8f34aa..01834d15 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
@@ -81,16 +81,29 @@ public static class SqlCatalogGate
// A named-query tag quotes no identifier into SQL: its statement is driver configuration (checked
// read-only at construction) and its column/rowKey/timestamp names are matched against the result
// set by the reader. There is nothing here to allow-list, so it passes through unchanged.
+ // A Procedure-mode write block (lmxopcua#781) names a routine, which is resolved here like any other
+ // identifier — for a Query tag too, whose read side needs no catalog.
+ SqlTagDefinition? canonical;
+ SqlCatalogRejection? rejection;
if (definition.Model == SqlTagModel.Query)
{
- accepted.Add(definition);
+ canonical = definition;
+ rejection = null;
+ }
+ else if (!TryCanonicalize(definition, catalog, dialect, out canonical, out rejection))
+ {
+ rejected.Add(rejection!);
continue;
}
- if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
- accepted.Add(canonical!);
- else
+ if (canonical!.Write is { Mode: SqlWriteMode.Procedure } write
+ && !TryResolveProcedure(canonical, write, catalog, dialect, out canonical, out rejection))
+ {
rejected.Add(rejection!);
+ continue;
+ }
+
+ accepted.Add(canonical!);
}
return new SqlCatalogGateResult(accepted, rejected);
@@ -162,6 +175,64 @@ public static class SqlCatalogGate
return true;
}
+ /// The rejection for a write block's procedure.
+ internal const string ProcedureField = "Write.Procedure";
+
+ ///
+ /// Resolves a Procedure-mode write block's routine against the catalog and substitutes its spelling
+ /// (lmxopcua#781). Same rules as a table: at most two parts, each renderable, schema then routine by the
+ /// exact-then-unique-case-insensitive match.
+ ///
+ private static bool TryResolveProcedure(
+ SqlTagDefinition definition,
+ SqlWriteSpec write,
+ SqlCatalog catalog,
+ ISqlDialect dialect,
+ out SqlTagDefinition? canonical,
+ out SqlCatalogRejection? rejection)
+ {
+ canonical = null;
+ rejection = null;
+
+ if (dialect.ListRoutinesSql is null)
+ {
+ rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
+ $"the {dialect.Provider} provider has no stored-procedure catalog to validate the procedure against, " +
+ "so a Procedure-mode write cannot be served on it");
+ return false;
+ }
+
+ var parts = (write.Procedure ?? string.Empty).Split('.');
+ if (parts.Length > MaxNameParts || parts.Any(p => !IsRenderableIdentifier(p, dialect)))
+ {
+ rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
+ "the authored procedure name is not a usable one- or two-part identifier (empty, over-long, more than " +
+ "two parts, or containing control or Unicode format characters); the value is withheld from this " +
+ "message because it cannot be safely rendered");
+ return false;
+ }
+
+ var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
+ var authoredRoutine = parts[^1];
+ if (!catalog.TryResolveSchema(authoredSchema, out var schema))
+ {
+ rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
+ $"schema '{authoredSchema}' does not exist (or matches more than one schema under a case-sensitive collation)");
+ return false;
+ }
+
+ if (!catalog.TryResolveRoutine(schema, authoredRoutine, out var routine))
+ {
+ rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
+ $"stored procedure '{authoredRoutine}' does not exist in schema '{schema}', or the connecting principal " +
+ "may not execute it (or the name matches more than one procedure under a case-sensitive collation)");
+ return false;
+ }
+
+ canonical = definition with { Write = write with { Procedure = SqlCatalog.QualifiedKey(schema, routine) } };
+ return true;
+ }
+
/// Splits and resolves the authored table to a canonical schema + relation.
private static bool TryResolveRelation(
SqlTagDefinition definition,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
index cc82aced..6bfc6563 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
@@ -37,6 +37,8 @@ internal static class SqlCatalogLoader
private const string ColumnNameColumn = "COLUMN_NAME";
/// Base name of the schema parameter every dialect's catalog SQL carries.
+ private const string RoutineNameColumn = "ROUTINE_NAME";
+
private const string SchemaParameter = "schema";
/// Base name of the table parameter carries.
@@ -50,6 +52,7 @@ internal static class SqlCatalogLoader
/// The distinct authored table strings, as written in the blobs.
/// Per-command server-side backstop.
/// Cancellation token for the operation.
+ /// Procedure-mode write targets (lmxopcua#781) whose schemas' procedures to list.
/// The loaded catalog.
/// The connection can see no schemas at all.
internal static async Task LoadAsync(
@@ -57,7 +60,8 @@ internal static class SqlCatalogLoader
ISqlDialect dialect,
IReadOnlyCollection authoredTables,
TimeSpan commandTimeout,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ IReadOnlyCollection? authoredRoutines = null)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
@@ -124,7 +128,35 @@ internal static class SqlCatalogLoader
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
- return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
+ // Procedure-mode write targets (lmxopcua#781): one routine listing per schema they name. A dialect with no
+ // routine catalog loads nothing, and the gate then refuses those tags by name.
+ var routinesBySchema = new Dictionary>(StringComparer.Ordinal);
+ if (dialect.ListRoutinesSql is { } listRoutinesSql)
+ {
+ foreach (var authored in authoredRoutines ?? [])
+ {
+ if (string.IsNullOrWhiteSpace(authored)) continue;
+ var parts = authored.Split('.');
+ if (parts.Length > 2) continue;
+ string schema;
+ if (parts.Length == 2)
+ {
+ if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
+ }
+ else
+ {
+ schema = defaultSchema;
+ }
+
+ if (routinesBySchema.ContainsKey(schema)) continue;
+ routinesBySchema[schema] = await QueryColumnAsync(
+ connection, dialect, listRoutinesSql, RoutineNameColumn,
+ command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
+ timeoutSeconds, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable, routinesBySchema);
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
index 39f0fe3c..207ed352 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -15,11 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// connectivity, and the subscription overlay, while
/// owns the read path.
/// Writes are opt-in at two levels and default off (design §3.4, lmxopcua#753). A write needs
-/// the driver's allowWrites, the tag's own "writable": true, and a model that names one row
-/// (KeyValue, or a WideRow where-pair) — on top of the node's Writable flag and the caller's WriteOperate
-/// role, which the server checks first. It is a parameterised single-row UPDATE over the tag's
-/// catalog-validated identifiers (), run in a transaction that commits only when
-/// exactly one row was touched. With allowWrites off every write is
+/// the driver's allowWrites, the tag's own "writable": true, and a target its write mode can
+/// use (lmxopcua#781: Update / Upsert one keyed row, Insert any table-backed tag, Procedure any tag) — on top of
+/// the node's Writable flag and the caller's WriteOperate role, which the server checks first. It is one
+/// parameterised statement over the tag's catalog-validated identifiers (), run in a
+/// transaction that commits only when a table write touched exactly one row (a procedure call commits when it
+/// does not throw). With allowWrites off every write is
/// and every variable discovers as
/// .
/// Health is classified here, because nothing below does it. The reader honours
@@ -626,17 +627,28 @@ public sealed class SqlDriver
/// Writable flag or the caller's role, which the server enforces before a write reaches the driver.
///
private bool IsWritePermitted(SqlTagDefinition tag)
- => _options.AllowWrites && tag.Writable
- && (tag.Model == SqlTagModel.KeyValue
- || (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null));
+ => _options.AllowWrites && tag.Writable && HasWriteTarget(tag) && _dialect.SupportsWriteMode(tag.EffectiveWrite.Mode);
+
+ ///
+ /// Whether the tag's model gives its write mode something to write (lmxopcua#781): a procedure always; an
+ /// insert any table-backed tag; an update or upsert one keyed row — KeyValue, or a WideRow where-pair.
+ ///
+ private static bool HasWriteTarget(SqlTagDefinition tag) => tag.EffectiveWrite.Mode switch
+ {
+ SqlWriteMode.Procedure => true,
+ SqlWriteMode.Insert => tag.Model is SqlTagModel.KeyValue or SqlTagModel.WideRow,
+ _ => tag.Model == SqlTagModel.KeyValue || (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null),
+ };
///
- // Each write is a parameterised single-row UPDATE (SqlWritePlanner) over the tag's CATALOG-VALIDATED
- // definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no unvalidated identifier
- // can reach an UPDATE. It runs in a transaction that commits only when exactly one row was touched: zero rows
- // is BadNoData (nothing to update — the driver never inserts), more than one is BadConfigurationError (the
- // selector is ambiguous, the read-side rule), and both roll back. Bounded by operationTimeout like a poll. A
- // write never changes driver health: its outcome is the write's answer, not a statement about the database.
+ // Each write is one parameterised statement in the tag's write mode (SqlWritePlanner; Update by default) over the
+ // tag's CATALOG-VALIDATED definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no
+ // unvalidated identifier (table, column or procedure) can reach a statement. It runs in a transaction: a table
+ // write commits only when it touched one row (two for a MySQL upsert that updated) — zero rows is BadNoData, more
+ // is BadConfigurationError (the selector is ambiguous, the read-side rule), and both roll back; a procedure call
+ // commits when it does not throw. A constraint the database enforced maps to BadEntryExists / BadOutOfRange
+ // (MapWriteFailure). Bounded by operationTimeout like a poll. Never retried (WriteIdempotent=false). A write
+ // never changes driver health: its outcome is the write's answer, not a statement about the database.
public async Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
@@ -656,7 +668,9 @@ public sealed class SqlDriver
if (!_options.AllowWrites) return SqlStatusCodes.BadNotWritable;
if (write.FullReference is null || Lookup(write.FullReference) is not { } tag)
return SqlStatusCodes.BadNodeIdUnknown;
- if (!IsWritePermitted(tag)) return SqlStatusCodes.BadNotWritable;
+ if (!tag.Writable || !HasWriteTarget(tag)) return SqlStatusCodes.BadNotWritable;
+ // Deploy refuses a mode the provider cannot serve (SqlWriteModeUnsupported); this is the runtime backstop.
+ if (!_dialect.SupportsWriteMode(tag.EffectiveWrite.Mode)) return SqlStatusCodes.BadNotSupported;
var planned = SqlWritePlanner.TryPlan(tag, write.Value, _dialect, out var plan);
if (planned != SqlStatusCodes.Good) return planned;
@@ -683,14 +697,29 @@ public sealed class SqlDriver
catch (Exception ex)
{
// Open or execute failed. Type only on any surface; the exception (never the connection string) to the
- // log sink. Nothing was committed.
- _logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type}. Driver={DriverInstanceId}",
- tag.Name, Endpoint, ex.GetType().Name, _driverInstanceId);
- return SqlStatusCodes.BadCommunicationError;
+ // log sink. Nothing was committed. A constraint the database enforced is the value's fault, not the
+ // link's (lmxopcua#781): a duplicate key is BadEntryExists, another integrity or data rule BadOutOfRange.
+ // Everything else — a missing grant included — stays BadCommunicationError.
+ var failure = _dialect.ClassifyWriteFailure(ex);
+ _logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type} ({Failure}). Driver={DriverInstanceId}",
+ tag.Name, Endpoint, ex.GetType().Name, failure, _driverInstanceId);
+ return MapWriteFailure(failure);
}
}
- /// Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.
+ /// The status a classified write failure publishes (lmxopcua#781; documented in docs/drivers/Sql.md).
+ internal static uint MapWriteFailure(SqlWriteFailure failure) => failure switch
+ {
+ SqlWriteFailure.UniqueViolation => SqlStatusCodes.BadEntryExists,
+ SqlWriteFailure.IntegrityViolation or SqlWriteFailure.DataException => SqlStatusCodes.BadOutOfRange,
+ _ => SqlStatusCodes.BadCommunicationError,
+ };
+
+ ///
+ /// Opens a connection and runs one planned write in a transaction. A table write commits only when it touched
+ /// between one and rows; a procedure call commits whenever it did
+ /// not throw (its row count is the procedure's own business).
+ ///
private async Task ExecuteWriteAsync(SqlTagDefinition tag, SqlWritePlan plan, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
@@ -725,7 +754,7 @@ public sealed class SqlDriver
rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
- if (rows == 1)
+ if (plan.Mode == SqlWriteMode.Procedure || (rows >= 1 && rows <= plan.MaxRowsAffected))
{
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return SqlStatusCodes.Good;
@@ -735,9 +764,9 @@ public sealed class SqlDriver
if (rows == 0) return SqlStatusCodes.BadNoData;
_logger.LogWarning(
- "Sql write to '{Tag}' matched {Rows} row(s) on '{Table}' and was rolled back: a write must update " +
+ "Sql {Mode} write to '{Tag}' touched {Rows} row(s) on '{Table}' and was rolled back: a write must touch " +
"exactly one row. Driver={DriverInstanceId}",
- tag.Name, rows, tag.Table, _driverInstanceId);
+ plan.Mode, tag.Name, rows, tag.Table, _driverInstanceId);
return rows > 1 ? SqlStatusCodes.BadConfigurationError : SqlStatusCodes.BadNotSupported;
}
}
@@ -1115,6 +1144,15 @@ public sealed class SqlDriver
continue;
}
+ if (definition.Write is { } write && !_dialect.SupportsWriteMode(write.Mode))
+ {
+ // Deploy refuses this (SqlWriteModeUnsupported); an older artifact still reads, read-only.
+ _logger.LogWarning(
+ "Sql tag {RawPath} asks for {Mode} writes, which the {Provider} provider does not serve; it " +
+ "is published read-only. Driver={DriverInstanceId}",
+ entry.RawPath, write.Mode, _dialect.Provider, _driverInstanceId);
+ }
+
table[entry.RawPath] = definition;
}
else
@@ -1197,10 +1235,17 @@ public sealed class SqlDriver
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
- var catalog = tables.Length == 0
+ // Procedure-mode write targets (lmxopcua#781) are identifiers too — a Query tag's included.
+ var routines = authored.Values
+ .Where(definition => definition.Write is { Mode: SqlWriteMode.Procedure })
+ .Select(definition => definition.Write!.Procedure!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ var catalog = tables.Length == 0 && routines.Length == 0
? SqlCatalog.Empty
: await RunBoundedAsync(
- token => LoadCatalogAsync(tables, token),
+ token => LoadCatalogAsync(tables, routines, token),
_options.OperationTimeout,
"the catalog queries did not return",
cancellationToken).ConfigureAwait(false);
@@ -1235,7 +1280,8 @@ public sealed class SqlDriver
/// Opens one connection and reads the catalog slice the authored tables need.
private async Task LoadCatalogAsync(
- IReadOnlyCollection authoredTables, CancellationToken cancellationToken)
+ IReadOnlyCollection authoredTables, IReadOnlyCollection authoredRoutines,
+ CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
@@ -1246,7 +1292,7 @@ public sealed class SqlDriver
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return await SqlCatalogLoader
- .LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
+ .LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken, authoredRoutines)
.ConfigureAwait(false);
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
index 6e98dfc3..3e476637 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
@@ -1257,7 +1257,22 @@ public static class SqlStatusCodes
///
public const uint BadNotWritable = 0x803B0000u;
- /// The provider could not report how many rows a write touched, so it was rolled back.
+ ///
+ /// A write the database refused for a unique / primary-key violation (lmxopcua#781) — an Insert of a key that
+ /// exists, typically. (BadEntryExists, read off Opc.Ua.StatusCodes.)
+ ///
+ public const uint BadEntryExists = 0x809F0000u;
+
+ ///
+ /// A write the database refused for another integrity rule (NOT NULL, foreign key, CHECK) or a data exception
+ /// (overflow, truncation, an unconvertible value) — lmxopcua#781. (BadOutOfRange.)
+ ///
+ public const uint BadOutOfRange = 0x803C0000u;
+
+ ///
+ /// The provider could not report how many rows a write touched, so it was rolled back — or the dialect does not
+ /// serve the tag's write mode (Upsert / Procedure on Odbc, lmxopcua#781).
+ ///
public const uint BadNotSupported = 0x803D0000u;
/// The quality-class mask — the top two bits of an OPC UA status code.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs
index f5388678..5669c0e6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs
@@ -4,94 +4,235 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
-/// One compiled single-row write (lmxopcua#753): a parameterised UPDATE whose identifiers are the
-/// tag's catalog-validated ones and whose values are all bound.
+/// One compiled write (lmxopcua#753, #781): a parameterised statement whose identifiers are the tag's
+/// catalog-validated ones and whose values are all bound.
///
/// The statement — dialect-quoted identifiers and parameter markers only.
-/// The bind names, in marker order (positional providers bind in this order).
+///
+/// The distinct bind names, in first-marker order (a positional provider binds in this order, and only the modes
+/// it supports — Update and Insert — are planned for it, where every marker appears exactly once).
+///
/// The bound values, aligned with .
+/// The statement shape — decides how the executor reads the affected-row count.
+///
+/// The most rows the statement may report for a success: 1, or 2 for a MySQL upsert. Ignored for
+/// , whose row count is the procedure's business.
+///
public sealed record SqlWritePlan(
string SqlText,
IReadOnlyList ParameterBindNames,
- IReadOnlyList