f5c30ed0ef
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
274 lines
15 KiB
C#
274 lines
15 KiB
C#
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,
|
||
};
|
||
}
|
||
}
|