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