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. /// 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"; /// /// 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.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, 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.ListTablesSql, TableNameColumn, command => Bind(command, "@schema", 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.ListColumnsSql, ColumnNameColumn, command => { Bind(command, "@schema", schema); Bind(command, "@table", 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). 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. /// private static async Task ReadDefaultSchemaAsync( DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken) { var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { 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(); return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema; } } /// Runs one catalog query and projects a single string column from every row. private static async Task> QueryColumnAsync( DbConnection connection, string sql, string columnName, Action bind, int timeoutSeconds, CancellationToken cancellationToken) { var results = new List(); var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { 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); } }