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
@@ -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)
{