Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogDefaultSchemaTests.cs
T
Joseph Doherty 69ad376f0d review(Driver.Sql): findings + fix the missing default-schema fallback, the rowSelector doc, and a dead package ref
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.
2026-08-10 06:58:08 -04:00

136 lines
6.2 KiB
C#

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;
/// <summary>
/// The catalog loader's <b>default-schema fallback</b>: a login with no default schema mapped (the
/// dialect's <c>DefaultSchemaSql</c> answers NULL/blank — a real Postgres shape when the connection's
/// <c>search_path</c> resolves to nothing, and a real SQL Server shape for a login with no
/// <c>DEFAULT_SCHEMA</c>) must still resolve an <em>unqualified</em> authored table when the connection
/// can see exactly one schema.
/// <para>Without the fallback the loader resolves the default schema to the empty string, the tables
/// query is issued for schema <c>''</c>, it matches nothing, and <b>every unqualified tag on the driver
/// is rejected by the gate</b> and reads <c>BadNodeIdUnknown</c> — a whole-driver outage with the
/// database perfectly healthy and every tag correctly authored.</para>
/// </summary>
public sealed class SqlCatalogDefaultSchemaTests
{
private const string DriverInstanceId = "sql-default-schema";
private const string ConfigJson = """{"provider":"SqlServer"}""";
/// <summary>
/// One visible schema + a blank default ⇒ the unqualified name resolves in that schema and polls.
/// </summary>
[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);
}
/// <summary>
/// The falsifiability control: with <b>more than one</b> 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.
/// </summary>
[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<SqlDriver>.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);
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="DefaultSchemaSql"/> (which
/// answers the empty string, standing in for a login with no default schema) and optionally
/// <see cref="ListSchemasSql"/> (which can report a second, empty schema so the ambiguous case is
/// reachable).
/// </summary>
/// <remarks>
/// A decorator rather than a subclass, for the reason <c>SqlCatalogGateDriverTests</c> gives:
/// <see cref="SqliteDialect"/> is sealed, and <c>new</c>-hiding a property would leave interface
/// dispatch calling the base, so the substitution would silently not happen.
/// </remarks>
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 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;
/// <summary>The server has no default schema for this login — the case the fallback covers.</summary>
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);
}
}