861a1d1df0
Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with @schema/@table bound as parameters at every level. Column leaves carry the dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only v1). The NodeId encoding deliberately departs from the design sketch's literal `schema.table|column`: SQL Server permits `.` and `|` inside a quoted identifier, so that form mis-parses (main.a.b|c reads equally as schema `main`+table `a.b` and schema `main.a`+table `b`) and silently binds an operator's tag to the wrong column. SqlBrowseNodeId encodes `<kind>:<part>[|<part>...]` with `\`/`|` escaped and the kind prefix carrying the arity; it is public because the picker body decodes it back. The session owns the connection it is handed and closes it on dispose -- the registry-held session is the only lifetime hook, so a non-owning session would leak one pooled connection per reaped picker. Per-call work stays bounded by the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout); no second deadline is invented here. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
191 lines
8.9 KiB
C#
191 lines
8.9 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// 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 <c>sqlite_schema</c> rows, real
|
|
/// <c>pragma_table_info</c> output, real parameter binding — with no SQL Server and no network.
|
|
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c></b>, for the same reason
|
|
/// <c>SqlitePollFixture</c> 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.</para>
|
|
/// <para><b>The seed is a contract.</b> Every table below exists to pin one browse behaviour, and the
|
|
/// awkward ones are the point: <see cref="AwkwardTable"/> and <see cref="AwkwardColumn"/> carry the
|
|
/// <c>.</c> and <c>|</c> characters that a naive <c>schema.table|column</c> NodeId would mis-parse, and
|
|
/// <see cref="HostileTable"/> is a live injection probe — if any catalog query concatenated a name
|
|
/// instead of binding it, expanding that table drops <see cref="KeyValueTable"/> and the assertions say
|
|
/// so.</para>
|
|
/// </summary>
|
|
public sealed class SqliteBrowseFixture : IAsyncDisposable
|
|
{
|
|
/// <summary>The ordinary key-value table, mirroring the poll fixture's shape.</summary>
|
|
public const string KeyValueTable = "TagValues";
|
|
|
|
/// <summary>The <see cref="KeyValueTable"/> column declared <c>REAL</c>, i.e. <c>Float64</c>.</summary>
|
|
public const string RealColumn = "num_value";
|
|
|
|
/// <summary>The <see cref="KeyValueTable"/> column declared <c>TEXT</c>, i.e. <c>String</c>.</summary>
|
|
public const string TextColumn = "tag_name";
|
|
|
|
/// <summary>A view over <see cref="KeyValueTable"/> — the browser must list views beside tables.</summary>
|
|
public const string ViewName = "TagValuesView";
|
|
|
|
/// <summary>
|
|
/// A table whose name contains BOTH NodeId metacharacters. A <c>schema.table|column</c> NodeId cannot
|
|
/// represent this unambiguously — <c>main.a.b|c</c> reads equally as schema <c>main.a</c>, table
|
|
/// <c>b</c>. Legal in SQL Server inside a quoted identifier, so the encoding must survive it.
|
|
/// </summary>
|
|
public const string AwkwardTable = "a.b|c";
|
|
|
|
/// <summary>A column name carrying the separator character.</summary>
|
|
public const string AwkwardColumn = "x|y";
|
|
|
|
/// <summary>A column name carrying a dot and the escape character.</summary>
|
|
public const string AwkwardColumn2 = @"d.o\t";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public const string HostileTable = "'); DROP TABLE TagValues; --";
|
|
|
|
/// <summary>The single column of <see cref="HostileTable"/>.</summary>
|
|
public const string HostileColumn = "hostile_col";
|
|
|
|
/// <summary>A table whose columns carry types no dialect map recognises.</summary>
|
|
public const string ExoticTable = "Exotic";
|
|
|
|
/// <summary>An <see cref="ExoticTable"/> column declared with a type outside the map's vocabulary.</summary>
|
|
public const string ExoticColumn = "shape_col";
|
|
|
|
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
|
|
public const string NonExistentTable = "NoSuchTable";
|
|
|
|
private readonly string _databasePath;
|
|
|
|
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
|
|
{
|
|
_databasePath = databasePath;
|
|
ConnectionString = connectionString;
|
|
Connection = connection;
|
|
}
|
|
|
|
/// <summary>The connection string over the temporary database file.</summary>
|
|
public string ConnectionString { get; }
|
|
|
|
/// <summary>
|
|
/// A long-lived open connection over the seeded database — what tests hand
|
|
/// <c>SqlBrowseSession</c>. Independent of any other connection over the same file.
|
|
/// </summary>
|
|
public SqliteConnection Connection { get; }
|
|
|
|
/// <summary>
|
|
/// The NodeId of SQLite's one and only schema, encoded exactly as
|
|
/// <c>SqlBrowseSession.RootAsync</c> emits it.
|
|
/// </summary>
|
|
public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema);
|
|
|
|
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
|
|
/// <returns>The ready fixture.</returns>
|
|
public static async Task<SqliteBrowseFixture> 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);
|
|
}
|
|
|
|
/// <summary>Opens a brand-new connection over the same database.</summary>
|
|
/// <returns>An open connection the caller owns.</returns>
|
|
public SqliteConnection OpenNewConnection()
|
|
{
|
|
var connection = new SqliteConnection(ConnectionString);
|
|
connection.Open();
|
|
return connection;
|
|
}
|
|
|
|
/// <summary>Counts rows in a table, on a connection of its own so a disposed session cannot affect it.</summary>
|
|
/// <param name="table">The (unquoted) table name to count.</param>
|
|
/// <returns>The row count, or <c>-1</c> when the table does not exist.</returns>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Closes the fixture connection, clears the pool, and deletes the temporary file.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
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.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>pragma_table_info</c> (and therefore <c>MapColumnType</c>) nothing real to report.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|