using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
/// Loads the slice the authored tags need, over one connection, using only the
/// dialect's catalog SQL (design §8.1).
///
///
/// Bounded by what is authored, not by what exists. One query for the schema list, one for
/// the default schema, then one per distinct authored schema and
/// one per distinct authored relation. A driver polling three
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.
/// Every parameter is bound. Authored names reach the catalog queries as
/// schema/table parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
/// The bind names come from over those two base names, so
/// they can never disagree with the markers in the dialect's own catalog SQL (@schema on SQL
/// Server, :p_schema on Oracle).
/// Failures throw; they do not degrade to an empty catalog. An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
/// confidently-empty address space.
///
internal static class SqlCatalogLoader
{
/// Column alias projects.
private const string SchemaColumn = "TABLE_SCHEMA";
/// Column alias projects.
private const string TableNameColumn = "TABLE_NAME";
/// Column alias projects.
private const string ColumnNameColumn = "COLUMN_NAME";
/// Base name of the schema parameter every dialect's catalog SQL carries.
private const string SchemaParameter = "schema";
/// Base name of the table parameter carries.
private const string TableParameter = "table";
///
/// Reads the catalog slice covering .
///
/// An already-open connection. Not disposed here; the caller owns it.
/// Supplies the catalog SQL.
/// The distinct authored table strings, as written in the blobs.
/// Per-command server-side backstop.
/// Cancellation token for the operation.
/// The loaded catalog.
/// The connection can see no schemas at all.
internal static async Task LoadAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyCollection authoredTables,
TimeSpan commandTimeout,
CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
// that basis would report an authoring fault for what is actually a grant problem, and would send the
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
if (schemas.Count == 0)
throw new InvalidOperationException(
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
"the catalog views, which is a grant problem rather than a tag-authoring one");
var defaultSchema = await ReadDefaultSchemaAsync(
connection, dialect, schemas, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var tablesBySchema = new Dictionary>(StringComparer.Ordinal);
var columnsByTable = new Dictionary>(StringComparer.Ordinal);
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
// one table and validating against another would be a silent wrong-column defect.
foreach (var authored in authoredTables)
{
if (string.IsNullOrWhiteSpace(authored)) continue;
var parts = authored.Split('.');
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
var authoredTable = parts[^1];
string schema;
if (parts.Length == 2)
{
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
}
else
{
schema = defaultSchema;
}
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
connection, dialect, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
var key = SqlCatalog.QualifiedKey(schema, table);
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
Bind(command, dialect.ParameterBindName(TableParameter), table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
}
///
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
/// query answers null/blank.
///
///
/// A null answer is possible (a login with no default schema mapped, or a Postgres connection
/// whose search_path resolves to nothing). Falling back to the single visible schema keeps the
/// common one-schema database working; with several visible schemas there is no defensible guess, so
/// unqualified names simply will not resolve and the gate says so by name.
/// The fallback is load-bearing, not cosmetic. Without it a blank answer resolves the
/// default schema to the empty string, the tables query runs for schema '', it matches nothing,
/// and every unqualified tag on the driver is rejected by the gate and reads
/// BadNodeIdUnknown — a whole-driver outage against a healthy database with every tag correctly
/// authored. Guessing among several schemas is the opposite error (a tag silently bound to the wrong
/// relation), which is why the fallback stops at exactly one candidate.
///
private static async Task ReadDefaultSchemaAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyList schemas,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var schema = value is null or DBNull ? null : value.ToString();
if (!string.IsNullOrWhiteSpace(schema)) return schema;
// No default schema for this login: the sole visible schema is the only defensible answer, and
// with more than one there is none — the empty string then resolves nothing, by design.
return schemas.Count == 1 ? schemas[0] : string.Empty;
}
}
/// Runs one catalog query and projects a single string column from every row.
private static async Task> QueryColumnAsync(
DbConnection connection,
ISqlDialect dialect,
string sql,
string columnName,
Action bind,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var results = new List();
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
// Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
dialect.ConfigureCommand(command);
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
var ordinal = -1;
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal)) continue;
var value = reader.GetValue(ordinal)?.ToString();
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
}
}
}
return results;
}
/// Binds one catalog-query parameter — the only way an authored name reaches the database here.
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);
}
}