diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs index 2b0bd7a1..e67d5bd8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser /// /// Maps an authored onto the dialect that browses it, or /// for a provider this build does not carry. Defaults to - /// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2 - /// provider gets added without touching this class. + /// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how + /// a further provider gets added without touching this class. /// /// /// Reads one environment variable. Defaults to . @@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser ConnectionStringEnvironmentPrefix + connectionStringRef; /// - /// The providers this build can browse. v1 constructs SQL Server only; every other - /// member is a reserved name, so it resolves to and - /// the caller reports it as unavailable rather than crashing on a null dialect. + /// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining + /// members (, ) + /// are reserved names, so they resolve to and the caller reports them as + /// unavailable rather than crashing on a null dialect. + /// Kept in step with SqlDriverFactoryExtensions.CreateDialect: a provider the runtime + /// driver constructs but the browser cannot select would leave the operator authoring tags by hand + /// against a database the AdminUI refuses to show them. /// /// The authored provider. /// The dialect, or when this build does not carry the provider. - public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => - provider == SqlProvider.SqlServer ? new SqlServerDialect() : null; + public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + SqlProvider.Postgres => new PostgresDialect(), + SqlProvider.Oracle => new OracleDialect(), + _ => null, + }; /// /// @@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser var literal = ReadPastedConnectionString(configJson); var config = Deserialize(configJson, literal); - // Provider first: "this build cannot browse Postgres" is true regardless of how the database is + // Provider first: "this build cannot browse MySql" is true regardless of how the database is // named, and answering it before touching the environment keeps the two failures from masking. var dialect = _dialectSelector(config.Provider) ?? throw new InvalidOperationException( - $"Sql provider '{config.Provider}' is not available in this build; v1 browses " - + $"'{SqlProvider.SqlServer}' only."); + $"Sql provider '{config.Provider}' is not available in this build; browsing supports " + + $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' " + + $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names)."); var reference = config.ConnectionStringRef; var connectionString = ResolveConnectionString(literal, reference); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs index 2bca5d24..1e29f05c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs @@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; /// public sealed class SqlDriverConfigDto { - /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs. + /// Which ADO.NET provider/dialect backs this instance. Defaults to ; and are also constructed. public SqlProvider Provider { get; init; } = SqlProvider.SqlServer; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs index 67879307..0dd44c55 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs @@ -1,21 +1,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; -/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer. +/// +/// Which ADO.NET provider/dialect backs a Sql driver instance. Constructed: SqlServer, Postgres, +/// Oracle — each over a fully managed provider package, no native client install. Reserved: MySql +/// and Odbc are declared names only; authoring one fails at driver construction and at browse. +/// public enum SqlProvider { - /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs. + /// Microsoft SQL Server via Microsoft.Data.SqlClient. SqlServer, - /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1. + /// PostgreSQL via the managed Npgsql provider. Postgres, - /// MySQL / MariaDB — reserved; not constructed in v1. + /// MySQL / MariaDB — reserved; no dialect ships, so this is not constructed. MySql, - /// Generic ODBC — reserved for P2; not constructed in v1. + /// Generic ODBC — reserved; no dialect ships, so this is not constructed. Odbc, - /// Oracle — reserved; not constructed in v1. + /// Oracle Database via the fully managed Oracle.ManagedDataAccess.Core provider. Oracle, } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs index 35096a4c..80fea8d4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs @@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// public interface ISqlDialect { - /// Which backend this dialect speaks. v1 constructs only. + /// + /// Which backend this dialect speaks. One implementation ships per constructed + /// member — , + /// and . + /// SqlProvider Provider { get; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index 2bbb1a0a..aafce4e7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// -/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting, +/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting, /// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map. /// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and /// the AdminUI browse session. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index a4ec55ef..5961e670 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions /// /// Selects the provider seam. - /// v1 constructs only. The other members exist on the shipped - /// enum as reserved names; authoring one is a config mistake that must fail at construction with a - /// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database. + /// This build constructs , + /// and . and + /// remain reserved names on the shipped enum; authoring one is a config + /// mistake that must fail at construction with a message that says so, rather than silently degrading + /// to another backend's SQL dialect against a database that does not speak it. + /// Every arm returns that provider's OWN dialect — there is no fallback arm. A default + /// that resolved to would construct happily and then emit T-SQL + /// (INFORMATION_SCHEMA, TOP 1, @ markers) at a Postgres or Oracle server, which + /// fails at poll time as an unintelligible provider error rather than here as a config error. /// private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId) => provider switch { SqlProvider.SqlServer => new SqlServerDialect(), + SqlProvider.Postgres => new PostgresDialect(), + SqlProvider.Oracle => new OracleDialect(), _ => throw new InvalidOperationException( $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " + - $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."), + $"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " + + $"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " + + $"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."), }; /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs index c984c7bd..93d1f29f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs @@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName); } + /// + /// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each + /// resolve to their own dialect, and every remaining member resolves to . + /// Enumerating for the reserved half is what makes a future enum + /// member fail here rather than silently browse as SQL Server. + /// [Fact] - public void DefaultDialectSelector_carriesSqlServerOnly() + public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle() { SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType(); + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType(); + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType(); - foreach (var provider in Enum.GetValues().Where(p => p != SqlProvider.SqlServer)) + SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle]; + foreach (var provider in Enum.GetValues().Where(p => !carried.Contains(p))) SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); } @@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests // ---- provider availability ---- + /// + /// The falsifiability control for the widened browse gate: is still + /// reserved and must be reported as unavailable, naming itself. + /// [Fact] public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild() { @@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => browser.OpenAsync( - ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None)); + ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None)); - ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("MySql"); ex.Message.ShouldContain("not available in this build"); } /// /// Provider availability is answered before the environment is touched: "this build cannot browse - /// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other. + /// MySql" is true whatever the ref resolves to, and the two failures must not mask each other. /// [Fact] public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider() @@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => browser.OpenAsync( - ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None)); + ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None)); ex.Message.ShouldContain("not available in this build"); reads.ShouldBeEmpty(); } + /// + /// A carried provider must get past the provider gate and on to the environment, so that a + /// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability. + /// Asserted through the reads list: reaching the environment reader is the observable proof the gate + /// let the provider through. + /// + [Theory] + [InlineData("Postgres")] + [InlineData("Oracle")] + public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider) + { + var reads = new List(); + var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; }); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldNotContain("not available in this build"); + reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging")); + } + // ---- malformed configuration ---- [Theory] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs index 0fb8fdb0..65d6a281 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests // ---- provider gate ---- + /// + /// The falsifiability control for the widened gate: is still a + /// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that + /// had quietly become "construct anything" would pass every test below. + /// [Fact] public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo() { @@ -130,12 +135,73 @@ public sealed class SqlDriverFactoryTests SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( - "d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null)); + "d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null)); - ex.Message.ShouldContain("Postgres"); + ex.Message.ShouldContain("MySql"); ex.Message.ShouldContain("not available in this build"); } + /// + /// The reserved names are named as reserved — an operator reading the rejection must be able to tell + /// "misspelled" from "this build does not carry it", so the message enumerates both halves. + /// + [Fact] + public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null)); + + ex.Message.ShouldContain(nameof(SqlProvider.SqlServer)); + ex.Message.ShouldContain(nameof(SqlProvider.Postgres)); + ex.Message.ShouldContain(nameof(SqlProvider.Oracle)); + } + + /// + /// The widened gate, asserted on the thing that actually varies: which dialect the config binds to. + /// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate + /// that fell through to SqlServerDialect would also construct without complaint and then speak + /// T-SQL at a Postgres server. + /// + [Theory] + [InlineData("SqlServer", typeof(SqlServerDialect))] + [InlineData("Postgres", typeof(PostgresDialect))] + [InlineData("Oracle", typeof(OracleDialect))] + public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected) + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + var binding = SqlDriverFactoryExtensions.ParseBinding( + "d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}"""); + + binding.Dialect.ShouldBeOfType(expected); + binding.Dialect.Provider.ShouldBe(Enum.Parse(provider)); + } + + /// + /// End-to-end through the registered factory entry point, so the widening is proven on the path the + /// bootstrapper actually calls — not only on the internal parse. + /// + [Theory] + [InlineData("Postgres")] + [InlineData("Oracle")] + public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider) + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + await using var driver = SqlDriverFactoryExtensions.CreateInstance( + "d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null); + + driver.ShouldNotBeNull(); + } + // ---- config validation (the reader and the shell deliberately do NOT do this) ---- [Fact]