using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
///
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
/// column's mapped DriverDataType in the attribute side-panel (design §4.1, mirroring Galaxy's
/// two-stage object-then-attribute pick). Created by SqlDriverBrowser on picker open and owned by
/// the AdminUI's BrowseSessionRegistry, whose TTL reaper disposes idle sessions.
/// Every level is the dialect's catalog SQL ( /
/// / ) — nothing here
/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
/// is deliberately not used on this path — the browse
/// never needs an identifier in text.
/// Connection ownership: this session owns the connection it is handed and closes it on
/// . 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.
/// No timeout of its own. The AdminUI already bounds each root/expand/attributes call with a
/// 20-second linked CTS (BrowserSessionService.PerCallTimeout); 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.
///
internal sealed class SqlBrowseSession : IBrowseSession
{
/// Column alias every dialect's projects.
private const string SchemaColumn = "TABLE_SCHEMA";
/// Column alias every dialect's projects.
private const string TableNameColumn = "TABLE_NAME";
/// Column alias carrying BASE TABLE / VIEW.
private const string TableTypeColumn = "TABLE_TYPE";
/// Column alias every dialect's projects.
private const string ColumnNameColumn = "COLUMN_NAME";
/// Column alias carrying the SQL type family name.
private const string DataTypeColumn = "DATA_TYPE";
/// The TABLE_TYPE value marking a view rather than a base table.
private const string ViewTableType = "VIEW";
///
/// The security class every browsed column reports. The Sql driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
///
private const string ReadOnlySecurityClass = "ViewOnly";
private readonly DbConnection _connection;
private readonly ISqlDialect _dialect;
///
/// 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.
///
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// Constructs a session over an already-open connection, which it takes ownership of.
/// The open connection to browse. Closed by .
/// The dialect supplying the catalog SQL and the column-type map.
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
}
///
public Guid Token { get; } = Guid.NewGuid();
///
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
///
public async Task> RootAsync(CancellationToken cancellationToken)
{
var schemas = await QueryAsync(
_dialect.ListSchemasSql,
static _ => { },
static reader => ReadString(reader, SchemaColumn),
cancellationToken).ConfigureAwait(false);
var nodes = new List(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;
}
///
/// is not a SQL schema-browse node.
public async Task> 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();
}
}
///
/// is not a SQL schema-browse node.
public async Task> 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();
}
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,
IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
];
}
// 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();
}
///
/// 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.
///
/// A task that represents the asynchronous operation.
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> 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(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> ExpandTableAsync(
string schema, string table, CancellationToken ct)
{
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
var nodes = new List(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;
}
///
/// 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.
///
private Task> 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);
///
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// inside the gate wait so a dispose racing an in-flight call cannot be missed, and
/// only advances on a call that actually completed.
///
private async Task> QueryAsync(
string sql,
Action bind,
Func project,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var results = new List();
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) { }
}
}
/// Binds one catalog-query parameter. The only way a name reaches the database.
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();
}
}