886ce1b87c
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was missing while modelling everything else that differs per backend: 2098 usable of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres Bind message. SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when its distinct keys exceed that ceiling, instead of one statement the provider refuses after the connection opened — which left every tag in an EAV group publishing BadCommunicationError on every poll, permanently, and reported the database endpoint as down. Chunks share the group key; members follow their own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs one command per plan and slices each plan's own Members back, proven by a fixture read at a ceiling of 1.
138 lines
6.3 KiB
C#
138 lines
6.3 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 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;
|
|
|
|
/// <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);
|
|
}
|
|
}
|