Files
lmxopcua/docs/plans/2026-08-05-sql-postgres-oracle-design.md
T
Joseph Doherty 4e4619514c docs(sql): chrome/AdminUI gate PASSED; records the Test-Connect provider-gate defect it found
Also adds the two fixture connection strings to both docker-dev centrals, so
the rig can author + browse the Postgres and Oracle fixtures on 10.100.0.35.
2026-08-05 07:55:12 -04:00

179 lines
13 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). Merged to master `d54f3c83`.
**Shared-host live run PASSED (2026-08-05, Task 8).** Both fixtures deployed to the shared docker
host (`/opt/otopcua-sql-pg` port 15432, `/opt/otopcua-sql-oracle` port 11521, both `project:
lmxopcua`-labelled, seeds exited 0) and left running; the env-gated suites ran against them from
this repo: **11/11 passed, 0 skipped** (5 Postgres + 6 Oracle — initialize/catalog, KeyValue,
WideRow `topByTimestamp`, catalog case-folding, browse walk). Env recipe used:
`SQL_PG_ENDPOINT=10.100.0.35:15432 SQL_PG_USER=otopcua SQL_PG_PASSWORD=<compose-up value>` +
`SQL_ORACLE_ENDPOINT=10.100.0.35:11521 SQL_ORACLE_USER=otopcua SQL_ORACLE_PASSWORD=<compose-up
value>` (service defaults `FREEPDB1`).
**§4.3's chrome/AdminUI gate PASSED (2026-08-05) — and it found a real defect.** Run against a
docker-dev rig rebuilt from master (both centrals on one image, provider assemblies confirmed
present in `/app`) with `Sql__ConnectionStrings__FixturePg` / `__FixtureOracle` added to central-1
**and** central-2 (`:9200` round-robins, so a browse can land on either node).
⚠️ **The gate's first finding: Test Connect was broken for BOTH new providers.**
`SqlDriverProbe.DefaultDialectFor` was never widened — it still constructed `SqlServerDialect`
only and threw `InvalidOperationException` for Postgres/Oracle, so a config that **deployed and
browsed fine failed Test Connect**. There are *three* dialect-selection sites and Task 4's `Files:`
block named two (`SqlDriverFactoryExtensions`, `SqlDriverBrowser`) plus the enum. No test caught
it because **every case in `SqlDriverProbeTests` builds its probe through `ForTest(...)`**, which
injects `_ => dialect` and never reaches the production switch — the parameterless constructor had
zero coverage. Fixed in `75a057f2`: switch widened arm-for-arm with the factory, plus an internal
seam covering the gate directly, a reserved-provider (MySql/Odbc) falsification control, and an
end-to-end production-constructor case. Red-first confirmed (3 failures) before the widening;
Driver.Sql.Tests then 361/361.
Legs, after the fix:
1. **Provider dropdown authoring ✅** — dropdown offers SqlServer/Postgres/Oracle; both saved and
persisted as `{"provider":"Postgres",…}` / `{"provider":"Oracle",…}`, i.e. serialized by **name**,
not ordinal (the driver-enum-serialization defect class does not recur here).
2. **Test-connect ✅** — Postgres `OK · 174 ms`, Oracle `OK · 386 ms`. **Negative control:** the
Oracle driver cross-wired to the Postgres connection string returns
`FAILED · … (ARGUMENTEXCEPTION)` — the provider itself rejecting the string, and *not*
`InvalidOperationException`, which proves the dialect was constructed and the green came from a
real round trip rather than an unconditional OK.
3. **`/raw` browse + browse-commit ✅** — Postgres browse discovered schema `sqlpoll` → `tag_values`
/ `latest_status` → per-column LEAFs; **the schema is owned by `sqlpoll_owner` while the poller
connects as `otopcua`**, so this is the live confirmation that `PostgresDialect.ListSchemasSql`
sources from `information_schema.tables` rather than `.schemata`. Oracle browse listed the PDB's
schemas → `OTOPCUA` → `TAG_VALUES`/`LATEST_STATUS` → LEAFs. One leaf committed per provider,
both persisting schema-qualified with the right type:
`{"model":"WideRow","table":"sqlpoll.tag_values","columnName":"num_value"}` [Double] and
`{"model":"WideRow","table":"OTOPCUA.TAG_VALUES","columnName":"NUM_VALUE"}` [Double].
## 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).