using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
/// shape and the caller's tests can assert on the reason.
///
/// The rejected tag's identity — its RawPath, which is trusted structure, not blob content.
/// The field that failed (e.g. ValueColumn).
/// An operator-actionable explanation, safe to log.
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
/// The outcome of applying a to a set of authored tags.
///
/// The surviving definitions, rewritten to catalog spellings. Safe to hand to
/// .
///
/// Every tag the gate refused, in input order.
public sealed record SqlCatalogGateResult(
IReadOnlyList Accepted,
IReadOnlyList Rejected);
///
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
/// before it can be quoted into SQL text, and replaces it with the catalog's own spelling.
///
///
/// What this closes. Until this existed, was the
/// sole defence: an identifier went from the authored TagConfig blob straight into a command text,
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
/// than the whole story.
/// Charset first, catalog second. Each identifier is passed through
/// before it is looked up, purely for its rejection rules
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
/// fails the charset check is rejected without its value being echoed, so nothing carrying a bidi
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
/// must, or an operator has no way to find the typo.
/// Rejection is per-tag, never driver-wide. A tag naming a dropped column is dropped from the
/// driver's table, so its RawPath resolves to nothing and it publishes
/// — design §8.1's specified outcome — while every other tag
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
/// down" rule the tag-table build already follows.
///
public static class SqlCatalogGate
{
///
/// The most name parts this gate can validate. A three-part db.schema.table (or a
/// four-part linked-server name) addresses a catalog this connection's INFORMATION_SCHEMA
/// cannot see, so it cannot be allow-listed.
///
private const int MaxNameParts = 2;
///
/// Applies to , returning the accepted tags
/// with catalog-spelled identifiers and the rejected ones with reasons.
///
/// The authored definitions, as parsed from their TagConfig blobs.
/// The catalog snapshot to validate against.
/// Supplies the identifier charset rules via .
/// The accepted and rejected sets.
/// A required argument is null.
public static SqlCatalogGateResult Apply(
IEnumerable definitions, SqlCatalog catalog, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(definitions);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(dialect);
var accepted = new List();
var rejected = new List();
foreach (var definition in definitions)
{
ArgumentNullException.ThrowIfNull(definition);
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
rejected.Add(rejection!);
}
return new SqlCatalogGateResult(accepted, rejected);
}
/// Resolves one definition's identifiers, or explains the first failure.
private static bool TryCanonicalize(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out SqlTagDefinition? canonical,
out SqlCatalogRejection? rejection)
{
canonical = null;
rejection = null;
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
return false;
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
// model does not use is null and simply skipped — the planner enforces which are required.
var resolved = new Dictionary(StringComparer.Ordinal);
foreach (var (field, authored) in new (string Field, string? Authored)[]
{
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
})
{
if (string.IsNullOrWhiteSpace(authored))
{
resolved[field] = authored;
continue;
}
if (!IsRenderableIdentifier(authored, dialect))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
"or Unicode format characters); the value is withheld from this message because it " +
"cannot be safely rendered");
return false;
}
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
"column under a case-sensitive collation)");
return false;
}
resolved[field] = column;
}
canonical = definition with
{
Table = SqlCatalog.QualifiedKey(schema, table),
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
};
return true;
}
/// Splits and resolves the authored table to a canonical schema + relation.
private static bool TryResolveRelation(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out string schema,
out string table,
out SqlCatalogRejection? rejection)
{
schema = string.Empty;
table = string.Empty;
rejection = null;
const string Field = nameof(SqlTagDefinition.Table);
if (string.IsNullOrWhiteSpace(definition.Table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
"the tag has no 'table'; author the table or view to read");
return false;
}
var parts = definition.Table.Split('.');
if (parts.Length > MaxNameParts)
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
"validated against this connection's catalog, so a cross-database or linked-server name " +
"cannot be allow-listed; expose the data through a view in this database instead");
return false;
}
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
var authoredTable = parts[^1];
foreach (var part in parts)
{
if (IsRenderableIdentifier(part, dialect)) continue;
rejection = new SqlCatalogRejection(definition.Name, Field,
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
"containing control or Unicode format characters); the value is withheld from this message " +
"because it cannot be safely rendered");
return false;
}
if (!catalog.TryResolveSchema(authoredSchema, out schema))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
"case-sensitive collation)");
return false;
}
if (!catalog.TryResolveTable(schema, authoredTable, out table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
(authoredSchema is null
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
: string.Empty));
return false;
}
return true;
}
///
/// True when the dialect's quoting rules accept — i.e. it is safe both to
/// embed in SQL and to render into a log line or an AdminUI label.
///
///
/// Uses as the charset authority rather than duplicating its
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
/// discarded: only whether it threw is interesting here.
///
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
{
try
{
dialect.QuoteIdentifier(identifier);
return true;
}
catch (ArgumentException)
{
return false;
}
}
}