feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run
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:
+15
-8
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+15
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user