diff --git a/Directory.Packages.props b/Directory.Packages.props
index c9ad0acf..4740412e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -89,6 +89,12 @@
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
+
+
+
diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index 9d32457f..a3988fb0 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -12,9 +12,20 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
`SELECT`s on a timer and turns cells into tag values.
-**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares
-`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends** —
-authoring one fails the driver rather than silently falling back.
+**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
+(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
+`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain
+**reserved names, not working backends** — authoring one fails the driver rather than silently
+falling back.
+
+Two provider-specific things are worth knowing before you author against them:
+
+- **Identifier case folds, and the catalog gate absorbs it.** Postgres lower-cases and Oracle
+ upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own
+ spelling, so an authored `widgets` binds to Oracle's `WIDGETS` rather than failing.
+- **Oracle `NUMBER` infers as `Float64`.** Column metadata does not carry precision/scale to the
+ type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
+ (see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
@@ -94,7 +105,7 @@ ignoring it later.
| Key | Default | Notes |
|---|---|---|
-| `provider` | `SqlServer` | The only provider v1 constructs. |
+| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. |
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
@@ -132,7 +143,9 @@ The two classes of authored string are handled differently, and deliberately so:
concatenated into command text.
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
- against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command.
+ against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`**
+ views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and
+ dialect-quoted before they reach a command.
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports
@@ -152,6 +165,10 @@ can commit columns as tags. A committed leaf carries **schema, table and column*
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
typed editor opened empty.
+Browse works against all three constructed providers; the catalog query is the dialect's own
+(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
+themselves excluded — and the `ALL_*` views on Oracle).
+
## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md
index f15a8ac4..29662df5 100644
--- a/docs/plans/2026-07-24-driver-expansion-tracking.md
+++ b/docs/plans/2026-07-24-driver-expansion-tracking.md
@@ -154,12 +154,12 @@ SQL poll reuses the always-on central SQL Server. **Both shipped.**
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
-- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
+- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped. ~~Postgres/ODBC remain deferred to P2/P3 behind the same seam.~~ **Postgres + Oracle shipped 2026-08-05** via managed providers (Npgsql 10.0.3, ODP.NET Core 23.26.300) — dialect-owned parameter markers/bind names + Oracle `BindByName`, `INFORMATION_SCHEMA` vs `ALL_*` catalog SQL, provider gates widened, AdminUI dropdown, env-gated docker suites (postgres:17-alpine / gvenzl/oracle-free:23-slim). The live suites found + fixed ORA-01745 (Oracle rejects reserved-word bind names — `:table` — so the first Oracle build was structurally broken until binds were `p_`-prefixed). MySql/Odbc stay reserved. See [`2026-08-05-sql-postgres-oracle-design.md`](2026-08-05-sql-postgres-oracle-design.md).
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
persisted `connectionString` (`1919a8e5`).
-- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
- shipped; Postgres/ODBC deferred behind `ISqlDialect`).
+- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server,
+ **PostgreSQL and Oracle** shipped behind `ISqlDialect`; MySql/Odbc reserved).
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
@@ -312,7 +312,8 @@ Both CI-simulatable on the shared docker host, no hardware.
pass-through) CLOSED 2026-08-05 — see the Wave 0 section above.
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
- `28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
+ `28c28667`). **Postgres + Oracle dialects shipped 2026-08-05** (managed providers, see the SQL
+ poll section above); remaining SQL work is optional follow-on — MySql/Odbc dialects behind
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and
live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506).
diff --git a/docs/plans/2026-08-05-sql-postgres-oracle-design.md b/docs/plans/2026-08-05-sql-postgres-oracle-design.md
new file mode 100644
index 00000000..608cba4c
--- /dev/null
+++ b/docs/plans/2026-08-05-sql-postgres-oracle-design.md
@@ -0,0 +1,134 @@
+# 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__` 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).
diff --git a/docs/plans/2026-08-05-sql-postgres-oracle.md b/docs/plans/2026-08-05-sql-postgres-oracle.md
new file mode 100644
index 00000000..5ec6f518
--- /dev/null
+++ b/docs/plans/2026-08-05-sql-postgres-oracle.md
@@ -0,0 +1,210 @@
+# Sql Postgres + Oracle providers Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
+> (opus implementers per user instruction).
+
+**Goal:** Make `SqlProvider.Postgres` and `SqlProvider.Oracle` working backends via managed
+ADO.NET providers (Npgsql, Oracle.ManagedDataAccess.Core).
+
+**Architecture:** See `docs/plans/2026-08-05-sql-postgres-oracle-design.md` (this branch). Two new
+dialects in the existing `.Dialect` project; dialect-owned parameter markers/bind-names +
+`ConfigureCommand` (Oracle BindByName); provider gates widened; AdminUI dropdown; env-gated docker
+integration suites. Chrome/AdminUI live gate is DEFERRED to the operator.
+
+**Tech Stack:** .NET 10, central package management (`Directory.Packages.props`), xUnit v3 +
+Shouldly, SQLite falsifiability control.
+
+**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/sql-multi-provider`
+(branch `feature/sql-multi-provider`). All paths below relative to it.
+
+---
+
+### Task 0: Package pins
+
+**Classification:** trivial
+**Estimated implement time:** ~3 min
+**Parallelizable with:** Task 1, Task 5
+
+**Files:**
+- Modify: `Directory.Packages.props`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj`
+
+Add `PackageVersion` entries for `Npgsql` (alphabetical slot after `MQTTnet`) and
+`Oracle.ManagedDataAccess.Core` (after the `OPCFoundation.*` block) — latest stable on the feed
+(`dotnet package search --exact-match` to confirm; Npgsql 9.x line, ODP.NET Core 23.x line;
+both must target net8.0+). Add both `PackageReference`s (version-less) to the `.Dialect` csproj
+under the existing `Microsoft.Data.SqlClient` reference, extending that ItemGroup's
+rationale comment. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` must come back clean WITH the audit
+active — if NU1903 fires on a transitive dep, apply the surgical direct-pin pattern documented at
+`Directory.Packages.props:118-145`, never a suppress. Commit
+`build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only)`.
+
+### Task 1: Dialect-owned parameter naming + `ConfigureCommand`
+
+**Classification:** high-risk
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 0, Task 5
+
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs` (:21-25 consts,
+ :143, :151, :188)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs` (bind names alongside
+ markers if the plan record needs both)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs` (:379-405 — ConfigureCommand
+ call + bind-name copy)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs` (:97-113, `Bind` :181-188)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs` (ConfigureCommand)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs` (ConfigureCommand)
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs` (implement new
+ members; keep linked copy in Browser.Tests compiling)
+- Test: `SqlGroupPlannerTests.cs`, `SqlPollReaderTests.cs`, `SqlCatalogGateDriverTests.cs` (+ a
+ colon-marker fake dialect in the test tree)
+
+Per design §2 bullet 2. `ISqlDialect` gains `ParameterMarker(string baseName)`,
+`ParameterBindName(string baseName)`, `ConfigureCommand(DbCommand)`; SqlServerDialect returns
+`"@"+name` / `"@"+name` / no-op — **behaviour today must be byte-identical** (existing planner
+SQL-text assertions must pass untouched; if any needs editing, that is a regression, stop).
+Planner takes markers from its dialect; catalog SQL text and loader bind names route through the
+dialect so the two can never disagree. TDD: the colon-marker fake proves markers flow (observed
+red against the `@k` consts first). Every `CreateCommand` site calls `ConfigureCommand`
+immediately after creation. Commit
+`refactor(sql): dialect-owned parameter markers, bind names and command configuration`.
+
+### Task 2: `PostgresDialect`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 3, Task 5
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs`
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs` (new)
+
+Per design §2 Postgres bullet. `Factory => NpgsqlFactory.Instance`; `Provider =>
+SqlProvider.Postgres`; `@` markers/bind names; `"`-quoting `""`-escaped, MaxIdentifierLength 63,
+same control-char/Unicode-Format rejection discipline as `SqlServerDialect.QuoteIdentifier`
+(:88-114); `LivenessSql "SELECT 1"`; limit `""`/`" LIMIT 1"`; catalog SQL over
+`information_schema.schemata/tables/columns` excluding `pg_catalog` + `information_schema`,
+`DefaultSchemaSql "SELECT current_schema()"` (parameter markers in catalog SQL via the Task 1
+members); `MapColumnType` per design (unknown → the same fallback SqlServerDialect uses — read it
+first). Tests mirror `SqlServerDialectTests` structure: quoting theory, SQL text, type-map theory,
+marker members. Commit `feat(sql): PostgresDialect (Npgsql)`.
+
+### Task 3: `OracleDialect`
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Task 2, Task 5
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs`
+- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs` (new)
+
+Per design §2 Oracle bullet. `OracleClientFactory.Instance`; `:` markers, **bare** bind names;
+`ConfigureCommand` casts to `OracleCommand` and sets `BindByName = true` (the test constructs a
+real `OracleCommand` and asserts the flag — this is the load-bearing test of the whole branch;
+also assert a foreign `DbCommand` passes through untouched). Quoting `"`/`""`, max 128;
+`LivenessSql "SELECT 1 FROM DUAL"`; limit `""`/`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL:
+`ALL_USERS` schemas, `ALL_TABLES` ∪ `ALL_VIEWS` tables, `ALL_TAB_COLUMNS` columns,
+`sys_context('userenv','current_schema')` default schema — markers via `:schema`/`:table` through
+the Task 1 members; `MapColumnType`: `NUMBER`→Float64 (doc the DeclaredType escape hatch in the
+XML doc comment), `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`/`CLOB`,
+`DATE`/`TIMESTAMP*`, `RAW`, plus `FLOAT`/`INTEGER` synonyms. Commit
+`feat(sql): OracleDialect (ODP.NET managed, BindByName)`.
+
+### Task 4: Widen the provider gates
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none
+
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs` (:208-215)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs` (:126-127,
+ :146-151 comment)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs` (doc comments)
+- Test: `SqlDriverFactoryTests.cs` (:133-135 → MySql as rejected control; new: Postgres/Oracle
+ construct with their dialects), `SqlDriverBrowserTests.cs` (:188-196 same flip)
+
+TDD: red first — author `{"provider":"Postgres"}` and assert construction reaches the
+connection-string resolver (or dialect type assertion via the binding) instead of the
+not-available throw. Rejection message updated to name the three supported providers. Commit
+`feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved`.
+
+### Task 5: AdminUI dropdown + docs
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 1, Task 2, Task 3
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor`
+ (:18-25)
+- Modify: `docs/drivers/Sql.md` (:15-18, :96 provider row, browse section)
+- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend the existing Sql driver-form
+ model test file (find `SqlDriverFormModelTests` or nearest equivalent) with round-trip of
+ `provider: Postgres` / `Oracle`
+
+Dropdown gains `Postgres` + `Oracle` options; helper text → "SQL Server, PostgreSQL and Oracle
+are supported; MySql/Odbc are reserved." Sql.md rewritten per design §5 including the Oracle
+`NUMBER`→Float64 note and the 12.2+ floor. Commit
+`feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs`.
+
+### Task 6: Integration fixtures + env-gated suites
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none
+
+**Files:**
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml`
+ + `seed-pg.sql`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml`
+ + `seed-oracle.sql`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs`,
+ `OracleReadTests.cs`
+
+Per design §4.2. Compose shapes mirror the existing mssql fixture (`Docker/docker-compose.yml`:
+project label `project: lmxopcua`, one-shot seed service, healthcheck): `postgres:17-alpine`
+port `15432:5432` env `POSTGRES_PASSWORD=${SQL_PG_PASSWORD}`; `gvenzl/oracle-free:23-slim` port
+`11521:1521` env `ORACLE_PASSWORD=${SQL_ORACLE_PASSWORD}` (gvenzl images take `APP_USER`/
+`APP_USER_PASSWORD` for a non-SYS user — use `otopcua`/the password env). Seeds create one
+KeyValue table + one WideRow table with a timestamp column, plus (Oracle) a deliberately
+UPPER-CASE table authored lower-case in the test to pin catalog case-folding. Test classes follow
+`SqlServerReadTests`/`SqlBlackholeTimeoutTests` conventions exactly: env vars
+`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`, `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/
+`SQL_ORACLE_PASSWORD`; `Assert.SkipWhen` absent; connection strings via each provider's builder
+obtained FROM THE FACTORY (`dialect.Factory.CreateConnectionStringBuilder()`) so the csproj keeps
+its no-direct-provider-reference discipline (`.IntegrationTests.csproj:29-33`). Legs per suite:
+initialize (liveness + catalog), KeyValue read, WideRow topByTimestamp read, browse-session
+walk, case-folding leg. Commit `test(sql): env-gated Postgres + Oracle integration suites`.
+
+### Task 7: Full-suite verification + bookkeeping
+
+**Classification:** small
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none
+
+`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors), full Sql unit + Browser.Tests + AdminUI.Tests +
+Configuration.Tests (the SqlCredentialGuard suites must stay green), integration suites compile +
+skip clean offline. Update `docs/plans/2026-07-24-driver-expansion-tracking.md` Sql row/deferral
+note. Commit.
+
+### Task 8: Fixture deploy + live integration run (main session)
+
+**Classification:** standard
+**Estimated implement time:** ~15 min (main session, after merge)
+
+Main session, not a subagent: rsync the two fixture dirs to `dohertj2@10.100.0.35:/opt/otopcua-sql-pg/`
+and `/opt/otopcua-sql-oracle/`, `docker compose up -d` each over ssh, then run
+`dotnet test --filter "FullyQualifiedName~PostgresRead|FullyQualifiedName~OracleRead"` with the
+env vars set. (oracle-free first pull is ~1.5 GB — allow time; if the host can't take it, record
+the suite as authored-but-not-yet-run.) Record results in the design doc §Outcome.
+
+**Chrome/AdminUI live gate — DEFERRED to the operator.** After merge the operator runs, in a
+chrome-capable session:
+`/superpowers-extended-cc:executing-plans docs/plans/2026-08-05-sql-postgres-oracle.md` — gate
+section: provider dropdown authoring, Test-connect, `/raw` browse + browse-commit against both
+fixtures at `http://localhost:9200`.
diff --git a/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json b/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json
new file mode 100644
index 00000000..de102425
--- /dev/null
+++ b/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json
@@ -0,0 +1,15 @@
+{
+ "planPath": "docs/plans/2026-08-05-sql-postgres-oracle.md",
+ "tasks": [
+ { "id": 0, "subject": "Task 0: Package pins", "status": "completed" },
+ { "id": 1, "subject": "Task 1: Dialect-owned parameter naming + ConfigureCommand", "status": "completed" },
+ { "id": 2, "subject": "Task 2: PostgresDialect", "status": "completed", "blockedBy": [0, 1] },
+ { "id": 3, "subject": "Task 3: OracleDialect", "status": "completed", "blockedBy": [0, 1] },
+ { "id": 4, "subject": "Task 4: Widen the provider gates", "status": "completed", "blockedBy": [2, 3] },
+ { "id": 5, "subject": "Task 5: AdminUI dropdown + docs", "status": "completed" },
+ { "id": 6, "subject": "Task 6: Integration fixtures + env-gated suites", "status": "completed", "blockedBy": [4], "note": "found+fixed ORA-01745 (ec90b7c4); both suites ran live locally (pg 5/5, oracle 6/6)" },
+ { "id": 7, "subject": "Task 7: Full-suite verification + bookkeeping", "status": "completed", "blockedBy": [5, 6] },
+ { "id": 8, "subject": "Task 8: Fixture deploy + live integration run (main session); chrome gate deferred to operator", "status": "pending", "blockedBy": [7] }
+ ],
+ "lastUpdated": "2026-08-05T00:00:00Z"
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
index ff397fcd..6cc9236b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// Every level is the dialect's catalog SQL ( /
/// / ) — nothing here
/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is
-/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId
-/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
+/// read verbatim and its schema / table parameters are bound as parameters under
+/// — so the bind names cannot drift from the markers the
+/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a
+/// hostile or merely awkward catalog name is inert here.
/// is deliberately not used on this path — the browse
/// never needs an identifier in text.
/// Connection ownership: this session owns the connection it is handed and closes it on
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// The TABLE_TYPE value marking a view rather than a base table.
private const string ViewTableType = "VIEW";
+ /// Base name of the schema parameter every dialect's catalog SQL carries.
+ private const string SchemaParameter = "schema";
+
+ /// Base name of the table parameter carries.
+ private const string TableParameter = "table";
+
///
/// The security class every browsed column reports. The Sql driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
{
var rows = await QueryAsync(
_dialect.ListTablesSql,
- command => Bind(command, "@schema", schema),
+ command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
static reader => (
Name: ReadString(reader, TableNameColumn),
Type: ReadOptionalString(reader, TableTypeColumn)),
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
_dialect.ListColumnsSql,
command =>
{
- Bind(command, "@schema", schema);
- Bind(command, "@table", table);
+ Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
+ Bind(command, _dialect.ParameterBindName(TableParameter), table);
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
var results = new List();
await using var command = _connection.CreateCommand();
+ // Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
+ // column query is exactly where positional binding swaps the schema and the table silently.
+ _dialect.ConfigureCommand(command);
command.CommandText = sql;
bind(command);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
index 2b0bd7a1..e67d5bd8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
///
/// Maps an authored onto the dialect that browses it, or
/// for a provider this build does not carry. Defaults to
- /// (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.
///
///
/// Reads one environment variable. Defaults to .
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
ConnectionStringEnvironmentPrefix + connectionStringRef;
///
- /// The providers this build can browse. v1 constructs SQL Server only; every other
- /// member is a reserved name, so it resolves to 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
+ /// members (, )
+ /// are reserved names, so they resolve to and the caller reports them as
+ /// unavailable rather than crashing on a null dialect.
+ /// Kept in step with SqlDriverFactoryExtensions.CreateDialect: 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.
///
/// The authored provider.
/// The dialect, or when this build does not carry the provider.
- 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,
+ };
///
///
@@ -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);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
index 2bca5d24..1e29f05c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
///
public sealed class SqlDriverConfigDto
{
- /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs.
+ /// Which ADO.NET provider/dialect backs this instance. Defaults to ; and are also constructed.
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
index 67879307..0dd44c55 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
@@ -1,21 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
-/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.
+///
+/// Which ADO.NET provider/dialect backs a Sql driver instance. Constructed: SqlServer, Postgres,
+/// Oracle — each over a fully managed provider package, no native client install. Reserved: MySql
+/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
+///
public enum SqlProvider
{
- /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs.
+ /// Microsoft SQL Server via Microsoft.Data.SqlClient.
SqlServer,
- /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.
+ /// PostgreSQL via the managed Npgsql provider.
Postgres,
- /// MySQL / MariaDB — reserved; not constructed in v1.
+ /// MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.
MySql,
- /// Generic ODBC — reserved for P2; not constructed in v1.
+ /// Generic ODBC — reserved; no dialect ships, so this is not constructed.
Odbc,
- /// Oracle — reserved; not constructed in v1.
+ /// Oracle Database via the fully managed Oracle.ManagedDataAccess.Core provider.
Oracle,
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index 31dae6ef..2dd04716 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
public interface ISqlDialect
{
- /// Which backend this dialect speaks. v1 constructs only.
+ ///
+ /// Which backend this dialect speaks. One implementation ships per constructed
+ /// member — ,
+ /// and .
+ ///
SqlProvider Provider { get; }
///
@@ -50,6 +54,58 @@ public interface ISqlDialect
/// The value cannot be a valid catalog identifier.
string QuoteIdentifier(string ident);
+ ///
+ /// The token that stands for a bound parameter in the command text — "@k0" for
+ /// SqlClient/Npgsql, ":p_k0" for ODP.NET — given the caller's bare base name ("k0",
+ /// "w", "schema").
+ /// Why the marker and the bind name are two members. They are the same string on every
+ /// provider but Oracle, where the SQL text carries :p_schema while
+ /// is the bare schema. Modelling one and deriving the
+ /// other means a call site guessing at a convention it does not own — the exact defect this seam
+ /// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
+ /// bind-by-position, as a silently mis-bound value) rather than a compile error.
+ /// The base name is caller-owned and never operator input: the poll planner mints
+ /// k0…kN/w, the catalog path uses schema/table. Authored values
+ /// ride the parameter; authored identifiers go through . Nothing
+ /// else reaches a command text.
+ ///
+ /// The bare base name, without any provider prefix.
+ /// The marker to embed in the SQL text.
+ string ParameterMarker(string baseName);
+
+ ///
+ /// The value to assign to for the same
+ /// — "@k0" for SqlClient-style providers (which expect the marker
+ /// verbatim), the bare "k0" for ODP.NET-style ones (whose documented convention is a
+ /// prefix-less bind name).
+ /// Always paired with over the same base name: a plan
+ /// carries both lists and every binding site copies them index-for-index rather than
+ /// re-deriving either from the other.
+ ///
+ /// The bare base name, without any provider prefix.
+ /// The parameter name to bind under.
+ string ParameterBindName(string baseName);
+
+ ///
+ /// Applies provider-specific setup to a freshly created , before its
+ /// parameters are added or it is executed. A no-op for providers that need none.
+ /// Oracle uses this to set OracleCommand.BindByName = true, and that call is the
+ /// sharpest correctness edge on this seam: ODP.NET binds by position by default, so a
+ /// multi-parameter statement (the planner's IN (:p_k0, :p_k1, …) list, the catalog path's
+ /// schema + table pair) would bind values to the wrong markers whenever the parameter
+ /// order and the marker order differ — publishing one tag's value onto another tag's node with no
+ /// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
+ /// browse path — therefore calls this immediately after CreateCommand() and before the
+ /// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
+ /// ones are indifferent: SqlDriverProbe calls it anyway for uniformity,
+ /// SqlDriver.PingAsync does not.)
+ /// Implementations must tolerate any they are handed: the
+ /// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
+ /// provider type must do so defensively rather than throwing.
+ ///
+ /// The command to configure.
+ void ConfigureCommand(DbCommand command);
+
/// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL).
string LivenessSql { get; }
@@ -105,10 +161,19 @@ public interface ISqlDialect
///
string DefaultSchemaSql { get; }
- /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema.
+ ///
+ /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
+ /// parameter under the base name schema, spelled with this dialect's own
+ /// (@schema on SqlClient, :p_schema on ODP.NET); callers bind
+ /// it under of the same base name.
+ ///
string ListTablesSql { get; }
- /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table.
+ ///
+ /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
+ /// parameters under the base names schema and table, spelled and bound exactly as
+ /// describes.
+ ///
string ListColumnsSql { get; }
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
new file mode 100644
index 00000000..a6fd198e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
@@ -0,0 +1,301 @@
+using System.Data.Common;
+using System.Globalization;
+using Oracle.ManagedDataAccess.Client;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// Oracle Database dialect over the fully managed ODP.NET provider
+/// (Oracle.ManagedDataAccess.Core) — no Instant Client, no native library, no bitness constraint.
+/// Double-quote identifier quoting, : parameter markers with colon-less bind names, the
+/// ALL_* catalog views in place of the INFORMATION_SCHEMA Oracle does not have, and
+/// FETCH FIRST row limiting.
+/// Supported floor: Oracle 12.2. Two things pin it — FETCH FIRST n ROWS ONLY arrived
+/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a
+/// long-but-legal-here identifier would be rejected by the server rather than by
+/// , which is a clear error at the right layer, not a silent wrong answer.
+/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
+/// the AdminUI browse session.
+///
+public sealed class OracleDialect : ISqlDialect
+{
+ ///
+ /// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog
+ /// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded.
+ ///
+ internal const int MaxIdentifierLength = 128;
+
+ ///
+ public SqlProvider Provider => SqlProvider.Oracle;
+
+ ///
+ public DbProviderFactory Factory => OracleClientFactory.Instance;
+
+ ///
+ /// Prepended to every Oracle bind name. It is what keeps a bind name from colliding with an
+ /// Oracle reserved word, which is a hard error rather than a style problem — see
+ /// .
+ ///
+ private const string BindPrefix = "p_";
+
+ ///
+ /// Oracle markers are :-prefixed and namespaced — ParameterMarker("k0") is
+ /// ":p_k0".
+ ///
+ ///
+ /// The p_ is load-bearing, and was found live. Oracle rejects a bind variable
+ /// whose name is a reserved word with ORA-01745: invalid host/bind variable name — and the
+ /// catalog readers' own base names include table (SqlCatalogLoader /
+ /// SqlBrowseSession bind schema + table). So an un-prefixed
+ /// could not execute at all: every Oracle driver instance failed its
+ /// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's
+ /// columns threw. Caught by OracleReadTests against a real database; invisible to any offline
+ /// test, because the SQL is only ever parsed by a server.
+ /// Prefixing everything rather than maintaining a reserved-word list is deliberate:
+ /// the list is long, version-dependent, and a future base name (date, user,
+ /// comment, level…) would re-open the same hole silently. A prefixed name can never be
+ /// a reserved word, and the marker and the bind name are derived from the same constant, so they
+ /// cannot drift apart.
+ ///
+ /// The bare base name.
+ /// The :-prefixed, namespaced marker.
+ public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
+
+ ///
+ /// The same namespaced name without the : — ODP.NET's documented convention for
+ /// , and deliberately not
+ /// . This is the divergence that makes the two members exist at all
+ /// (see ).
+ ///
+ /// The bare base name.
+ /// The colon-less bind name.
+ public string ParameterBindName(string baseName) => BindPrefix + baseName;
+
+ ///
+ /// Sets OracleCommand.BindByName = true. This is the sharpest correctness edge in the
+ /// driver's provider support.
+ /// ODP.NET binds by position by default: parameter 0 fills the first marker in the
+ /// statement, parameter 1 the second, and so on, regardless of the names. Every statement this
+ /// driver builds with more than one marker would then be a coin flip — the planner's
+ /// IN (:p_k0, :p_k1, …) key list, and 's schema + table
+ /// pair — and the failure mode is not an exception but a successful query over the wrong rows,
+ /// publishing one tag's value onto another tag's node. Worse still,
+ /// names :p_schematwice (once per branch of its
+ /// UNION ALL) and is bound once: under positional binding that statement cannot even execute,
+ /// because the second occurrence has no parameter to take.
+ /// Must be called while the parameter collection is still empty — which is exactly what every
+ /// call site does (immediately after CreateCommand(), before the first bind).
+ /// A foreign command passes through untouched rather than throwing. The dialect can be
+ /// handed a from another provider — an instrumented or substituted factory in
+ /// a shared test harness is the realistic case — and an InvalidCastException there would fail a
+ /// poll for a reason that has nothing to do with the database. There is nothing to configure on a
+ /// non-ODP.NET command anyway: every other provider this repo speaks binds by name already.
+ ///
+ /// The command to configure; only an is touched.
+ public void ConfigureCommand(DbCommand command)
+ {
+ if (command is OracleCommand oracle) oracle.BindByName = true;
+ }
+
+ /// Oracle has no bare SELECT; every projection needs a table, and DUAL is the one-row one.
+ public string LivenessSql => "SELECT 1 FROM DUAL";
+
+ ///
+ /// Empty — Oracle 12c+ spells the row limit at the end of the statement (see
+ /// ). Anything here would land between SELECT and the column
+ /// list, where Oracle has no limit syntax at all.
+ ///
+ public string SingleRowLimitPrefix => string.Empty;
+
+ ///
+ /// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
+ /// finished statement.
+ ///
+ ///
+ /// Deliberately not the older WHERE ROWNUM = 1: ROWNUM is assigned before
+ /// ORDER BY runs, so it would take an arbitrary row and then sort it — silently returning the
+ /// wrong row for the topByTimestamp selector, whose entire purpose is "the newest one". Making
+ /// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the
+ /// statement shape, which this seam deliberately leaves with the planner.
+ ///
+ public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
+
+ ///
+ /// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
+ /// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
+ ///
+ ///
+ /// Broader than the SQL Server dialect's DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES,
+ /// which lists only schemas that own an object: ALL_USERS includes empty ones. Harmless here —
+ /// the list is used to resolve an authored schema name to the catalog's own spelling, and a schema
+ /// holding no tables simply resolves and then matches no relation.
+ ///
+ public string ListSchemasSql =>
+ "SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
+
+ ///
+ /// sys_context('userenv','current_schema') is the schema an unqualified name resolves in for
+ /// this session — which is what the catalog gate asks, and it correctly follows an
+ /// ALTER SESSION SET CURRENT_SCHEMA rather than reporting the login name.
+ ///
+ public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
+
+ ///
+ /// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
+ /// INFORMATION_SCHEMA.TABLES carries both. The synthesised TABLE_TYPE literals are the
+ /// INFORMATION_SCHEMA spellings the browser compares against to mark a view node.
+ ///
+ ///
+ /// UNION ALL rather than UNION: the two sources are disjoint by construction (a name
+ /// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort.
+ /// Note this statement names :p_schema twice and is bound once — see
+ /// .
+ ///
+ public string ListTablesSql =>
+ "SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " +
+ "UNION ALL " +
+ "SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " +
+ "ORDER BY TABLE_NAME";
+
+ ///
+ /// Columns of one relation, from ALL_TAB_COLUMNS — which covers views as well as tables, so it
+ /// serves both halves of . column_id is Oracle's
+ /// ORDINAL_POSITION.
+ ///
+ ///
+ /// ALL_TAB_COLUMNS.NULLABLE is Y/N where INFORMATION_SCHEMA.IS_NULLABLE is
+ /// YES/NO, so it is translated rather than merely aliased: a column that carries a
+ /// shared name across dialects has to carry shared values too, or the first consumer to read
+ /// it gets a different answer per provider.
+ ///
+ public string ListColumnsSql =>
+ "SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " +
+ "CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " +
+ "FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id";
+
+ ///
+ /// Double-quotes one identifier part, doubling any embedded " — the Oracle escape rule, and the
+ /// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the
+ /// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged.
+ ///
+ ///
+ /// Case matters once an identifier is quoted. Oracle upper-cases unquoted identifiers but
+ /// takes a quoted one literally, so "speed" and "SPEED" are different columns. This is
+ /// safe here only because the catalog gate has already substituted the catalog's own spelling
+ /// (ALL_TAB_COLUMNS.column_name, i.e. what the server actually stored) before anything reaches
+ /// this method — an operator who authored speed against an Oracle SPEED column resolves
+ /// to SPEED at Initialize, and it is SPEED that gets quoted.
+ /// Rejects (all as ): null / empty / all-whitespace; longer
+ /// than ; any control character (which covers embedded NUL, and the
+ /// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
+ /// character — bidi overrides, zero-width spaces, soft hyphen,
+ /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line or
+ /// an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source class,
+ /// CVE-2021-42574) — the same log/audit-integrity concern that motivates the control-character rule.
+ /// The rejection messages deliberately do not echo the offending value — it is untrusted
+ /// input and this exception's text reaches the driver log.
+ /// This is a backstop, not the only defence — see
+ /// on the catalog gate. The rules below still run, and must
+ /// hold for any future caller that has no catalog to check against.
+ ///
+ /// The bare, unquoted identifier part.
+ /// The double-quoted identifier.
+ /// The value cannot be a valid Oracle identifier.
+ public string QuoteIdentifier(string ident)
+ {
+ if (string.IsNullOrWhiteSpace(ident))
+ throw new ArgumentException(
+ "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
+
+ if (ident.Length > MaxIdentifierLength)
+ throw new ArgumentException(
+ $"An Oracle identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
+ nameof(ident));
+
+ foreach (var ch in ident)
+ {
+ if (char.IsControl(ch))
+ throw new ArgumentException(
+ "A SQL identifier may not contain control characters (including NUL).", nameof(ident));
+
+ // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
+ // zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
+ if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
+ throw new ArgumentException(
+ "A SQL identifier may not contain Unicode format characters "
+ + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
+ }
+
+ return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
+ }
+
+ ///
+ /// Folds an ALL_TAB_COLUMNS.DATA_TYPE family name onto a .
+ ///
+ ///
+ /// NUMBER maps to , and that is a deliberate
+ /// under-specification. Oracle has one numeric type: an integer column, a currency column and a
+ /// scientific measurement are all NUMBER, differing only in the precision/scale this method
+ /// never sees — its whole input is the family name. Choosing an integral driver type would truncate
+ /// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a
+ /// NUMBER(38) beyond 2^53 loses low-order digits in a double, and an integer-valued
+ /// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
+ /// an explicit "type" on the tag (Int64 for a true integer key, String to keep an
+ /// exact high-precision decimal), which overrides this inference.
+ /// TIMESTAMP is matched by prefix, unlike every other entry. Oracle reports the
+ /// fractional-seconds precision and the time-zone qualifier inside the family name —
+ /// TIMESTAMP(6), TIMESTAMP(6) WITH TIME ZONE, TIMESTAMP(6) WITH LOCAL TIME ZONE —
+ /// where INFORMATION_SCHEMA would report a bare timestamp. An exact-match table would
+ /// send all of them to the String fallback.
+ /// Fallback: an unrecognised family maps to and never
+ /// throws — a schema browse must render a table containing an exotic column (RAW,
+ /// LONG RAW, BLOB, BFILE, ROWID, XMLTYPE, an object type) rather
+ /// than crash on it. Null/blank input takes the same fallback. RAW/BLOB land here for
+ /// the same reason SQL Server's varbinary does: there is no binary driver type.
+ /// Deliberate non-mapping: the INTERVAL types are durations, not points in time,
+ /// and take the String fallback rather than being mistaken for a
+ /// — the same call the SQL Server dialect makes for
+ /// time.
+ /// Oracle's DATE carries a time-of-day component (it is not a date-only type), so
+ /// is the faithful mapping rather than a widening.
+ ///
+ /// The bare SQL type family name; matched case-insensitively.
+ /// The mapped driver data type, or when unrecognised.
+ public DriverDataType MapColumnType(string sqlDataType)
+ {
+ if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
+
+ // ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server
+ // dialect does) keeps the arms readable as the names an operator sees in the browse tree.
+ var family = sqlDataType.Trim().ToUpperInvariant();
+
+ // Prefix, not equality: the precision and time-zone qualifier live inside the family name.
+ if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime;
+
+ return family switch
+ {
+ // One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are
+ // resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are
+ // here so a server or gateway that does surface one is not silently mapped to String.
+ "NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION"
+ or "INTEGER" or "INT" or "SMALLINT"
+ or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64,
+
+ // The only single-precision storage type Oracle has.
+ "BINARY_FLOAT" => DriverDataType.Float32,
+
+ "VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR"
+ or "CLOB" or "NCLOB" => DriverDataType.String,
+
+ // Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as
+ // NUMBER(1) or CHAR(1), which are invisible here and map by their own family.
+ "BOOLEAN" => DriverDataType.Boolean,
+
+ "DATE" => DriverDataType.DateTime,
+
+ _ => DriverDataType.String,
+ };
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
new file mode 100644
index 00000000..eaa5a9b6
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs
@@ -0,0 +1,251 @@
+using System.Data.Common;
+using System.Globalization;
+using Npgsql;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// PostgreSQL dialect (design §2), over the fully managed Npgsql ADO.NET provider — no native
+/// client install anywhere. Double-quote identifier quoting, information_schema catalog SQL, an
+/// end-of-statement LIMIT 1, and a type map over the type names
+/// information_schema.columns.data_type actually reports.
+/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
+/// the AdminUI browse session — the same contract SqlServerDialect holds to.
+///
+public sealed class PostgresDialect : ISqlDialect
+{
+ ///
+ /// PostgreSQL's identifier ceiling — NAMEDATALEN - 1, 63 by default. Enforced because the
+ /// server silently truncates anything longer, so a 70-character authored name would resolve to
+ /// a different object than it spells; rejecting it surfaces the config mistake instead.
+ ///
+ internal const int MaxIdentifierLength = 63;
+
+ ///
+ public SqlProvider Provider => SqlProvider.Postgres;
+
+ ///
+ public DbProviderFactory Factory => NpgsqlFactory.Instance;
+
+ ///
+ /// Npgsql markers are @-prefixed, exactly as SQL Server's are — ParameterMarker("k0")
+ /// is "@k0". (Npgsql also accepts :, but this dialect commits to one spelling so the
+ /// catalog SQL below and the planner's markers can never drift apart.)
+ ///
+ /// The bare base name.
+ /// The @-prefixed marker.
+ public string ParameterMarker(string baseName) => "@" + baseName;
+
+ ///
+ /// Identical to : NpgsqlParameter.ParameterName is matched against
+ /// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps
+ /// the parameter collection readable in a log and matches what the SQL text says.
+ ///
+ /// The bare base name.
+ /// The @-prefixed bind name.
+ public string ParameterBindName(string baseName) => "@" + baseName;
+
+ ///
+ /// No-op. NpgsqlCommand binds by name out of the box, so there is nothing Postgres needs set on
+ /// a fresh command — the member exists for Oracle's BindByName (see
+ /// ). It deliberately does not cast
+ /// , so a substituted or instrumented factory's command passes through
+ /// untouched rather than throwing.
+ ///
+ /// The command to configure; untouched here.
+ public void ConfigureCommand(DbCommand command)
+ {
+ // Intentionally empty: see the summary.
+ }
+
+ ///
+ public string LivenessSql => "SELECT 1";
+
+ ///
+ /// Empty — Postgres spells its row limit at the end of the statement, in
+ /// . The mirror image of T-SQL's TOP 1, which is exactly why
+ /// the seam carries both ends.
+ ///
+ public string SingleRowLimitPrefix => string.Empty;
+
+ ///
+ /// " LIMIT 1", leading space included so it appends straight onto the finished statement
+ /// (… ORDER BY "sample_ts" DESC LIMIT 1).
+ ///
+ public string SingleRowLimitSuffix => " LIMIT 1";
+
+ ///
+ /// The schemas holding relations this connection can actually see, system schemas excluded.
+ ///
+ ///
+ /// Sourced from information_schema.tables, not information_schema.schemata
+ /// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
+ /// Postgres' schemata view lists only schemas the current role owns, so a service
+ /// account granted USAGE + SELECT on someone else's schema — the normal way a read-only
+ /// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving
+ /// the list from the relations the principal can see answers the question the catalog gate is really
+ /// asking: "which schemas hold something I may read?"
+ /// What counts as a system schema.pg_catalog and information_schema are
+ /// named explicitly (design §2). pg_toast and the per-backend pg_temp_N /
+ /// pg_toast_temp_N namespaces are excluded by prefix as well: they hold storage internals and
+ /// session-local temp objects, can never contain an authored poll target, and a pg_temp_N in
+ /// particular names a different thing per session — offering it in the browse tree would present a
+ /// schema that vanishes. The ESCAPE clause makes the _ literal rather than the
+ /// single-character wildcard LIKE would otherwise treat it as.
+ /// Aliased "TABLE_SCHEMA" in double quotes — see .
+ ///
+ public string ListSchemasSql =>
+ "SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " +
+ "WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
+ "AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " +
+ "AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " +
+ "ORDER BY \"TABLE_SCHEMA\"";
+
+ ///
+ /// current_schema() is the first schema on the connection's search_path — the one an
+ /// unqualified object name resolves in, which is precisely the question the catalog gate asks.
+ ///
+ public string DefaultSchemaSql => "SELECT current_schema()";
+
+ ///
+ /// Tables + views in one schema, matching SqlServerDialect.ListTablesSql column for column
+ /// (TABLE_NAME, TABLE_TYPE, ordered by name) so both consumers read the same shape.
+ ///
+ ///
+ /// The aliases are quoted, and that is load-bearing.SqlCatalogLoader and
+ /// SqlBrowseSession both read every catalog column by name
+ /// (DbDataReader.GetOrdinal("TABLE_NAME")), while Postgres folds an unquoted output
+ /// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those
+ /// consumers ask for rather than relying on a provider's case-insensitive GetOrdinal
+ /// fallback.
+ /// The @schema marker is written literally here because
+ /// spells it identically; the two agree by construction on this dialect
+ /// (they diverge only on ODP.NET), and a test pins that they do.
+ ///
+ public string ListTablesSql =>
+ "SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " +
+ "FROM information_schema.tables " +
+ "WHERE table_schema = @schema ORDER BY \"TABLE_NAME\"";
+
+ ///
+ /// Columns of one table in ordinal order — the same three columns
+ /// SqlServerDialect.ListColumnsSql projects (COLUMN_NAME, DATA_TYPE,
+ /// IS_NULLABLE), quoted for the reason gives.
+ ///
+ public string ListColumnsSql =>
+ "SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " +
+ "is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " +
+ "WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position";
+
+ ///
+ /// Double-quotes one identifier part, doubling any embedded " — the SQL-standard escape rule
+ /// Postgres follows, and the only metacharacter that could terminate a quoted identifier early.
+ /// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through
+ /// unchanged.
+ ///
+ ///
+ /// Rejects (all as , mirroring
+ /// SqlServerDialect.QuoteIdentifier so a test written against one dialect fails the same way
+ /// against the other): null / empty / all-whitespace; longer than
+ /// ; any control character (which covers embedded NUL and the C0/C1
+ /// ranges that can truncate or corrupt a logged/audited statement); and any Unicode
+ /// character — bidi overrides, zero-width spaces, soft hyphen,
+ /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line
+ /// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source
+ /// class, CVE-2021-42574).
+ /// NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string
+ /// outright, so passing one through would fail the whole statement rather than just the one
+ /// identifier.
+ /// The rejection messages deliberately do not echo the offending value — it is untrusted
+ /// input and this exception's text reaches the driver log.
+ /// This is a backstop, not the only defence. Design §8.1's catalog gate
+ /// (SqlCatalogGate) resolves an authored table/column against the live catalog at Initialize
+ /// and substitutes the catalog's own spelling — which on Postgres is the lower-cased one an
+ /// unquoted CREATE TABLE produced — so on the poll path the value arriving here is a string
+ /// read back out of the catalog.
+ ///
+ /// The bare, unquoted identifier part.
+ /// The double-quoted identifier.
+ /// The value cannot be a valid PostgreSQL identifier.
+ public string QuoteIdentifier(string ident)
+ {
+ if (string.IsNullOrWhiteSpace(ident))
+ throw new ArgumentException(
+ "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
+
+ if (ident.Length > MaxIdentifierLength)
+ throw new ArgumentException(
+ $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
+ nameof(ident));
+
+ foreach (var ch in ident)
+ {
+ if (char.IsControl(ch))
+ throw new ArgumentException(
+ "A SQL identifier may not contain control characters (including NUL).", nameof(ident));
+
+ // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
+ // zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
+ if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
+ throw new ArgumentException(
+ "A SQL identifier may not contain Unicode format characters "
+ + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
+ }
+
+ return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
+ }
+
+ ///
+ /// Folds an information_schema.columns.data_type family name onto a
+ /// per design §3.7.
+ ///
+ ///
+ /// The input is the canonical, spelled-out name Postgres reports —
+ /// double precision, character varying, timestamp without time zone — never the
+ /// float8/varchar/timestamptz aliases and never a length
+ /// (character varying(50) is reported as character varying). The internal aliases are
+ /// accepted anyway, so a caller reading types from pg_catalog instead maps identically.
+ /// Precision caveat:numeric / decimal / money collapse to
+ /// , as they do on SQL Server. A Postgres numeric is
+ /// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
+ /// "type": "String" on the tag preserves the exact textual value when that matters.
+ /// Fallback: an unrecognised family maps to and never
+ /// throws — a schema browse must render a table containing an exotic column (json,
+ /// jsonb, inet, tsvector, an enum or composite reported as
+ /// USER-DEFINED, an array reported as ARRAY) rather than crash on it. Null/blank input
+ /// takes the same fallback, and so does bytea — matching how SQL Server's varbinary
+ /// falls back rather than pretending binary is a scalar.
+ /// Deliberate non-mappings. Postgres bit / bit varying are bit
+ /// strings, not booleans — mapping bit to the way
+ /// T-SQL's bit demands would silently coerce a multi-bit value, so they fall back.
+ /// time / time with time zone are times of day rather than points in time and take the
+ /// same fallback as T-SQL's time; interval is a duration, likewise.
+ /// uuid maps to , matching how SQL Server's
+ /// uniqueidentifier is handled.
+ ///
+ /// The bare SQL type family name; matched case-insensitively.
+ /// The mapped driver data type, or when unrecognised.
+ public DriverDataType MapColumnType(string sqlDataType)
+ {
+ if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
+
+ return sqlDataType.Trim().ToLowerInvariant() switch
+ {
+ "boolean" or "bool" => DriverDataType.Boolean,
+ "smallint" or "int2" => DriverDataType.Int16,
+ "integer" or "int" or "int4" => DriverDataType.Int32,
+ "bigint" or "int8" => DriverDataType.Int64,
+ "real" or "float4" => DriverDataType.Float32,
+ "double precision" or "float8"
+ or "numeric" or "decimal" or "money" => DriverDataType.Float64,
+ "text" or "character varying" or "varchar" or "character" or "char" or "bpchar"
+ or "name" or "uuid" => DriverDataType.String,
+ "date"
+ or "timestamp without time zone" or "timestamp with time zone"
+ or "timestamp" or "timestamptz" => DriverDataType.DateTime,
+ _ => DriverDataType.String,
+ };
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index 1f9b4c5b..aafce4e7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
///
-/// 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,
/// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map.
/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
///
public DbProviderFactory Factory => SqlClientFactory.Instance;
+ ///
+ /// T-SQL markers are @-prefixed — ParameterMarker("k0") is "@k0".
+ ///
+ /// The bare base name.
+ /// The @-prefixed marker.
+ public string ParameterMarker(string baseName) => "@" + baseName;
+
+ ///
+ /// Identical to : Microsoft.Data.SqlClient expects
+ /// to carry the same @ the statement does. (It also
+ /// accepts a bare name and prepends the @ itself, but spelling it out keeps the parameter
+ /// collection readable in a profiler trace and matches what the SQL text says.)
+ ///
+ /// The bare base name.
+ /// The @-prefixed bind name.
+ public string ParameterBindName(string baseName) => "@" + baseName;
+
+ ///
+ /// No-op. SqlCommand binds by name out of the box, so there is nothing SQL Server needs set on a
+ /// fresh command — the member exists for Oracle's BindByName (see
+ /// ).
+ ///
+ /// The command to configure; untouched here.
+ public void ConfigureCommand(DbCommand command)
+ {
+ // Intentionally empty: see the summary.
+ }
+
///
public string LivenessSql => "SELECT 1";
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
index dd9f264b..bf77262f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
@@ -21,8 +21,13 @@
+ the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type.
+ PostgresDialect and OracleDialect land here for the same reason, each returning its
+ provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully
+ managed ADO.NET drivers, so adding them costs no native client install anywhere. -->
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
index f0802333..7635112c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.
/// Every parameter is bound. Authored names reach the catalog queries as
-/// @schema/@table parameters — the same discipline the schema browser follows — so loading
-/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
+/// schema/table parameters — the same discipline the schema browser follows — so loading
+/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
+/// The bind names come from over those two base names, so
+/// they can never disagree with the markers in the dialect's own catalog SQL (@schema on SQL
+/// Server, :p_schema on Oracle).
/// Failures throw; they do not degrade to an empty catalog. An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
/// Column alias projects.
private const string ColumnNameColumn = "COLUMN_NAME";
+ /// Base name of the schema parameter every dialect's catalog SQL carries.
+ private const string SchemaParameter = "schema";
+
+ /// Base name of the table parameter carries.
+ private const string TableParameter = "table";
+
///
/// Reads the catalog slice covering .
///
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
- connection, dialect.ListSchemasSql, SchemaColumn,
+ connection, dialect, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
- connection, dialect.ListTablesSql, TableNameColumn,
- command => Bind(command, "@schema", schema),
+ connection, dialect, dialect.ListTablesSql, TableNameColumn,
+ command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
- connection, dialect.ListColumnsSql, ColumnNameColumn,
+ connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
- Bind(command, "@schema", schema);
- Bind(command, "@table", table);
+ Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
+ Bind(command, dialect.ParameterBindName(TableParameter), table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ dialect.ConfigureCommand(command);
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
/// Runs one catalog query and projects a single string column from every row.
private static async Task> QueryColumnAsync(
DbConnection connection,
+ ISqlDialect dialect,
string sql,
string columnName,
Action bind,
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ // Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
+ dialect.ConfigureCommand(command);
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
index a4ec55ef..5961e670 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
///
/// Selects the provider seam.
- /// v1 constructs 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.
+ /// This build constructs ,
+ /// and . and
+ /// 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.
+ /// Every arm returns that provider's OWN dialect — there is no fallback arm. A default
+ /// that resolved to would construct happily and then emit T-SQL
+ /// (INFORMATION_SCHEMA, TOP 1, @ markers) at a Postgres or Oracle server, which
+ /// fails at poll time as an unintelligible provider error rather than here as a config error.
///
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)}'."),
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
index 79c5b986..95222661 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ // Uniformly applied at every command site, even where this statement takes no
+ // parameters — a provider hook that is only *sometimes* honoured is the harder bug.
+ dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
index 73510d1d..1dfc08bf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// SqlGroupPlannerTests.
/// Injection boundary (design §8.1). Identifiers reach the command text through
/// and nothing else; every authored value becomes a
-/// @k0/@w marker with the value carried in . There is no
-/// interpolation of tag content into SQL anywhere in this type.
+/// k0/w parameter — spelled with the dialect's own
+/// (@k0 on SQL Server, :p_k0 on Oracle) — with the value carried in
+/// . There is no interpolation of tag content into SQL anywhere in
+/// this type.
+/// The base names are this planner's, the spelling is the dialect's. Each plan carries the
+/// markers it emitted and the matching s, so the
+/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
+/// ODP.NET (:p_k0 in the text, bare p_k0 on the parameter).
///
public static class SqlGroupPlanner
{
- /// The parameter-marker prefix for a key-value model's IN list.
- private const string KeyMarkerPrefix = "@k";
+ /// The base-name prefix for a key-value model's IN-list parameters (k0…kN).
+ private const string KeyBaseNamePrefix = "k";
- /// The single parameter marker for a wide-row model's row selector.
- private const string RowSelectorMarker = "@w";
+ /// The single parameter base name for a wide-row model's row selector.
+ private const string RowSelectorBaseName = "w";
///
/// Groups by source and emits one per group.
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
- var names = new List(members.Count);
+ var markers = new List(members.Count);
+ var bindNames = new List(members.Count);
var values = new List