feat(sql): SqlBrowseSession schema-walk over dialect catalog
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
This commit is contained in:
@@ -94,6 +94,7 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
|
||||
public enum SqlBrowseNodeKind
|
||||
{
|
||||
/// <summary>A schema — the browse root level.</summary>
|
||||
Schema,
|
||||
|
||||
/// <summary>A table or view inside a schema.</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>A column of a table or view — the terminal (leaf) level.</summary>
|
||||
Column,
|
||||
}
|
||||
|
||||
/// <summary>A decoded browse <c>NodeId</c>.</summary>
|
||||
/// <param name="Kind">Which catalog level this reference addresses.</param>
|
||||
/// <param name="Schema">The schema name. Always present.</param>
|
||||
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
|
||||
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
|
||||
public readonly record struct SqlBrowseNodeRef(
|
||||
SqlBrowseNodeKind Kind,
|
||||
string Schema,
|
||||
string? Table,
|
||||
string? Column);
|
||||
|
||||
/// <summary>
|
||||
/// Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
|
||||
/// on expand / attribute fetch / column commit.
|
||||
/// <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
|
||||
/// <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
|
||||
/// inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
|
||||
/// both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
|
||||
/// <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
|
||||
/// + table <c>b</c>, and a table named <c>x|y</c> loses its second half entirely. That is not a cosmetic
|
||||
/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
|
||||
/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
|
||||
/// </para>
|
||||
/// <para><b>The encoding.</b> <c><kind>:<part>[|<part>…]</c>, where <c>kind</c> is one
|
||||
/// of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
|
||||
/// can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
|
||||
/// <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
|
||||
/// readable (<c>column:dbo|TagValues|num_value</c>). The kind prefix carries the arity, so decoding never
|
||||
/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is
|
||||
/// rejected rather than truncated or padded.</para>
|
||||
/// <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
|
||||
/// column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. Encode and decode
|
||||
/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
|
||||
/// exists to prevent.</para>
|
||||
/// </summary>
|
||||
public static class SqlBrowseNodeId
|
||||
{
|
||||
private const char Separator = '|';
|
||||
private const char Escape = '\\';
|
||||
private const char KindTerminator = ':';
|
||||
|
||||
private const string SchemaKind = "schema";
|
||||
private const string TableKind = "table";
|
||||
private const string ColumnKind = "column";
|
||||
|
||||
/// <summary>Encodes a schema-level NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
|
||||
public static string ForSchema(string schema) => Encode(SchemaKind, schema);
|
||||
|
||||
/// <summary>Encodes a table-level NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
|
||||
public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
|
||||
|
||||
/// <summary>Encodes a column-level (leaf) NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
|
||||
/// <param name="column">The column name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
|
||||
public static string ForColumn(string schema, string table, string column) =>
|
||||
Encode(ColumnKind, schema, table, column);
|
||||
|
||||
/// <summary>Attempts to decode a NodeId.</summary>
|
||||
/// <param name="nodeId">The NodeId to decode.</param>
|
||||
/// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
|
||||
/// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
|
||||
public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
|
||||
{
|
||||
reference = default;
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) return false;
|
||||
|
||||
var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
|
||||
if (terminator <= 0) return false;
|
||||
|
||||
var kind = nodeId[..terminator];
|
||||
if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
|
||||
if (parts.Exists(string.IsNullOrWhiteSpace)) return false;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case SchemaKind when parts.Count == 1:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
|
||||
return true;
|
||||
case TableKind when parts.Count == 2:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
|
||||
return true;
|
||||
case ColumnKind when parts.Count == 3:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a NodeId, throwing when it is malformed.
|
||||
/// <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
|
||||
/// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The NodeId to decode.</param>
|
||||
/// <returns>The decoded reference.</returns>
|
||||
/// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
|
||||
public static SqlBrowseNodeRef Parse(string? nodeId)
|
||||
{
|
||||
if (TryParse(nodeId, out var reference)) return reference;
|
||||
throw new ArgumentException(
|
||||
"Not a SQL schema-browse node. Expected 'schema:<schema>', 'table:<schema>|<table>' or " +
|
||||
"'column:<schema>|<table>|<column>'. Re-open the browser and expand from the root.",
|
||||
nameof(nodeId));
|
||||
}
|
||||
|
||||
private static string Encode(string kind, params string[] parts)
|
||||
{
|
||||
var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
|
||||
builder.Append(kind).Append(KindTerminator);
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parts[i]))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
|
||||
}
|
||||
|
||||
if (i > 0) builder.Append(Separator);
|
||||
AppendEscaped(builder, parts[i]);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendEscaped(StringBuilder builder, string part)
|
||||
{
|
||||
foreach (var ch in part)
|
||||
{
|
||||
if (ch is Separator or Escape) builder.Append(Escape);
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
|
||||
/// <c>false</c> on a dangling trailing escape, which cannot be produced by
|
||||
/// <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
|
||||
/// </summary>
|
||||
private static bool TrySplit(string payload, out List<string> parts)
|
||||
{
|
||||
parts = [];
|
||||
var current = new StringBuilder(payload.Length);
|
||||
var escaped = false;
|
||||
|
||||
foreach (var ch in payload)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
current.Append(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case Escape:
|
||||
escaped = true;
|
||||
break;
|
||||
case Separator:
|
||||
parts.Add(current.ToString());
|
||||
current.Clear();
|
||||
break;
|
||||
default:
|
||||
current.Append(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) return false;
|
||||
parts.Add(current.ToString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
|
||||
/// column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
|
||||
/// two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
|
||||
/// the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
|
||||
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
|
||||
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
|
||||
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
|
||||
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
|
||||
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
|
||||
/// never needs an identifier in text.</para>
|
||||
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
|
||||
/// <see cref="DisposeAsync"/>.</b> It does not open one, and it never re-opens a closed one. The handoff
|
||||
/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
|
||||
/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
|
||||
/// if the session did not close it, nothing would, and every reaped picker would leak a pooled
|
||||
/// connection.</para>
|
||||
/// <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
|
||||
/// 20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); this session's job is simply to
|
||||
/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
|
||||
/// independent deadline here would only produce two competing, differently-worded failures.</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlBrowseSession : IBrowseSession
|
||||
{
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
|
||||
private const string SchemaColumn = "TABLE_SCHEMA";
|
||||
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
|
||||
private const string TableNameColumn = "TABLE_NAME";
|
||||
|
||||
/// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
|
||||
private const string TableTypeColumn = "TABLE_TYPE";
|
||||
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Column alias carrying the SQL type family name.</summary>
|
||||
private const string DataTypeColumn = "DATA_TYPE";
|
||||
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
/// <summary>
|
||||
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
|
||||
/// (design §4.3), so there is nothing else a picked column could be.
|
||||
/// </summary>
|
||||
private const string ReadOnlySecurityClass = "ViewOnly";
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly ISqlDialect _dialect;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
|
||||
/// the AdminUI tree happily fires several expands at once when an operator clicks quickly.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
|
||||
/// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
|
||||
/// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
|
||||
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var schemas = await QueryAsync(
|
||||
_dialect.ListSchemasSql,
|
||||
static _ => { },
|
||||
static reader => ReadString(reader, SchemaColumn),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(schemas.Count);
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schema)) continue;
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForSchema(schema),
|
||||
DisplayName: schema,
|
||||
Kind: BrowseNodeKind.Folder,
|
||||
HasChildrenHint: true));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
|
||||
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var reference = SqlBrowseNodeId.Parse(nodeId);
|
||||
switch (reference.Kind)
|
||||
{
|
||||
case SqlBrowseNodeKind.Schema:
|
||||
return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
|
||||
case SqlBrowseNodeKind.Table:
|
||||
return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
default:
|
||||
// A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
|
||||
// it has re-read the node's Kind.
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<BrowseNode>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(
|
||||
string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var reference = SqlBrowseNodeId.Parse(nodeId);
|
||||
if (reference.Kind != SqlBrowseNodeKind.Column)
|
||||
{
|
||||
// Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
|
||||
// uniform and returns empty for every node.
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<AttributeInfo>();
|
||||
}
|
||||
|
||||
var columns = await ReadColumnsAsync(
|
||||
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
|
||||
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
|
||||
// that legitimately carries both "Value" and "value".
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
|
||||
return
|
||||
[
|
||||
new AttributeInfo(
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass),
|
||||
];
|
||||
}
|
||||
|
||||
// The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
|
||||
// the honest answer; the picker simply has nothing to prefill.
|
||||
return Array.Empty<AttributeInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
|
||||
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { await _connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort: the connection may already be broken or closed. */ }
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
|
||||
{
|
||||
var rows = await QueryAsync(
|
||||
_dialect.ListTablesSql,
|
||||
command => Bind(command, "@schema", schema),
|
||||
static reader => (
|
||||
Name: ReadString(reader, TableNameColumn),
|
||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(rows.Count);
|
||||
foreach (var (name, type) in rows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForTable(schema, name),
|
||||
// The label is decorated, never the NodeId — a view and a table of the same name in the same
|
||||
// schema cannot exist, so the suffix is presentation only.
|
||||
DisplayName: isView ? $"{name} (view)" : name,
|
||||
Kind: BrowseNodeKind.Folder,
|
||||
HasChildrenHint: true));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BrowseNode>> ExpandTableAsync(
|
||||
string schema, string table, CancellationToken ct)
|
||||
{
|
||||
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(columns.Count);
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(column.Name)) continue;
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
|
||||
DisplayName: column.Name,
|
||||
Kind: BrowseNodeKind.Leaf,
|
||||
HasChildrenHint: false));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
|
||||
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
|
||||
/// relation, and neither is a reason to fail an operator's click.
|
||||
/// </summary>
|
||||
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
|
||||
string schema, string table, CancellationToken ct) =>
|
||||
QueryAsync(
|
||||
_dialect.ListColumnsSql,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
|
||||
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
|
||||
/// </summary>
|
||||
private async Task<List<T>> QueryAsync<T>(
|
||||
string sql,
|
||||
Action<DbCommand> bind,
|
||||
Func<DbDataReader, T> project,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var results = new List<T>();
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
bind(command);
|
||||
|
||||
await using var reader = await command
|
||||
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
results.Add(project(reader));
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
|
||||
// ignore and must not mask the real result.
|
||||
try { _gate.Release(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
|
||||
private static void Bind(DbCommand command, string name, string value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
private static string ReadString(DbDataReader reader, string columnName) =>
|
||||
ReadOptionalString(reader, columnName) ?? string.Empty;
|
||||
|
||||
private static string? ReadOptionalString(DbDataReader reader, string columnName)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(columnName);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Records how many command executions are ever in flight at once, and how long each one is artificially
|
||||
/// held open. Shared by <see cref="ConcurrencyProbingDbConnection"/> and the commands it creates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This exists because "the session serializes on a gate" is otherwise unfalsifiable from the outside:
|
||||
/// against a real SQLite connection, overlapping calls mostly still *work*, so a test that only asserted
|
||||
/// correct results would pass with the gate deleted. Measuring the overlap directly is what makes the
|
||||
/// gate's removal visible.
|
||||
/// </remarks>
|
||||
public sealed class ConcurrencyProbe
|
||||
{
|
||||
private int _inFlight;
|
||||
private int _peak;
|
||||
|
||||
/// <summary>How long each command execution is held before it reaches the real provider.</summary>
|
||||
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120);
|
||||
|
||||
/// <summary>The greatest number of executions observed in flight simultaneously.</summary>
|
||||
public int Peak => Volatile.Read(ref _peak);
|
||||
|
||||
/// <summary>Marks the start of one command execution.</summary>
|
||||
public void Enter()
|
||||
{
|
||||
var current = Interlocked.Increment(ref _inFlight);
|
||||
int observed;
|
||||
while (current > (observed = Volatile.Read(ref _peak)))
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _peak, current, observed) == observed) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marks the end of one command execution.</summary>
|
||||
public void Exit() => Interlocked.Decrement(ref _inFlight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pass-through <see cref="DbConnection"/> that hands out commands instrumented with a
|
||||
/// <see cref="ConcurrencyProbe"/>. Everything else delegates to the wrapped connection, so the code under
|
||||
/// test runs against a real SQLite catalog.
|
||||
/// </summary>
|
||||
/// <param name="inner">The real connection to delegate to. Not owned — the caller disposes it.</param>
|
||||
/// <param name="probe">The probe that observes command overlap.</param>
|
||||
public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[System.Diagnostics.CodeAnalysis.AllowNull]
|
||||
public override string ConnectionString
|
||||
{
|
||||
get => inner.ConnectionString;
|
||||
set => inner.ConnectionString = value!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Database => inner.Database;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string DataSource => inner.DataSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ServerVersion => inner.ServerVersion;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ConnectionState State => inner.State;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Close() => inner.Close();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Open() => inner.Open();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
|
||||
inner.BeginTransaction(isolationLevel);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbCommand CreateDbCommand() =>
|
||||
new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pass-through <see cref="DbCommand"/> that reports every execution to a <see cref="ConcurrencyProbe"/>
|
||||
/// and holds it open for the probe's delay, so overlapping executions are observable.
|
||||
/// </summary>
|
||||
/// <param name="inner">The real command to delegate to.</param>
|
||||
/// <param name="owner">The connection that created this command.</param>
|
||||
/// <param name="probe">The probe that observes command overlap.</param>
|
||||
public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe)
|
||||
: DbCommand
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[System.Diagnostics.CodeAnalysis.AllowNull]
|
||||
public override string CommandText
|
||||
{
|
||||
get => inner.CommandText;
|
||||
set => inner.CommandText = value!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int CommandTimeout
|
||||
{
|
||||
get => inner.CommandTimeout;
|
||||
set => inner.CommandTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CommandType CommandType
|
||||
{
|
||||
get => inner.CommandType;
|
||||
set => inner.CommandType = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool DesignTimeVisible
|
||||
{
|
||||
get => inner.DesignTimeVisible;
|
||||
set => inner.DesignTimeVisible = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override UpdateRowSource UpdatedRowSource
|
||||
{
|
||||
get => inner.UpdatedRowSource;
|
||||
set => inner.UpdatedRowSource = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbConnection? DbConnection
|
||||
{
|
||||
get => owner;
|
||||
set { /* The session never reassigns the connection; the wrapped command keeps its own. */ }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameterCollection DbParameterCollection => inner.Parameters;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction? DbTransaction
|
||||
{
|
||||
get => inner.Transaction;
|
||||
set => inner.Transaction = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Cancel() => inner.Cancel();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int ExecuteNonQuery() => inner.ExecuteNonQuery();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ExecuteScalar() => inner.ExecuteScalar();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Prepare() => inner.Prepare();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameter CreateDbParameter() => inner.CreateParameter();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
|
||||
{
|
||||
probe.Enter();
|
||||
try
|
||||
{
|
||||
Thread.Sleep(probe.Delay);
|
||||
return inner.ExecuteReader(behavior);
|
||||
}
|
||||
finally
|
||||
{
|
||||
probe.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
|
||||
CommandBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
probe.Enter();
|
||||
try
|
||||
{
|
||||
await Task.Delay(probe.Delay, cancellationToken).ConfigureAwait(false);
|
||||
return await inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
probe.Exit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) inner.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the browse NodeId encoding. This codec is the load-bearing detail of the whole picker: a NodeId
|
||||
/// that mis-parses sends the operator to a different column than the one they clicked, and the resulting
|
||||
/// tag polls the wrong data forever with no error anywhere. SQL Server identifiers may legally contain
|
||||
/// <c>.</c> and <c>|</c> (inside a quoted identifier), so the round-trip has to hold for those too.
|
||||
/// </summary>
|
||||
public sealed class SqlBrowseNodeIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForSchema_roundTrips()
|
||||
{
|
||||
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("dbo"));
|
||||
|
||||
reference.Kind.ShouldBe(SqlBrowseNodeKind.Schema);
|
||||
reference.Schema.ShouldBe("dbo");
|
||||
reference.Table.ShouldBeNull();
|
||||
reference.Column.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForTable_roundTrips()
|
||||
{
|
||||
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("dbo", "TagValues"));
|
||||
|
||||
reference.Kind.ShouldBe(SqlBrowseNodeKind.Table);
|
||||
reference.Schema.ShouldBe("dbo");
|
||||
reference.Table.ShouldBe("TagValues");
|
||||
reference.Column.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForColumn_roundTrips()
|
||||
{
|
||||
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn("dbo", "TagValues", "num_value"));
|
||||
|
||||
reference.Kind.ShouldBe(SqlBrowseNodeKind.Column);
|
||||
reference.Schema.ShouldBe("dbo");
|
||||
reference.Table.ShouldBe("TagValues");
|
||||
reference.Column.ShouldBe("num_value");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact case the plan's <c>schema.table|column</c> sketch cannot express: <c>main.a.b|c</c> reads
|
||||
/// equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c> + table <c>b</c>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a.b", "c", "d")]
|
||||
[InlineData("a", "b.c", "d")]
|
||||
[InlineData("a", "b", "c.d")]
|
||||
[InlineData("a|b", "c", "d")]
|
||||
[InlineData("a", "b|c", "d")]
|
||||
[InlineData("a", "b", "c|d")]
|
||||
[InlineData(@"a\b", "c", "d")]
|
||||
[InlineData("a", @"b\|c", "d")]
|
||||
[InlineData("a", "b", @"c\\d")]
|
||||
[InlineData("a.b|c", "d.e|f", "g.h|i")]
|
||||
[InlineData("schema", "table", "column")]
|
||||
[InlineData("x:y", "z:w", "q:r")]
|
||||
public void AwkwardIdentifiers_roundTripExactly(string schema, string table, string column)
|
||||
{
|
||||
var reference = SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForColumn(schema, table, column));
|
||||
|
||||
reference.Kind.ShouldBe(SqlBrowseNodeKind.Column);
|
||||
reference.Schema.ShouldBe(schema);
|
||||
reference.Table.ShouldBe(table);
|
||||
reference.Column.ShouldBe(column);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two genuinely different columns must never collide on one NodeId. Under the plan's literal
|
||||
/// <c>schema.table|column</c> sketch both of these render as <c>a.b|c</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DistinctReferences_neverCollide()
|
||||
{
|
||||
var nested = SqlBrowseNodeId.ForColumn("a.b", "t", "c");
|
||||
var flat = SqlBrowseNodeId.ForColumn("a", "b.t", "c");
|
||||
|
||||
nested.ShouldNotBe(flat);
|
||||
SqlBrowseNodeId.Parse(nested).Schema.ShouldBe("a.b");
|
||||
SqlBrowseNodeId.Parse(flat).Schema.ShouldBe("a");
|
||||
}
|
||||
|
||||
/// <summary>A table NodeId and a schema NodeId are never confusable, whatever the names contain.</summary>
|
||||
[Fact]
|
||||
public void KindsAreDistinguishable()
|
||||
{
|
||||
SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForSchema("a|b")).Kind.ShouldBe(SqlBrowseNodeKind.Schema);
|
||||
SqlBrowseNodeId.Parse(SqlBrowseNodeId.ForTable("a", "b")).Kind.ShouldBe(SqlBrowseNodeKind.Table);
|
||||
SqlBrowseNodeId.ForSchema("a|b").ShouldNotBe(SqlBrowseNodeId.ForTable("a", "b"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("main")] // no kind prefix
|
||||
[InlineData("bogus:main")] // unknown kind
|
||||
[InlineData("table:main")] // table kind with only one part
|
||||
[InlineData("schema:main|extra")] // schema kind with two parts
|
||||
[InlineData("column:a|b")] // column kind with two parts
|
||||
[InlineData("column:a|b|c|d")] // column kind with four parts
|
||||
[InlineData("schema:")] // empty part
|
||||
[InlineData("table:main|")] // empty trailing part
|
||||
[InlineData(@"schema:main\")] // dangling escape
|
||||
[InlineData(":main")] // empty kind
|
||||
public void MalformedNodeIds_areRejected(string? nodeId)
|
||||
{
|
||||
SqlBrowseNodeId.TryParse(nodeId, out _).ShouldBeFalse();
|
||||
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.Parse(nodeId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void EmptyIdentifiers_areRejectedAtEncode(string? identifier)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForSchema(identifier!));
|
||||
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForTable("main", identifier!));
|
||||
Should.Throw<ArgumentException>(() => SqlBrowseNodeId.ForColumn("main", "t", identifier!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Data;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Walks <see cref="SqlBrowseSession"/> over a real, seeded SQLite catalog through the
|
||||
/// <see cref="SqliteDialect"/> — the only non-SQL-Server dialect in the tree, and therefore the control
|
||||
/// that proves the session runs the <em>dialect's</em> catalog SQL rather than <c>INFORMATION_SCHEMA</c>
|
||||
/// with a quoting helper bolted on.
|
||||
/// </summary>
|
||||
public sealed class SqlBrowseSessionTests
|
||||
{
|
||||
private static readonly string SchemaNodeId = SqliteBrowseFixture.SchemaNodeId;
|
||||
|
||||
[Fact]
|
||||
public async Task RootAsync_yieldsSchemaFolders()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var roots = await session.RootAsync(CancellationToken.None);
|
||||
|
||||
roots.Count.ShouldBe(1);
|
||||
roots[0].DisplayName.ShouldBe(SqliteDialect.MainSchema);
|
||||
roots[0].NodeId.ShouldBe(SchemaNodeId);
|
||||
roots[0].Kind.ShouldBe(BrowseNodeKind.Folder);
|
||||
roots[0].HasChildrenHint.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpandSchema_yieldsTablesAndViewsAsFolders()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var children = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
|
||||
|
||||
children.ShouldAllBe(n => n.Kind == BrowseNodeKind.Folder && n.HasChildrenHint);
|
||||
children.ShouldContain(n =>
|
||||
n.NodeId == SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable));
|
||||
// The view is listed beside the tables, and is labelled so the operator can tell them apart.
|
||||
var view = children
|
||||
.Where(n => n.NodeId ==
|
||||
SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ViewName))
|
||||
.ShouldHaveSingleItem();
|
||||
view.DisplayName.ShouldContain(SqliteBrowseFixture.ViewName);
|
||||
view.DisplayName.ShouldNotBe(SqliteBrowseFixture.ViewName);
|
||||
}
|
||||
|
||||
/// <summary>The plan's headline case: table expand yields columns as committable leaves.</summary>
|
||||
[Fact]
|
||||
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
// Exact match, not Contains: the injection-probe table is named "'); DROP TABLE TagValues; --",
|
||||
// so a substring selector picks *it*. Worth keeping in mind when reading a browse tree by eye too.
|
||||
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
|
||||
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
|
||||
|
||||
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
|
||||
|
||||
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn
|
||||
&& c.Kind == BrowseNodeKind.Leaf
|
||||
&& !c.HasChildrenHint);
|
||||
|
||||
var columnNode = columns.First(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
|
||||
var attributes = await session.AttributesAsync(columnNode.NodeId, CancellationToken.None);
|
||||
|
||||
var attribute = attributes.ShouldHaveSingleItem();
|
||||
attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
attribute.IsArray.ShouldBeFalse();
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly");
|
||||
attribute.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttributesAsync_mapsTextColumnToString()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
|
||||
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
|
||||
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An exotic declared type must fall back to <see cref="DriverDataType.String"/> via
|
||||
/// <c>MapColumnType</c> rather than crash the browse — a table with one <c>geography</c> column must
|
||||
/// still render.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExoticColumnType_fallsBackToString_ratherThanThrowing()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable);
|
||||
var columns = await session.ExpandAsync(tableNodeId, CancellationToken.None);
|
||||
columns.Count.ShouldBe(2);
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.ExoticTable, SqliteBrowseFixture.ExoticColumn);
|
||||
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
|
||||
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end-to-end awkward-identifier walk: a table named <c>a.b|c</c> holding a column named
|
||||
/// <c>x|y</c>. Both characters are the ones a naive <c>schema.table|column</c> NodeId splits on, so
|
||||
/// this is the case that proves the encoding, not just the codec unit test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AwkwardIdentifiers_surviveTheFullWalk()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var tables = await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
|
||||
var awkward = tables
|
||||
.Where(n => n.DisplayName == SqliteBrowseFixture.AwkwardTable)
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
var columns = await session.ExpandAsync(awkward.NodeId, CancellationToken.None);
|
||||
columns.Select(c => c.DisplayName)
|
||||
.ShouldBe(new[] { SqliteBrowseFixture.AwkwardColumn, SqliteBrowseFixture.AwkwardColumn2 },
|
||||
ignoreOrder: true);
|
||||
|
||||
var awkwardColumn = columns.First(c => c.DisplayName == SqliteBrowseFixture.AwkwardColumn);
|
||||
var parsed = SqlBrowseNodeId.Parse(awkwardColumn.NodeId);
|
||||
parsed.Table.ShouldBe(SqliteBrowseFixture.AwkwardTable);
|
||||
parsed.Column.ShouldBe(SqliteBrowseFixture.AwkwardColumn);
|
||||
|
||||
var attributes = await session.AttributesAsync(awkwardColumn.NodeId, CancellationToken.None);
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table whose <em>name</em> is an injection payload. If any catalog query concatenated the name
|
||||
/// instead of binding <c>@table</c>, expanding it would drop <c>TagValues</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HostileTableName_isBound_notConcatenated()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable);
|
||||
var columns = await session.ExpandAsync(nodeId, CancellationToken.None);
|
||||
|
||||
// Survival first, and deliberately so: this is the assertion that catches a spliced name. Assert it
|
||||
// before anything about the columns, or a splice that returns no rows reds on the wrong line and the
|
||||
// test stops proving the payload was inert.
|
||||
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
|
||||
columns.ShouldHaveSingleItem().DisplayName.ShouldBe(SqliteBrowseFixture.HostileColumn);
|
||||
|
||||
var columnNodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.HostileTable, SqliteBrowseFixture.HostileColumn);
|
||||
var attributes = await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
||||
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.Int32));
|
||||
}
|
||||
|
||||
/// <summary>A hostile <em>schema</em> name takes the same bound path at the table level.</summary>
|
||||
[Fact]
|
||||
public async Task HostileSchemaName_isBound_notConcatenated()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForSchema("'); DROP TABLE TagValues; --");
|
||||
var tables = await session.ExpandAsync(nodeId, CancellationToken.None);
|
||||
|
||||
// SqliteDialect answers only to 'main', so an unknown schema legitimately lists nothing.
|
||||
tables.ShouldBeEmpty();
|
||||
db.CountRows(SqliteBrowseFixture.KeyValueTable).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table node whose table is gone (dropped between browse and expand) yields an empty column list.
|
||||
/// This is also the "table with zero columns" case: SQLite cannot create a genuinely column-less
|
||||
/// table, and an empty catalog answer is what both shapes look like from the browser's side.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TableWithNoColumns_yieldsEmpty_ratherThanThrowing()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable);
|
||||
|
||||
(await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();
|
||||
|
||||
var columnNodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.NonExistentTable, "whatever");
|
||||
(await session.AttributesAsync(columnNodeId, CancellationToken.None)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A column NodeId is terminal — expanding it is a no-op, never an error.</summary>
|
||||
[Fact]
|
||||
public async Task ExpandColumn_yieldsEmpty()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
|
||||
|
||||
(await session.ExpandAsync(nodeId, CancellationToken.None)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Non-leaf nodes have no attribute side-panel — empty, matching the OPC UA client browser.</summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_onNonLeafNode_yieldsEmpty()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
(await session.AttributesAsync(SchemaNodeId, CancellationToken.None)).ShouldBeEmpty();
|
||||
|
||||
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
|
||||
(await session.AttributesAsync(tableNodeId, CancellationToken.None)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A garbage NodeId must surface as an <see cref="ArgumentException"/> carrying an operator-readable
|
||||
/// message — the same contract the Galaxy and OPC UA client sessions have, and what
|
||||
/// <c>DriverBrowseTree.razor</c> renders inline as the node's error. It must not be a
|
||||
/// <see cref="NullReferenceException"/> or an <see cref="IndexOutOfRangeException"/> from a blind split.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-node-id")]
|
||||
[InlineData("main.TagValues|num_value")] // the plan's literal sketch — not this codec's format
|
||||
[InlineData("column:a|b|c|d")]
|
||||
public async Task MalformedNodeId_failsWithAnActionableArgumentException(string nodeId)
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var expand = await Should.ThrowAsync<ArgumentException>(
|
||||
() => session.ExpandAsync(nodeId, CancellationToken.None));
|
||||
expand.Message.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
() => session.AttributesAsync(nodeId, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One ADO.NET connection is not concurrent, so every catalog call serializes on the session's gate.
|
||||
/// Asserted by measuring overlap directly through <see cref="ConcurrencyProbe"/>: a results-only
|
||||
/// assertion would pass with the gate deleted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentCalls_areSerializedByTheGate()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var probe = new ConcurrencyProbe();
|
||||
var probing = new ConcurrencyProbingDbConnection(db.Connection, probe);
|
||||
await using var session = new SqlBrowseSession(probing, new SqliteDialect());
|
||||
|
||||
var tableNodeId = SqlBrowseNodeId.ForTable(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable);
|
||||
var calls = new[]
|
||||
{
|
||||
session.RootAsync(CancellationToken.None),
|
||||
session.ExpandAsync(SchemaNodeId, CancellationToken.None),
|
||||
session.ExpandAsync(tableNodeId, CancellationToken.None),
|
||||
session.ExpandAsync(tableNodeId, CancellationToken.None),
|
||||
};
|
||||
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
probe.Peak.ShouldBe(1);
|
||||
// TagValues has three columns — proves the serialized calls still returned real catalog rows.
|
||||
results[2].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ownership: the session takes over the connection it is handed and closes it on dispose. The
|
||||
/// AdminUI's <c>BrowseSessionRegistry</c> is the only thing holding a session, so if the session did
|
||||
/// not close the connection, nothing would — every reaped picker would leak one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_closesTheConnectionItWasGiven()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var connection = db.OpenNewConnection();
|
||||
var session = new SqlBrowseSession(connection, new SqliteDialect());
|
||||
|
||||
connection.State.ShouldBe(ConnectionState.Open);
|
||||
await session.DisposeAsync();
|
||||
|
||||
connection.State.ShouldBe(ConnectionState.Closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_isIdempotent()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());
|
||||
|
||||
await session.DisposeAsync();
|
||||
await Should.NotThrowAsync(async () => await session.DisposeAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallsAfterDispose_throwObjectDisposedException()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var session = new SqlBrowseSession(db.OpenNewConnection(), new SqliteDialect());
|
||||
await session.DisposeAsync();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
() => session.RootAsync(CancellationToken.None));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI bounds every call with a 20 s linked CTS
|
||||
/// (<c>BrowserSessionService.PerCallTimeout</c>) — the session invents no timeout of its own, it just
|
||||
/// has to honour the token it is handed, all the way into the ADO.NET call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CancelledToken_isHonoured()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => session.RootAsync(cts.Token));
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => session.ExpandAsync(SchemaNodeId, cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IBrowseSession.LastUsedUtc"/> feeds the registry's idle reaper — a session that never
|
||||
/// refreshed it would be evicted out from under an actively browsing operator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LastUsedUtc_advancesOnEverySuccessfulCall()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var atStart = session.LastUsedUtc;
|
||||
await Task.Delay(15, TestContext.Current.CancellationToken);
|
||||
await session.RootAsync(CancellationToken.None);
|
||||
var afterRoot = session.LastUsedUtc;
|
||||
afterRoot.ShouldBeGreaterThan(atStart);
|
||||
|
||||
await Task.Delay(15, TestContext.Current.CancellationToken);
|
||||
await session.ExpandAsync(SchemaNodeId, CancellationToken.None);
|
||||
var afterExpand = session.LastUsedUtc;
|
||||
afterExpand.ShouldBeGreaterThan(afterRoot);
|
||||
|
||||
await Task.Delay(15, TestContext.Current.CancellationToken);
|
||||
var columnNodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
|
||||
await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
||||
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<!--
|
||||
TEST-ONLY, same rationale as Driver.Sql.Tests: SQLite is the offline substrate that lets the
|
||||
schema browser run against a real DbConnection (real parameter binding, real catalog rows) with
|
||||
no SQL Server and no network. No product project references SQLite. The bundle_e_sqlite3 line is
|
||||
the surgical direct pin that promotes the native bundle to 2.1.12, outside the
|
||||
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q range Microsoft.Data.Sqlite's transitive 2.1.11 sits in.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.Sqlite"/>
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
LINKED, not duplicated. SqliteDialect is the only non-SQL-Server ISqlDialect in the tree and is
|
||||
therefore the falsifiability control for the whole seam: it proves the browser runs the *dialect's*
|
||||
catalog SQL rather than INFORMATION_SCHEMA with a quoting function attached. Linking the one file
|
||||
(rather than referencing Driver.Sql.Tests) keeps a second copy from drifting while avoiding a
|
||||
test-project-to-test-project reference. The file is public + self-contained for exactly this reason
|
||||
— see its class docs.
|
||||
-->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user