Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs
T
Joseph Doherty 886ce1b87c fix(sql): chunk the key-value IN list at the dialect's bind ceiling (Driver.Sql-002, Driver.Sql.Dialect-002)
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was
missing while modelling everything else that differs per backend: 2098 usable
of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list
limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres
Bind message.

SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when
its distinct keys exceed that ceiling, instead of one statement the provider
refuses after the connection opened — which left every tag in an EAV group
publishing BadCommunicationError on every poll, permanently, and reported the
database endpoint as down. Chunks share the group key; members follow their
own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs
one command per plan and slices each plan's own Members back, proven by a
fixture read at a ceiling of 1.
2026-08-10 08:02:44 -04:00

190 lines
10 KiB
C#

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>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</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;
/// <summary>
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>Identical to the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>No-op: <c>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <summary>
/// 999 — <c>SQLITE_MAX_VARIABLE_NUMBER</c>'s historical default, and still the safe floor: SQLite
/// raised it to 32766 in 3.32, but the value is a compile-time setting of whatever native library the
/// bundle happens to carry, so the lower number is the one that always executes.
/// </summary>
public int MaxBindParameters => 999;
/// <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>
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
/// </summary>
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
/// <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,
};
}
}