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