using System.Data.Common; using System.Globalization; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// The catalog loader's default-schema fallback: a login with no default schema mapped (the /// dialect's DefaultSchemaSql answers NULL/blank — a real Postgres shape when the connection's /// search_path resolves to nothing, and a real SQL Server shape for a login with no /// DEFAULT_SCHEMA) must still resolve an unqualified authored table when the connection /// can see exactly one schema. /// Without the fallback the loader resolves the default schema to the empty string, the tables /// query is issued for schema '', it matches nothing, and every unqualified tag on the driver /// is rejected by the gate and reads BadNodeIdUnknown — a whole-driver outage with the /// database perfectly healthy and every tag correctly authored. /// public sealed class SqlCatalogDefaultSchemaTests { private const string DriverInstanceId = "sql-default-schema"; private const string ConfigJson = """{"provider":"SqlServer"}"""; /// /// One visible schema + a blank default ⇒ the unqualified name resolves in that schema and polls. /// [Fact] public async Task An_unqualified_table_resolves_when_the_server_reports_no_default_schema() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect()); await driver.InitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem(); SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue( "a login with no default schema must still resolve an unqualified name in the sole visible schema"); snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue); } /// /// The falsifiability control: with more than one visible schema there is no defensible guess, /// so an unqualified name must NOT be resolved against an arbitrary one — it is rejected, and the gate /// says so by name. A fallback that picked the first schema would pass the test above and silently /// bind tags to whichever schema sorted first here. /// [Fact] public async Task An_unqualified_table_is_still_rejected_when_several_schemas_are_visible() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, new BlankDefaultSchemaDialect(twoSchemas: true)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem(); snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); } private static SqlDriver NewDriver(SqlitePollFixture fixture, ISqlDialect dialect) => new( new SqlDriverOptions { RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], OperationTimeout = TimeSpan.FromSeconds(15), CommandTimeout = TimeSpan.FromSeconds(10), }, DriverInstanceId, dialect, fixture.ConnectionString, factory: fixture.Factory, logger: NullLogger.Instance); private static RawTagEntry KvEntry(string rawPath, string keyValue) => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" { "driver": "Sql", "model": "KeyValue", "table": "{{SqlitePollFixture.KeyValueTable}}", "keyColumn": "{{SqlitePollFixture.KeyColumn}}", "keyValue": "{{keyValue}}", "valueColumn": "{{SqlitePollFixture.ValueColumn}}" } """), WriteIdempotent: false); /// /// Delegates every member to except (which /// answers the empty string, standing in for a login with no default schema) and optionally /// (which can report a second, empty schema so the ambiguous case is /// reachable). /// /// /// A decorator rather than a subclass, for the reason SqlCatalogGateDriverTests gives: /// is sealed, and new-hiding a property would leave interface /// dispatch calling the base, so the substitution would silently not happen. /// private sealed class BlankDefaultSchemaDialect(bool twoSchemas = false) : ISqlDialect { private static readonly SqliteDialect Inner = new(); public SqlProvider Provider => Inner.Provider; public DbProviderFactory Factory => Inner.Factory; public int MaxBindParameters => Inner.MaxBindParameters; public string LivenessSql => Inner.LivenessSql; public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix; public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix; public string ListSchemasSql => twoSchemas ? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA" : Inner.ListSchemasSql; /// The server has no default schema for this login — the case the fallback covers. public string DefaultSchemaSql => "SELECT ''"; public string ListTablesSql => Inner.ListTablesSql; public string ListColumnsSql => Inner.ListColumnsSql; public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName); public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName); public void ConfigureCommand(DbCommand command) => Inner.ConfigureCommand(command); public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident); public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType); } }