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; /// /// A test-only over SQLite, so the poll reader and the schema browser can /// be exercised against a real — real parameter binding, real type coercion, /// real NULLs — with no SQL Server and no network. No product project references SQLite. /// It is also the seam's falsifiability control: it is the only non-SQL-Server /// in the tree, so it is what proves the planner emits dialect SQL /// rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's /// TOP 1 does not parse, and the fixture tests fail loudly if that regresses. /// Kept public and self-contained on purpose: Driver.Sql.Browser.Tests links this file /// (<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />) /// rather than referencing this project, so it must not depend on anything else defined here. /// public sealed class SqliteDialect : ISqlDialect { /// /// 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. /// public const string MainSchema = "main"; /// /// Reports . /// Why not a Sqlite member: 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. /// Why of the existing members: it is the enum's generic, /// not-yet-constructed member, so nothing branches on it today. Crucially it is not /// — any future T-SQL-only code path that keys off /// 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. /// public SqlProvider Provider => SqlProvider.Odbc; /// public DbProviderFactory Factory => SqliteFactory.Instance; /// public string LivenessSql => "SELECT 1"; /// /// SQLite spells the row limit at the end of the statement, so the after-SELECT /// position is empty — the mirror image of SqlServerDialect, which is the point of having both /// ends on the seam. /// public string SingleRowLimitPrefix => string.Empty; /// /// " LIMIT 1", leading space included so it appends straight onto the finished statement /// (… ORDER BY "sample_ts" DESC LIMIT 1). /// public string SingleRowLimitSuffix => " LIMIT 1"; /// /// SQLite has no schema namespace, so the schema level is the single literal /// — enough to give the browser a real root to expand. /// public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; /// /// SQLite has no per-principal default schema, so an unqualified name always resolves in /// — the same single schema reports. /// public string DefaultSchemaSql => $"SELECT '{MainSchema}'"; /// /// Tables + views from sqlite_schema (the modern name for sqlite_master), with the /// internal sqlite_* objects filtered out and the type folded onto the /// INFORMATION_SCHEMA.TABLES vocabulary the browser already speaks. @schema is bound and /// honoured — anything but legitimately lists nothing. /// 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"; /// /// Columns via the pragma_table_info table-valued function rather than the bare /// PRAGMA table_info(x) statement, because only the function form lets the table name be a /// bound parameter — 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. /// DATA_TYPE is the column's declared type (SQLite stores affinity, not a type). /// 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"; /// /// Double-quotes one identifier part, doubling any embedded " — SQLite's escape rule, and the /// only metacharacter that could close a quoted identifier early. /// /// /// Mirrors SqlServerDialect.QuoteIdentifier'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. /// /// The bare, unquoted identifier part. /// The double-quote-quoted identifier. /// The value cannot be a valid SQLite 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)); 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), "\""); } /// /// Folds a SQLite declared column type onto a . SQLite is /// dynamically typed and a column's declared type is advisory, which is exactly why the fixture /// declares every seeded column explicitly. /// /// /// Never throws, mirroring SqlServerDialect: an unrecognised (or blank, or /// parameterised like VARCHAR(50)) declaration falls back to /// so a browse over an oddly-declared table still renders. /// Deliberate divergence from SQL Server: REAL maps to /// here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's /// real is 4-byte — mapping it to Float32 would narrow every value the fixture returns. /// /// The declared type as pragma_table_info reports it; case-insensitive. /// 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 { "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, }; } }