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
@@ -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"}"""),