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"; 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 ); """).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); } }