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