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