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)))" />
|
||||
|
||||
Reference in New Issue
Block a user