Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
T
Joseph Doherty 0a5aa9fc43 fix(sql): row-cap key-value and where-pair plans with a TOP/LIMIT N+1 dialect seam (R06:S-1)
Key-value and where-pair plans had no row limit, so a selector pointed at a history
or events table materialised every matching row on every poll. ISqlDialect gains
RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH
FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1
rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the
selector ambiguous: the group publishes BadConfigurationError with a rate-limited
warning. The reader reports those answered refusals so the driver counts them as
answer evidence, and any refusal-only poll now restores Healthy after a comms fault.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:13 -04:00

226 lines
12 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,
};
}
}