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
@@ -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,
};
}
}