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;
///
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
/// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map.
/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.
///
public sealed class SqlServerDialect : ISqlDialect
{
///
/// T-SQL's regular-identifier ceiling (sysname = nvarchar(128)). 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.
/// The unit here is genuinely CHARACTERS (UTF-16 code units, which is what
/// nvarchar(128) counts), deliberately unlike PostgresDialect and OracleDialect,
/// whose ceilings are byte limits in the database encoding. Measuring bytes here would reject a legal
/// 128-character non-ASCII name.
///
internal const int MaxIdentifierLength = 128;
///
public SqlProvider Provider => SqlProvider.SqlServer;
///
public DbProviderFactory Factory => SqlClientFactory.Instance;
//
// T-SQL markers are @-prefixed — ParameterMarker("k0") is "@k0".
//
// The bare base name.
// The @-prefixed marker.
///
public string ParameterMarker(string baseName) => "@" + baseName;
//
// Identical to : Microsoft.Data.SqlClient expects
// to carry the same @ the statement does. (It also
// accepts a bare name and prepends the @ itself, but spelling it out keeps the parameter
// collection readable in a profiler trace and matches what the SQL text says.)
//
// The bare base name.
// The @-prefixed bind name.
///
public string ParameterBindName(string baseName) => "@" + baseName;
//
// No-op. SqlCommand 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 BindByName (see
// ).
//
// The command to configure; untouched here.
///
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
//
// 2098 — SQL Server's hard cap is 2100 parameters per statement, and
// Microsoft.Data.SqlClient spends two of them itself: a parameterized command is executed
// through sp_executesql, whose first two arguments are the statement text and the parameter
// declaration list. A caller filling 2100 would therefore be rejected with
// "The incoming request has too many parameters" for a limit it appeared to respect.
//
///
public int MaxBindParameters => 2098;
///
public string LivenessSql => "SELECT 1";
//
// T-SQL limits after the SELECT keyword, so the whole limit lives in the prefix —
// "TOP 1 ", trailing space included so SELECT TOP 1 [col] composes with no extra
// separator at the call site.
//
///
public string SingleRowLimitPrefix => "TOP 1 ";
// Empty — T-SQL has no trailing row-limit clause (OFFSET/FETCH is a paging construct, not this).
///
public string SingleRowLimitSuffix => string.Empty;
///
// 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) + ") ";
}
///
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
///
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
//
// SCHEMA_NAME() 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.
//
///
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
///
public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
///
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";
//
// Brackets one identifier part, doubling any embedded ] — the T-SQL escape rule, and the only
// metacharacter that could terminate a bracketed identifier early. [, quotes, semicolons and
// comment markers are inert inside brackets and are therefore passed through unchanged.
//
//
// Rejects (all as ): null / empty / all-whitespace; longer
// than ; any control character (which covers embedded NUL, and the
// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
// character — bidi overrides, zero-width
// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the rendered
// 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.
// The rejection messages deliberately do not echo the offending value — it is untrusted
// input and this exception's text reaches the driver log.
// This is a backstop, not the only defence. Design §8.1's catalog gate
// (SqlCatalogGate) 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.
//
// The bare, unquoted identifier part.
// The bracket-quoted identifier.
// The value cannot be a valid SQL Server identifier.
///
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), "]");
}
//
// Folds an INFORMATION_SCHEMA.COLUMNS.DATA_TYPE family name onto a
// per design §3.7.
//
//
// Precision caveat: decimal / numeric / money / smallmoney
// collapse to in v1. A .NET decimal carries more
// significant digits than a double, so a high-precision column (e.g. decimal(38,10))
// loses low-order digits. Authoring an explicit "type": "String" on the tag preserves the exact
// textual value when that matters.
// Fallback: an unrecognised family maps to and never
// throws — a schema browse must render a table containing an exotic column (xml,
// geography, hierarchyid, sql_variant, a CLR UDT, varbinary) rather than
// crash on it. Null/blank input takes the same fallback.
// Deliberate non-mappings: SQL Server's timestamp/rowversion is an opaque
// 8-byte row version, not a date — it falls back to
// rather than being mistaken for a . time is a
// time-of-day span, likewise not a point in time, and takes the same fallback.
// tinyint widens to (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 Byte).
//
// The bare SQL type family name; matched case-insensitively.
// The mapped driver data type, or when unrecognised.
///
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) ----
///
// 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)), ");");
///
// 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));
///
// 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";
///
// 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,
};
}
}