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