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