Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqliteBrowseFixture.cs
T
Joseph Doherty 7498d39e5b fix(sql): browse-commit states a topByTimestamp row selector (#552)
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only
table + columnName. A wide-row tag must also say WHICH row it reads, so both
`SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate`
(the authoring gate) refused it — a browse-committed Sql tag was never readable,
first silently as BadNodeIdUnknown and, since #551 put the gate on the import,
loudly at commit.

`SqlBrowseSession` now states a fourth address field: the table's preferred
date/time-typed column, computed from the columns its attributes call ALREADY
read (no extra catalog round-trip). Candidacy is decided by
`ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column
name, which would order a wide-row read by a string column that merely looks like
a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every
dialect's column SQL orders by ordinal position), so the pick is stable across
calls and servers; it is a visible, editable default the typed editor round-trips.

No candidate (a table with no date/time column) is an operator-actionable refusal,
not an invented selector: the only remaining selector is a whereColumn/whereValue
pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that
leaf by name, names the table, and points at the tag editor.

The session and the mapper now agree via `SqlBrowseAddressFields` constants rather
than two independently spelled string literals — a disagreement there is silent,
and is exactly the shape this defect had.

`RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit
output passes the gate", with the no-candidate case pinned as its own test.
2026-08-10 01:20:12 -04:00

236 lines
11 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";
/// <summary>
/// A wide-row table with exactly ONE date/time-typed column — the sole-candidate case for the
/// browse-commit row selector (Gitea #552).
/// </summary>
/// <remarks>
/// Note <see cref="KeyValueTable"/> is the no-candidate control and needs no table of its own: its
/// <c>sample_ts</c> column is declared <c>TEXT</c>, so it is a timestamp by NAME and a
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String"/> by type — exactly the
/// column a name heuristic would order a wide-row read by, and exactly the one the type test must
/// refuse.
/// </remarks>
public const string TimestampedTable = "Readings";
/// <summary>The one <c>DATETIME</c> column of <see cref="TimestampedTable"/>.</summary>
public const string TimestampedTableTimeColumn = "recorded";
/// <summary>A <see cref="TimestampedTable"/> value column — an ordinary wide-row signal.</summary>
public const string TimestampedTableValueColumn = "oven_temp";
/// <summary>
/// A table carrying TWO date/time columns, deliberately named so that catalog order and alphabetical
/// order disagree — <see cref="MultiTimeFirstColumn"/> is declared first but sorts last.
/// </summary>
public const string MultiTimeTable = "MultiStamped";
/// <summary>The FIRST-declared date/time column of <see cref="MultiTimeTable"/> — the expected pick.</summary>
public const string MultiTimeFirstColumn = "zulu_time";
/// <summary>The second-declared date/time column of <see cref="MultiTimeTable"/> — alphabetically first.</summary>
public const string MultiTimeSecondColumn = "alpha_time";
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
public const string MultiTimeValueColumn = "level";
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
);
CREATE TABLE "{TimestampedTable}" (
"{TimestampedTableValueColumn}" REAL NULL,
"{TimestampedTableTimeColumn}" DATETIME NOT NULL
);
CREATE TABLE "{MultiTimeTable}" (
"{MultiTimeFirstColumn}" DATETIME NOT NULL,
"{MultiTimeValueColumn}" REAL NULL,
"{MultiTimeSecondColumn}" TIMESTAMP 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);
}
}