feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled

Item 2: a tag's optional `write` block picks the statement the driver builds from its own
catalog-validated identifiers (no authored SQL; values always bound):
- Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData)
- Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag)
- Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL
  ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL
- Procedure: named stored procedure resolved against the routine catalog by the catalog gate
  (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional)
- stampTimestamp sets the tag's timestamp column to the write time (UTC)
Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to
BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else,
permission denied included, stays BadCommunicationError. WriteIdempotent stays false.
The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits
blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI
editor surfaces the same message.

Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for
SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every
Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login
limited to their write targets.

Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end),
DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server,
PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:56:21 -05:00
parent 2da58d0360
commit f5c30ed0ef
33 changed files with 2361 additions and 132 deletions
@@ -43,6 +43,7 @@ public static class DraftValidator
ValidateVirtualTagTriggers(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft));
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
@@ -51,6 +51,90 @@ public static class SqlTagDeployChecks
return errors;
}
/// <summary>Error code for a Sql tag whose <c>write</c> block the driver's parser refuses (lmxopcua#781 item 2).</summary>
public const string WriteBlockInvalidCode = "SqlWriteBlockInvalid";
/// <summary>Error code for a write mode the Sql driver instance's provider does not serve (lmxopcua#781 item 2).</summary>
public const string WriteModeUnsupportedCode = "SqlWriteModeUnsupported";
/// <summary>
/// Item 2. Always blocking: (a) a tag whose <c>write</c> block the driver's own parser refuses — the driver
/// would skip the whole tag (reads too) — explained by <see cref="SqlTagDefinitionFactory.DescribeWriteBlock"/>;
/// (b) a valid block whose mode the instance's <c>provider</c> does not serve
/// (<see cref="SqlWriteSupport.IsSupported"/> — Upsert / Procedure on Odbc), which the driver would publish
/// read-only. The provider is read from the same merged config the driver binds; an unreadable provider is
/// left to the driver's own construction to refuse.
/// </summary>
/// <param name="draft">The snapshot being validated.</param>
/// <returns>One error per offending tag.</returns>
public static IReadOnlyList<ValidationError> WriteBlockErrors(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
foreach (var (driver, devices) in SqlInstances(draft))
{
SqlProvider? provider = null;
var providerRead = false;
foreach (var tag in draft.Tags)
{
if (!devices.Any(d => string.Equals(d.DeviceId, tag.DeviceId, StringComparison.Ordinal))) continue;
if (SqlTagDefinitionFactory.DescribeWriteBlock(tag.TagConfig) is { } invalid)
{
errors.Add(new(WriteBlockInvalidCode, $"Sql tag '{tag.TagId}': {invalid}", tag.TagId));
continue;
}
if (!SqlEquipmentTagParser.TryParse(tag.TagConfig, tag.Name, out var def) || def.Write is not { } write)
continue;
if (!providerRead)
{
provider = ReadProvider(driver, devices);
providerRead = true;
}
if (provider is { } p && !SqlWriteSupport.IsSupported(p, write.Mode))
{
errors.Add(new(WriteModeUnsupportedCode,
$"Sql tag '{tag.TagId}' writes in mode {write.Mode}, which Sql driver instance " +
$"'{driver.DriverInstanceId}' (provider {p}) does not serve — Odbc writes are Update or Insert " +
"only. Use the backend's native provider, or change the tag's 'write.mode'.",
tag.TagId));
}
}
}
return errors;
}
/// <summary>The merged config's <c>provider</c> (default SqlServer), or null when it is not a provider name.</summary>
private static SqlProvider? ReadProvider(DriverInstance driver, List<Device> devices)
{
try
{
var merged = DriverDeviceConfigMerger.Merge(
driver.DriverConfig, devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(), []);
using var doc = JsonDocument.Parse(merged);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var p in doc.RootElement.EnumerateObject())
{
if (!string.Equals(p.Name, "provider", StringComparison.OrdinalIgnoreCase)) continue;
return p.Value.ValueKind switch
{
JsonValueKind.String when Enum.TryParse<SqlProvider>(p.Value.GetString(), ignoreCase: true, out var v)
&& Enum.IsDefined(v) => v,
JsonValueKind.Number when p.Value.TryGetInt32(out var n) && Enum.IsDefined((SqlProvider)n) => (SqlProvider)n,
_ => null,
};
}
return SqlProvider.SqlServer;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Item 3. The node's write permission needs BOTH the Tag entity's <c>AccessLevel</c> (ReadWrite) and the
/// Sql blob's <c>"writable": true</c>; when they disagree the tag is silently read-only. A warning, never