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
@@ -30,8 +30,39 @@ public static class SqlEquipmentTagParser
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
=> TryParseDetailed(reference, rawPath, out def, out _);
/// <summary>
/// <see cref="TryParse"/>, also reporting why an otherwise-valid tag's
/// <c>write</c> block refused it (lmxopcua#781). <paramref name="writeError"/> is null when the refusal — if
/// any — came from the tag's own fields, or when the tag parsed.
/// </summary>
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath.</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <param name="writeError">The write block's rejection reason, when that is what refused the tag.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
public static bool TryParseDetailed(string reference, string rawPath, out SqlTagDefinition def, out string? writeError)
{
writeError = null;
if (!TryParseFields(reference, rawPath, out def, out var writeBlock)) return false;
if (writeBlock is not { } block) return true;
if (!SqlWriteSpecParser.TryParse(block, def, out var spec, out writeError))
{
def = null!;
return false;
}
def = def with { Write = spec };
return true;
}
/// <summary>The tag's own fields; the <c>write</c> block, when present, is handed back (cloned) unparsed.</summary>
private static bool TryParseFields(string reference, string rawPath, out SqlTagDefinition def, out JsonElement? writeBlock)
{
def = null!;
writeBlock = null;
if (string.IsNullOrWhiteSpace(reference)) return false;
if (reference.TrimStart().FirstOrDefault() != '{') return false;
try
@@ -55,6 +86,7 @@ public static class SqlEquipmentTagParser
: null;
var timestampColumn = ReadString(root, "timestampColumn");
if (root.TryGetProperty("write", out var writeEl)) writeBlock = writeEl.Clone();
// The per-tag write opt-in (design §3.4). Strict like the enums: absent ⇒ read-only, a JSON boolean ⇒
// that, anything else (a "true" string, a number) ⇒ the tag is refused rather than guessed at — a
@@ -43,6 +43,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
/// driver infers the type from the result set's column metadata.
/// </param>
/// <param name="Write">
/// The tag's optional <c>write</c> block (lmxopcua#781 item 2): the statement shape a write becomes. Null ⇒ the
/// default single-row UPDATE (<see cref="SqlWriteSpec.Default"/>). Meaningful only when <paramref name="Writable"/>.
/// </param>
public sealed record SqlTagDefinition(
string Name,
SqlTagModel Model,
@@ -59,4 +63,9 @@ public sealed record SqlTagDefinition(
string? QueryName = null,
string? RowKeyColumn = null,
string? RowKeyValue = null,
bool Writable = false);
bool Writable = false,
SqlWriteSpec? Write = null)
{
/// <summary>The effective write spec — the authored block, or the default UPDATE.</summary>
public SqlWriteSpec EffectiveWrite => Write ?? SqlWriteSpec.Default;
}
@@ -51,7 +51,14 @@ public static class SqlTagDefinitionFactory
return warnings;
// The accept gate: whatever the driver serves draws nothing. See the class remarks.
if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings;
if (SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError)) return warnings;
// The tag's own fields parsed and its write block is what refused it: the parser said why.
if (writeError is not null)
{
warnings.Add(FormatWriteError(writeError));
return warnings;
}
try
{
@@ -83,6 +90,25 @@ public static class SqlTagDefinitionFactory
return warnings;
}
/// <summary>
/// Why the blob's <c>write</c> block (lmxopcua#781 item 2) refuses an otherwise-valid tag, or null when the
/// block is valid, absent, or the tag's own fields already refuse it (which <see cref="Inspect"/> reports).
/// The verdict is the runtime parser's own (<see cref="SqlEquipmentTagParser.TryParseDetailed"/>);
/// the deploy gate turns it into a blocking error. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The operator-facing message, or null.</returns>
public static string? DescribeWriteBlock(string reference)
{
if (string.IsNullOrWhiteSpace(reference)) return null;
return SqlEquipmentTagParser.TryParseDetailed(reference, InspectionRawPath, out _, out var writeError) || writeError is null
? null
: FormatWriteError(writeError);
}
private static string FormatWriteError(string writeError)
=> $"Sql TagConfig 'write' block is invalid: {writeError} — the driver will skip this tag (BadNodeIdUnknown).";
/// <summary>
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
@@ -0,0 +1,275 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>The statement shape a Sql tag's write becomes (design §3.4, lmxopcua#781 item 2).</summary>
public enum SqlWriteMode
{
/// <summary>
/// <c>UPDATE … SET value WHERE key</c> on exactly one existing row — the default, and the only shape before
/// #781. Zero rows is <c>BadNoData</c>; the driver never inserts in this mode.
/// </summary>
Update,
/// <summary><c>INSERT</c> of one new row — the key (when the model has one), the value and, optionally, the stamp.</summary>
Insert,
/// <summary>
/// Update the keyed row, or insert it when absent, in one statement (SQL Server / Oracle <c>MERGE</c>,
/// PostgreSQL <c>INSERT … ON CONFLICT</c>, MySQL <c>INSERT … ON DUPLICATE KEY UPDATE</c>). Not on Odbc.
/// </summary>
Upsert,
/// <summary>A call of one named, catalog-validated stored procedure with the value (and optionally key / stamp) bound.</summary>
Procedure,
}
/// <summary>
/// The parsed form of a tag's optional <c>write</c> block. Every string is authored and NEVER reaches SQL text
/// unvalidated: <see cref="Procedure"/> is resolved against the live routine catalog by the catalog gate and
/// dialect-quoted, and the parameter names are restricted to a plain identifier charset by the parser
/// (<see cref="SqlWriteSpecParser"/>). Values are always bound.
/// </summary>
/// <param name="Mode">The statement shape.</param>
/// <param name="StampTimestamp">
/// Update / Insert / Upsert: also set the tag's timestamp column (<c>timestampColumn</c>, or a WideRow's
/// <c>topByTimestamp</c>) to the write time, in UTC.
/// </param>
/// <param name="Procedure">Procedure: the <c>procedure</c> or <c>schema.procedure</c> to call.</param>
/// <param name="ValueParameter">Procedure: the procedure parameter the written value binds to (default <c>value</c>).</param>
/// <param name="KeyParameter">Procedure: the parameter the tag's key binds to; null ⇒ the key is not passed.</param>
/// <param name="TimestampParameter">Procedure: the parameter the write time (UTC) binds to; null ⇒ not passed.</param>
public sealed record SqlWriteSpec(
SqlWriteMode Mode,
bool StampTimestamp = false,
string? Procedure = null,
string? ValueParameter = null,
string? KeyParameter = null,
string? TimestampParameter = null)
{
/// <summary>The default when a writable tag carries no <c>write</c> block: today's single-row UPDATE.</summary>
public static SqlWriteSpec Default { get; } = new(SqlWriteMode.Update);
}
/// <summary>
/// Which <see cref="SqlWriteMode"/>s each provider serves. One table, read by the dialects (runtime), the deploy
/// gate (<c>SqlTagDeployChecks</c>) and the docs, so what deploy accepts is what the driver serves.
/// <para><b>Odbc is Update / Insert only.</b> The ANSI profile has no upsert every ODBC backend accepts
/// (<c>MERGE</c> is missing or partial on several), and positional <c>?</c> markers cannot be reused, which every
/// single-statement upsert needs; a stored-procedure call would need a routine catalog and calling convention per
/// backend. Use the backend's native provider for those modes.</para>
/// </summary>
public static class SqlWriteSupport
{
/// <summary>True when <paramref name="provider"/> serves <paramref name="mode"/>.</summary>
/// <param name="provider">The driver's provider.</param>
/// <param name="mode">The tag's write mode.</param>
/// <returns>Whether the driver will execute the mode on that provider.</returns>
public static bool IsSupported(SqlProvider provider, SqlWriteMode mode) => mode switch
{
SqlWriteMode.Update or SqlWriteMode.Insert => true,
SqlWriteMode.Upsert or SqlWriteMode.Procedure => provider != SqlProvider.Odbc,
_ => false,
};
}
/// <summary>
/// Parses and validates a tag's <c>write</c> block against the tag it belongs to. Used by
/// <see cref="SqlEquipmentTagParser.TryParse"/> (a failure refuses the whole tag) and — through
/// <see cref="SqlTagDefinitionFactory.DescribeWriteBlock"/> — by the deploy gate and the AdminUI, so all three
/// share one verdict and one explanation.
/// </summary>
public static partial class SqlWriteSpecParser
{
/// <summary>The keys a <c>write</c> block may carry. Anything else is refused — a typo in a write permission is
/// the last place to guess.</summary>
private static readonly string[] KnownKeys =
["mode", "stampTimestamp", "procedure", "valueParam", "keyParam", "timestampParam"];
private static readonly string[] ProcedureOnlyKeys = ["procedure", "valueParam", "keyParam", "timestampParam"];
/// <summary>The default procedure parameter the value binds to.</summary>
public const string DefaultValueParameter = "value";
/// <summary>
/// Parses <paramref name="write"/> for <paramref name="tag"/> (whose own fields are already parsed).
/// </summary>
/// <param name="write">The <c>write</c> property's value.</param>
/// <param name="tag">The tag definition the block belongs to.</param>
/// <param name="spec">The spec, when this returns <see langword="true"/>.</param>
/// <param name="error">An operator-facing reason, when this returns <see langword="false"/>.</param>
/// <returns>Whether the block is valid for the tag.</returns>
public static bool TryParse(JsonElement write, SqlTagDefinition tag, out SqlWriteSpec? spec, out string? error)
{
ArgumentNullException.ThrowIfNull(tag);
spec = null;
error = null;
if (write.ValueKind != JsonValueKind.Object)
return Fail("'write' must be a JSON object", out error);
foreach (var property in write.EnumerateObject())
{
if (!KnownKeys.Contains(property.Name, StringComparer.Ordinal))
{
return Fail(
$"'write' has an unknown key '{property.Name}' (valid: {string.Join(", ", KnownKeys)})", out error);
}
}
var mode = SqlWriteMode.Update;
if (write.TryGetProperty("mode", out var modeEl))
{
if (modeEl.ValueKind != JsonValueKind.String
|| TagConfigJson.ClassifyEnumText(modeEl.GetString(), out mode) != JsonEnumRead.Valid)
{
return Fail(
$"'write.mode' must be one of {string.Join(", ", Enum.GetNames<SqlWriteMode>())}", out error);
}
}
var stamp = false;
if (write.TryGetProperty("stampTimestamp", out var stampEl))
{
if (stampEl.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
return Fail("'write.stampTimestamp' must be a JSON boolean", out error);
stamp = stampEl.GetBoolean();
}
if (mode != SqlWriteMode.Procedure)
{
foreach (var key in ProcedureOnlyKeys)
{
if (write.TryGetProperty(key, out _))
return Fail($"'write.{key}' applies only to mode Procedure (this block's mode is {mode})", out error);
}
if (tag.Model == SqlTagModel.Query)
{
return Fail(
$"a Query tag reads a named query, which names no table to {mode.ToString().ToLowerInvariant()}; " +
"only mode Procedure can write a Query tag", out error);
}
var hasWherePair = tag.Model == SqlTagModel.KeyValue || tag.RowSelectorColumn is not null;
if (mode is SqlWriteMode.Update or SqlWriteMode.Upsert && !hasWherePair)
{
return Fail(
$"mode {mode} needs a row key — a KeyValue tag, or a WideRow 'whereColumn'/'whereValue' pair; " +
"a 'topByTimestamp' row moves under a concurrent insert (use Insert or Procedure)", out error);
}
if (stamp && StampColumn(tag) is null)
{
return Fail(
"'write.stampTimestamp' is true but the tag has no timestamp column to stamp — author " +
"'timestampColumn' (or a WideRow 'topByTimestamp')", out error);
}
spec = new SqlWriteSpec(mode, stamp);
return true;
}
// ---- Procedure ----
if (stamp)
return Fail("'write.stampTimestamp' does not apply to mode Procedure — bind the write time with 'timestampParam'", out error);
if (!write.TryGetProperty("procedure", out var procEl) || procEl.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(procEl.GetString()))
{
return Fail("mode Procedure needs 'write.procedure' — the procedure or schema.procedure to call", out error);
}
var procedure = procEl.GetString()!.Trim();
if (procedure.Split('.').Length > 2)
{
return Fail(
"'write.procedure' may be 'procedure' or 'schema.procedure' only — a package, cross-database or " +
"linked-server name cannot be validated against this connection's catalog", out error);
}
if (!TryReadParameter(write, "valueParam", DefaultValueParameter, out var valueParam, out error)) return false;
if (!TryReadParameter(write, "keyParam", null, out var keyParam, out error)) return false;
if (!TryReadParameter(write, "timestampParam", null, out var timestampParam, out error)) return false;
if (keyParam is not null && KeyValue(tag) is null)
{
return Fail(
"'write.keyParam' is set but the tag has no key to bind — a KeyValue 'keyValue', a WideRow " +
"'whereValue' or a Query 'rowKey.value'", out error);
}
var names = new[] { valueParam, keyParam, timestampParam }.Where(n => n is not null).ToArray();
if (names.Distinct(StringComparer.OrdinalIgnoreCase).Count() != names.Length)
return Fail("'write' binds two values to the same procedure parameter", out error);
spec = new SqlWriteSpec(SqlWriteMode.Procedure, false, procedure, valueParam, keyParam, timestampParam);
return true;
}
/// <summary>The column a <c>stampTimestamp</c> write sets: <c>timestampColumn</c>, else a WideRow's <c>topByTimestamp</c>.</summary>
/// <param name="tag">The tag.</param>
/// <returns>The column, or null when the tag has none.</returns>
public static string? StampColumn(SqlTagDefinition tag)
{
ArgumentNullException.ThrowIfNull(tag);
if (tag.Model == SqlTagModel.Query) return null;
if (!string.IsNullOrWhiteSpace(tag.TimestampColumn)) return tag.TimestampColumn;
return tag.Model == SqlTagModel.WideRow && !string.IsNullOrWhiteSpace(tag.RowSelectorTopByTimestamp)
? tag.RowSelectorTopByTimestamp
: null;
}
/// <summary>The tag's key value — what a procedure's <c>keyParam</c> binds: keyValue, whereValue or rowKey.value.</summary>
/// <param name="tag">The tag.</param>
/// <returns>The key, or null when the model names none.</returns>
public static string? KeyValue(SqlTagDefinition tag)
{
ArgumentNullException.ThrowIfNull(tag);
return tag.Model switch
{
SqlTagModel.KeyValue => tag.KeyValue,
SqlTagModel.WideRow => tag.RowSelectorValue,
SqlTagModel.Query => tag.RowKeyColumn is null ? null : tag.RowKeyValue,
_ => null,
};
}
/// <summary>
/// Reads a procedure parameter name: absent ⇒ <paramref name="fallback"/>; otherwise a plain identifier
/// (letter or underscore, then letters, digits, underscores; at most 128 characters), one leading <c>@</c> or
/// <c>:</c> tolerated and stripped. The name reaches SQL text unquoted (named notation), so this charset is
/// the injection boundary for it.
/// </summary>
private static bool TryReadParameter(
JsonElement write, string key, string? fallback, out string? name, out string? error)
{
name = fallback;
error = null;
if (!write.TryGetProperty(key, out var el)) return true;
if (el.ValueKind != JsonValueKind.String) return Fail($"'write.{key}' must be a string", out error);
var text = el.GetString()!.Trim();
if (text.Length > 0 && text[0] is '@' or ':') text = text[1..];
if (!ParameterName().IsMatch(text))
{
return Fail(
$"'write.{key}' must be a plain parameter name — a letter or underscore, then letters, digits or " +
"underscores (at most 128)", out error);
}
name = text;
return true;
}
private static bool Fail(string message, out string? error)
{
error = message;
return false;
}
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.CultureInvariant)]
private static partial Regex ParameterName();
}
@@ -243,4 +243,65 @@ public interface ISqlDialect
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
DriverDataType MapColumnType(string sqlDataType);
// ---- writes (lmxopcua#781 item 2) ----
/// <summary>
/// Whether this dialect executes <paramref name="mode"/>. Defaults to the shared
/// <see cref="SqlWriteSupport"/> table for <see cref="Provider"/> — the same table the deploy gate checks, so
/// a mode deploy accepts is a mode the driver serves.
/// </summary>
/// <param name="mode">The tag's write mode.</param>
/// <returns>Whether a write in that mode is executed.</returns>
bool SupportsWriteMode(SqlWriteMode mode) => SqlWriteSupport.IsSupported(Provider, mode);
/// <summary>
/// The single-statement upsert of one keyed row, or null when this dialect has none. Every argument is
/// already dialect-quoted (identifiers) or a parameter marker — the dialect only arranges them, so nothing
/// authored and nothing a client sent reaches the text through here. A marker may appear more than once in
/// the result, so a positional-marker dialect must return null.
/// </summary>
/// <param name="quotedTable">The quoted, catalog-canonical target table.</param>
/// <param name="quotedKeyColumn">The quoted key column.</param>
/// <param name="keyMarker">The key's parameter marker.</param>
/// <param name="assignments">The quoted columns to set and their markers — the value first, then any stamp.</param>
/// <returns>The statement, or null when the dialect cannot upsert.</returns>
string? UpsertSql(
string quotedTable, string quotedKeyColumn, string keyMarker,
IReadOnlyList<(string QuotedColumn, string Marker)> assignments) => null;
/// <summary>
/// The most rows an upsert may report touching for one key. 1 everywhere except MySQL, whose
/// <c>ON DUPLICATE KEY UPDATE</c> reports 2 for "updated an existing row".
/// </summary>
int UpsertMaxRowsAffected => 1;
/// <summary>
/// The statement that calls one stored procedure, or null when this dialect cannot. Arguments are passed by
/// name (<c>EXEC p @a = @v</c>, <c>CALL p(a => @v)</c>, <c>BEGIN p(a => :p_v); END;</c>) except where the
/// backend has no named notation (MySQL: positional, in list order). Parameter names reach the text
/// unquoted; the parser restricts them to a plain identifier charset.
/// </summary>
/// <param name="quotedProcedure">The quoted, catalog-canonical procedure name.</param>
/// <param name="arguments">Procedure parameter name → marker, in order.</param>
/// <returns>The statement, or null.</returns>
string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
=> null;
/// <summary>
/// Catalog query listing the stored procedures in one schema (column <c>ROUTINE_NAME</c>, bound like
/// <see cref="ListTablesSql"/> under the base name <c>schema</c>), or null when this dialect has no routine
/// catalog — the catalog gate then refuses every Procedure-mode tag rather than quote an unvalidated name.
/// </summary>
string? ListRoutinesSql => null;
/// <summary>
/// Classifies a write's provider exception so a constraint the database enforced reads as an authoring /
/// data fault rather than "the database is unreachable". Must never throw. The default reads the ANSI
/// <see cref="DbException.SqlState"/> when the provider reports one.
/// </summary>
/// <param name="exception">What the write threw.</param>
/// <returns>The failure class.</returns>
SqlWriteFailure ClassifyWriteFailure(Exception exception)
=> SqlWriteFailures.FromSqlState((exception as DbException)?.SqlState);
}
@@ -177,4 +177,48 @@ public sealed class MySqlDialect : ISqlDialect
_ => DriverDataType.String,
};
}
// ---- writes (lmxopcua#781 item 2) ----
/// <inheritdoc />
// INSERT … ON DUPLICATE KEY UPDATE — fires on ANY unique key of the table, so the key column must be the table's
// primary key or a unique key (and the table should carry no OTHER unique key a write could collide with).
// MySqlConnector substitutes parameters client-side, so a marker may repeat.
public string? UpsertSql(
string quotedTable, string quotedKeyColumn, string keyMarker,
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
=> string.Concat(
"INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
") ON DUPLICATE KEY UPDATE ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)));
/// <inheritdoc />
// 1 for an inserted row, 2 for an updated one (MySQL's documented ON DUPLICATE KEY UPDATE count). MySqlConnector
// defaults to found-rows (UseAffectedRows=false), so an update to the same value still reports a row, not 0.
public int UpsertMaxRowsAffected => 2;
/// <inheritdoc />
// MySQL has no named-argument notation: arguments are POSITIONAL, in the order the driver lists them — the value,
// then the key (keyParam), then the write time (timestampParam). The parameter names are not sent.
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
=> "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Marker)) + ")";
/// <inheritdoc />
public string? ListRoutinesSql =>
"SELECT ROUTINE_NAME AS ROUTINE_NAME FROM information_schema.ROUTINES " +
"WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
/// <inheritdoc />
// 1062 duplicate entry; 3819 CHECK violated (reported as SQLSTATE HY000, so it needs the number); the rest by
// SQLSTATE — 23000 covers NOT NULL (1048) and foreign keys (1451/1452), class 22 overflow/truncation.
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
{
if (exception is not MySqlException my) return SqlWriteFailure.Other;
return my.Number switch
{
1062 => SqlWriteFailure.UniqueViolation,
3819 => SqlWriteFailure.IntegrityViolation,
_ => SqlWriteFailures.FromSqlState(my.SqlState),
};
}
}
@@ -170,4 +170,15 @@ public sealed class OdbcDialect : ISqlDialect
_ => DriverDataType.String,
};
}
// ---- writes (lmxopcua#781 item 2) ----
// Update and Insert only (SqlWriteSupport): no Upsert (positional markers cannot repeat, and MERGE is missing or
// partial on several ODBC backends) and no Procedure (no portable routine catalog or calling convention).
/// <inheritdoc />
// The driver manager reports the backend's SQLSTATE on each OdbcError.
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
=> exception is OdbcException odbc && odbc.Errors.Count > 0
? SqlWriteFailures.FromSqlState(odbc.Errors[0].SQLState)
: SqlWriteFailure.Other;
}
@@ -325,4 +325,50 @@ public sealed class OracleDialect : ISqlDialect
_ => DriverDataType.String,
};
}
// ---- writes (lmxopcua#781 item 2) ----
/// <inheritdoc />
// MERGE from a constant one-row DUAL source, matching the target's key to the bound key; BindByName (set in
// ConfigureCommand) is what lets the key and value markers appear twice. No trailing semicolon — ODP.NET refuses
// one on a SQL statement.
public string? UpsertSql(
string quotedTable, string quotedKeyColumn, string keyMarker,
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
=> string.Concat(
"MERGE INTO ", quotedTable, " tgt USING (SELECT 1 AS one FROM dual) src ON (tgt.", quotedKeyColumn, " = ",
keyMarker, ") WHEN MATCHED THEN UPDATE SET ",
string.Join(", ", assignments.Select(a => "tgt." + a.QuotedColumn + " = " + a.Marker)),
" WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ")");
/// <inheritdoc />
// An anonymous PL/SQL block with named notation. Unquoted parameter names fold to upper case, as Oracle's own do.
// A standalone procedure only — a package member (pkg.proc) is a three-part name the gate cannot validate.
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
=> "BEGIN " + quotedProcedure
+ (arguments.Count == 0 ? string.Empty : "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")")
+ "; END;";
/// <inheritdoc />
// ALL_PROCEDURES lists a standalone procedure as OBJECT_TYPE 'PROCEDURE' with OBJECT_NAME its name; it shows only
// what the principal may execute or owns.
public string? ListRoutinesSql =>
"SELECT object_name AS ROUTINE_NAME FROM ALL_PROCEDURES " +
"WHERE owner = " + ParameterMarker("schema") + " AND object_type = 'PROCEDURE' ORDER BY object_name";
/// <inheritdoc />
// ODP.NET reports no SQLSTATE; the ORA- number decides. 1 unique; 1400/1407 NULL, 2290 CHECK, 2291/2292 FK;
// 1438 precision, 12899 too large, 1722 invalid number, 18xx date conversion.
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
{
if (exception is not OracleException ora) return SqlWriteFailure.Other;
return ora.Number switch
{
1 => SqlWriteFailure.UniqueViolation,
1400 or 1407 or 2290 or 2291 or 2292 => SqlWriteFailure.IntegrityViolation,
1438 or 12899 or 1722 or 1830 or 1840 or 1841 or 1843 or 1847 or 1858 or 1861 => SqlWriteFailure.DataException,
_ => SqlWriteFailure.Other,
};
}
}
@@ -267,4 +267,35 @@ public sealed class PostgresDialect : ISqlDialect
_ => DriverDataType.String,
};
}
// ---- writes (lmxopcua#781 item 2) ----
/// <inheritdoc />
// INSERT … ON CONFLICT (key) DO UPDATE — atomic under concurrency, but it REQUIRES a unique constraint or unique
// index on exactly the key column (the server refuses the statement otherwise: SQLSTATE 42P10, which surfaces as
// BadCommunicationError). EXCLUDED is the row the insert proposed, so each value marker appears once in VALUES.
// Because a conflict target is unique, the statement touches exactly one row.
public string? UpsertSql(
string quotedTable, string quotedKeyColumn, string keyMarker,
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
=> string.Concat(
"INSERT INTO ", quotedTable, " (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)),
") ON CONFLICT (", quotedKeyColumn, ") DO UPDATE SET ",
string.Join(", ", assignments.Select(a => a.QuotedColumn + " = EXCLUDED." + a.QuotedColumn)));
/// <inheritdoc />
// CALL with named notation (PostgreSQL 11+ procedures). Argument types must resolve by IMPLICIT cast: a Float64
// binds as double precision, so declare a numeric parameter as double precision (or cast inside the procedure),
// and a write time binds as timestamptz.
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
=> "CALL " + quotedProcedure + "(" + string.Join(", ", arguments.Select(a => a.Parameter + " => " + a.Marker)) + ")";
/// <inheritdoc />
// information_schema.routines shows only routines the current role may execute or owns — the gate's question.
public string? ListRoutinesSql =>
"SELECT routine_name AS \"ROUTINE_NAME\" FROM information_schema.routines " +
"WHERE routine_schema = @schema AND routine_type = 'PROCEDURE' ORDER BY \"ROUTINE_NAME\"";
// ClassifyWriteFailure: the interface default reads PostgresException.SqlState, which Npgsql always reports.
}
@@ -222,4 +222,52 @@ public sealed class SqlServerDialect : ISqlDialect
_ => DriverDataType.String,
};
}
// ---- writes (lmxopcua#781 item 2) ----
/// <inheritdoc />
// MERGE … WITH (HOLDLOCK): the key-range lock HOLDLOCK takes is what makes the matched/not-matched decision and
// the insert one atomic step under concurrency — a bare MERGE (or UPDATE-then-INSERT) can race two writers into a
// duplicate insert. The source is a constant one-row derived table and the ON clause compares the target's key to
// the bound key directly, so a key matching several rows updates them all and the driver's row-count rule rolls it
// back. The trailing semicolon is mandatory for MERGE.
public string? UpsertSql(
string quotedTable, string quotedKeyColumn, string keyMarker,
IReadOnlyList<(string QuotedColumn, string Marker)> assignments)
=> string.Concat(
"MERGE INTO ", quotedTable, " WITH (HOLDLOCK) AS tgt USING (SELECT 1 AS one) AS src ON tgt.",
quotedKeyColumn, " = ", keyMarker,
" WHEN MATCHED THEN UPDATE SET ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)),
" WHEN NOT MATCHED THEN INSERT (", quotedKeyColumn, ", ", string.Join(", ", assignments.Select(a => a.QuotedColumn)),
") VALUES (", keyMarker, ", ", string.Join(", ", assignments.Select(a => a.Marker)), ");");
/// <inheritdoc />
// EXEC with named arguments: the left-hand @name is the procedure's own parameter, the right-hand marker the bind.
public string? ProcedureCallSql(string quotedProcedure, IReadOnlyList<(string Parameter, string Marker)> arguments)
=> arguments.Count == 0
? "EXEC " + quotedProcedure
: "EXEC " + quotedProcedure + " " + string.Join(", ", arguments.Select(a => "@" + a.Parameter + " = " + a.Marker));
/// <inheritdoc />
// INFORMATION_SCHEMA.ROUTINES lists only routines the principal may see (it holds a permission on), which is the
// question the gate asks. Procedures only — a function is not callable with EXEC semantics here.
public string? ListRoutinesSql =>
"SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES " +
"WHERE ROUTINE_SCHEMA = @schema AND ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME";
/// <inheritdoc />
// SqlClient reports no SQLSTATE, so the engine error number decides. 2627/2601 duplicate key (constraint /
// unique index); 547 FK or CHECK, 515 NULL into NOT NULL; 8115/220/232 arithmetic overflow, 8152/2628 truncation,
// 245/8114 conversion, 241/242 date conversion or out of range.
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
{
if (exception is not SqlException sql) return SqlWriteFailure.Other;
return sql.Number switch
{
2627 or 2601 => SqlWriteFailure.UniqueViolation,
547 or 515 => SqlWriteFailure.IntegrityViolation,
8115 or 220 or 232 or 8152 or 2628 or 245 or 8114 or 241 or 242 => SqlWriteFailure.DataException,
_ => SqlWriteFailure.Other,
};
}
}
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>Why a write's statement failed, as far as the provider's error says (lmxopcua#781 item 2).</summary>
public enum SqlWriteFailure
{
/// <summary>Anything else — permission denied, a missing object, a broken connection. <c>BadCommunicationError</c>.</summary>
Other,
/// <summary>A unique / primary-key violation (SQLSTATE 23505, SQL Server 2627/2601, MySQL 1062, ORA-00001). <c>BadEntryExists</c>.</summary>
UniqueViolation,
/// <summary>Another integrity constraint — NOT NULL, foreign key, CHECK (SQLSTATE class 23). <c>BadOutOfRange</c>.</summary>
IntegrityViolation,
/// <summary>A data exception — overflow, truncation, an unconvertible value (SQLSTATE class 22). <c>BadOutOfRange</c>.</summary>
DataException,
}
/// <summary>SQLSTATE-based classification shared by the dialects.</summary>
public static class SqlWriteFailures
{
/// <summary>Classifies an ANSI SQLSTATE: 23505 unique, class 23 integrity, class 22 data; anything else (or null) other.</summary>
/// <param name="sqlState">The five-character SQLSTATE, or null.</param>
/// <returns>The failure class.</returns>
public static SqlWriteFailure FromSqlState(string? sqlState)
{
if (string.IsNullOrEmpty(sqlState) || sqlState.Length < 2) return SqlWriteFailure.Other;
if (sqlState == "23505") return SqlWriteFailure.UniqueViolation;
if (sqlState.StartsWith("23", StringComparison.Ordinal)) return SqlWriteFailure.IntegrityViolation;
if (sqlState.StartsWith("22", StringComparison.Ordinal)) return SqlWriteFailure.DataException;
return SqlWriteFailure.Other;
}
}
@@ -23,17 +23,23 @@ public sealed class SqlCatalog
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
/// <summary>Stored procedures per canonical schema — loaded only for schemas a Procedure-mode tag names (lmxopcua#781).</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _routinesBySchema;
/// <summary>Constructs a catalog snapshot.</summary>
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
/// <param name="schemas">Every schema the connection can see.</param>
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
/// <param name="routinesBySchema">Canonical schema → its stored procedures; null ⇒ none loaded.</param>
internal SqlCatalog(
string defaultSchema,
IReadOnlyList<string> schemas,
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable,
IReadOnlyDictionary<string, IReadOnlyList<string>>? routinesBySchema = null)
{
_routinesBySchema = routinesBySchema ?? new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
@@ -93,6 +99,21 @@ public sealed class SqlCatalog
&& TryMatch(columns, authored, out canonical);
}
/// <summary>
/// Resolves an authored procedure name in a canonical schema to the catalog's spelling, by the same
/// exact-then-unique-case-insensitive rule as tables and columns.
/// </summary>
/// <param name="canonicalSchema">The schema, as <see cref="TryResolveSchema"/> returned it.</param>
/// <param name="authored">The authored procedure name.</param>
/// <param name="canonical">The catalog spelling, when this returns true.</param>
/// <returns>True when exactly one procedure matches.</returns>
public bool TryResolveRoutine(string canonicalSchema, string authored, out string canonical)
{
canonical = string.Empty;
return _routinesBySchema.TryGetValue(canonicalSchema, out var routines)
&& TryMatch(routines, authored, out canonical);
}
/// <summary>The dictionary key for a canonical relation.</summary>
/// <param name="schema">The canonical schema.</param>
/// <param name="table">The canonical table.</param>
@@ -81,16 +81,29 @@ public static class SqlCatalogGate
// A named-query tag quotes no identifier into SQL: its statement is driver configuration (checked
// read-only at construction) and its column/rowKey/timestamp names are matched against the result
// set by the reader. There is nothing here to allow-list, so it passes through unchanged.
// A Procedure-mode write block (lmxopcua#781) names a routine, which is resolved here like any other
// identifier — for a Query tag too, whose read side needs no catalog.
SqlTagDefinition? canonical;
SqlCatalogRejection? rejection;
if (definition.Model == SqlTagModel.Query)
{
accepted.Add(definition);
canonical = definition;
rejection = null;
}
else if (!TryCanonicalize(definition, catalog, dialect, out canonical, out rejection))
{
rejected.Add(rejection!);
continue;
}
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
if (canonical!.Write is { Mode: SqlWriteMode.Procedure } write
&& !TryResolveProcedure(canonical, write, catalog, dialect, out canonical, out rejection))
{
rejected.Add(rejection!);
continue;
}
accepted.Add(canonical!);
}
return new SqlCatalogGateResult(accepted, rejected);
@@ -162,6 +175,64 @@ public static class SqlCatalogGate
return true;
}
/// <summary>The rejection <see cref="SqlCatalogRejection.Field"/> for a write block's procedure.</summary>
internal const string ProcedureField = "Write.Procedure";
/// <summary>
/// Resolves a Procedure-mode write block's routine against the catalog and substitutes its spelling
/// (lmxopcua#781). Same rules as a table: at most two parts, each renderable, schema then routine by the
/// exact-then-unique-case-insensitive match.
/// </summary>
private static bool TryResolveProcedure(
SqlTagDefinition definition,
SqlWriteSpec write,
SqlCatalog catalog,
ISqlDialect dialect,
out SqlTagDefinition? canonical,
out SqlCatalogRejection? rejection)
{
canonical = null;
rejection = null;
if (dialect.ListRoutinesSql is null)
{
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
$"the {dialect.Provider} provider has no stored-procedure catalog to validate the procedure against, " +
"so a Procedure-mode write cannot be served on it");
return false;
}
var parts = (write.Procedure ?? string.Empty).Split('.');
if (parts.Length > MaxNameParts || parts.Any(p => !IsRenderableIdentifier(p, dialect)))
{
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
"the authored procedure name is not a usable one- or two-part identifier (empty, over-long, more than " +
"two parts, or containing control or Unicode format characters); the value is withheld from this " +
"message because it cannot be safely rendered");
return false;
}
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
var authoredRoutine = parts[^1];
if (!catalog.TryResolveSchema(authoredSchema, out var schema))
{
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a case-sensitive collation)");
return false;
}
if (!catalog.TryResolveRoutine(schema, authoredRoutine, out var routine))
{
rejection = new SqlCatalogRejection(definition.Name, ProcedureField,
$"stored procedure '{authoredRoutine}' does not exist in schema '{schema}', or the connecting principal " +
"may not execute it (or the name matches more than one procedure under a case-sensitive collation)");
return false;
}
canonical = definition with { Write = write with { Procedure = SqlCatalog.QualifiedKey(schema, routine) } };
return true;
}
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
private static bool TryResolveRelation(
SqlTagDefinition definition,
@@ -37,6 +37,8 @@ internal static class SqlCatalogLoader
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
private const string RoutineNameColumn = "ROUTINE_NAME";
private const string SchemaParameter = "schema";
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
@@ -50,6 +52,7 @@ internal static class SqlCatalogLoader
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
/// <param name="commandTimeout">Per-command server-side backstop.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <param name="authoredRoutines">Procedure-mode write targets (lmxopcua#781) whose schemas' procedures to list.</param>
/// <returns>The loaded catalog.</returns>
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
internal static async Task<SqlCatalog> LoadAsync(
@@ -57,7 +60,8 @@ internal static class SqlCatalogLoader
ISqlDialect dialect,
IReadOnlyCollection<string> authoredTables,
TimeSpan commandTimeout,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IReadOnlyCollection<string>? authoredRoutines = null)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
@@ -124,7 +128,35 @@ internal static class SqlCatalogLoader
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
// Procedure-mode write targets (lmxopcua#781): one routine listing per schema they name. A dialect with no
// routine catalog loads nothing, and the gate then refuses those tags by name.
var routinesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
if (dialect.ListRoutinesSql is { } listRoutinesSql)
{
foreach (var authored in authoredRoutines ?? [])
{
if (string.IsNullOrWhiteSpace(authored)) continue;
var parts = authored.Split('.');
if (parts.Length > 2) continue;
string schema;
if (parts.Length == 2)
{
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
}
else
{
schema = defaultSchema;
}
if (routinesBySchema.ContainsKey(schema)) continue;
routinesBySchema[schema] = await QueryColumnAsync(
connection, dialect, listRoutinesSql, RoutineNameColumn,
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable, routinesBySchema);
}
/// <summary>
@@ -15,11 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
/// <see cref="SqlPollReader"/> owns the read path.
/// <para><b>Writes are opt-in at two levels and default off</b> (design §3.4, lmxopcua#753). A write needs
/// the driver's <c>allowWrites</c>, the tag's own <c>"writable": true</c>, and a model that names one row
/// (KeyValue, or a WideRow where-pair) — on top of the node's Writable flag and the caller's WriteOperate
/// role, which the server checks first. It is a parameterised single-row <c>UPDATE</c> over the tag's
/// catalog-validated identifiers (<see cref="SqlWritePlanner"/>), run in a transaction that commits only when
/// exactly one row was touched. With <c>allowWrites</c> off every write is
/// the driver's <c>allowWrites</c>, the tag's own <c>"writable": true</c>, and a target its <c>write</c> mode can
/// use (lmxopcua#781: Update / Upsert one keyed row, Insert any table-backed tag, Procedure any tag) — on top of
/// the node's Writable flag and the caller's WriteOperate role, which the server checks first. It is one
/// parameterised statement over the tag's catalog-validated identifiers (<see cref="SqlWritePlanner"/>), run in a
/// transaction that commits only when a table write touched exactly one row (a procedure call commits when it
/// does not throw). With <c>allowWrites</c> off every write is
/// <see cref="SqlStatusCodes.BadNotWritable"/> and every variable discovers as
/// <see cref="SecurityClassification.ViewOnly"/>.</para>
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
@@ -626,17 +627,28 @@ public sealed class SqlDriver
/// Writable flag or the caller's role, which the server enforces before a write reaches the driver.
/// </summary>
private bool IsWritePermitted(SqlTagDefinition tag)
=> _options.AllowWrites && tag.Writable
&& (tag.Model == SqlTagModel.KeyValue
|| (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null));
=> _options.AllowWrites && tag.Writable && HasWriteTarget(tag) && _dialect.SupportsWriteMode(tag.EffectiveWrite.Mode);
/// <summary>
/// Whether the tag's model gives its write mode something to write (lmxopcua#781): a procedure always; an
/// insert any table-backed tag; an update or upsert one keyed row — KeyValue, or a WideRow where-pair.
/// </summary>
private static bool HasWriteTarget(SqlTagDefinition tag) => tag.EffectiveWrite.Mode switch
{
SqlWriteMode.Procedure => true,
SqlWriteMode.Insert => tag.Model is SqlTagModel.KeyValue or SqlTagModel.WideRow,
_ => tag.Model == SqlTagModel.KeyValue || (tag.Model == SqlTagModel.WideRow && tag.RowSelectorColumn is not null),
};
/// <inheritdoc />
// Each write is a parameterised single-row UPDATE (SqlWritePlanner) over the tag's CATALOG-VALIDATED
// definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no unvalidated identifier
// can reach an UPDATE. It runs in a transaction that commits only when exactly one row was touched: zero rows
// is BadNoData (nothing to update — the driver never inserts), more than one is BadConfigurationError (the
// selector is ambiguous, the read-side rule), and both roll back. Bounded by operationTimeout like a poll. A
// write never changes driver health: its outcome is the write's answer, not a statement about the database.
// Each write is one parameterised statement in the tag's write mode (SqlWritePlanner; Update by default) over the
// tag's CATALOG-VALIDATED definition — a tag the gate rejected misses here and answers BadNodeIdUnknown, so no
// unvalidated identifier (table, column or procedure) can reach a statement. It runs in a transaction: a table
// write commits only when it touched one row (two for a MySQL upsert that updated) — zero rows is BadNoData, more
// is BadConfigurationError (the selector is ambiguous, the read-side rule), and both roll back; a procedure call
// commits when it does not throw. A constraint the database enforced maps to BadEntryExists / BadOutOfRange
// (MapWriteFailure). Bounded by operationTimeout like a poll. Never retried (WriteIdempotent=false). A write
// never changes driver health: its outcome is the write's answer, not a statement about the database.
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -656,7 +668,9 @@ public sealed class SqlDriver
if (!_options.AllowWrites) return SqlStatusCodes.BadNotWritable;
if (write.FullReference is null || Lookup(write.FullReference) is not { } tag)
return SqlStatusCodes.BadNodeIdUnknown;
if (!IsWritePermitted(tag)) return SqlStatusCodes.BadNotWritable;
if (!tag.Writable || !HasWriteTarget(tag)) return SqlStatusCodes.BadNotWritable;
// Deploy refuses a mode the provider cannot serve (SqlWriteModeUnsupported); this is the runtime backstop.
if (!_dialect.SupportsWriteMode(tag.EffectiveWrite.Mode)) return SqlStatusCodes.BadNotSupported;
var planned = SqlWritePlanner.TryPlan(tag, write.Value, _dialect, out var plan);
if (planned != SqlStatusCodes.Good) return planned;
@@ -683,14 +697,29 @@ public sealed class SqlDriver
catch (Exception ex)
{
// Open or execute failed. Type only on any surface; the exception (never the connection string) to the
// log sink. Nothing was committed.
_logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type}. Driver={DriverInstanceId}",
tag.Name, Endpoint, ex.GetType().Name, _driverInstanceId);
return SqlStatusCodes.BadCommunicationError;
// log sink. Nothing was committed. A constraint the database enforced is the value's fault, not the
// link's (lmxopcua#781): a duplicate key is BadEntryExists, another integrity or data rule BadOutOfRange.
// Everything else — a missing grant included — stays BadCommunicationError.
var failure = _dialect.ClassifyWriteFailure(ex);
_logger.LogWarning(ex, "Sql write to '{Tag}' failed against {Endpoint}: {Type} ({Failure}). Driver={DriverInstanceId}",
tag.Name, Endpoint, ex.GetType().Name, failure, _driverInstanceId);
return MapWriteFailure(failure);
}
}
/// <summary>Opens a connection and runs one planned UPDATE in a transaction that commits only on one row.</summary>
/// <summary>The status a classified write failure publishes (lmxopcua#781; documented in docs/drivers/Sql.md).</summary>
internal static uint MapWriteFailure(SqlWriteFailure failure) => failure switch
{
SqlWriteFailure.UniqueViolation => SqlStatusCodes.BadEntryExists,
SqlWriteFailure.IntegrityViolation or SqlWriteFailure.DataException => SqlStatusCodes.BadOutOfRange,
_ => SqlStatusCodes.BadCommunicationError,
};
/// <summary>
/// Opens a connection and runs one planned write in a transaction. A table write commits only when it touched
/// between one and <see cref="SqlWritePlan.MaxRowsAffected"/> rows; a procedure call commits whenever it did
/// not throw (its row count is the procedure's own business).
/// </summary>
private async Task<uint> ExecuteWriteAsync(SqlTagDefinition tag, SqlWritePlan plan, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
@@ -725,7 +754,7 @@ public sealed class SqlDriver
rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
if (rows == 1)
if (plan.Mode == SqlWriteMode.Procedure || (rows >= 1 && rows <= plan.MaxRowsAffected))
{
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return SqlStatusCodes.Good;
@@ -735,9 +764,9 @@ public sealed class SqlDriver
if (rows == 0) return SqlStatusCodes.BadNoData;
_logger.LogWarning(
"Sql write to '{Tag}' matched {Rows} row(s) on '{Table}' and was rolled back: a write must update " +
"Sql {Mode} write to '{Tag}' touched {Rows} row(s) on '{Table}' and was rolled back: a write must touch " +
"exactly one row. Driver={DriverInstanceId}",
tag.Name, rows, tag.Table, _driverInstanceId);
plan.Mode, tag.Name, rows, tag.Table, _driverInstanceId);
return rows > 1 ? SqlStatusCodes.BadConfigurationError : SqlStatusCodes.BadNotSupported;
}
}
@@ -1115,6 +1144,15 @@ public sealed class SqlDriver
continue;
}
if (definition.Write is { } write && !_dialect.SupportsWriteMode(write.Mode))
{
// Deploy refuses this (SqlWriteModeUnsupported); an older artifact still reads, read-only.
_logger.LogWarning(
"Sql tag {RawPath} asks for {Mode} writes, which the {Provider} provider does not serve; it " +
"is published read-only. Driver={DriverInstanceId}",
entry.RawPath, write.Mode, _dialect.Provider, _driverInstanceId);
}
table[entry.RawPath] = definition;
}
else
@@ -1197,10 +1235,17 @@ public sealed class SqlDriver
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = tables.Length == 0
// Procedure-mode write targets (lmxopcua#781) are identifiers too — a Query tag's included.
var routines = authored.Values
.Where(definition => definition.Write is { Mode: SqlWriteMode.Procedure })
.Select(definition => definition.Write!.Procedure!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = tables.Length == 0 && routines.Length == 0
? SqlCatalog.Empty
: await RunBoundedAsync(
token => LoadCatalogAsync(tables, token),
token => LoadCatalogAsync(tables, routines, token),
_options.OperationTimeout,
"the catalog queries did not return",
cancellationToken).ConfigureAwait(false);
@@ -1235,7 +1280,8 @@ public sealed class SqlDriver
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
private async Task<SqlCatalog> LoadCatalogAsync(
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
IReadOnlyCollection<string> authoredTables, IReadOnlyCollection<string> authoredRoutines,
CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
@@ -1246,7 +1292,7 @@ public sealed class SqlDriver
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return await SqlCatalogLoader
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken, authoredRoutines)
.ConfigureAwait(false);
}
}
@@ -1257,7 +1257,22 @@ public static class SqlStatusCodes
/// </summary>
public const uint BadNotWritable = 0x803B0000u;
/// <summary>The provider could not report how many rows a write touched, so it was rolled back.</summary>
/// <summary>
/// A write the database refused for a unique / primary-key violation (lmxopcua#781) — an Insert of a key that
/// exists, typically. (<c>BadEntryExists</c>, read off <c>Opc.Ua.StatusCodes</c>.)
/// </summary>
public const uint BadEntryExists = 0x809F0000u;
/// <summary>
/// A write the database refused for another integrity rule (NOT NULL, foreign key, CHECK) or a data exception
/// (overflow, truncation, an unconvertible value) — lmxopcua#781. (<c>BadOutOfRange</c>.)
/// </summary>
public const uint BadOutOfRange = 0x803C0000u;
/// <summary>
/// The provider could not report how many rows a write touched, so it was rolled back — or the dialect does not
/// serve the tag's write mode (Upsert / Procedure on Odbc, lmxopcua#781).
/// </summary>
public const uint BadNotSupported = 0x803D0000u;
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
@@ -4,94 +4,235 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One compiled single-row write (lmxopcua#753): a parameterised <c>UPDATE</c> whose identifiers are the
/// tag's catalog-validated ones and whose values are all bound.
/// One compiled write (lmxopcua#753, #781): a parameterised statement whose identifiers are the tag's
/// catalog-validated ones and whose values are all bound.
/// </summary>
/// <param name="SqlText">The statement — dialect-quoted identifiers and parameter markers only.</param>
/// <param name="ParameterBindNames">The bind names, in marker order (positional providers bind in this order).</param>
/// <param name="ParameterBindNames">
/// The distinct bind names, in first-marker order (a positional provider binds in this order, and only the modes
/// it supports — Update and Insert — are planned for it, where every marker appears exactly once).
/// </param>
/// <param name="Parameters">The bound values, aligned with <paramref name="ParameterBindNames"/>.</param>
/// <param name="Mode">The statement shape — decides how the executor reads the affected-row count.</param>
/// <param name="MaxRowsAffected">
/// The most rows the statement may report for a success: 1, or 2 for a MySQL upsert. Ignored for
/// <see cref="SqlWriteMode.Procedure"/>, whose row count is the procedure's business.
/// </param>
public sealed record SqlWritePlan(
string SqlText,
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters);
IReadOnlyList<object?> Parameters,
SqlWriteMode Mode = SqlWriteMode.Update,
int MaxRowsAffected = 1);
/// <summary>
/// Turns one OPC UA write into a parameterised single-row <c>UPDATE</c> (design §3.4, lmxopcua#753) — pure, no
/// I/O. <b>The write surface is deliberately narrow:</b> no authored SQL, no INSERT/UPSERT/procedure, only
/// <c>UPDATE &lt;table&gt; SET &lt;value column&gt; = @v WHERE &lt;key column&gt; = @k</c> built from the
/// tag's own identifiers.
/// Turns one OPC UA write into one parameterised statement (design §3.4, lmxopcua#753 / #781 item 2) — pure, no
/// I/O. The tag's <c>write</c> block picks the shape; there is no authored write SQL:
/// <list type="bullet">
/// <item><see cref="SqlTagModel.KeyValue"/>: <c>SET valueColumn = @v WHERE keyColumn = @k</c>.</item>
/// <item><see cref="SqlTagModel.WideRow"/> with a where-pair: <c>SET columnName = @v WHERE whereColumn = @w</c>.</item>
/// <item><see cref="SqlTagModel.WideRow"/> by <c>topByTimestamp</c>, and <see cref="SqlTagModel.Query"/>:
/// refused (<see cref="SqlStatusCodes.BadNotWritable"/>) — "the newest row" moves under a concurrent
/// insert, and a named query's projection names no table to update.</item>
/// <item><see cref="SqlWriteMode.Update"/> (the default): <c>UPDATE t SET value [, stamp] WHERE key = @k</c>
/// on a KeyValue tag or a WideRow where-pair.</item>
/// <item><see cref="SqlWriteMode.Insert"/>: <c>INSERT INTO t (key, value [, stamp]) VALUES (…)</c> — no key
/// column for a WideRow <c>topByTimestamp</c> tag (an append).</item>
/// <item><see cref="SqlWriteMode.Upsert"/>: the dialect's single-statement upsert
/// (<see cref="ISqlDialect.UpsertSql"/>).</item>
/// <item><see cref="SqlWriteMode.Procedure"/>: the dialect's call of the catalog-validated procedure
/// (<see cref="ISqlDialect.ProcedureCallSql"/>) with the value, and optionally the key and write time, bound.</item>
/// </list>
/// <para><b>Injection boundary.</b> Every identifier is one the catalog gate resolved and substituted (the
/// driver plans writes only for <em>polled</em>, i.e. gate-accepted, definitions) and reaches the text through
/// <see cref="ISqlDialect.QuoteIdentifier"/>; the written value and the key are bound parameters. Nothing an
/// OPC UA client sends reaches the command text.</para>
/// <para><b>Injection boundary.</b> Every identifier is one the catalog gate resolved and substituted (the driver
/// plans writes only for gate-accepted definitions) and reaches the text through
/// <see cref="ISqlDialect.QuoteIdentifier"/>; procedure parameter names are restricted to a plain identifier
/// charset by the tag parser; the value, the key and the stamp are bound parameters. Nothing an OPC UA client
/// sends reaches the command text.</para>
/// </summary>
public static class SqlWritePlanner
{
private const string ValueBaseName = "v";
private const string KeyBaseName = "k";
private const string TimestampBaseName = "t";
/// <summary>
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
/// </summary>
/// <summary>Plans a write of <paramref name="value"/> to <paramref name="tag"/>, stamping the current UTC time.</summary>
/// <param name="tag">The catalog-validated definition.</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="dialect">Quoting, markers and bind names.</param>
/// <param name="dialect">Quoting, markers, bind names and the dialect-specific statements.</param>
/// <param name="plan">The plan, when this returns <see cref="SqlStatusCodes.Good"/>.</param>
/// <returns><see cref="SqlStatusCodes.Good"/>, or the status the write publishes.</returns>
public static uint TryPlan(SqlTagDefinition tag, object? value, ISqlDialect dialect, out SqlWritePlan? plan)
=> TryPlan(tag, value, dialect, DateTime.UtcNow, out plan);
/// <summary>
/// Plans a write of <paramref name="value"/> to <paramref name="tag"/>, or explains why it is refused.
/// <see cref="SqlStatusCodes.BadNotWritable"/>: the model has no target for the mode (a Query tag outside
/// Procedure, a <c>topByTimestamp</c> row for Update/Upsert). <see cref="SqlStatusCodes.BadNotSupported"/>: the
/// dialect does not serve the mode. <see cref="SqlStatusCodes.BadTypeMismatch"/>: the value will not coerce.
/// </summary>
/// <param name="tag">The catalog-validated definition.</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="dialect">Quoting, markers, bind names and the dialect-specific statements.</param>
/// <param name="utcNow">The write time bound to a stamp column or <c>timestampParam</c> (UTC).</param>
/// <param name="plan">The plan, when this returns <see cref="SqlStatusCodes.Good"/>.</param>
/// <returns><see cref="SqlStatusCodes.Good"/>, or the status the write publishes.</returns>
public static uint TryPlan(
SqlTagDefinition tag, object? value, ISqlDialect dialect, DateTime utcNow, out SqlWritePlan? plan)
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(dialect);
plan = null;
string setColumn, keyColumn;
string? keyValue;
switch (tag.Model)
{
case SqlTagModel.KeyValue when !Blank(tag.ValueColumn) && !Blank(tag.KeyColumn) && tag.KeyValue is not null:
setColumn = tag.ValueColumn!;
keyColumn = tag.KeyColumn!;
keyValue = tag.KeyValue;
break;
case SqlTagModel.WideRow when !Blank(tag.ColumnName) && !Blank(tag.RowSelectorColumn)
&& tag.RowSelectorValue is not null:
setColumn = tag.ColumnName!;
keyColumn = tag.RowSelectorColumn!;
keyValue = tag.RowSelectorValue;
break;
default:
return SqlStatusCodes.BadNotWritable;
}
var write = tag.EffectiveWrite;
if (!dialect.SupportsWriteMode(write.Mode)) return SqlStatusCodes.BadNotSupported;
if (!TryConvert(value, tag.DeclaredType, out var bound)) return SqlStatusCodes.BadTypeMismatch;
var stamp = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc);
string sql;
try
{
sql = string.Concat(
"UPDATE ", QuoteQualifiedName(tag.Table, dialect),
" SET ", dialect.QuoteIdentifier(setColumn), " = ", dialect.ParameterMarker(ValueBaseName),
" WHERE ", dialect.QuoteIdentifier(keyColumn), " = ", dialect.ParameterMarker(KeyBaseName));
return write.Mode == SqlWriteMode.Procedure
? PlanProcedure(tag, write, bound, stamp, dialect, out plan)
: PlanTableWrite(tag, write, bound, stamp, dialect, out plan);
}
catch (ArgumentException)
{
// Unreachable for a gate-accepted definition (its identifiers came from the catalog); refuse rather
// than build a statement around a name the dialect would not quote.
plan = null;
return SqlStatusCodes.BadConfigurationError;
}
}
// Marker order: SET's value first, then WHERE's key — a positional provider (ODBC) binds in this order.
plan = new SqlWritePlan(
sql,
[dialect.ParameterBindName(ValueBaseName), dialect.ParameterBindName(KeyBaseName)],
[bound, keyValue]);
/// <summary>Update / Insert / Upsert against the tag's own table.</summary>
private static uint PlanTableWrite(
SqlTagDefinition tag, SqlWriteSpec write, object? bound, DateTime stamp, ISqlDialect dialect, out SqlWritePlan? plan)
{
plan = null;
string valueColumn;
string? keyColumn;
string? keyValue;
switch (tag.Model)
{
case SqlTagModel.KeyValue when !Blank(tag.ValueColumn) && !Blank(tag.KeyColumn) && tag.KeyValue is not null:
valueColumn = tag.ValueColumn!;
keyColumn = tag.KeyColumn!;
keyValue = tag.KeyValue;
break;
case SqlTagModel.WideRow when !Blank(tag.ColumnName) && !Blank(tag.RowSelectorColumn)
&& tag.RowSelectorValue is not null:
valueColumn = tag.ColumnName!;
keyColumn = tag.RowSelectorColumn!;
keyValue = tag.RowSelectorValue;
break;
// Newest-row selector: an Insert appends a row (no key); Update/Upsert have no row to aim at — "the
// newest row" moves under a concurrent insert.
case SqlTagModel.WideRow when write.Mode == SqlWriteMode.Insert && !Blank(tag.ColumnName):
valueColumn = tag.ColumnName!;
keyColumn = null;
keyValue = null;
break;
default:
return SqlStatusCodes.BadNotWritable;
}
var stampColumn = write.StampTimestamp ? SqlWriteSpecParser.StampColumn(tag) : null;
if (write.StampTimestamp && stampColumn is null) return SqlStatusCodes.BadConfigurationError;
var table = QuoteQualifiedName(tag.Table, dialect);
var valueMarker = dialect.ParameterMarker(ValueBaseName);
var keyMarker = dialect.ParameterMarker(KeyBaseName);
var stampMarker = dialect.ParameterMarker(TimestampBaseName);
// The value first, then the stamp: the order of the SET list and of the insert's non-key columns.
var assignments = new List<(string QuotedColumn, string Marker)> { (dialect.QuoteIdentifier(valueColumn), valueMarker) };
if (stampColumn is not null) assignments.Add((dialect.QuoteIdentifier(stampColumn), stampMarker));
var binds = new Binds(dialect);
string sql;
var maxRows = 1;
switch (write.Mode)
{
case SqlWriteMode.Update:
// Marker order: SET's value [and stamp], then WHERE's key — a positional provider binds in this order.
sql = string.Concat(
"UPDATE ", table,
" SET ", string.Join(", ", assignments.Select(a => a.QuotedColumn + " = " + a.Marker)),
" WHERE ", dialect.QuoteIdentifier(keyColumn!), " = ", keyMarker);
binds.Add(ValueBaseName, bound);
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
binds.Add(KeyBaseName, keyValue);
break;
case SqlWriteMode.Insert:
{
var columns = new List<string>();
var markers = new List<string>();
if (keyColumn is not null)
{
columns.Add(dialect.QuoteIdentifier(keyColumn));
markers.Add(keyMarker);
binds.Add(KeyBaseName, keyValue);
}
columns.AddRange(assignments.Select(a => a.QuotedColumn));
markers.AddRange(assignments.Select(a => a.Marker));
binds.Add(ValueBaseName, bound);
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
sql = string.Concat(
"INSERT INTO ", table, " (", string.Join(", ", columns), ") VALUES (", string.Join(", ", markers), ")");
break;
}
case SqlWriteMode.Upsert:
{
var upsert = dialect.UpsertSql(table, dialect.QuoteIdentifier(keyColumn!), keyMarker, assignments);
if (upsert is null) return SqlStatusCodes.BadNotSupported;
sql = upsert;
// A named-marker dialect (the only kind with an upsert) binds by name, so first-appearance order is
// documentation rather than load-bearing here.
binds.Add(KeyBaseName, keyValue);
binds.Add(ValueBaseName, bound);
if (stampColumn is not null) binds.Add(TimestampBaseName, stamp);
maxRows = Math.Max(1, dialect.UpsertMaxRowsAffected);
break;
}
default:
return SqlStatusCodes.BadNotWritable;
}
plan = new SqlWritePlan(sql, binds.Names, binds.Values, write.Mode, maxRows);
return SqlStatusCodes.Good;
}
/// <summary>A call of the tag's catalog-validated procedure: value, then key, then write time — each when bound.</summary>
private static uint PlanProcedure(
SqlTagDefinition tag, SqlWriteSpec write, object? bound, DateTime stamp, ISqlDialect dialect, out SqlWritePlan? plan)
{
plan = null;
if (Blank(write.Procedure)) return SqlStatusCodes.BadConfigurationError;
var binds = new Binds(dialect);
var arguments = new List<(string Parameter, string Marker)>
{
(write.ValueParameter ?? SqlWriteSpecParser.DefaultValueParameter, dialect.ParameterMarker(ValueBaseName)),
};
binds.Add(ValueBaseName, bound);
if (write.KeyParameter is { } keyParameter)
{
var key = SqlWriteSpecParser.KeyValue(tag);
if (key is null) return SqlStatusCodes.BadConfigurationError;
arguments.Add((keyParameter, dialect.ParameterMarker(KeyBaseName)));
binds.Add(KeyBaseName, key);
}
if (write.TimestampParameter is { } timestampParameter)
{
arguments.Add((timestampParameter, dialect.ParameterMarker(TimestampBaseName)));
binds.Add(TimestampBaseName, stamp);
}
var sql = dialect.ProcedureCallSql(QuoteQualifiedName(write.Procedure!, dialect), arguments);
if (sql is null) return SqlStatusCodes.BadNotSupported;
plan = new SqlWritePlan(sql, binds.Names, binds.Values, SqlWriteMode.Procedure);
return SqlStatusCodes.Good;
}
@@ -116,9 +257,23 @@ public static class SqlWritePlanner
return false;
}
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.table</c> name.</summary>
/// <summary>Quotes each part of a (catalog-canonical) <c>schema.name</c>.</summary>
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
=> string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
/// <summary>The ordered (bind name, value) list a plan carries.</summary>
private sealed class Binds(ISqlDialect dialect)
{
public List<string> Names { get; } = [];
public List<object?> Values { get; } = [];
public void Add(string baseName, object? value)
{
Names.Add(dialect.ParameterBindName(baseName));
Values.Add(value);
}
}
}