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
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (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.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> 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
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
/// unavailable rather than crashing on a null dialect.
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: 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.</para>
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
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,
};
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
@@ -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);
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
@@ -1,21 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
/// <summary>
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
/// </summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
Oracle,
}
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
/// <summary>
/// Which backend this dialect speaks. One implementation ships per constructed
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
/// </summary>
SqlProvider Provider { get; }
/// <summary>
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// 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,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> 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.</para>
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
/// <see cref="SqlProvider.Odbc"/> 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.</para>
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
/// </summary>
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)}'."),
};
/// <summary>