test(sql): SqliteDialect + poll fixture
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
|
||||
/// be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
|
||||
/// real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
|
||||
/// <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
|
||||
/// <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
|
||||
/// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
|
||||
/// <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
|
||||
/// <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
|
||||
/// (<c><Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /></c>)
|
||||
/// rather than referencing this project, so it must not depend on anything else defined here.</para>
|
||||
/// </summary>
|
||||
public sealed class SqliteDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// The only schema name SQLite's main database answers to. SQLite has no schema namespace — the
|
||||
/// catalog queries accept this one value so the browser's schema level has something real to expand.
|
||||
/// </summary>
|
||||
public const string MainSchema = "main";
|
||||
|
||||
/// <summary>
|
||||
/// Reports <see cref="SqlProvider.Odbc"/>.
|
||||
/// <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> is a shipped product
|
||||
/// contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain
|
||||
/// a case that can never occur in production.</para>
|
||||
/// <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
|
||||
/// not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
|
||||
/// <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
|
||||
/// <see cref="Provider"/> stays switched off against this dialect and fails visibly here rather than
|
||||
/// silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp
|
||||
/// for exactly the assumptions it exists to catch.</para>
|
||||
/// </summary>
|
||||
public SqlProvider Provider => SqlProvider.Odbc;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqliteFactory.Instance;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
|
||||
/// position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
|
||||
/// ends on the seam.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
|
||||
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no schema namespace, so the schema level is the single literal
|
||||
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
|
||||
/// </summary>
|
||||
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
|
||||
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
|
||||
/// <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
|
||||
/// honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
|
||||
/// </summary>
|
||||
public string ListTablesSql =>
|
||||
"SELECT name AS TABLE_NAME, " +
|
||||
"CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " +
|
||||
"FROM sqlite_schema " +
|
||||
"WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " +
|
||||
$"AND @schema = '{MainSchema}' ORDER BY name";
|
||||
|
||||
/// <summary>
|
||||
/// Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
|
||||
/// <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
|
||||
/// <b>bound parameter</b> — a bare PRAGMA takes its argument as text, which would put an authored name
|
||||
/// back into a command string and reopen the injection boundary this seam exists to close.
|
||||
/// <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
|
||||
/// </summary>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " +
|
||||
"CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " +
|
||||
"FROM pragma_table_info(@table) " +
|
||||
$"WHERE @schema = '{MainSchema}' ORDER BY cid";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
|
||||
/// only metacharacter that could close a quoted identifier early.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'s rejections (null / empty / all-whitespace /
|
||||
/// control characters) so a test written against one dialect fails the same way against the other.
|
||||
/// SQLite imposes no identifier length ceiling, so there is no length rule here.
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The double-quote-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid SQLite 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));
|
||||
|
||||
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 a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
|
||||
/// dynamically typed and a column's declared type is advisory, which is exactly why the fixture
|
||||
/// declares every seeded column explicitly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
|
||||
/// parameterised like <c>VARCHAR(50)</c>) declaration falls back to
|
||||
/// <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
|
||||
/// <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
|
||||
/// <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
|
||||
/// <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
|
||||
/// </remarks>
|
||||
/// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</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
|
||||
{
|
||||
"boolean" or "bool" or "bit" => DriverDataType.Boolean,
|
||||
"tinyint" or "smallint" or "int2" => DriverDataType.Int16,
|
||||
"int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32,
|
||||
"bigint" or "int8" => DriverDataType.Int64,
|
||||
// SQLite's REAL is a double — deliberately NOT Float32 (see remarks).
|
||||
"real" or "double" or "double precision" or "float"
|
||||
or "numeric" or "decimal" => DriverDataType.Float64,
|
||||
"text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String,
|
||||
"date" or "datetime" or "timestamp" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user