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
@@ -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]