Files
lmxopcua/docs/plans/2026-08-05-sql-postgres-oracle-design.md
T

135 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sql driver: Postgres + Oracle providers (managed drivers only) — design
**Status:** DONE 2026-08-05 — all eight tasks delivered and reviewed. Suites: Driver.Sql.Tests
355/355, Browser.Tests 75/75, IntegrationTests 33 skip-clean offline; **both new integration suites
also ran LIVE during development** against real local containers (postgres:17-alpine 5/5,
gvenzl/oracle-free:23-slim 6/6), which surfaced and fixed a provider-fatal defect no offline test
could see: **ORA-01745** — Oracle rejects a bind variable named after a reserved word (`:table`),
so every Oracle catalog load failed until OracleDialect namespaced all binds with a `p_` prefix
(`ec90b7c4`, pinned by a never-bare-base-name invariant theory). §4.3's chrome/AdminUI gate remains
with the operator; the shared-host fixture deploy + live run is recorded below when complete.
## 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).