Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
T
Joseph Doherty f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
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
2026-11-09 22:59:09 -05:00

274 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Data.Common;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class SqlServerDialect : ISqlDialect
{
/// <summary>
/// T-SQL's regular-identifier ceiling (<c>sysname</c> = <c>nvarchar(128)</c>). Enforced because a
/// longer string cannot name a real catalog object — it is either a config mistake or padding, and
/// rejecting it keeps the quoted output bounded.
/// <para><b>The unit here is genuinely CHARACTERS</b> (UTF-16 code units, which is what
/// <c>nvarchar(128)</c> counts), deliberately unlike <c>PostgresDialect</c> and <c>OracleDialect</c>,
/// whose ceilings are byte limits in the database encoding. Measuring bytes here would reject a legal
/// 128-character non-ASCII name.</para>
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.SqlServer;
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
// <summary>
// T-SQL markers are <c>@</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>"@k0"</c>.
// </summary>
// <param name="baseName">The bare base name.</param>
// <returns>The <c>@</c>-prefixed marker.</returns>
/// <inheritdoc />
public string ParameterMarker(string baseName) => "@" + baseName;
// <summary>
// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
// collection readable in a profiler trace and matches what the SQL text says.)
// </summary>
// <param name="baseName">The bare base name.</param>
// <returns>The <c>@</c>-prefixed bind name.</returns>
/// <inheritdoc />
public string ParameterBindName(string baseName) => "@" + baseName;
// <summary>
// No-op. <c>SqlCommand</c> binds by name out of the box, so there is nothing SQL Server needs set on a
// fresh command — the member exists for Oracle's <c>BindByName</c> (see
// <see cref="ISqlDialect.ConfigureCommand"/>).
// </summary>
// <param name="command">The command to configure; untouched here.</param>
/// <inheritdoc />
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
// <summary>
// 2098 — SQL Server's hard cap is <b>2100 parameters per statement</b>, and
// <c>Microsoft.Data.SqlClient</c> spends two of them itself: a parameterized command is executed
// through <c>sp_executesql</c>, whose first two arguments are the statement text and the parameter
// declaration list. A caller filling 2100 would therefore be rejected with
// <c>"The incoming request has too many parameters"</c> for a limit it appeared to respect.
// </summary>
/// <inheritdoc />
public int MaxBindParameters => 2098;
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
// <summary>
// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
// separator at the call site.
// </summary>
/// <inheritdoc />
public string SingleRowLimitPrefix => "TOP 1 ";
// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
/// <inheritdoc />
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc />
// Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
// backwards-compatibility spelling.
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
}
/// <inheritdoc />
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
// <summary>
// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
// unqualified object name resolves in, which is precisely the question the catalog gate asks.
// </summary>
/// <inheritdoc />
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
/// <inheritdoc/>
public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
/// <inheritdoc/>
public string ListColumnsSql =>
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION";
// <summary>
// Brackets one identifier part, doubling any embedded <c>]</c> — the T-SQL escape rule, and the only
// metacharacter that could terminate a bracketed identifier early. <c>[</c>, quotes, semicolons and
// comment markers are inert inside brackets and are therefore passed through unchanged.
// </summary>
// <remarks>
// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
// <see cref="System.Globalization.UnicodeCategory.Format"/> character — bidi overrides, zero-width
// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the <em>rendered</em>
// text of a log line or an AdminUI label while comparing byte-different from the real catalog name
// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the
// control-character rule.</para>
// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
// input and this exception's text reaches the driver log.</para>
// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at
// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
// </remarks>
// <param name="ident">The bare, unquoted identifier part.</param>
// <returns>The bracket-quoted identifier.</returns>
// <exception cref="ArgumentException">The value cannot be a valid SQL Server identifier.</exception>
/// <inheritdoc />
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
if (ident.Length > MaxIdentifierLength)
throw new ArgumentException(
$"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
// zero-width character. They are inert inside brackets but spoof rendered log/UI text.
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
throw new ArgumentException(
"A SQL identifier may not contain Unicode format characters "
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
}
return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]");
}
// <summary>
// Folds an <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> family name onto a
// <see cref="DriverDataType"/> per design §3.7.
// </summary>
// <remarks>
// <para><b>Precision caveat:</b> <c>decimal</c> / <c>numeric</c> / <c>money</c> / <c>smallmoney</c>
// collapse to <see cref="DriverDataType.Float64"/> in v1. A .NET <c>decimal</c> carries more
// significant digits than a <c>double</c>, so a high-precision column (e.g. <c>decimal(38,10)</c>)
// loses low-order digits. Authoring an explicit <c>"type": "String"</c> on the tag preserves the exact
// textual value when that matters.</para>
// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
// throws — a schema browse must render a table containing an exotic column (<c>xml</c>,
// <c>geography</c>, <c>hierarchyid</c>, <c>sql_variant</c>, a CLR UDT, <c>varbinary</c>) rather than
// crash on it. Null/blank input takes the same fallback.</para>
// <para><b>Deliberate non-mappings:</b> SQL Server's <c>timestamp</c>/<c>rowversion</c> is an opaque
// 8-byte row version, <em>not</em> a date — it falls back to <see cref="DriverDataType.String"/>
// rather than being mistaken for a <see cref="DriverDataType.DateTime"/>. <c>time</c> is a
// time-of-day span, likewise not a point in time, and takes the same fallback.</para>
// <para><c>tinyint</c> widens to <see cref="DriverDataType.Int16"/> (it is unsigned 0–255 in T-SQL, so
// a signed 8-bit type would not hold it, and the driver type set has no <c>Byte</c>).</para>
// </remarks>
// <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>
/// <inheritdoc />
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"bit" or "boolean" => DriverDataType.Boolean,
"tinyint" or "smallint" => DriverDataType.Int16,
"int" or "integer" => DriverDataType.Int32,
"bigint" => DriverDataType.Int64,
"real" => DriverDataType.Float32,
"float" or "double" or "double precision"
or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64,
"char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext"
or "uniqueidentifier" => DriverDataType.String,
"date" or "datetime" or "datetime2" or "smalldatetime"
or "datetimeoffset" => DriverDataType.DateTime,
_ => 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,
};
}
}