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:
Joseph Doherty
2026-07-24 14:49:36 -04:00
parent 9b30bdeb7a
commit 861a1d1df0
8 changed files with 1452 additions and 0 deletions
@@ -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>&lt;kind&gt;:&lt;part&gt;[|&lt;part&gt;…]</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();
}
}