6a01358b9a
A parity guard already existed for DriverTypeNames <-> the /raw driver picker, but not for the two dispatch maps downstream, so a driver could be registered, offered in the picker, and then have no config form. That is exactly what happened to Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2) — both survived review because nothing could see them. The maps had to become data first. A Razor @switch compiles into BuildRenderTree's IL, so no test can enumerate its cases; the picker guard works only because RawDriverTypeDialog keeps its data in a field the markup enumerates. Both modals now render from DriverConfigFormMap / DeviceFormMap through <DynamicComponent>, and being public those maps need none of the picker test's BindingFlags.NonPublic fragility. - G-1 CalculationDriverForm.razor — RunTimeout was unauthorable via the UI. - G-2 Sql/MTConnect/Calculation declared single-connection rather than given hollow device forms; each holds one connection at the driver level. - G-3 DriverConfigModal's hardcoded "Galaxy or Mqtt" -> IsSingleConnection, so Sql/MTConnect authors are no longer sent to a device form with no endpoint. - G-4 DriverFormMapParityTests (5, both directions) + DriverDispatchMapParityTests. - G-5 CsvColumnMap entries for Sql, Mqtt, MTConnect. - G-6 RawBrowseCommitMapper Sql branch — and the browser end, which the audit missed: SqlBrowseSession emitted NO AddressFields, so a browsed leaf carried only a column name, and a column alone cannot address a Sql tag. It now travels schema/table/column and the mapper builds a WideRow config from them. A branch alone would have produced a half-built blob. Two findings while writing the guards. The G-6 test showed Modbus and Calculation also hit the generic address fallback — correctly, as neither is browsable — so it asserts the fall-through set EQUALS a documented non-browsable list in both directions; a one-way check would let a newly browsable driver be quietly added to the exclusion list. And TagConfigDriverTypeNameGuardTests was hollow: it enumerated a hand-written TheoryData that had already drifted (omitting Galaxy, Sql and Mqtt), guarding renames but not gaps. Now enumerated from the map with a coverage test facing the other way. Live-verified on docker-dev, since this repo has no bUnit and no unit test reaches Blazor parameter binding: opened Calculation's config (previously "No typed config form"), typed 3500, saved, reopened — the value persisted, so the DynamicComponent two-way binding round-trips. Sql's form renders fully and now reads "This driver holds a single connection, authored above". AdminUI.Tests 930 passed.
317 lines
15 KiB
C#
317 lines
15 KiB
C#
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,
|
|
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<string, string>(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<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();
|
|
}
|
|
}
|