docs(plan): Sql Postgres + Oracle provider design + implementation plan

This commit is contained in:
Joseph Doherty
2026-08-05 06:08:45 -04:00
parent de036c62aa
commit ab54fb013a
3 changed files with 352 additions and 0 deletions
@@ -0,0 +1,127 @@
# Sql driver: Postgres + Oracle providers (managed drivers only) — design
**Status:** DRAFT 2026-08-05.
## 1. Problem
`docs/drivers/Sql.md:15-18`: v1 constructs SQL Server only; `SqlProvider` declares `Postgres`,
`MySql`, `Odbc`, `Oracle` as reserved names that fail the driver. This branch makes **Postgres and
Oracle** working backends via fully managed ADO.NET providers — `Npgsql` and
`Oracle.ManagedDataAccess.Core` — no native/thin-client installs. `MySql`/`Odbc` stay reserved.
The seam already exists and is proven: `ISqlDialect` exposes an abstract `DbProviderFactory`
(`ISqlDialect.cs:38`), all runtime I/O goes through `DbConnection`/`DbCommand`/`DbParameter`
(`SqlPollReader.cs:379-389`), and the test-only `SqliteDialect` is the standing falsifiability
control that the planner emits dialect SQL rather than T-SQL. The concrete gaps:
1. **Parameter markers are hardcoded `@`** in two places: `SqlGroupPlanner.cs:21-25` (`@k`/`@w`)
and the catalog path (`SqlServerDialect.cs:56/:61` SQL text + `SqlCatalogLoader.cs:97-113` bind
names). Npgsql happens to accept `@`; Oracle requires `:`.
2. **Two provider gates** reject everything but SqlServer: `CreateDialect`
(`SqlDriverFactoryExtensions.cs:208-215`) and the browser's `DefaultDialectSelector`
(`SqlDriverBrowser.cs:126-127`).
3. **Oracle has no `INFORMATION_SCHEMA`** — catalog SQL must come from `ALL_USERS` /
`ALL_TABLES` + `ALL_VIEWS` / `ALL_TAB_COLUMNS`; liveness is `SELECT 1 FROM DUAL`; row-limit is
`FETCH FIRST 1 ROWS ONLY`.
4. **AdminUI** provider dropdown is single-option (`SqlDriverForm.razor:18-25`).
5. **Packages** — `Npgsql` and `Oracle.ManagedDataAccess.Core` are absent from
`Directory.Packages.props`.
## 2. Decisions
- **Both dialects live in the existing `ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` project**, which
exists precisely because provider packages are unavoidable there (`.Dialect.csproj:20-26`). Its
csproj gains the two `PackageReference`s; consumers get them transitively exactly as
`Microsoft.Data.SqlClient` flows today. No new projects, no `.slnx` churn.
- **Dialect-owned parameter naming.** `ISqlDialect` gains three members with SqlServer-compatible
semantics implemented on every dialect:
- `string ParameterMarker(string baseName)` — the token in SQL text (`"@k0"` / `":k0"`).
- `string ParameterBindName(string baseName)` — the `DbParameter.ParameterName` value (`"@k0"`
for SqlClient/Npgsql; bare `"k0"` for ODP.NET, its documented convention).
- `void ConfigureCommand(DbCommand command)` — default no-op; **OracleDialect sets
`OracleCommand.BindByName = true`** (ODP.NET defaults to bind-by-position, which would silently
mis-bind the planner's multi-parameter `IN` lists — this is the sharpest correctness edge in
the branch).
`SqlGroupPlanner` drops its `@k`/`@w` consts and takes markers from the dialect (bind names ride
`SqlQueryPlan` alongside, keeping `SqlPollReader.cs:398-405`'s copy-don't-derive discipline).
`SqlCatalogLoader` binds `schema`/`table` through the dialect instead of the literal `"@schema"`.
Every command creation site (`SqlPollReader`, `SqlCatalogLoader`, `SqlDriverProbe`,
`SqlBrowseSession`) calls `ConfigureCommand` after `CreateCommand`.
- **`PostgresDialect`** (`NpgsqlFactory.Instance`): `@` markers; `"`-quoting with `""` escape, max
identifier 63; `LivenessSql = "SELECT 1"`; limit prefix `""` / suffix `" LIMIT 1"`;
`INFORMATION_SCHEMA` catalog SQL with `pg_catalog`/`information_schema` schemas excluded and
`DefaultSchemaSql = "SELECT current_schema()"`; type map over Postgres names (`integer`,
`bigint`, `smallint`, `real`, `double precision`, `numeric`, `boolean`, `text`,
`character varying`, `timestamp*`, `uuid`, `bytea`, …).
- **`OracleDialect`** (`OracleClientFactory.Instance`): `:` markers, bare bind names, BindByName;
`"`-quoting with `""` escape, max identifier 128 (declares Oracle 12.2+ as the supported floor —
`FETCH FIRST` needs 12c anyway); `LivenessSql = "SELECT 1 FROM DUAL"`; limit suffix
`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL from `ALL_USERS` (schemas),
`ALL_TABLES` ∪ `ALL_VIEWS` (tables — matching `INFORMATION_SCHEMA.TABLES`' view inclusion),
`ALL_TAB_COLUMNS` (columns), `DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"`;
type map over Oracle names — **`NUMBER` maps to `Float64`** (precision/scale are not visible to
`MapColumnType(string)`; the per-tag `DeclaredType` override is the documented escape hatch),
plus `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`, `DATE`/`TIMESTAMP*`,
`CLOB`, `RAW`.
- **Case-folding rides the existing catalog gate.** Postgres lower-cases and Oracle upper-cases
unquoted identifiers; `SqlCatalogGate` already resolves every authored identifier to the
catalog's own spelling, so authored `widgets` binds to Oracle `WIDGETS`. The implementer verifies
the gate's comparison is case-insensitive and pins it per-dialect with tests.
- **Gates widen to exactly these two.** `CreateDialect` and `DefaultDialectSelector` add
`Postgres`/`Oracle`; `MySql`/`Odbc` keep the existing loud rejection (tests updated: the
"Postgres is rejected" assertions at `SqlDriverFactoryTests.cs:133-135` and
`SqlDriverBrowserTests.cs:188-196` flip to `MySql` as the still-reserved control).
- **AdminUI**: `SqlDriverForm.razor` dropdown gains `Postgres` and `Oracle` options; helper text
updated. No other UI change — Sql stays a single-connection driver with no device form
(`DriverFormMaps.cs:81-89`), `connectionStringRef` + `Sql__ConnectionStrings__<ref>` env
resolution is provider-agnostic already, and the `SqlConnectionStringPersisted` deploy gate needs
no change (it keys on the literal `connectionString` key, not the provider).
- **Packages**: `Directory.Packages.props` gains `Npgsql` (between `MQTTnet` and `Novell.*`) and
`Oracle.ManagedDataAccess.Core` (between the `OPCFoundation.*` block and `OpenTelemetry`), latest
stable versions on the feed. `CentralPackageTransitivePinningEnabled` is OFF by design, so if
restore surfaces an NU1903 in either package's transitive tree, the fix is the established
surgical direct-pin pattern (`Directory.Packages.props:118-145`), never a suppress.
- **Credential discipline holds automatically**: `SqlDriver` already renders endpoints via the
provider-neutral `DbConnectionStringBuilder` reading `host`/`data source` keys
(`SqlDriver.cs:47-51, :952-968`) and logs exception types only.
## 3. Out of scope
- `MySql`/`Odbc` dialects, write support (`IWritable` stays structurally absent, `allowWrites`
stays inert), the deferred `Query` tag model, connection pooling knobs, and Kerberos/wallet
auth schemes (connection-string concerns, owned by the operator's `Sql__ConnectionStrings__*`
env value).
## 4. Testing
1. **Unit.**
- `PostgresDialectTests` / `OracleDialectTests`: quoting (escape doubling, length caps,
control-char rejection — mirror `SqlServerDialectTests`), liveness/limit/catalog SQL text,
type maps, markers/bind names, and `ConfigureCommand` (Oracle: a real `OracleCommand` has
`BindByName == true` after the call; others: no-op).
- Planner/loader marker plumbing: a colon-marker fake dialect (or `SqliteDialect` extended)
proves the planner emits dialect markers and the reader binds the dialect's bind names —
the falsifiability control for gap 1.
- Gate tests updated as in §2 (MySql becomes the rejected control).
- The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism.
2. **Integration (env-gated, skip-clean, docker).** Two new fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/`, deployed to the shared
docker host per the established pattern:
- `postgres:17-alpine` (project `otopcua-sql-pg`, host port 15432) — env
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`.
- `gvenzl/oracle-free:23-slim` (project `otopcua-sql-oracle`, host port 11521) — env
`SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/`SQL_ORACLE_PASSWORD`.
Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded
schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog),
browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider-
reference discipline (`.IntegrationTests.csproj:29-33`) — packages arrive transitively.
3. **Live `/run` chrome gate — DEFERRED, run separately by the operator.** AdminUI legs (provider
dropdown authoring, Test-connect, `/raw` browse against the Postgres and Oracle fixtures,
browse-commit of a column). The executing session must NOT run this; it hands the operator the
command at the end.
## 5. Bookkeeping
`docs/drivers/Sql.md` (§providers table, config table, browse section),
`SqlProvider.cs` doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and
`docs/research/drivers/sql-poll.md` stays as-is (research is a record, not living doc).