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.
136 lines
6.2 KiB
C#
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);
|
|
}
|
|
}
|