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:
@@ -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"}"""),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user