69ad376f0d
Driver.Sql-001 (Medium): SqlCatalogLoader.ReadDefaultSchemaAsync documented a fallback to the sole visible schema when the server reports no default schema and did not implement it, so a login with no DEFAULT_SCHEMA (or a Postgres search_path that resolves to nothing) rejected EVERY unqualified authored table at the catalog gate — a whole-driver BadNodeIdUnknown outage against a healthy database. Fixed; SqlCatalogDefaultSchemaTests written red first, with a control proving the ambiguous multi-schema case still refuses to guess. Driver.Sql-003 (Medium): docs/drivers/Sql.md spelled the WideRow row selector at the top level, but the parser reads those keys only inside a nested rowSelector — a tag authored from the doc is refused and reads BadNodeIdUnknown (the #552 class, via the documentation). Also removed the documented-but-nonexistent pollIntervalMs per-tag field. Driver.Sql-007 (Low): dropped the unused Microsoft.Data.SqlClient PackageReference left behind by the .Dialect extraction (#526). Open: 002 (no IN-list chunking vs the 2100/1000 provider bind caps), 004, 005, 006, 008, 009, 010.
218 lines
11 KiB
C#
218 lines
11 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
|
|
/// <summary>
|
|
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
|
|
/// dialect's catalog SQL (design §8.1).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
|
|
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
|
|
/// one <see cref="ISqlDialect.ListColumnsSql"/> 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.</para>
|
|
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
|
/// <c>schema</c>/<c>table</c> 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 <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
|
|
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
|
|
/// Server, <c>:p_schema</c> on Oracle).</para>
|
|
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> 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.</para>
|
|
/// </remarks>
|
|
internal static class SqlCatalogLoader
|
|
{
|
|
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
|
|
private const string SchemaColumn = "TABLE_SCHEMA";
|
|
|
|
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
|
|
private const string TableNameColumn = "TABLE_NAME";
|
|
|
|
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
|
private const string ColumnNameColumn = "COLUMN_NAME";
|
|
|
|
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
|
private const string SchemaParameter = "schema";
|
|
|
|
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
|
private const string TableParameter = "table";
|
|
|
|
/// <summary>
|
|
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
|
/// </summary>
|
|
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
|
|
/// <param name="dialect">Supplies the catalog SQL.</param>
|
|
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
|
|
/// <param name="commandTimeout">Per-command server-side backstop.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The loaded catalog.</returns>
|
|
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
|
|
internal static async Task<SqlCatalog> LoadAsync(
|
|
DbConnection connection,
|
|
ISqlDialect dialect,
|
|
IReadOnlyCollection<string> 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<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
|
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
|
|
/// query answers null/blank.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>A null answer is possible (a login with no default schema mapped, or a Postgres connection
|
|
/// whose <c>search_path</c> 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.</para>
|
|
/// <para><b>The fallback is load-bearing, not cosmetic.</b> Without it a blank answer resolves the
|
|
/// default schema to the empty string, the tables query runs for schema <c>''</c>, it matches nothing,
|
|
/// and <em>every unqualified tag on the driver</em> is rejected by the gate and reads
|
|
/// <c>BadNodeIdUnknown</c> — 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.</para>
|
|
/// </remarks>
|
|
private static async Task<string> ReadDefaultSchemaAsync(
|
|
DbConnection connection,
|
|
ISqlDialect dialect,
|
|
IReadOnlyList<string> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
|
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
|
DbConnection connection,
|
|
ISqlDialect dialect,
|
|
string sql,
|
|
string columnName,
|
|
Action<DbCommand> bind,
|
|
int timeoutSeconds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<string>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
|
|
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);
|
|
}
|
|
}
|