feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL
The dialect owns the two things ADO.NET's System.Data.Common base types cannot abstract: identifier quoting and the metadata-catalog SQL. It is the driver's SQL-injection boundary — values always bind as DbParameters, identifiers cannot, so the few that reach a command text are catalog- sourced and pass through QuoteIdentifier. QuoteIdentifier brackets one identifier part and doubles embedded ] (the only metacharacter that can terminate a bracketed identifier early); rejects null/empty/whitespace, >128 chars (T-SQL sysname ceiling), and any control character (incl. NUL) as ArgumentException, without echoing the untrusted value into the message. MapColumnType folds the design §3.7 families case-insensitively and never throws — an unrecognised family falls back to String so a browse over an exotic column still renders; timestamp/rowversion and time are deliberately NOT mapped to DateTime. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
|
||||||
|
/// connections, commands, parameters and readers; they do <b>not</b> abstract the two things that differ
|
||||||
|
/// per backend — <b>identifier quoting</b> and the <b>metadata-catalog SQL</b>. Those live here, so no
|
||||||
|
/// dialect assumption leaks into the reader, the poll planner, or the schema browser.
|
||||||
|
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
|
||||||
|
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
|
||||||
|
/// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated
|
||||||
|
/// (<c>INFORMATION_SCHEMA</c>) names and emitted through <see cref="QuoteIdentifier"/>. Any code path that
|
||||||
|
/// builds SQL by concatenating an authored tag field <em>without</em> going through
|
||||||
|
/// <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
|
||||||
|
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
|
||||||
|
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
|
||||||
|
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
|
||||||
|
/// <see cref="DbProviderFactory"/>).</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface ISqlDialect
|
||||||
|
{
|
||||||
|
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
|
||||||
|
SqlProvider Provider { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the
|
||||||
|
/// abstract base type so consumers never bind to a concrete provider package.
|
||||||
|
/// </summary>
|
||||||
|
DbProviderFactory Factory { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quotes <b>one</b> identifier part so it can be safely embedded in a command text, escaping the
|
||||||
|
/// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather
|
||||||
|
/// than emitting it.
|
||||||
|
/// <para><b>One part only.</b> A multi-part name such as <c>dbo.TagValues</c> must be split by the
|
||||||
|
/// caller and each part quoted separately; passing the dotted form yields a single (nonexistent)
|
||||||
|
/// identifier — safe, but not what the caller meant.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ident">The bare, unquoted identifier — as returned by the catalog, not pre-quoted.</param>
|
||||||
|
/// <returns>The quoted identifier, ready to concatenate into a command text.</returns>
|
||||||
|
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||||
|
string QuoteIdentifier(string ident);
|
||||||
|
|
||||||
|
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||||
|
string LivenessSql { get; }
|
||||||
|
|
||||||
|
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||||
|
string ListSchemasSql { get; }
|
||||||
|
|
||||||
|
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||||
|
string ListTablesSql { get; }
|
||||||
|
|
||||||
|
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
|
||||||
|
string ListColumnsSql { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds a catalog data-type family name (as <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> reports it —
|
||||||
|
/// e.g. <c>nvarchar</c>, never <c>nvarchar(50)</c>) onto a <see cref="DriverDataType"/>.
|
||||||
|
/// <para>Must <b>never throw</b>: a browse over a table holding one exotic column must still render.
|
||||||
|
/// An unrecognised family falls back to <see cref="DriverDataType.String"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <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>
|
||||||
|
DriverDataType MapColumnType(string sqlDataType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
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 only provider v1 constructs. 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.
|
||||||
|
/// </summary>
|
||||||
|
internal const int MaxIdentifierLength = 128;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public SqlProvider Provider => SqlProvider.SqlServer;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string LivenessSql => "SELECT 1";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ListSchemasSql =>
|
||||||
|
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||||
|
|
||||||
|
/// <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).</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>Rejection is a backstop, not the primary defence: an identifier only reaches here after being
|
||||||
|
/// validated against the live catalog (design §8.1).</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>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Golden tests for the driver's SQL-injection boundary. Values are always bound as
|
||||||
|
/// <c>DbParameter</c>s; identifiers cannot be, so <see cref="SqlServerDialect.QuoteIdentifier"/> is the
|
||||||
|
/// only thing standing between a catalog-sourced name and a command text. These tests pin the escape
|
||||||
|
/// rule, the rejection rules, and the exact catalog SQL.
|
||||||
|
/// </summary>
|
||||||
|
public class SqlServerDialectTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||||
|
{
|
||||||
|
var d = new SqlServerDialect();
|
||||||
|
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
|
||||||
|
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuoteIdentifier_rejectsControlCharsAndNul()
|
||||||
|
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("bit", DriverDataType.Boolean)]
|
||||||
|
[InlineData("int", DriverDataType.Int32)]
|
||||||
|
[InlineData("bigint", DriverDataType.Int64)]
|
||||||
|
[InlineData("real", DriverDataType.Float32)]
|
||||||
|
[InlineData("float", DriverDataType.Float64)]
|
||||||
|
[InlineData("decimal", DriverDataType.Float64)]
|
||||||
|
[InlineData("nvarchar", DriverDataType.String)]
|
||||||
|
[InlineData("datetime2", DriverDataType.DateTime)]
|
||||||
|
[InlineData("uniqueidentifier", DriverDataType.String)]
|
||||||
|
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||||
|
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CatalogSql_isInformationSchemaAndParameterized()
|
||||||
|
{
|
||||||
|
var d = new SqlServerDialect();
|
||||||
|
d.LivenessSql.ShouldBe("SELECT 1");
|
||||||
|
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
|
||||||
|
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
|
||||||
|
d.ListColumnsSql.ShouldContain("@table");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- extra guards on the injection boundary (beyond the plan's golden set) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dialect_identifiesItselfAsSqlServer_andExposesTheAbstractFactory()
|
||||||
|
{
|
||||||
|
var d = new SqlServerDialect();
|
||||||
|
d.Provider.ShouldBe(SqlProvider.SqlServer);
|
||||||
|
d.Factory.ShouldNotBeNull();
|
||||||
|
// The seam must not leak Microsoft.Data.SqlClient into its signature.
|
||||||
|
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
|
||||||
|
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("\t")]
|
||||||
|
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||||
|
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident!));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButABracket()
|
||||||
|
=> new SqlServerDialect().QuoteIdentifier("]").ShouldBe("[]]]"); // T-SQL for the 1-char name "]"
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
|
||||||
|
{
|
||||||
|
var d = new SqlServerDialect();
|
||||||
|
d.QuoteIdentifier(new string('a', 128)).ShouldBe("[" + new string('a', 128) + "]");
|
||||||
|
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
|
||||||
|
{
|
||||||
|
// A hostile "column name" cannot escape the brackets: the only metacharacter that could is ],
|
||||||
|
// and it is doubled. The result is a single (nonexistent) identifier, never executable SQL.
|
||||||
|
new SqlServerDialect().QuoteIdentifier("x] ; DROP TABLE Users --")
|
||||||
|
.ShouldBe("[x]] ; DROP TABLE Users --]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("NVARCHAR", DriverDataType.String)]
|
||||||
|
[InlineData("BiT", DriverDataType.Boolean)]
|
||||||
|
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
|
||||||
|
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("geography")]
|
||||||
|
[InlineData("sql_variant")]
|
||||||
|
[InlineData("timestamp")] // SQL Server rowversion — binary, deliberately NOT a DateTime
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
|
||||||
|
=> new SqlServerDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user