861a1d1df0
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
202 lines
9.2 KiB
C#
202 lines
9.2 KiB
C#
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;
|
|
}
|
|
}
|