feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Item 2: a tag's optional `write` block picks the statement the driver builds from its own catalog-validated identifiers (no authored SQL; values always bound): - Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData) - Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag) - Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL - Procedure: named stored procedure resolved against the routine catalog by the catalog gate (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional) - stampTimestamp sets the tag's timestamp column to the write time (UTC) Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else, permission denied included, stays BadCommunicationError. WriteIdempotent stays false. The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI editor surfaces the same message. Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login limited to their write targets. Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end), DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server, PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -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. |
|
||||
|
||||
+145
-42
@@ -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 <table> SET <valueColumn | columnName> = @v WHERE <keyColumn | whereColumn> = @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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user