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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user