using Microsoft.Data.Sqlite; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; /// /// A real, seeded SQLite database standing in for the SQL Server the schema browser walks. It exists so /// the browse path runs against a genuine catalog — real sqlite_schema rows, real /// pragma_table_info output, real parameter binding — with no SQL Server and no network. /// Backed by a temporary FILE, not :memory:, for the same reason /// SqlitePollFixture is: an in-memory database dies with its last connection, and several tests /// here deliberately open a second connection or dispose the session's connection mid-test. /// The seed is a contract. Every table below exists to pin one browse behaviour, and the /// awkward ones are the point: and carry the /// . and | characters that a naive schema.table|column NodeId would mis-parse, and /// is a live injection probe — if any catalog query concatenated a name /// instead of binding it, expanding that table drops and the assertions say /// so. /// public sealed class SqliteBrowseFixture : IAsyncDisposable { /// The ordinary key-value table, mirroring the poll fixture's shape. public const string KeyValueTable = "TagValues"; /// The column declared REAL, i.e. Float64. public const string RealColumn = "num_value"; /// The column declared TEXT, i.e. String. public const string TextColumn = "tag_name"; /// A view over — the browser must list views beside tables. public const string ViewName = "TagValuesView"; /// /// A table whose name contains BOTH NodeId metacharacters. A schema.table|column NodeId cannot /// represent this unambiguously — main.a.b|c reads equally as schema main.a, table /// b. Legal in SQL Server inside a quoted identifier, so the encoding must survive it. /// public const string AwkwardTable = "a.b|c"; /// A column name carrying the separator character. public const string AwkwardColumn = "x|y"; /// A column name carrying a dot and the escape character. public const string AwkwardColumn2 = @"d.o\t"; /// /// A table named as a SQL injection payload. Nothing about it is special to the browser — that is /// exactly the assertion: it is bound as a value, never spliced into a command text. /// public const string HostileTable = "'); DROP TABLE TagValues; --"; /// The single column of . public const string HostileColumn = "hostile_col"; /// A table whose columns carry types no dialect map recognises. public const string ExoticTable = "Exotic"; /// An column declared with a type outside the map's vocabulary. public const string ExoticColumn = "shape_col"; /// A table name that exists in no catalog — the "vanished table" / zero-column probe. public const string NonExistentTable = "NoSuchTable"; /// /// A wide-row table with exactly ONE date/time-typed column — the sole-candidate case for the /// browse-commit row selector (Gitea #552). /// /// /// Note is the no-candidate control and needs no table of its own: its /// sample_ts column is declared TEXT, so it is a timestamp by NAME and a /// by type — exactly the /// column a name heuristic would order a wide-row read by, and exactly the one the type test must /// refuse. /// public const string TimestampedTable = "Readings"; /// The one DATETIME column of . public const string TimestampedTableTimeColumn = "recorded"; /// A value column — an ordinary wide-row signal. public const string TimestampedTableValueColumn = "oven_temp"; /// /// A table carrying TWO date/time columns, deliberately named so that catalog order and alphabetical /// order disagree — is declared first but sorts last. /// public const string MultiTimeTable = "MultiStamped"; /// The FIRST-declared date/time column of — the expected pick. public const string MultiTimeFirstColumn = "zulu_time"; /// The second-declared date/time column of — alphabetically first. public const string MultiTimeSecondColumn = "alpha_time"; /// A value column, sitting BETWEEN the two timestamps. public const string MultiTimeValueColumn = "level"; /// /// A table whose FIRST date/time-typed column is date-only, with a genuinely time-bearing one /// declared after it. A date-only column maps to /// like any other, so a /// candidacy rule that only asked the mapped type would order the wide-row read by /// — one row per DAY, ties broken by storage order. /// public const string DateOnlyFirstTable = "DailyTotals"; /// The date-only column of , declared first. public const string DateOnlyFirstColumn = "shift_day"; /// A value column, sitting between the two candidates. public const string DateOnlyFirstValueColumn = "units"; /// /// The time-bearing column of , declared LAST — the expected pick, /// even though the date-only one comes first in catalog order. /// public const string DateOnlyFirstTimeColumn = "logged_at"; /// /// A table whose ONLY date/time-typed column is date-only — the fallback control. Dropping date-only /// types from candidacy outright would make this table unbindable by browse; ranking keeps it bindable /// while still preferring a better column wherever one exists. /// public const string DateOnlySoleTable = "DayBook"; /// The sole (date-only) candidate of . public const string DateOnlySoleColumn = "book_day"; /// A value column. public const string DateOnlySoleValueColumn = "opening_balance"; private readonly string _databasePath; private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection) { _databasePath = databasePath; ConnectionString = connectionString; Connection = connection; } /// The connection string over the temporary database file. public string ConnectionString { get; } /// /// A long-lived open connection over the seeded database — what tests hand /// SqlBrowseSession. Independent of any other connection over the same file. /// public SqliteConnection Connection { get; } /// /// The NodeId of SQLite's one and only schema, encoded exactly as /// SqlBrowseSession.RootAsync emits it. /// public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema); /// Creates the temporary database, applies the schema, and seeds it. /// The ready fixture. public static async Task CreateAsync() { var databasePath = Path.Combine(Path.GetTempPath(), $"otopcua-sql-browse-{Guid.NewGuid():N}.db"); var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString(); var connection = new SqliteConnection(connectionString); await connection.OpenAsync().ConfigureAwait(false); await SeedAsync(connection).ConfigureAwait(false); return new SqliteBrowseFixture(databasePath, connectionString, connection); } /// Opens a brand-new connection over the same database. /// An open connection the caller owns. public SqliteConnection OpenNewConnection() { var connection = new SqliteConnection(ConnectionString); connection.Open(); return connection; } /// Counts rows in a table, on a connection of its own so a disposed session cannot affect it. /// The (unquoted) table name to count. /// The row count, or -1 when the table does not exist. public long CountRows(string table) { using var connection = OpenNewConnection(); using var command = connection.CreateCommand(); command.CommandText = $"SELECT COUNT(*) FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; try { return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); } catch (SqliteException) { return -1; } } /// Closes the fixture connection, clears the pool, and deletes the temporary file. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { await Connection.DisposeAsync().ConfigureAwait(false); // Microsoft.Data.Sqlite pools per connection string; without this the file stays held open and the // delete silently fails, leaking one file per test into the temp directory. SqliteConnection.ClearAllPools(); try { if (File.Exists(_databasePath)) File.Delete(_databasePath); } catch (IOException) { // A leaked temp file must never fail a test run. } } /// /// Applies the schema described by this type's constants. Every column is explicitly declared — /// SQLite stores affinity rather than a type, so an undeclared column would give /// pragma_table_info (and therefore MapColumnType) nothing real to report. /// private static async Task SeedAsync(SqliteConnection connection) { await ExecuteAsync(connection, $""" CREATE TABLE "{KeyValueTable}" ( "{TextColumn}" TEXT NOT NULL, "{RealColumn}" REAL NULL, sample_ts TEXT NOT NULL ); INSERT INTO "{KeyValueTable}" ("{TextColumn}", "{RealColumn}", sample_ts) VALUES ('Line1.Speed', 42.0, '2026-07-24T10:00:00Z'); CREATE VIEW "{ViewName}" AS SELECT "{TextColumn}", "{RealColumn}" FROM "{KeyValueTable}"; CREATE TABLE "{ExoticTable}" ( "{ExoticColumn}" GEOGRAPHY NULL, blob_col BLOB NULL ); CREATE TABLE "{TimestampedTable}" ( "{TimestampedTableValueColumn}" REAL NULL, "{TimestampedTableTimeColumn}" DATETIME NOT NULL ); CREATE TABLE "{MultiTimeTable}" ( "{MultiTimeFirstColumn}" DATETIME NOT NULL, "{MultiTimeValueColumn}" REAL NULL, "{MultiTimeSecondColumn}" TIMESTAMP NULL ); CREATE TABLE "{DateOnlyFirstTable}" ( "{DateOnlyFirstColumn}" DATE NOT NULL, "{DateOnlyFirstValueColumn}" REAL NULL, "{DateOnlyFirstTimeColumn}" DATETIME NOT NULL ); CREATE TABLE "{DateOnlySoleTable}" ( "{DateOnlySoleColumn}" DATE NOT NULL, "{DateOnlySoleValueColumn}" REAL NULL ); """).ConfigureAwait(false); // Separate statements: these names must be quoted by the seed itself, so keep them off the // interpolation path above where a stray quote would be hard to spot. await ExecuteAsync(connection, $"CREATE TABLE {Quote(AwkwardTable)} ({Quote(AwkwardColumn)} REAL NULL, {Quote(AwkwardColumn2)} TEXT NULL)") .ConfigureAwait(false); await ExecuteAsync(connection, $"CREATE TABLE {Quote(HostileTable)} ({Quote(HostileColumn)} INTEGER NULL)") .ConfigureAwait(false); } private static string Quote(string identifier) => string.Concat("\"", identifier.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); private static async Task ExecuteAsync(SqliteConnection connection, string sql) { await using var command = connection.CreateCommand(); command.CommandText = sql; await command.ExecuteNonQueryAsync().ConfigureAwait(false); } }