feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved

Widens the two provider gates so an authored `{"provider":"Postgres"}` or
`{"provider":"Oracle"}` binds that provider's own dialect instead of failing
"not available in this build":

- `SqlDriverFactoryExtensions.CreateDialect` gains a Postgres and an Oracle
  arm. There is deliberately no fallback arm — a default resolving to
  SqlServerDialect would construct happily and then emit T-SQL at a Postgres
  or Oracle server. The rejection message now names the three supported and
  the two still-reserved providers.
- `SqlDriverBrowser.DefaultDialectSelector` widens identically, so a provider
  the runtime constructs is also browsable; MySql/Odbc keep resolving to null
  and the caller keeps reporting them as unavailable.

Tests: the existing rejected-provider controls flip from Postgres to MySql
(factory) and Odbc (browser) so the reserved half stays falsifiable, and the
new legs assert the bound dialect TYPE rather than mere construction —
"no throw" alone would pass against a SqlServer fallback.

Doc-comment sweep (contract addition, one line each): the "v1 constructs
SqlServer only" / "reserved for P2" claims in SqlProvider, SqlDriverConfigDto,
ISqlDialect.Provider and SqlServerDialect are now false and were corrected in
the same change.
This commit is contained in:
Joseph Doherty
2026-08-05 06:51:48 -04:00
parent 74435801bb
commit 429ead837f
8 changed files with 160 additions and 31 deletions
@@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
/// <summary>
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
/// member fail here rather than silently browse as SQL Server.
/// </summary>
[Fact]
public void DefaultDialectSelector_carriesSqlServerOnly()
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
@@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests
// ---- provider availability ----
/// <summary>
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
/// reserved and must be reported as unavailable, naming itself.
/// </summary>
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
@@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
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");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
@@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
/// <summary>
/// A carried provider must get <b>past</b> 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.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldNotContain("not available in this build");
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
}
// ---- malformed configuration ----
[Theory]
@@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests
// ---- provider gate ----
/// <summary>
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> 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.
/// </summary>
[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<InvalidOperationException>(() => 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");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => 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));
}
/// <summary>
/// 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 <c>SqlServerDialect</c> would also construct without complaint and then speak
/// T-SQL at a Postgres server.
/// </summary>
[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<SqlProvider>(provider));
}
/// <summary>
/// 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.
/// </summary>
[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]