diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md
index 6fe44613..a90a8e34 100644
--- a/.claude/rules/drivers.md
+++ b/.claude/rules/drivers.md
@@ -207,6 +207,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the
server-wide `IHistorianDataSource`. `isHistorized` on an OpcUaClient tag means OtOpcUa records it, not that upstream
history is proxied. A stored `ResilienceConfig` still carrying a `HistoryRead` key parses (diagnostic only).
+- **Sql (#753):** five providers through ONE map (`SqlDialects` — factory, probe and browser all read it; they drifted
+ when each had a switch). `Odbc` is the ANSI profile only (INFORMATION_SCHEMA + FETCH FIRST + positional `?` — callers
+ must add parameters in marker order). Named `Query` tags run inside an always-rolled-back transaction behind the
+ `SqlReadOnlyStatement` guard; writes are `allowWrites` + tag `"writable": true` + a single-row model, as a
+ parameterised UPDATE over gate-canonical identifiers that commits only on exactly one row. `docs/drivers/Sql.md`.
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index 78bc92f6..59ae77cf 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -3,8 +3,9 @@
> **Status lives in Gitea.** Open follow-ups and known gaps noted in this doc are tracked as issues on
> gitea.dohertylan.com/dohertj2/lmxopcua (label `migrated`, 2026-11-06). This doc describes the system — do not track status here; file new work as an issue.
-Getting-started guide for the `Sql` driver — a **read-only** Equipment-kind driver that polls rows
-out of a relational database and publishes them as OPC UA variables. For the design rationale read
+Getting-started guide for the `Sql` driver — an Equipment-kind driver that polls rows out of a
+relational database and publishes them as OPC UA variables. It is **read-only unless writes are
+explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753)). For the design rationale read
[`docs/plans/2026-07-15-sql-poll-driver-design.md`](../plans/2026-07-15-sql-poll-driver-design.md);
for the build-vs-plan record read
[`docs/plans/2026-07-24-sql-poll-driver.md`](../plans/2026-07-24-sql-poll-driver.md).
@@ -54,10 +55,8 @@ Two provider-specific things are worth knowing before you author against them:
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
-**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
-produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
-is authored `true` rather than failing, on the grounds that the flag cannot do harm but an operator
-who believes writes are enabled can. → #753
+**Read-only by default.** `allowWrites` is off unless authored `true`, and even then only a tag whose
+own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753).
## Tag models
@@ -186,7 +185,8 @@ row:
| Field | Effect |
|---|---|
| `timestampColumn` | Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
-| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. |
+| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. A write is coerced to it. |
+| `writable` | The tag's write opt-in — a JSON **boolean**; absent ⇒ read-only. Anything else (`"true"`, `1`) refuses the tag. Effective only with the driver's `allowWrites`. |
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
@@ -216,7 +216,7 @@ existed in the parser — nothing reads it.)
| `commandTimeout` | `00:00:10` | ADO.NET server-side backstop. **Must be less than `operationTimeout`** — it is the backstop, not the deadline. |
| `maxConcurrentGroups` | `4` | Connection-pool guard: cap on group queries in flight. |
| `nullIsBad` | `false` | `false` ⇒ a NULL cell publishes **Uncertain**; `true` ⇒ **Bad**. |
-| `allowWrites` | — | **Inert.** v1 has no write path; authoring `true` logs a warning. |
+| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753). |
| `queries` | — | Named read-only queries for `Query` tags — see [`Query`](#query--a-named-read-only-query-753). Edited as JSON in the driver form, which applies the factory's own rules. |
### Credentials never enter the deployment artifact
@@ -329,6 +329,43 @@ catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
+## Writes (opt-in, #753)
+
+A write reaches the database only when **every** gate holds, outermost first:
+
+1. the OPC UA node is Writable (the Tag's own flag) and the caller holds `WriteOperate` — the server;
+2. the driver config says `"allowWrites": true` — otherwise `BadNotWritable`;
+3. the tag's TagConfig says `"writable": true` — otherwise `BadNotWritable`;
+4. the model names exactly one row: **KeyValue**, or **WideRow with a `whereColumn`/`whereValue`
+ pair**. `topByTimestamp` ("the newest row" moves under a concurrent insert) and `Query` (a projection
+ names no table) are `BadNotWritable`.
+
+The write is the **only** statement shape the driver can produce — there is no authored write SQL,
+INSERT, UPSERT or procedure call:
+
+```sql
+UPDATE
SET = @v WHERE = @k
+```
+
+- **Identifiers are the catalog gate's**: a write resolves against the gate-accepted definition, so
+ every name in that statement was read back out of the catalog and dialect-quoted. A gate-rejected tag
+ answers `BadNodeIdUnknown`. The value and the key are always **bound parameters**.
+- **Exactly one row or nothing.** The UPDATE runs in a transaction that commits only when it touched
+ one row. Zero rows ⇒ `BadNoData` (the driver never inserts); more than one ⇒ rolled back,
+ `BadConfigurationError` (the selector is ambiguous — the read path's rule); a provider that cannot
+ report a row count ⇒ rolled back, `BadNotSupported`.
+- The value is coerced to the tag's `type` when it has one (`BadTypeMismatch` when it will not
+ coerce), else bound as the scalar the client sent. A null writes SQL NULL.
+- Bounded by `operationTimeout` (`BadTimeout`). A provider error after the connection opened — a
+ missing `UPDATE` grant, a constraint — is `BadCommunicationError`, nothing committed. Writes never
+ change driver health.
+- Discovery advertises `Operate` only for a tag where all of 2–4 hold, `ViewOnly` otherwise.
+- **Grant the login `UPDATE` on just the tables that carry writable tags.** The live gate
+ (`MySqlReadTests`) proves the server refuses a write to a SELECT-only table even with every driver
+ gate open.
+- A source timestamp column is **not** touched by a write, so the next read keeps the row's old
+ timestamp unless the database maintains it (a trigger or `ON UPDATE CURRENT_TIMESTAMP`).
+
## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
@@ -338,9 +375,10 @@ selector-less rather than dropping to a generic `{"address": …}` the typed edi
## Capabilities
-`IDriver`, `ITagDiscovery`, `IReadable`, `ISubscribable`, `IHostConnectivityProbe`.
+`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable` (opt-in, above), `ISubscribable`,
+`IHostConnectivityProbe`.
-No `IWritable` (structural, above). No `IRediscoverable` — a SQL schema change is not signalled, and
+No `IRediscoverable` — a SQL schema change is not signalled, and
`DiscoverAsync` echoes authored config rather than enumerating the database, so this driver is
deliberately excluded from any discovery-diff machinery.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
index 4f3f4b00..79e2372a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
@@ -44,10 +44,10 @@ public sealed class SqlDriverConfigDto
public bool? NullIsBad { get; init; }
///
- /// Master write kill-switch. v1 is read-only and this field is inert — the driver does not
- /// implement IWritable, so no value here can produce a write. The factory warns when it
- /// is authored rather than failing the driver, since the flag cannot do harm but
- /// an operator who believes writes are enabled can.
+ /// Master write switch (design §3.4, lmxopcua#753). Absent or ⇒ every write is
+ /// refused. ⇒ a tag may be written when its own TagConfig also says
+ /// "writable": true and its model names exactly one row; the node additionally needs its Writable
+ /// flag and the caller the WriteOperate role.
///
public bool? AllowWrites { get; init; }
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 1754c9af..bd3ef307 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
@@ -56,6 +56,16 @@ public static class SqlEquipmentTagParser
var timestampColumn = ReadString(root, "timestampColumn");
+ // 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
+ // write permission is the last field to coerce leniently.
+ var writable = false;
+ if (root.TryGetProperty("writable", out var writableEl))
+ {
+ if (writableEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
+ writable = writableEl.GetBoolean();
+ }
+
if (model == SqlTagModel.Query)
{
// A named query reads no table of its own: the tag names the driver-config query, the result
@@ -77,7 +87,7 @@ public static class SqlEquipmentTagParser
Name: rawPath, Model: model, Table: string.Empty,
TimestampColumn: timestampColumn, ColumnName: column,
DeclaredType: declaredType, QueryName: queryName,
- RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue);
+ RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue, Writable: writable);
return true;
}
@@ -98,7 +108,7 @@ public static class SqlEquipmentTagParser
def = new SqlTagDefinition(
Name: rawPath, Model: model, Table: table!,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
- TimestampColumn: timestampColumn, DeclaredType: declaredType);
+ TimestampColumn: timestampColumn, DeclaredType: declaredType, Writable: writable);
return true;
}
@@ -122,7 +132,7 @@ public static class SqlEquipmentTagParser
RowSelectorColumn: hasWherePair ? selectorColumn : null,
RowSelectorValue: hasWherePair ? selectorValue : null,
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
- DeclaredType: declaredType);
+ DeclaredType: declaredType, Writable: writable);
return true;
}
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 8bf64cc9..7144cf7f 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
@@ -34,6 +34,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// return exactly one row. Matched against the result set, never quoted into SQL.
///
/// : the value (matched as text).
+///
+/// The tag's own write opt-in ("writable": true in its TagConfig, design §3.4). Necessary but not
+/// sufficient: the driver also needs allowWrites, the model must name one row to update (KeyValue, or
+/// a WideRow where-pair), and the node needs its Writable flag plus the caller's WriteOperate role.
+///
///
/// Optional explicit type override from the blob's type field. ⇒ the
/// driver infers the type from the result set's column metadata.
@@ -53,4 +58,5 @@ public sealed record SqlTagDefinition(
DriverDataType? DeclaredType = null,
string? QueryName = null,
string? RowKeyColumn = null,
- string? RowKeyValue = null);
+ string? RowKeyValue = null,
+ bool Writable = false);
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 6750f1a1..2eb8b9c5 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
@@ -110,6 +110,12 @@ public static class SqlTagDefinitionFactory
if (typeWarning is not null) warnings.Add(typeWarning);
if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run
+ if (root.TryGetProperty("writable", out var writable)
+ && writable.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ {
+ warnings.Add("Sql TagConfig 'writable' must be a JSON boolean (true/false) — the driver will skip this tag.");
+ }
+
var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue);
if (model == SqlTagModel.Query)
{
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 f1f2e3b1..39f0fe3c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -9,15 +9,19 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
-/// The Sql driver instance: a read-only Equipment-kind driver that polls SQL tables/views
-/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
+/// The Sql driver instance: an Equipment-kind driver that polls SQL tables/views (and named read-only
+/// queries) and publishes selected cells as OPC UA variables — read-only unless writes are explicitly enabled. Structurally the SQL analogue of
/// ModbusDriver — this type owns lifecycle, the authored RawPath table, health, host
/// connectivity, and the subscription overlay, while
/// owns the read path.
-/// Read-only is structural, not configured. is deliberately not
-/// implemented, so no amount of config (and no WriteOperate role) can produce a write; every
-/// discovered variable is . A future write feature is a
-/// new capability, not a flag flip.
+/// 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
+/// and every variable discovers as
+/// .
/// Health is classified here, because nothing below does it. The reader honours
/// literally: it throws only when the database cannot be reached, and turns
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
@@ -28,7 +32,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// manufacture an exception to force backoff.
///
public sealed class SqlDriver
- : IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
+ : IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter,
+ IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
private readonly SkippedTagLedger _skippedTags = new();
@@ -532,8 +537,9 @@ public sealed class SqlDriver
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
IsArray: false,
ArrayDim: null,
- // v1 is read-only: no tag, and no configuration, can widen this.
- SecurityClass: SecurityClassification.ViewOnly,
+ // Operate only when a write could actually succeed — the driver switch, the tag's opt-in and a
+ // single-row model all hold; otherwise the node advertises what it is: read-only.
+ SecurityClass: IsWritePermitted(tag) ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
@@ -612,6 +618,131 @@ public sealed class SqlDriver
}
}
+ // ---- IWritable (design §3.4, lmxopcua#753) ----
+
+ ///
+ /// Whether a write to is permitted by configuration: the driver's allowWrites,
+ /// the tag's own writable opt-in, and a model with a single-row target. Says nothing about the node's
+ /// 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));
+
+ ///
+ // 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.
+ public async Task> WriteAsync(
+ IReadOnlyList writes, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(writes);
+ var results = new WriteResult[writes.Count];
+ for (var i = 0; i < writes.Count; i++)
+ {
+ var write = writes[i];
+ results[i] = new WriteResult(await WriteOneAsync(write, cancellationToken).ConfigureAwait(false));
+ }
+
+ return results;
+ }
+
+ private async Task WriteOneAsync(WriteRequest write, CancellationToken cancellationToken)
+ {
+ 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;
+
+ var planned = SqlWritePlanner.TryPlan(tag, write.Value, _dialect, out var plan);
+ if (planned != SqlStatusCodes.Good) return planned;
+
+ try
+ {
+ return await RunBoundedAsync(
+ token => ExecuteWriteAsync(tag, plan!, token),
+ _options.OperationTimeout,
+ "the write did not return",
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning("Sql write to '{Tag}' timed out against {Endpoint}. Driver={DriverInstanceId}",
+ tag.Name, Endpoint, _driverInstanceId);
+ return SqlStatusCodes.BadTimeout;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller's write deadline fired; the uncommitted transaction dies with its connection.
+ return SqlStatusCodes.BadTimeout;
+ }
+ 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;
+ }
+ }
+
+ /// Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.
+ private async Task ExecuteWriteAsync(SqlTagDefinition tag, SqlWritePlan plan, CancellationToken cancellationToken)
+ {
+ var connection = _factory.CreateConnection()
+ ?? throw new InvalidOperationException(
+ $"the {_dialect.Provider} provider factory returned no connection.");
+
+ await using (connection.ConfigureAwait(false))
+ {
+ connection.ConnectionString = _connectionString;
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await using (transaction.ConfigureAwait(false))
+ {
+ int rows;
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+ _dialect.ConfigureCommand(command);
+ command.Transaction = transaction;
+ command.CommandText = plan.SqlText;
+ command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
+ for (var i = 0; i < plan.ParameterBindNames.Count; i++)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = plan.ParameterBindNames[i];
+ parameter.Value = plan.Parameters[i] ?? DBNull.Value;
+ _dialect.ConfigureParameter(parameter, plan.Parameters[i]);
+ command.Parameters.Add(parameter);
+ }
+
+ rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ if (rows == 1)
+ {
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return SqlStatusCodes.Good;
+ }
+
+ await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
+ 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 " +
+ "exactly one row. Driver={DriverInstanceId}",
+ tag.Name, rows, tag.Table, _driverInstanceId);
+ return rows > 1 ? SqlStatusCodes.BadConfigurationError : SqlStatusCodes.BadNotSupported;
+ }
+ }
+ }
+
// ---- ISubscribable (polling overlay via the shared engine) ----
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
index a0bfc63c..445b77fd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
@@ -148,18 +148,6 @@ public static class SqlDriverFactoryExtensions
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
?? NullLogger.Instance;
- if (dto.AllowWrites is true)
- {
- // Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
- // would brick a deployment for a flag that cannot do harm. The operator who believes writes are
- // on, however, can — so this is loud.
- logger.LogWarning(
- "Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
- "driver is read-only (it implements no write capability), so no configuration can enable a " +
- "write. Remove the flag, or expect reads only.",
- driverInstanceId);
- }
-
var driver = new SqlDriver(
options,
driverInstanceId,
@@ -176,9 +164,10 @@ public static class SqlDriverFactoryExtensions
// permitted outside the provider call.
logger.LogInformation(
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
- "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
+ "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}, writes {Writes}.",
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
- options.DefaultPollInterval, options.OperationTimeout);
+ options.DefaultPollInterval, options.OperationTimeout,
+ options.AllowWrites ? "allowed for opted-in tags" : "off");
return driver;
}
@@ -273,6 +262,7 @@ public static class SqlDriverFactoryExtensions
CommandTimeout = commandTimeout,
MaxConcurrentGroups = maxConcurrentGroups,
NullIsBad = dto.NullIsBad ?? false,
+ AllowWrites = dto.AllowWrites ?? false,
Queries = BuildQueries(dto, driverInstanceId, dialect),
};
}
@@ -372,8 +362,8 @@ public static class SqlDriverFactoryExtensions
/// The typed driver options.
/// The provider seam the provider key selects.
/// The connection string resolved from connectionStringRef. Never logged.
-/// The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
-/// allowWrites, which the driver ignores but warns about) need not re-deserialize.
+/// The raw parsed DTO, so a caller needing a key the options do not carry need not
+/// re-deserialize.
public sealed record SqlDriverBinding(
SqlDriverOptions Options,
ISqlDialect Dialect,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
index 45582b6f..5c84a896 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverOptions.cs
@@ -54,6 +54,14 @@ public sealed class SqlDriverOptions
///
public bool NullIsBad { get; init; }
+ ///
+ /// The driver-level write switch (design §3.4, lmxopcua#753). (the default) refuses
+ /// every write with whatever a tag says;
+ /// permits writes to tags that ALSO opted in () and whose model names
+ /// one row (KeyValue, or a WideRow where-pair).
+ ///
+ public bool AllowWrites { get; init; }
+
///
/// The validated named queries (design §5.4) a tag reads by name, keyed
/// ordinally. Empty when the config declares none.
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 3c69bee0..6e98dfc3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs
@@ -1021,7 +1021,7 @@ public sealed class SqlPollReader : IDisposable
/// Coerces a provider cell to the tag's effective , so the published CLR
/// type matches the type the address space declared for the node. Never throws.
///
- private static bool TryCoerce(object cell, DriverDataType target, out object? value)
+ internal static bool TryCoerce(object cell, DriverDataType target, out object? value)
{
try
{
@@ -1251,6 +1251,15 @@ public static class SqlStatusCodes
/// A defect in the reader itself; never expected in the field.
public const uint BadInternalError = 0x80020000u;
+ ///
+ /// A write the driver refuses by configuration: allowWrites is off, the tag did not opt in, or its
+ /// model names no single row to update (lmxopcua#753).
+ ///
+ public const uint BadNotWritable = 0x803B0000u;
+
+ /// The provider could not report how many rows a write touched, so it was rolled back.
+ public const uint BadNotSupported = 0x803D0000u;
+
/// The quality-class mask — the top two bits of an OPC UA status code.
private const uint SeverityMask = 0xC0000000u;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs
new file mode 100644
index 00000000..f5388678
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlWritePlanner.cs
@@ -0,0 +1,124 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+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.
+///
+/// The statement — dialect-quoted identifiers and parameter markers only.
+/// The bind names, in marker order (positional providers bind in this order).
+/// The bound values, aligned with .
+public sealed record SqlWritePlan(
+ string SqlText,
+ IReadOnlyList ParameterBindNames,
+ IReadOnlyList