7498d39e5b
`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.
236 lines
11 KiB
C#
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);
|
|
}
|
|
}
|