feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run

allowWrites is no longer inert: SqlDriver implements IWritable, default off.

- Gates (outermost first): node Writable + WriteOperate (server), driver
  allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean;
  anything else refuses the tag, and the deploy gate says why), and a model that
  names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and
  Query are BadNotWritable.
- SqlWritePlanner: the only statement a write can become is
  UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the
  catalog-gate-canonical identifiers (dialect-quoted) with value and key bound;
  value-before-key marker order so positional ODBC binds correctly. No authored
  write SQL, INSERT, UPSERT or procedure.
- Executed in a transaction that commits only when exactly one row was touched:
  0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError,
  unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout;
  provider errors -> BadCommunicationError; health untouched.
- Discovery advertises Operate only where a write could succeed.
- AdminUI: driver form's Allow writes switch is live; tag editor gains a
  per-tag write switch; model validates writable strictly (parity corpus).
- Factory no longer warns "allowWrites ignored"; it logs whether writes are on.
- Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate,
  readback, absent key, two-row rollback, gate-rejected tag, discovery);
  MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback,
  server-side grant refusal, default-off). Seed adds a setpoints table and the
  one UPDATE grant.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:21:31 -05:00
parent 3c9ab4b668
commit 2331f7ed14
22 changed files with 916 additions and 75 deletions
+5
View File
@@ -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
+48 -10
View File
@@ -3,8 +3,9 @@
> **Status lives in Gitea.** Open follow-ups and known gaps noted in this doc are tracked as issues on
> gitea.dohertylan.com/dohertj2/lmxopcua (label `migrated`, 2026-11-06). This doc describes the system — do not track status here; file new work as an issue.
Getting-started guide for the `Sql` driver — a **read-only** Equipment-kind driver that polls rows
out of a relational database and publishes them as OPC UA variables. For the design rationale read
Getting-started guide for the `Sql` driver — an Equipment-kind driver that polls rows out of a
relational database and publishes them as OPC UA variables. It is **read-only unless writes are
explicitly enabled** at both the driver and the tag (see [Writes](#writes-opt-in-753)). For the design rationale read
[`docs/plans/2026-07-15-sql-poll-driver-design.md`](../plans/2026-07-15-sql-poll-driver-design.md);
for the build-vs-plan record read
[`docs/plans/2026-07-24-sql-poll-driver.md`](../plans/2026-07-24-sql-poll-driver.md).
@@ -54,10 +55,8 @@ Two provider-specific things are worth knowing before you author against them:
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
is authored `true` rather than failing, on the grounds that the flag cannot do harm but an operator
who believes writes are enabled can. → #753
**Read-only by default.** `allowWrites` is off unless authored `true`, and even then only a tag whose
own TagConfig says `"writable": true` can be written — see [Writes](#writes-opt-in-753).
## Tag models
@@ -186,7 +185,8 @@ row:
| Field | Effect |
|---|---|
| `timestampColumn` | Source timestamp for the value. Absent ⇒ the driver stamps the poll time. |
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. |
| `type` | Explicit OPC UA type override. Absent ⇒ inferred from the result set's column metadata. A write is coerced to it. |
| `writable` | The tag's write opt-in — a JSON **boolean**; absent ⇒ read-only. Anything else (`"true"`, `1`) refuses the tag. Effective only with the driver's `allowWrites`. |
There is **no per-tag poll-interval field**. The interval is the subscriber's, floored by
`PollGroupEngine.DefaultMinInterval`, falling back to the instance's `defaultPollInterval` when the
@@ -216,7 +216,7 @@ existed in the parser — nothing reads it.)
| `commandTimeout` | `00:00:10` | ADO.NET server-side backstop. **Must be less than `operationTimeout`** — it is the backstop, not the deadline. |
| `maxConcurrentGroups` | `4` | Connection-pool guard: cap on group queries in flight. |
| `nullIsBad` | `false` | `false` ⇒ a NULL cell publishes **Uncertain**; `true` ⇒ **Bad**. |
| `allowWrites` | — | **Inert.** v1 has no write path; authoring `true` logs a warning. |
| `allowWrites` | `false` | Driver-level write switch. Off ⇒ every write is `BadNotWritable`. See [Writes](#writes-opt-in-753). |
| `queries` | — | Named read-only queries for `Query` tags — see [`Query`](#query--a-named-read-only-query-753). Edited as JSON in the driver form, which applies the factory's own rules. |
### Credentials never enter the deployment artifact
@@ -329,6 +329,43 @@ catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
## Writes (opt-in, #753)
A write reaches the database only when **every** gate holds, outermost first:
1. the OPC UA node is Writable (the Tag's own flag) and the caller holds `WriteOperate` — the server;
2. the driver config says `"allowWrites": true` — otherwise `BadNotWritable`;
3. the tag's TagConfig says `"writable": true` — otherwise `BadNotWritable`;
4. the model names exactly one row: **KeyValue**, or **WideRow with a `whereColumn`/`whereValue`
pair**. `topByTimestamp` ("the newest row" moves under a concurrent insert) and `Query` (a projection
names no table) are `BadNotWritable`.
The write is the **only** statement shape the driver can produce — there is no authored write SQL,
INSERT, UPSERT or procedure call:
```sql
UPDATE <table> SET <valueColumn | columnName> = @v WHERE <keyColumn | whereColumn> = @k
```
- **Identifiers are the catalog gate's**: a write resolves against the gate-accepted definition, so
every name in that statement was read back out of the catalog and dialect-quoted. A gate-rejected tag
answers `BadNodeIdUnknown`. The value and the key are always **bound parameters**.
- **Exactly one row or nothing.** The UPDATE runs in a transaction that commits only when it touched
one row. Zero rows ⇒ `BadNoData` (the driver never inserts); more than one ⇒ rolled back,
`BadConfigurationError` (the selector is ambiguous — the read path's rule); a provider that cannot
report a row count ⇒ rolled back, `BadNotSupported`.
- The value is coerced to the tag's `type` when it has one (`BadTypeMismatch` when it will not
coerce), else bound as the scalar the client sent. A null writes SQL NULL.
- Bounded by `operationTimeout` (`BadTimeout`). A provider error after the connection opened — a
missing `UPDATE` grant, a constraint — is `BadCommunicationError`, nothing committed. Writes never
change driver health.
- Discovery advertises `Operate` only for a tag where all of 2–4 hold, `ViewOnly` otherwise.
- **Grant the login `UPDATE` on just the tables that carry writable tags.** The live gate
(`MySqlReadTests`) proves the server refuses a write to a SELECT-only table even with every driver
gate open.
- A source timestamp column is **not** touched by a write, so the next read keeps the row's old
timestamp unless the database maintains it (a trigger or `ON UPDATE CURRENT_TIMESTAMP`).
## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
@@ -338,9 +375,10 @@ selector-less rather than dropping to a generic `{"address": …}` the typed edi
## Capabilities
`IDriver`, `ITagDiscovery`, `IReadable`, `ISubscribable`, `IHostConnectivityProbe`.
`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable` (opt-in, above), `ISubscribable`,
`IHostConnectivityProbe`.
No `IWritable` (structural, above). No `IRediscoverable` — a SQL schema change is not signalled, and
No `IRediscoverable` — a SQL schema change is not signalled, and
`DiscoverAsync` echoes authored config rather than enumerating the database, so this driver is
deliberately excluded from any discovery-diff machinery.
@@ -44,10 +44,10 @@ public sealed class SqlDriverConfigDto
public bool? NullIsBad { get; init; }
/// <summary>
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
/// is authored <see langword="true"/> 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 <see langword="false"/> ⇒ every write is
/// refused. <see langword="true"/> ⇒ a tag may be written when its own TagConfig also says
/// <c>"writable": true</c> and its model names exactly one row; the node additionally needs its Writable
/// flag and the caller the WriteOperate role.
/// </summary>
public bool? AllowWrites { get; init; }
@@ -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;
}
@@ -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.
/// </param>
/// <param name="RowKeyValue"><see cref="SqlTagModel.Query"/>: the <see cref="RowKeyColumn"/> value (matched as text).</param>
/// <param name="Writable">
/// The tag's own write opt-in (<c>"writable": true</c> in its TagConfig, design §3.4). Necessary but not
/// sufficient: the driver also needs <c>allowWrites</c>, 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.
/// </param>
/// <param name="DeclaredType">
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ 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);
@@ -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)
{
@@ -9,15 +9,19 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
/// The <c>Sql</c> 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
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
/// <see cref="SqlPollReader"/> owns the read path.
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
/// new capability, not a flag flip.</para>
/// <para><b>Writes are opt-in at two levels and default off</b> (design §3.4, lmxopcua#753). A write needs
/// the driver's <c>allowWrites</c>, the tag's own <c>"writable": true</c>, 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 <c>UPDATE</c> over the tag's
/// catalog-validated identifiers (<see cref="SqlWritePlanner"/>), run in a transaction that commits only when
/// exactly one row was touched. With <c>allowWrites</c> off every write is
/// <see cref="SqlStatusCodes.BadNotWritable"/> and every variable discovers as
/// <see cref="SecurityClassification.ViewOnly"/>.</para>
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
/// <see cref="IReadable"/> 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.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter,
IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table.</summary>
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) ----
/// <summary>
/// Whether a write to <paramref name="tag"/> is permitted by configuration: the driver's <c>allowWrites</c>,
/// the tag's own <c>writable</c> 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.
/// </summary>
private bool IsWritePermitted(SqlTagDefinition tag)
=> _options.AllowWrites && tag.Writable
&& (tag.Model == SqlTagModel.KeyValue
|| (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null));
/// <inheritdoc />
// 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<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> 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<uint> 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;
}
}
/// <summary>Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.</summary>
private async Task<uint> 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) ----
/// <inheritdoc />
@@ -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
/// <param name="Options">The typed driver options.</param>
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry need not
/// re-deserialize.</param>
public sealed record SqlDriverBinding(
SqlDriverOptions Options,
ISqlDialect Dialect,
@@ -54,6 +54,14 @@ public sealed class SqlDriverOptions
/// </summary>
public bool NullIsBad { get; init; }
/// <summary>
/// The driver-level write switch (design §3.4, lmxopcua#753). <see langword="false"/> (the default) refuses
/// every write with <see cref="SqlStatusCodes.BadNotWritable"/> whatever a tag says; <see langword="true"/>
/// permits writes to tags that ALSO opted in (<see cref="SqlTagDefinition.Writable"/>) and whose model names
/// one row (KeyValue, or a WideRow where-pair).
/// </summary>
public bool AllowWrites { get; init; }
/// <summary>
/// The validated named queries (design §5.4) a <see cref="SqlTagModel.Query"/> tag reads by name, keyed
/// ordinally. Empty when the config declares none.
@@ -1021,7 +1021,7 @@ public sealed class SqlPollReader : IDisposable
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
/// type matches the type the address space declared for the node. Never throws.
/// </summary>
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
/// <summary>A defect in the reader itself; never expected in the field.</summary>
public const uint BadInternalError = 0x80020000u;
/// <summary>
/// A write the driver refuses by configuration: <c>allowWrites</c> is off, the tag did not opt in, or its
/// model names no single row to update (lmxopcua#753).
/// </summary>
public const uint BadNotWritable = 0x803B0000u;
/// <summary>The provider could not report how many rows a write touched, so it was rolled back.</summary>
public const uint BadNotSupported = 0x803D0000u;
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
private const uint SeverityMask = 0xC0000000u;
@@ -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;
/// <summary>
/// One compiled single-row write (lmxopcua#753): a parameterised <c>UPDATE</c> whose identifiers are the
/// tag's catalog-validated ones and whose values are all bound.
/// </summary>
/// <param name="SqlText">The statement — dialect-quoted identifiers and parameter markers only.</param>
/// <param name="ParameterBindNames">The bind names, in marker order (positional providers bind in this order).</param>
/// <param name="Parameters">The bound values, aligned with <paramref name="ParameterBindNames"/>.</param>
public sealed record SqlWritePlan(
string SqlText,
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters);
/// <summary>
/// Turns one OPC UA write into a parameterised single-row <c>UPDATE</c> (design §3.4, lmxopcua#753) — pure, no
/// I/O. <b>The write surface is deliberately narrow:</b> no authored SQL, no INSERT/UPSERT/procedure, only
/// <c>UPDATE &lt;table&gt; SET &lt;value column&gt; = @v WHERE &lt;key column&gt; = @k</c> built from the
/// tag's own identifiers.
/// <list type="bullet">
/// <item><see cref="SqlTagModel.KeyValue"/>: <c>SET valueColumn = @v WHERE keyColumn = @k</c>.</item>
/// <item><see cref="SqlTagModel.WideRow"/> with a where-pair: <c>SET columnName = @v WHERE whereColumn = @w</c>.</item>
/// <item><see cref="SqlTagModel.WideRow"/> by <c>topByTimestamp</c>, and <see cref="SqlTagModel.Query"/>:
/// refused (<see cref="SqlStatusCodes.BadNotWritable"/>) — "the newest row" moves under a concurrent
/// insert, and a named query's projection names no table to update.</item>
/// </list>
/// <para><b>Injection boundary.</b> Every identifier is one the catalog gate resolved and substituted (the
/// driver plans writes only for <em>polled</em>, i.e. gate-accepted, definitions) and reaches the text through
/// <see cref="ISqlDialect.QuoteIdentifier"/>; the written value and the key are bound parameters. Nothing an
/// OPC UA client sends reaches the command text.</para>
/// </summary>
public static class SqlWritePlanner
{
private const string ValueBaseName = "v";
private const string KeyBaseName = "k";
/// <summary>
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
/// </summary>
/// <param name="tag">The catalog-validated definition.</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="dialect">Quoting, markers and bind names.</param>
/// <param name="plan">The plan, when this returns <see cref="SqlStatusCodes.Good"/>.</param>
/// <returns><see cref="SqlStatusCodes.Good"/>, or the status the write publishes.</returns>
public static uint TryPlan(SqlTagDefinition tag, object? value, ISqlDialect dialect, out SqlWritePlan? plan)
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(dialect);
plan = null;
string setColumn, keyColumn;
string? keyValue;
switch (tag.Model)
{
case SqlTagModel.KeyValue when !Blank(tag.ValueColumn) && !Blank(tag.KeyColumn) && tag.KeyValue is not null:
setColumn = tag.ValueColumn!;
keyColumn = tag.KeyColumn!;
keyValue = tag.KeyValue;
break;
case SqlTagModel.WideRow when !Blank(tag.ColumnName) && !Blank(tag.RowSelectorColumn)
&& tag.RowSelectorValue is not null:
setColumn = tag.ColumnName!;
keyColumn = tag.RowSelectorColumn!;
keyValue = tag.RowSelectorValue;
break;
default:
return SqlStatusCodes.BadNotWritable;
}
if (!TryConvert(value, tag.DeclaredType, out var bound)) return SqlStatusCodes.BadTypeMismatch;
string sql;
try
{
sql = string.Concat(
"UPDATE ", QuoteQualifiedName(tag.Table, dialect),
" SET ", dialect.QuoteIdentifier(setColumn), " = ", dialect.ParameterMarker(ValueBaseName),
" WHERE ", dialect.QuoteIdentifier(keyColumn), " = ", dialect.ParameterMarker(KeyBaseName));
}
catch (ArgumentException)
{
// Unreachable for a gate-accepted definition (its identifiers came from the catalog); refuse rather
// than build a statement around a name the dialect would not quote.
return SqlStatusCodes.BadConfigurationError;
}
// Marker order: SET's value first, then WHERE's key — a positional provider (ODBC) binds in this order.
plan = new SqlWritePlan(
sql,
[dialect.ParameterBindName(ValueBaseName), dialect.ParameterBindName(KeyBaseName)],
[bound, keyValue]);
return SqlStatusCodes.Good;
}
/// <summary>
/// Converts the client's value to what is bound: coerced to the tag's declared <c>type</c> when it has one
/// (the same coercion a read applies), otherwise passed through when it is a scalar a provider can bind.
/// A null writes SQL NULL.
/// </summary>
private static bool TryConvert(object? value, DriverDataType? declared, out object? bound)
{
bound = null;
if (value is null) return true;
if (declared is { } type) return SqlPollReader.TryCoerce(value, type, out bound);
if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong
or float or double or decimal or DateTime or DateTimeOffset or Guid)
{
bound = value is DateTime dt && dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : value;
return true;
}
return false;
}
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.table</c> name.</summary>
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
=> string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
}
@@ -1,4 +1,4 @@
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
@* Embeddable Sql driver (connection/dialect) config form body. There is NO per-device
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
@@ -90,11 +90,13 @@
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-2">
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
<InputCheckbox @bind-Value="_form.AllowWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlAllowWrites" />
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
</div>
<div class="form-text mt-0">
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
Off = every write refused (default). On = only tags whose TagConfig says <code>writable: true</code> are
written, as a single-row parameterised <code>UPDATE</code> committed only when exactly one row matches.
Give the connecting login UPDATE on just those tables.
</div>
</div>
</div>
@@ -159,7 +161,7 @@
}
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
/// rawTags is never emitted (the composer adds it at deploy); allowWrites only when switched on.</summary>
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
private async Task EmitAsync()
@@ -182,8 +184,8 @@
}
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags is not mirrored — the
/// composer owns it.</summary>
public sealed class FormModel
{
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
@@ -194,6 +196,9 @@
public int? MaxConcurrentGroups { get; set; }
public bool NullIsBad { get; set; }
/// <summary>The driver-level write switch; unchecked ⇒ omitted ⇒ read-only.</summary>
public bool AllowWrites { get; set; }
/// <summary>The named queries the form emits — the last text that parsed and validated.</summary>
public Dictionary<string, SqlNamedQueryDto>? Queries { get; set; }
@@ -254,6 +259,7 @@
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
MaxConcurrentGroups = d.MaxConcurrentGroups,
NullIsBad = d.NullIsBad ?? false,
AllowWrites = d.AllowWrites ?? false,
Queries = d.Queries,
QueriesJson = d.Queries is { Count: > 0 } q ? JsonSerializer.Serialize(q, QueryJsonOptions) : "",
};
@@ -266,9 +272,10 @@
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
MaxConcurrentGroups = MaxConcurrentGroups,
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
// and rawTags are deliberately left null so they are omitted from the authored blob.
// Emit nullIsBad / allowWrites only when true; unchecked ⇒ null ⇒ omitted ⇒ the factory default
// (Uncertain / read-only). rawTags is deliberately left null — the composer owns it.
NullIsBad = NullIsBad ? true : null,
AllowWrites = AllowWrites ? true : null,
Queries = Queries is { Count: > 0 } ? Queries : null,
};
@@ -87,6 +87,21 @@ else
</div>
}
<div class="row g-2 mt-1">
<div class="col-md-6">
<div class="form-check form-switch mt-2">
<input type="checkbox" class="form-check-input" id="sqlTagWritable" checked="@(_m.Writable == true)"
disabled="@(_m.Model == SqlTagModel.Query)"
@onchange="@(e => Update(() => _m.Writable = e.Value is true ? true : null))" />
<label class="form-check-label" for="sqlTagWritable">Allow writes to this tag</label>
</div>
<div class="form-text mt-0">
Also needs the driver's <em>Allow writes</em> and the tag's Writable flag. Writes are a single-row
<code>UPDATE</code> of the value column — KeyValue, or WideRow by where-pair only.
</div>
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
@@ -54,6 +54,11 @@ public sealed class SqlTagConfigModel
/// exactly one row.</summary>
public SqlRowKeyModel RowKey { get; set; } = new();
/// <summary>The tag's own write opt-in (the blob's <c>writable</c>). <c>null</c> ⇒ absent ⇒ read-only. Takes effect
/// only when the driver's <c>allowWrites</c> is on and the model names one row (KeyValue, or a WideRow where-pair);
/// the Tag's Writable flag still gates the OPC UA node.</summary>
public bool? Writable { get; set; }
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
/// driver infers the type from the result-set column metadata.</summary>
public DriverDataType? Type { get; set; }
@@ -65,6 +70,9 @@ public sealed class SqlTagConfigModel
private string? _invalidModelRaw;
private string? _invalidTypeRaw;
// True when "writable" is present but not a JSON boolean — the parser refuses that tag.
private bool _invalidWritable;
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// parse consult (see TagConfigParseGate).
private string? _source;
@@ -91,6 +99,9 @@ public sealed class SqlTagConfigModel
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
ColumnName = TagConfigJson.GetString(o, "columnName"),
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
Writable = o.TryGetPropertyValue("writable", out var wn) && wn is JsonValue wv && wv.TryGetValue<bool>(out var wb) ? wb : null,
_invalidWritable = o.TryGetPropertyValue("writable", out var wn2)
&& !(wn2 is JsonValue wv2 && wv2.TryGetValue<bool>(out _)),
QueryName = TagConfigJson.GetString(o, "query"),
Column = TagConfigJson.GetString(o, "column"),
RowKey = SqlRowKeyModel.FromJson(o.TryGetPropertyValue("rowKey", out var rk) ? rk : null),
@@ -117,6 +128,8 @@ public sealed class SqlTagConfigModel
TagConfigJson.Set(_bag, "columnName", ColumnName);
TagConfigJson.Set(_bag, "type", Type);
TagConfigJson.Set(_bag, "query", QueryName);
if (Writable is { } writable) { _bag["writable"] = writable; }
else if (!_invalidWritable) { _bag.Remove("writable"); }
TagConfigJson.Set(_bag, "column", Column);
var rowKey = RowKey.ToJsonNode();
@@ -138,6 +151,7 @@ public sealed class SqlTagConfigModel
// Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag.
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
if (_invalidWritable && Writable is null) { return "writable must be true or false (a JSON boolean)."; }
if (Model == SqlTagModel.Query)
{
@@ -6,8 +6,8 @@
-- backends. Two things are load-bearing:
--
-- 1. The suite connects as `otopcua`, which holds SELECT on `sqlpoll` and nothing else (plus EXECUTE on the
-- side-effect function, below). A MySQL "schema" is a database, and information_schema shows a principal
-- only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
-- side-effect function and UPDATE on the one write-target table, below). A MySQL "schema" is a database,
-- and information_schema shows a principal only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
-- login is the real provisioning check, not a superuser's view.
-- 2. Every identifier is lower case. The suite authors the table name in UPPER case and expects the catalog
-- gate to resolve it through a unique case-insensitive match: on Linux, MySQL table names are case-
@@ -65,9 +65,23 @@ BEGIN
END//
DELIMITER ;
-- Write target (lmxopcua#753): the ONLY table the suite login may UPDATE. `dup` deliberately holds two rows
-- for one key, so a write keyed on it matches two rows and must be rolled back. tag_values stays SELECT-only,
-- so a write tag pointed there proves the server's own grant refuses it.
CREATE TABLE setpoints (
tag_name varchar(128) NOT NULL,
sp_value double NULL
) ENGINE = InnoDB;
INSERT INTO setpoints (tag_name, sp_value) VALUES
('Line1.SpeedSp', 10.0),
('Line1.TempSp', 20.0),
('dup', 1.0),
('dup', 2.0);
-- ── the read-only login ──────────────────────────────────────────────────────────────────────
DROP USER IF EXISTS 'otopcua'@'%';
CREATE USER 'otopcua'@'%' IDENTIFIED BY '__APP_PASSWORD__';
GRANT SELECT ON sqlpoll.* TO 'otopcua'@'%';
GRANT EXECUTE ON FUNCTION sqlpoll.bump_counter TO 'otopcua'@'%';
GRANT UPDATE ON sqlpoll.setpoints TO 'otopcua'@'%';
FLUSH PRIVILEGES;
@@ -265,6 +265,88 @@ public sealed class MySqlReadTests
(await _my.ScalarAsync("SELECT COUNT(*) FROM sqlpoll.side_effects")).ShouldBe(0L);
}
// ---- writes (design §3.4) against a real server ----
/// <summary>All gates open: the UPDATE lands on exactly the keyed row, and the driver reads it back.</summary>
[Fact]
public async Task Write_realMySql_updatesTheKeyedRowOnly()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true,
Setpoint("Sql/Sp/Speed", "Line1.SpeedSp", writable: true));
var target = 10.0 + Random.Shared.Next(1, 1000);
(await WriteAsync(driver, "Sql/Sp/Speed", target)).ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.SpeedSp'"),
CultureInfo.InvariantCulture).ShouldBe(target);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.TempSp'"),
CultureInfo.InvariantCulture).ShouldBe(20.0);
Convert.ToDouble((await driver.ReadAsync(["Sql/Sp/Speed"], TestContext.Current.CancellationToken))[0].Value,
CultureInfo.InvariantCulture).ShouldBe(target);
}
/// <summary>A key matching two rows is rolled back on InnoDB — neither row changes.</summary>
[Fact]
public async Task Write_realMySql_aKeyMatchingTwoRowsIsRolledBack()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true, Setpoint("Sql/Sp/Dup", "dup", writable: true));
(await WriteAsync(driver, "Sql/Sp/Dup", 99.0)).ShouldBe(SqlStatusCodes.BadConfigurationError);
(await _my.ScalarAsync("SELECT COUNT(*) FROM sqlpoll.setpoints WHERE tag_name = 'dup' AND sp_value = 99")).ShouldBe(0L);
}
/// <summary>
/// Defence in depth: with every driver gate open, a table the login holds no UPDATE on is refused by the
/// server itself (the provider error surfaces as BadCommunicationError) and the row is unchanged.
/// </summary>
[Fact]
public async Task Write_realMySql_aTableWithoutAnUpdateGrantIsRefusedByTheServer()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: true, Tag("Sql/Kv/Speed", new JsonObject
{
["model"] = "KeyValue", ["table"] = MySqlFixture.Qualified("tag_values"), ["keyColumn"] = "tag_name",
["keyValue"] = MySqlFixture.PresentKey, ["valueColumn"] = "num_value", ["writable"] = true,
}));
(await WriteAsync(driver, "Sql/Kv/Speed", 1.0)).ShouldBe(SqlStatusCodes.BadCommunicationError);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT num_value FROM sqlpoll.tag_values WHERE tag_name = 'Line1.Speed'"),
CultureInfo.InvariantCulture).ShouldBe(MySqlFixture.PresentValue);
}
/// <summary>The default: no allowWrites ⇒ refused before any connection, whatever the tag says.</summary>
[Fact]
public async Task Write_realMySql_withoutAllowWritesIsRefused()
{
SkipUnlessLive();
await using var driver = await StartAsync(null, allowWrites: false, Setpoint("Sql/Sp/Temp", "Line1.TempSp", writable: true));
(await WriteAsync(driver, "Sql/Sp/Temp", 77.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
Convert.ToDouble(await _my.ScalarRawAsync("SELECT sp_value FROM sqlpoll.setpoints WHERE tag_name = 'Line1.TempSp'"),
CultureInfo.InvariantCulture).ShouldBe(20.0);
}
private static RawTagEntry Setpoint(string rawPath, string key, bool writable) => Tag(rawPath, new JsonObject
{
["model"] = "KeyValue",
["table"] = MySqlFixture.Qualified("setpoints"),
["keyColumn"] = "tag_name",
["keyValue"] = key,
["valueColumn"] = "sp_value",
["type"] = "Float64",
["writable"] = writable,
});
private static async Task<uint> WriteAsync(SqlDriver driver, string reference, object? value)
=> (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().StatusCode;
// ---- helpers ----
private void SkipUnlessLive()
@@ -274,13 +356,16 @@ public sealed class MySqlReadTests
private Task<SqlDriver> StartAsync(params RawTagEntry[] tags) => StartAsync(queriesJson: null, tags);
private Task<SqlDriver> StartAsync(string? queriesJson, params RawTagEntry[] tags)
=> StartAsync(queriesJson, allowWrites: false, tags);
/// <summary>
/// Builds and initializes a driver through the PRODUCTION factory (<c>provider: MySql</c>, the connection
/// string resolved from the fixture's <c>connectionStringRef</c> environment variable), so the suite
/// exercises <c>MySqlConnectorFactory.Instance</c> and the factory's own named-query validation exactly as a
/// deployed config would.
/// </summary>
private async Task<SqlDriver> StartAsync(string? queriesJson, params RawTagEntry[] tags)
private async Task<SqlDriver> StartAsync(string? queriesJson, bool allowWrites, params RawTagEntry[] tags)
{
var rawTags = new JsonArray();
foreach (var tag in tags)
@@ -302,6 +387,7 @@ public sealed class MySqlReadTests
["rawTags"] = rawTags,
};
if (queriesJson is not null) config["queries"] = JsonNode.Parse(queriesJson);
if (allowWrites) config["allowWrites"] = true;
var json = config.ToJsonString();
var driver = SqlDriverFactoryExtensions.CreateInstance("sql-mysql-integration", json);
@@ -433,6 +519,15 @@ public sealed class MySqlFixture : IAsyncLifetime
/// <param name="sql">The statement.</param>
/// <returns>The scalar, as a 64-bit integer when numeric.</returns>
public async Task<object?> ScalarAsync(string sql)
{
var value = await ScalarRawAsync(sql).ConfigureAwait(false);
return value is IConvertible and not string ? Convert.ToInt64(value, CultureInfo.InvariantCulture) : value;
}
/// <summary>Runs a scalar query as the fixture login and returns the provider's value unconverted.</summary>
/// <param name="sql">The statement.</param>
/// <returns>The scalar.</returns>
public async Task<object?> ScalarRawAsync(string sql)
{
var connection = new MySqlDialect().Factory.CreateConnection()
?? throw new InvalidOperationException("MySqlConnector returned no connection object.");
@@ -444,8 +539,7 @@ public sealed class MySqlFixture : IAsyncLifetime
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
var value = await command.ExecuteScalarAsync().ConfigureAwait(false);
return value is IConvertible and not string ? Convert.ToInt64(value, CultureInfo.InvariantCulture) : value;
return await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
@@ -337,34 +337,43 @@ public sealed class SqlDriverFactoryTests
options.RawTags.ShouldBeEmpty();
}
// ---- v1 is read-only, structurally ----
// ---- allowWrites (lmxopcua#753): honoured now, default off ----
[Theory]
[InlineData(",\"allowWrites\":true", true)]
[InlineData(",\"allowWrites\":false", false)]
[InlineData("", false)] // absent ⇒ read-only
public void ParseBinding_carriesAllowWritesOntoTheOptions_defaultOff(string fragment, bool expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
SqlDriverFactoryExtensions.ParseBinding("d1", $$"""{"connectionStringRef":"{{name}}"{{fragment}}}""")
.Options.AllowWrites.ShouldBe(expected);
}
[Fact]
public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly()
public void CreateInstance_withAllowWrites_logsNoWarning_andSaysWritesAreOn()
{
// allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible
// and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure
// mode is an operator who believes writes are enabled — which only a loud warning closes.
// allowWrites used to be inert and drew a "this build ignores it" warning; it is honoured now, so the
// flag is an ordinary setting — reported in the configured line, never as a warning.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
var loggerFactory = new RecordingLoggerFactory();
var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory)
.ShouldBeAssignableTo<IWritable>();
driver.ShouldNotBeAssignableTo<IWritable>();
var warning = loggerFactory.Entries
.Where(e => e.Level >= LogLevel.Warning)
.ShouldHaveSingleItem();
warning.Message.ShouldContain("allowWrites");
warning.Message.ShouldContain("read-only");
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
loggerFactory.Entries.ShouldContain(e => e.Message.Contains("allowed for opted-in tags"));
}
[Fact]
public void CreateInstance_withoutAllowWrites_logsNoWarning()
{
// The falsifiability control for the warning above — a warning that always fires proves nothing.
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
@@ -46,8 +46,9 @@ public sealed class SqlDriverTests
capture.Variables.ShouldContain(v =>
v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly);
// v1 is read-only by construction, not by configuration.
driver.ShouldNotBeAssignableTo<IWritable>();
// Writes are off by default (no allowWrites): the driver is IWritable, but refuses.
(await ((IWritable)driver).WriteAsync([new WriteRequest("Speed", 1.0)], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNotWritable);
}
[Fact]
@@ -0,0 +1,307 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The opt-in write path (design §3.4, lmxopcua#753): <c>allowWrites</c> + the tag's <c>"writable": true</c>
/// + a single-row model, executed as a parameterised <c>UPDATE</c> over catalog-validated identifiers that
/// commits only when exactly one row matched. Over SQLite, end to end through <see cref="SqlDriver"/>.
/// </summary>
public sealed class SqlWriteTests
{
private const string DriverInstanceId = "sql-write";
// ---- tag opt-in parse ----
[Fact]
public void TryParse_readsWritable_andDefaultsItOff()
{
SqlEquipmentTagParser.TryParse(Kv(writable: true), "a", out var on).ShouldBeTrue();
on.Writable.ShouldBeTrue();
SqlEquipmentTagParser.TryParse(Kv(writable: null), "b", out var absent).ShouldBeTrue();
absent.Writable.ShouldBeFalse();
}
[Theory]
[InlineData("\"true\"")]
[InlineData("1")]
[InlineData("null")]
public void TryParse_aNonBooleanWritable_refusesTheTag_andTheGateSaysWhy(string writable)
{
var json = $$"""{"model":"KeyValue","table":"T","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":{{writable}}}""";
SqlEquipmentTagParser.TryParse(json, "a", out _).ShouldBeFalse();
SqlTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("writable", StringComparison.Ordinal));
}
// ---- planner: the only statement a write can become ----
[Fact]
public void Plan_keyValue_isASingleRowUpdate_withTheValueAndKeyBound()
{
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "dbo.TagValues",
KeyColumn: "tag_name", KeyValue: "Line1.Speed", ValueColumn: "num_value", Writable: true);
SqlWritePlanner.TryPlan(tag, 12.5, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldBe("UPDATE [dbo].[TagValues] SET [num_value] = @v WHERE [tag_name] = @k");
plan.ParameterBindNames.ShouldBe(["@v", "@k"]);
plan.Parameters.ShouldBe([12.5, "Line1.Speed"]);
}
[Fact]
public void Plan_wideRowWherePair_updatesTheSelectedRowsColumn_valueBeforeKeyForPositionalProviders()
{
var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "plant.status", ColumnName: "setpoint",
RowSelectorColumn: "station_id", RowSelectorValue: "7", Writable: true);
SqlWritePlanner.TryPlan(tag, 3, new OdbcDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldBe("UPDATE \"plant\".\"status\" SET \"setpoint\" = ? WHERE \"station_id\" = ?");
plan.Parameters.ShouldBe([3, "7"]); // marker order: SET value, then WHERE key
}
[Fact]
public void Plan_aHostileValueIsBound_neverConcatenated()
{
const string hostile = "x'; DROP TABLE TagValues; --";
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v");
SqlWritePlanner.TryPlan(tag, hostile, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.SqlText.ShouldNotContain("DROP");
plan.Parameters[0].ShouldBe(hostile);
}
[Fact]
public void Plan_refusesModelsWithNoSingleRowTarget()
{
var newest = new SqlTagDefinition("A", SqlTagModel.WideRow, "T", ColumnName: "c", RowSelectorTopByTimestamp: "ts");
var query = new SqlTagDefinition("B", SqlTagModel.Query, string.Empty, ColumnName: "c", QueryName: "q");
SqlWritePlanner.TryPlan(newest, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable);
SqlWritePlanner.TryPlan(query, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable);
}
[Fact]
public void Plan_coercesToTheDeclaredType_andRefusesWhatWillNotCoerce()
{
var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v",
DeclaredType: DriverDataType.Int32);
SqlWritePlanner.TryPlan(tag, "41", new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good);
plan!.Parameters[0].ShouldBe(41);
SqlWritePlanner.TryPlan(tag, "not a number", new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadTypeMismatch);
SqlWritePlanner.TryPlan(tag with { DeclaredType = null }, new object(), new SqlServerDialect(), out _)
.ShouldBe(SqlStatusCodes.BadTypeMismatch);
}
// ---- the gates, end to end ----
[Fact]
public async Task Write_withAllowWritesOff_isRefused_andTheTableIsUntouched()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: false, Entry("Speed", Kv(writable: true)));
(await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task Write_toATagThatDidNotOptIn_isRefused_evenWithAllowWritesOn()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: null)));
(await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task Write_allGatesOpen_updatesTheRow_andAReadSeesIt()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Speed", Kv(writable: true)), Entry("Pressure", Kv(writable: true, key: SqlitePollFixture.SecondPresentKey)));
(await WriteAsync(driver, "Speed", 99.5)).ShouldBe(SqlStatusCodes.Good);
ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(99.5);
ValueOf(fixture, SqlitePollFixture.SecondPresentKey).ShouldBe(SqlitePollFixture.SecondPresentValue); // only one row
var read = (await driver.ReadAsync(["Speed"], CancellationToken.None))[0];
Convert.ToDouble(read.Value, CultureInfo.InvariantCulture).ShouldBe(99.5);
}
[Fact]
public async Task Write_aWideRowWherePair_updatesThatStationsColumnOnly()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Oven7", $$"""
{"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp",
"rowSelector":{"whereColumn":"station_id","whereValue":7},"writable":true}
"""));
(await WriteAsync(driver, "Oven7", 190.0)).ShouldBe(SqlStatusCodes.Good);
Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 7").ShouldBe(190.0);
Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 8").ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
}
[Fact]
public async Task Write_toAnAbsentKey_isBadNoData_andInsertsNothing()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Missing", Kv(writable: true, key: SqlitePollFixture.AbsentKey)));
(await WriteAsync(driver, "Missing", 1.0)).ShouldBe(SqlStatusCodes.BadNoData);
Scalar(fixture, "SELECT COUNT(*) FROM TagValues").ShouldBe(3.0);
}
/// <summary>
/// A key that matches two rows would update both. The write is rolled back and refused, so neither row
/// changes — the write-side twin of the read path's duplicate-key refusal.
/// </summary>
[Fact]
public async Task Write_matchingMoreThanOneRow_isRolledBack_andRefused()
{
using var fixture = new SqlitePollFixture();
fixture.Execute($"INSERT INTO TagValues (tag_name, num_value, sample_ts) VALUES ('{SqlitePollFixture.PresentKey}', 7.0, '2026-07-24T11:00:00Z')");
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: true)));
(await WriteAsync(driver, "Speed", 55.0)).ShouldBe(SqlStatusCodes.BadConfigurationError);
Scalar(fixture, $"SELECT COUNT(*) FROM TagValues WHERE num_value = 55.0").ShouldBe(0.0);
}
[Fact]
public async Task Write_toAnUnknownOrGateRejectedTag_isBadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Ghost", """
{"model":"KeyValue","table":"NoSuchTable","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":true}
"""));
(await WriteAsync(driver, "Ghost", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown); // the catalog gate rejected it
(await WriteAsync(driver, "Nobody", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task Write_aHostileString_isStoredVerbatim()
{
using var fixture = new SqlitePollFixture();
fixture.Execute("CREATE TABLE Notes (k TEXT NOT NULL, note TEXT NULL)");
fixture.Execute("INSERT INTO Notes VALUES ('a', 'old')");
const string hostile = "x'); DROP TABLE Notes; --";
await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Note", """
{"model":"KeyValue","table":"Notes","keyColumn":"k","keyValue":"a","valueColumn":"note","writable":true,"type":"String"}
"""));
(await WriteAsync(driver, "Note", hostile)).ShouldBe(SqlStatusCodes.Good);
using var command = fixture.Connection.CreateCommand();
command.CommandText = "SELECT note FROM Notes WHERE k = 'a'";
command.ExecuteScalar().ShouldBe(hostile);
}
[Fact]
public async Task Discover_advertisesOperate_onlyWhereAWriteCouldSucceed()
{
using var fixture = new SqlitePollFixture();
await using var driver = await StartAsync(fixture, allowWrites: true,
Entry("Writable", Kv(writable: true)),
Entry("ReadOnly", Kv(writable: null)),
Entry("Newest", $$"""
{"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp",
"rowSelector":{"topByTimestamp":"sample_ts"},"writable":true}
"""));
var builder = new ClassCapturingBuilder();
await driver.DiscoverAsync(builder, CancellationToken.None);
builder.Classes["Writable"].ShouldBe(SecurityClassification.Operate);
builder.Classes["ReadOnly"].ShouldBe(SecurityClassification.ViewOnly);
builder.Classes["Newest"].ShouldBe(SecurityClassification.ViewOnly); // no single-row target
}
// ---- helpers ----
private static string Kv(bool? writable, string key = SqlitePollFixture.PresentKey)
{
var o = new JsonObject
{
["model"] = "KeyValue",
["table"] = SqlitePollFixture.KeyValueTable,
["keyColumn"] = SqlitePollFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = SqlitePollFixture.ValueColumn,
["type"] = "Float64",
};
if (writable is { } w) o["writable"] = w;
return o.ToJsonString();
}
private static RawTagEntry Entry(string rawPath, string json) => new(rawPath, json, WriteIdempotent: false);
private static async Task<uint> WriteAsync(SqlDriver driver, string reference, object? value)
=> (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode;
private static double ValueOf(SqlitePollFixture fixture, string key)
=> Scalar(fixture, $"SELECT num_value FROM TagValues WHERE tag_name = '{key}'");
private static double Scalar(SqlitePollFixture fixture, string sql)
{
using var command = fixture.Connection.CreateCommand();
command.CommandText = sql;
return Convert.ToDouble(command.ExecuteScalar(), CultureInfo.InvariantCulture);
}
private static async Task<SqlDriver> StartAsync(SqlitePollFixture fixture, bool allowWrites, params RawTagEntry[] tags)
{
var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = tags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
AllowWrites = allowWrites,
},
DriverInstanceId, new SqliteDialect(), fixture.ConnectionString, fixture.Factory);
await driver.InitializeAsync("{}", CancellationToken.None);
return driver;
}
/// <summary>Records each discovered variable's security classification.</summary>
private sealed class ClassCapturingBuilder : IAddressSpaceBuilder
{
public Dictionary<string, SecurityClassification> Classes { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Classes[attributeInfo.FullName] = attributeInfo.SecurityClass;
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
/// composer-owned <c>rawTags</c> or an unrequested <c>allowWrites</c> leaking into the authored blob. A numeric enum
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
/// </summary>
public sealed class SqlDriverFormContractTests
@@ -74,7 +74,7 @@ public sealed class SqlDriverFormContractTests
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
json.ShouldNotContain("allowWrites"); // off ⇒ omitted ⇒ read-only
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
}
@@ -169,6 +169,32 @@ public sealed class SqlDriverFormContractTests
}
}
[Fact]
public void AllowWrites_is_emitted_only_when_switched_on_and_the_factory_honours_it()
{
var off = JsonSerializer.Serialize(new SqlDriverForm.FormModel { ConnectionStringRef = "R" }.ToDto(), FormOptions);
off.ShouldNotContain("allowWrites");
var form = new SqlDriverForm.FormModel { ConnectionStringRef = "AdminUiFormTest", AllowWrites = true };
var on = JsonSerializer.Serialize(form.ToDto(), FormOptions);
on.ShouldContain("\"allowWrites\":true");
SqlDriverForm.FormModel.FromDto(JsonSerializer.Deserialize<SqlDriverConfigDto>(on, FormOptions)!)
.AllowWrites.ShouldBeTrue();
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
var previous = Environment.GetEnvironmentVariable(envVar);
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
try
{
SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", on)
.ShouldBeAssignableTo<ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable>();
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}
[Fact]
public void Queries_survive_the_form_model_round_trip_and_a_typo_keeps_the_last_valid_set()
{
@@ -258,6 +258,27 @@ public sealed class SqlTagConfigModelTests
def.TimestampColumn.ShouldBe("ts");
}
[Fact]
public void Writable_round_trips_as_a_json_boolean_and_absent_means_read_only()
{
var on = SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","writable":true}""");
on.Writable.ShouldBe(true);
on.ToJson().ShouldContain("\"writable\":true");
SqlEquipmentTagParser.TryParse(on.ToJson(), "raw", out var def).ShouldBeTrue();
def.Writable.ShouldBeTrue();
on.Writable = null;
on.ToJson().ShouldNotContain("writable");
}
[Theory]
[InlineData("\"true\"")]
[InlineData("1")]
public void Validate_rejects_a_non_boolean_writable(string writable)
=> SqlTagConfigModel.FromJson(
$$"""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","writable":{{writable}}}""")
.Validate().ShouldNotBeNull();
[Fact]
public void Clearing_the_row_key_omits_it_from_the_blob()
{
@@ -410,6 +410,12 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus","column":"speed","rowKey":"line"}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"lineStatus"}"""),
(DriverTypeNames.Sql, """{"model":"Query","column":"speed","query":" "}"""),
// lmxopcua#753 — the per-tag write opt-in is a strict JSON boolean on both sides.
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x","writable":true}"""),
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x","writable":false}"""),
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x","writable":"true"}"""),
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x","writable":1}"""),
(DriverTypeNames.Sql, """{"model":"Query","query":"q","column":"c","writable":null}"""),
(DriverTypeNames.Sql, """{"model":"WideRow","table":"dbo.T","columnName":"Speed","rowSelector":{"whereColumn":"LineId","whereValue":"L1"}}"""),
(DriverTypeNames.Sql, """{"model":"WideRow","table":"dbo.T","columnName":"Speed"}"""),