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