using System.Text; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; /// Which catalog level a browse NodeId addresses. public enum SqlBrowseNodeKind { /// A schema — the browse root level. Schema, /// A table or view inside a schema. Table, /// A column of a table or view — the terminal (leaf) level. Column, } /// A decoded browse NodeId. /// Which catalog level this reference addresses. /// The schema name. Always present. /// The table/view name; null for . /// The column name; null unless . public readonly record struct SqlBrowseNodeRef( SqlBrowseNodeKind Kind, string Schema, string? Table, string? Column); /// /// Codec for the browse NodeIds the SQL schema browser hands the picker, and the picker hands back /// on expand / attribute fetch / column commit. /// Why not the design's literal schema.table|column. That sketch assumes . and /// | cannot occur inside an identifier. They can: SQL Server permits essentially any character /// inside a quoted (bracketed) identifier, and SqlServerDialect.QuoteIdentifier deliberately passes /// both through — only ], control characters and Unicode format characters are special to it. So /// main.a.b|c decodes equally as schema main + table a.b and as schema main.a /// + table b, and a table named x|y 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. /// /// The encoding. <kind>:<part>[|<part>…], where kind is one /// of schema / table / column and every part is escaped so no identifier character /// can be mistaken for structure: a literal \ becomes \\ and a literal | becomes /// \|. . needs no escape at all — it is not structural here — so the common case stays /// readable (column:dbo|TagValues|num_value). 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. /// Public on purpose, unlike the session itself: the AdminUI picker body decodes a committed /// column NodeId back into schema/table/column to compose the tag's TagConfig. 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. /// 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"; /// Encodes a schema-level NodeId. /// The schema name, exactly as the catalog reports it. /// The encoded NodeId. /// is null, empty or whitespace. public static string ForSchema(string schema) => Encode(SchemaKind, schema); /// Encodes a table-level NodeId. /// The schema name, exactly as the catalog reports it. /// The table or view name, exactly as the catalog reports it. /// The encoded NodeId. /// Either name is null, empty or whitespace. public static string ForTable(string schema, string table) => Encode(TableKind, schema, table); /// Encodes a column-level (leaf) NodeId. /// The schema name, exactly as the catalog reports it. /// The table or view name, exactly as the catalog reports it. /// The column name, exactly as the catalog reports it. /// The encoded NodeId. /// Any name is null, empty or whitespace. public static string ForColumn(string schema, string table, string column) => Encode(ColumnKind, schema, table, column); /// Attempts to decode a NodeId. /// The NodeId to decode. /// The decoded reference on success; default otherwise. /// true when is a well-formed SQL browse NodeId. 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; } } /// /// Decodes a NodeId, throwing when it is malformed. /// The message deliberately does not echo the offending value: a NodeId can carry an /// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log. /// /// The NodeId to decode. /// The decoded reference. /// The value is not a well-formed SQL browse NodeId. 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:', 'table:|' or " + "'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); } } /// /// Splits an encoded payload on unescaped separators, unescaping as it goes. Returns /// false on a dangling trailing escape, which cannot be produced by /// and therefore means the value was hand-made or truncated. /// private static bool TrySplit(string payload, out List 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; } }