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(); } }