merge: Sql driver Postgres + Oracle providers (managed Npgsql/ODP.NET, dialect-owned binding, live-gated fixtures)

This commit is contained in:
Joseph Doherty
2026-08-05 07:28:29 -04:00
42 changed files with 3607 additions and 82 deletions
+11
View File
@@ -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. -->
<!-- Fully managed PostgreSQL ADO.NET provider for the Sql poll driver's PostgresDialect
(NpgsqlFactory.Instance). No native libpq / client install — the same "managed only"
constraint that rules out Odbc. Referenced by Driver.Sql.Dialect alongside
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
native lib/net10.0 TFM. -->
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
@@ -97,6 +103,11 @@
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- Fully managed ODP.NET Core for the Sql poll driver's OracleDialect
(OracleClientFactory.Instance). The ".Core" package is the 100%-managed driver — no Oracle
Instant Client / OCI native install, matching the Npgsql constraint above. Latest stable of
the 23.x (Oracle 23ai) line, which ships lib/net8.0 + netstandard2.1. -->
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.300" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
is pulled transitively at 1.9.0; pin to 1.15.3 (matches the Extensions.Hosting pin below),
applied via a direct PackageReference in the Cluster project so the server stack inherits it. -->
+22 -5
View File
@@ -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
@@ -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).
@@ -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__<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).
@@ -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 <id> --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`.
@@ -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"
}
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; 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 <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
/// <see cref="ISqlDialect.ParameterBindName"/> — 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.
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
/// never needs an identifier in text.</para>
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
private const string SchemaParameter = "schema";
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
private const string TableParameter = "table";
/// <summary>
/// The security class every browsed column reports. The <c>Sql</c> 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<T>();
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);
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
/// provider gets added without touching this class.
/// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how
/// a further provider gets added without touching this class.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
/// the caller reports it as unavailable rather than crashing on a null dialect.
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
/// unavailable rather than crashing on a null dialect.
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
/// against a database the AdminUI refuses to show them.</para>
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => null,
};
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
@@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
var literal = ReadPastedConnectionString(configJson);
var config = Deserialize(configJson, literal);
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
// Provider first: "this build cannot browse MySql" is true regardless of how the database is
// named, and answering it before touching the environment keeps the two failures from masking.
var dialect = _dialectSelector(config.Provider)
?? throw new InvalidOperationException(
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ $"'{SqlProvider.SqlServer}' only.");
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
var reference = config.ConnectionStringRef;
var connectionString = ResolveConnectionString(literal, reference);
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
@@ -1,21 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
/// <summary>
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
/// </summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
Oracle,
}
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
/// <summary>
/// Which backend this dialect speaks. One implementation ships per constructed
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
/// </summary>
SqlProvider Provider { get; }
/// <summary>
@@ -50,6 +54,58 @@ public interface ISqlDialect
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
string QuoteIdentifier(string ident);
/// <summary>
/// The token that stands for a bound parameter <b>in the command text</b> — <c>"@k0"</c> for
/// SqlClient/Npgsql, <c>":p_k0"</c> for ODP.NET — given the caller's bare base name (<c>"k0"</c>,
/// <c>"w"</c>, <c>"schema"</c>).
/// <para><b>Why the marker and the bind name are two members.</b> They are the same string on every
/// provider but Oracle, where the SQL text carries <c>:p_schema</c> while
/// <see cref="DbParameter.ParameterName"/> is the bare <c>schema</c>. 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 <em>silently mis-bound value</em>) rather than a compile error.</para>
/// <para>The base name is caller-owned and never operator input: the poll planner mints
/// <c>k0…kN</c>/<c>w</c>, the catalog path uses <c>schema</c>/<c>table</c>. Authored <em>values</em>
/// ride the parameter; authored <em>identifiers</em> go through <see cref="QuoteIdentifier"/>. Nothing
/// else reaches a command text.</para>
/// </summary>
/// <param name="baseName">The bare base name, without any provider prefix.</param>
/// <returns>The marker to embed in the SQL text.</returns>
string ParameterMarker(string baseName);
/// <summary>
/// The value to assign to <see cref="DbParameter.ParameterName"/> for the same
/// <paramref name="baseName"/> — <c>"@k0"</c> for SqlClient-style providers (which expect the marker
/// verbatim), the bare <c>"k0"</c> for ODP.NET-style ones (whose documented convention is a
/// prefix-less bind name).
/// <para>Always paired with <see cref="ParameterMarker"/> over the <em>same</em> base name: a plan
/// carries both lists and every binding site <b>copies</b> them index-for-index rather than
/// re-deriving either from the other.</para>
/// </summary>
/// <param name="baseName">The bare base name, without any provider prefix.</param>
/// <returns>The parameter name to bind under.</returns>
string ParameterBindName(string baseName);
/// <summary>
/// Applies provider-specific setup to a freshly created <see cref="DbCommand"/>, <b>before</b> its
/// parameters are added or it is executed. A no-op for providers that need none.
/// <para><b>Oracle uses this to set <c>OracleCommand.BindByName = true</c></b>, and that call is the
/// sharpest correctness edge on this seam: ODP.NET binds <em>by position</em> by default, so a
/// multi-parameter statement (the planner's <c>IN (:p_k0, :p_k1, …)</c> list, the catalog path's
/// <c>schema</c> + <c>table</c> 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 <c>CreateCommand()</c> and <b>before</b> the
/// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
/// ones are indifferent: <c>SqlDriverProbe</c> calls it anyway for uniformity,
/// <c>SqlDriver.PingAsync</c> does not.)</para>
/// <para>Implementations must tolerate <em>any</em> <see cref="DbCommand"/> 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.</para>
/// </summary>
/// <param name="command">The command to configure.</param>
void ConfigureCommand(DbCommand command);
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
string LivenessSql { get; }
@@ -105,10 +161,19 @@ public interface ISqlDialect
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
/// <summary>
/// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
/// parameter under the base name <c>schema</c>, spelled with this dialect's own
/// <see cref="ParameterMarker"/> (<c>@schema</c> on SqlClient, <c>:p_schema</c> on ODP.NET); callers bind
/// it under <see cref="ParameterBindName"/> of the same base name.
/// </summary>
string ListTablesSql { get; }
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
/// <summary>
/// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
/// parameters under the base names <c>schema</c> and <c>table</c>, spelled and bound exactly as
/// <see cref="ListTablesSql"/> describes.
/// </summary>
string ListColumnsSql { get; }
/// <summary>
@@ -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;
/// <summary>
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>colon-less</b> bind names, the
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
/// <c>FETCH FIRST</c> row limiting.
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> 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
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class OracleDialect : ISqlDialect
{
/// <summary>
/// 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.
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.Oracle;
/// <inheritdoc/>
public DbProviderFactory Factory => OracleClientFactory.Instance;
/// <summary>
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
/// <see cref="ParameterMarker"/>.
/// </summary>
private const string BindPrefix = "p_";
/// <summary>
/// Oracle markers are <c>:</c>-prefixed <b>and namespaced</b> — <c>ParameterMarker("k0")</c> is
/// <c>":p_k0"</c>.
/// </summary>
/// <remarks>
/// <para><b>The <c>p_</c> is load-bearing, and was found live.</b> Oracle rejects a bind variable
/// whose name is a reserved word with <c>ORA-01745: invalid host/bind variable name</c> — and the
/// catalog readers' own base names include <c>table</c> (<c>SqlCatalogLoader</c> /
/// <c>SqlBrowseSession</c> bind <c>schema</c> + <c>table</c>). So an un-prefixed
/// <see cref="ListColumnsSql"/> 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 <c>OracleReadTests</c> against a real database; invisible to any offline
/// test, because the SQL is only ever parsed by a server.</para>
/// <para>Prefixing <em>everything</em> rather than maintaining a reserved-word list is deliberate:
/// the list is long, version-dependent, and a future base name (<c>date</c>, <c>user</c>,
/// <c>comment</c>, <c>level</c>…) 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.</para>
/// </remarks>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>:</c>-prefixed, namespaced marker.</returns>
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
/// <summary>
/// The same namespaced name <b>without the <c>:</c></b> — ODP.NET's documented convention for
/// <see cref="DbParameter.ParameterName"/>, and deliberately <em>not</em>
/// <see cref="ParameterMarker"/>. This is the divergence that makes the two members exist at all
/// (see <see cref="ISqlDialect.ParameterBindName"/>).
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The colon-less bind name.</returns>
public string ParameterBindName(string baseName) => BindPrefix + baseName;
/// <summary>
/// Sets <c>OracleCommand.BindByName = true</c>. <b>This is the sharpest correctness edge in the
/// driver's provider support.</b>
/// <para>ODP.NET binds <em>by position</em> by default: parameter 0 fills the first marker in the
/// statement, parameter 1 the second, and so on, <em>regardless of the names</em>. Every statement this
/// driver builds with more than one marker would then be a coin flip — the planner's
/// <c>IN (:p_k0, :p_k1, …)</c> key list, and <see cref="ListColumnsSql"/>'s <c>schema</c> + <c>table</c>
/// pair — and the failure mode is not an exception but a <em>successful query over the wrong rows</em>,
/// publishing one tag's value onto another tag's node. Worse still,
/// <see cref="ListTablesSql"/> names <c>:p_schema</c> <b>twice</b> (once per branch of its
/// <c>UNION ALL</c>) and is bound once: under positional binding that statement cannot even execute,
/// because the second occurrence has no parameter to take.</para>
/// <para>Must be called while the parameter collection is still empty — which is exactly what every
/// call site does (immediately after <c>CreateCommand()</c>, before the first bind).</para>
/// <para><b>A foreign command passes through untouched rather than throwing.</b> The dialect can be
/// handed a <see cref="DbCommand"/> from another provider — an instrumented or substituted factory in
/// a shared test harness is the realistic case — and an <c>InvalidCastException</c> 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.</para>
/// </summary>
/// <param name="command">The command to configure; only an <see cref="OracleCommand"/> is touched.</param>
public void ConfigureCommand(DbCommand command)
{
if (command is OracleCommand oracle) oracle.BindByName = true;
}
/// <summary>Oracle has no bare <c>SELECT</c>; every projection needs a table, and <c>DUAL</c> is the one-row one.</summary>
public string LivenessSql => "SELECT 1 FROM DUAL";
/// <summary>
/// Empty — Oracle 12c+ spells the row limit at the <em>end</em> of the statement (see
/// <see cref="SingleRowLimitSuffix"/>). Anything here would land between <c>SELECT</c> and the column
/// list, where Oracle has no limit syntax at all.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
/// finished statement.
/// </summary>
/// <remarks>
/// Deliberately <b>not</b> the older <c>WHERE ROWNUM = 1</c>: <c>ROWNUM</c> is assigned <em>before</em>
/// <c>ORDER BY</c> runs, so it would take an arbitrary row and then sort it — silently returning the
/// wrong row for the <c>topByTimestamp</c> 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.
/// </remarks>
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
/// <summary>
/// Schemas visible to the connecting principal, from <c>ALL_USERS</c> (Oracle equates a schema with a
/// user), aliased to the <c>TABLE_SCHEMA</c> column name the catalog readers project by.
/// </summary>
/// <remarks>
/// Broader than the SQL Server dialect's <c>DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES</c>,
/// which lists only schemas that own an object: <c>ALL_USERS</c> 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.
/// </remarks>
public string ListSchemasSql =>
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
/// <summary>
/// <c>sys_context('userenv','current_schema')</c> is the schema an unqualified name resolves in for
/// this session — which is what the catalog gate asks, and it correctly follows an
/// <c>ALTER SESSION SET CURRENT_SCHEMA</c> rather than reporting the login name.
/// </summary>
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
/// <summary>
/// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
/// <c>INFORMATION_SCHEMA.TABLES</c> carries both. The synthesised <c>TABLE_TYPE</c> literals are the
/// <c>INFORMATION_SCHEMA</c> spellings the browser compares against to mark a view node.
/// </summary>
/// <remarks>
/// <c>UNION ALL</c> rather than <c>UNION</c>: 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.
/// <para>Note this statement names <c>:p_schema</c> twice and is bound <b>once</b> — see
/// <see cref="ConfigureCommand"/>.</para>
/// </remarks>
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";
/// <summary>
/// Columns of one relation, from <c>ALL_TAB_COLUMNS</c> — which covers views as well as tables, so it
/// serves both halves of <see cref="ListTablesSql"/>. <c>column_id</c> is Oracle's
/// <c>ORDINAL_POSITION</c>.
/// </summary>
/// <remarks>
/// <c>ALL_TAB_COLUMNS.NULLABLE</c> is <c>Y</c>/<c>N</c> where <c>INFORMATION_SCHEMA.IS_NULLABLE</c> is
/// <c>YES</c>/<c>NO</c>, so it is translated rather than merely aliased: a column that carries a
/// shared name across dialects has to carry shared <em>values</em> too, or the first consumer to read
/// it gets a different answer per provider.
/// </remarks>
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";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — 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.
/// </summary>
/// <remarks>
/// <para><b>Case matters once an identifier is quoted.</b> Oracle upper-cases unquoted identifiers but
/// takes a quoted one literally, so <c>"speed"</c> and <c>"SPEED"</c> are different columns. This is
/// safe here only because the catalog gate has already substituted the catalog's own spelling
/// (<c>ALL_TAB_COLUMNS.column_name</c>, i.e. what the server actually stored) before anything reaches
/// this method — an operator who authored <c>speed</c> against an Oracle <c>SPEED</c> column resolves
/// to <c>SPEED</c> at Initialize, and it is <c>SPEED</c> that gets quoted.</para>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> 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.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence</b> — see
/// <see cref="ISqlDialect.QuoteIdentifier"/> on the catalog gate. The rules below still run, and must
/// hold for any future caller that has no catalog to check against.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid Oracle identifier.</exception>
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), "\"");
}
/// <summary>
/// Folds an <c>ALL_TAB_COLUMNS.DATA_TYPE</c> family name onto a <see cref="DriverDataType"/>.
/// </summary>
/// <remarks>
/// <para><b><c>NUMBER</c> maps to <see cref="DriverDataType.Float64"/>, and that is a deliberate
/// under-specification.</b> Oracle has one numeric type: an integer column, a currency column and a
/// scientific measurement are all <c>NUMBER</c>, 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
/// <c>NUMBER(38)</c> beyond 2^53 loses low-order digits in a <c>double</c>, and an integer-valued
/// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
/// an explicit <c>"type"</c> on the tag (<c>Int64</c> for a true integer key, <c>String</c> to keep an
/// exact high-precision decimal), which overrides this inference.</para>
/// <para><b><c>TIMESTAMP</c> is matched by prefix</b>, unlike every other entry. Oracle reports the
/// fractional-seconds precision and the time-zone qualifier <em>inside</em> the family name —
/// <c>TIMESTAMP(6)</c>, <c>TIMESTAMP(6) WITH TIME ZONE</c>, <c>TIMESTAMP(6) WITH LOCAL TIME ZONE</c> —
/// where <c>INFORMATION_SCHEMA</c> would report a bare <c>timestamp</c>. An exact-match table would
/// send all of them to the String fallback.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>RAW</c>,
/// <c>LONG RAW</c>, <c>BLOB</c>, <c>BFILE</c>, <c>ROWID</c>, <c>XMLTYPE</c>, an object type) rather
/// than crash on it. Null/blank input takes the same fallback. <c>RAW</c>/<c>BLOB</c> land here for
/// the same reason SQL Server's <c>varbinary</c> does: there is no binary driver type.</para>
/// <para><b>Deliberate non-mapping:</b> the <c>INTERVAL</c> types are durations, not points in time,
/// and take the String fallback rather than being mistaken for a
/// <see cref="DriverDataType.DateTime"/> — the same call the SQL Server dialect makes for
/// <c>time</c>.</para>
/// <para>Oracle's <c>DATE</c> carries a time-of-day component (it is not a date-only type), so
/// <see cref="DriverDataType.DateTime"/> is the faithful mapping rather than a widening.</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
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,
};
}
}
@@ -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;
/// <summary>
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
/// <c>information_schema.columns.data_type</c> actually reports.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
/// </summary>
public sealed class PostgresDialect : ISqlDialect
{
/// <summary>
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
/// a different object than it spells; rejecting it surfaces the config mistake instead.
/// </summary>
internal const int MaxIdentifierLength = 63;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.Postgres;
/// <inheritdoc/>
public DbProviderFactory Factory => NpgsqlFactory.Instance;
/// <summary>
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
/// catalog SQL below and the planner's markers can never drift apart.)
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> 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.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
/// untouched rather than throwing.
/// </summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// Empty — Postgres spells its row limit at the end of the statement, in
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
/// the seam carries both ends.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// The schemas holding relations this connection can actually see, system schemas excluded.
/// </summary>
/// <remarks>
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
/// account granted <c>USAGE</c> + <c>SELECT</c> 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?"</para>
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
/// <c>pg_toast_temp_N</c> 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 <c>pg_temp_N</c> in
/// particular names a different thing per session — offering it in the browse tree would present a
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
/// </remarks>
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\"";
/// <summary>
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
/// </summary>
public string DefaultSchemaSql => "SELECT current_schema()";
/// <summary>
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
/// </summary>
/// <remarks>
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
/// <c>SqlBrowseSession</c> both read every catalog column by name
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> 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 <c>GetOrdinal</c>
/// fallback.</para>
/// <para>The <c>@schema</c> marker is written literally here because
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
/// </remarks>
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\"";
/// <summary>
/// Columns of one table in ordinal order — the same three columns
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
/// </summary>
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";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — 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.
/// </summary>
/// <remarks>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
/// against the other): null / empty / all-whitespace; longer than
/// <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL and the C0/C1
/// ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> 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).</para>
/// <para>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.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
/// read back out of the catalog.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid PostgreSQL identifier.</exception>
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), "\"");
}
/// <summary>
/// Folds an <c>information_schema.columns.data_type</c> family name onto a
/// <see cref="DriverDataType"/> per design §3.7.
/// </summary>
/// <remarks>
/// <para><b>The input is the canonical, spelled-out name Postgres reports</b> —
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
/// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
/// <c>"type": "String"</c> on the tag preserves the exact textual value when that matters.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>json</c>,
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
/// falls back rather than pretending binary is a scalar.</para>
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
/// <c>uniqueidentifier</c> is handled.</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
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,
};
}
}
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
/// <summary>
/// T-SQL markers are <c>@</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>"@k0"</c>.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>
/// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
/// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
/// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
/// collection readable in a profiler trace and matches what the SQL text says.)
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>
/// No-op. <c>SqlCommand</c> 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 <c>BindByName</c> (see
/// <see cref="ISqlDialect.ConfigureCommand"/>).
/// </summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
@@ -21,8 +21,13 @@
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
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. -->
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
@@ -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.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> 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.</para>
/// <c>schema</c>/<c>table</c> 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 <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
/// Server, <c>:p_schema</c> on Oracle).</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> 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
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
private const string SchemaParameter = "schema";
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
private const string TableParameter = "table";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
@@ -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
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
ISqlDialect dialect,
string sql,
string columnName,
Action<DbCommand> 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);
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
/// <see cref="SqlProvider.Odbc"/> remain reserved names on the shipped enum; authoring one is a config
/// mistake that must fail at construction with a message that says so, rather than silently degrading
/// to another backend's SQL dialect against a database that does not speak it.</para>
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
/// </summary>
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
=> provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
$"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
};
/// <summary>
@@ -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);
}
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <c>SqlGroupPlannerTests</c>.</para>
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
/// interpolation of tag content into SQL anywhere in this type.</para>
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
/// (<c>@k0</c> on SQL Server, <c>:p_k0</c> on Oracle) — with the value carried in
/// <see cref="SqlQueryPlan.Parameters"/>. There is no interpolation of tag content into SQL anywhere in
/// this type.</para>
/// <para><b>The base names are this planner's, the spelling is the dialect's.</b> Each plan carries the
/// markers it emitted <em>and</em> the matching <see cref="ISqlDialect.ParameterBindName"/>s, so the
/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
/// ODP.NET (<c>:p_k0</c> in the text, bare <c>p_k0</c> on the parameter).</para>
/// </summary>
public static class SqlGroupPlanner
{
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
private const string KeyMarkerPrefix = "@k";
/// <summary>The base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
private const string KeyBaseNamePrefix = "k";
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
private const string RowSelectorMarker = "@w";
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
private const string RowSelectorBaseName = "w";
/// <summary>
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> 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<string>(members.Count);
var markers = new List<string>(members.Count);
var bindNames = new List<string>(members.Count);
var values = new List<object?>(members.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
var keyValue = member.KeyValue
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
if (!seen.Add(keyValue)) continue;
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture);
markers.Add(dialect.ParameterMarker(baseName));
bindNames.Add(dialect.ParameterBindName(baseName));
values.Add(keyValue);
}
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", names).Append(')')
.Append(" IN (").AppendJoin(", ", markers).Append(')')
.ToString();
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
return new SqlQueryPlan(
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
}
/// <summary>
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
var marker = dialect.ParameterMarker(RowSelectorBaseName);
var sql = string.Concat(
"SELECT ", columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
[first.RowSelectorValue], members, selected);
}
if (!Blank(first.RowSelectorTopByTimestamp))
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
dialect.SingleRowLimitSuffix);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
}
throw new ArgumentException(
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
// Before any parameter is added: on ODP.NET this is what turns bind-by-position off, and
// a positionally-bound IN list feeds one tag's value to another tag's node in silence.
_dialect.ConfigureCommand(command);
command.CommandText = plan.SqlText;
command.CommandTimeout = _commandTimeoutSeconds;
for (var i = 0; i < plan.ParameterNames.Count; i++)
for (var i = 0; i < plan.ParameterBindNames.Count; i++)
{
var parameter = command.CreateParameter();
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
parameter.ParameterName = plan.ParameterNames[i];
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
parameter.ParameterName = plan.ParameterBindNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
command.Parameters.Add(parameter);
}
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="ParameterBindNames">
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
@@ -18,10 +18,13 @@
<div class="col-md-4">
<label class="form-label">Provider</label>
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
@* SqlServer, Postgres and Oracle construct a dialect; MySql/Odbc stay reserved
SqlProvider members and are deliberately not offered (authoring one fails the driver). *@
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
</InputSelect>
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
<div class="form-text">SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.</div>
</div>
<div class="col-md-8">
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
@@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests
await session.AttributesAsync(columnNodeId, CancellationToken.None);
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
}
/// <summary>
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
/// names) — ODP.NET's shape, and the only place the two spellings differ.
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
/// recorded names would carry the prefix.</para>
/// </summary>
[Fact]
public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var dialect = new ColonMarkerDialect();
await using var session = new SqlBrowseSession(db.Connection, dialect);
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
dialect.BoundParameterNames.ShouldContain("schema");
dialect.BoundParameterNames.ShouldContain("table");
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
dialect.ConfiguredCommands.ShouldBe(2);
}
}
@@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
/// <summary>
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
/// member fail here rather than silently browse as SQL Server.
/// </summary>
[Fact]
public void DefaultDialectSelector_carriesSqlServerOnly()
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
@@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests
// ---- provider availability ----
/// <summary>
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
/// reserved and must be reported as unavailable, naming itself.
/// </summary>
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
@@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("MySql");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// Provider availability is answered before the environment is touched: "this build cannot browse
/// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
/// MySql" is true whatever the ref resolves to, and the two failures must not mask each other.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
@@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
/// <summary>
/// A carried provider must get <b>past</b> the provider gate and on to the environment, so that a
/// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability.
/// Asserted through the reads list: reaching the environment reader is the observable proof the gate
/// let the provider through.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldNotContain("not available in this build");
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
}
// ---- malformed configuration ----
[Theory]
@@ -45,6 +45,13 @@
— see its class docs.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
<!--
Linked for the same reason and on the same terms: ColonMarkerDialect is the control for
dialect-owned parameter naming (":schema" in the text, bare "schema" on the parameter — ODP.NET's
shape), and the browse session binds those names. It decorates SqliteDialect, which is already
linked above, so the pair travels together.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\ColonMarkerDialect.cs" Link="ColonMarkerDialect.cs"/>
</ItemGroup>
</Project>
@@ -0,0 +1,81 @@
# Dedicated, DISPOSABLE Oracle Database Free for the Sql driver's Oracle live gate
# (OracleReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN container, `otopcua-sql-oracle`, on host port 11521 — deliberately not
# the default 1521, so it can never be confused with a real Oracle listener on the rig. `restart:
# "no"` so a killed container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-oracle # rsync this dir → /opt/otopcua-sql-oracle/
# lmxopcua-fix up sql-oracle # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-oracle/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-oracle && SQL_ORACLE_PASSWORD=... docker compose up -d'
#
# ⚠️ FIRST START TAKES MINUTES, not seconds — the image creates the database on first boot. The
# healthcheck's start_period reflects that; the seed one-shot simply waits for it.
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_ORACLE_ENDPOINT=10.100.0.35:11521
# export SQL_ORACLE_USER=otopcua # optional; this is the default
# export SQL_ORACLE_PASSWORD=... # the same value this compose was brought up with
# export SQL_ORACLE_SERVICE=FREEPDB1 # optional; this is the default (the image's PDB)
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~OracleReadTests"
#
# The one-shot `oracle-seed` service applies seed-oracle.sql once the server is healthy. The
# gvenzl images also auto-run scripts mounted at /container-entrypoint-initdb.d, but that path
# runs ONLY on the very first boot of an empty data volume — a re-seed after editing the script
# would silently not happen. The one-shot service matches the mssql fixture and re-runs every
# `docker compose up`, which is why it is used here.
name: otopcua-sql-oracle
services:
oracle:
image: gvenzl/oracle-free:23-slim
container_name: otopcua-sql-oracle
labels:
project: lmxopcua
restart: "no"
environment:
# SYS/SYSTEM password. Dev-only sandbox value for a throwaway container.
ORACLE_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
# The image creates this non-SYS user (and its schema) inside the FREEPDB1 pluggable
# database. It is the principal the suite connects as — nothing here ever uses SYS.
APP_USER: otopcua
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
ports:
- "11521:1521"
healthcheck:
# healthcheck.sh ships in the image and reports the database OPEN, not merely the listener up.
test: ["CMD", "healthcheck.sh"]
interval: 15s
timeout: 10s
retries: 40
start_period: 180s
# One-shot seed: waits for the database to pass its healthcheck, applies seed-oracle.sql, exits.
# Reuses the server image purely because it already carries sqlplus.
oracle-seed:
image: gvenzl/oracle-free:23-slim
labels:
project: lmxopcua
restart: "no"
depends_on:
oracle:
condition: service_healthy
environment:
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
volumes:
- ./seed-oracle.sql:/seed.sql:ro
entrypoint:
- "/bin/bash"
- "-c"
# -S silent, -L single login attempt (never prompt). The script's WHENEVER SQLERROR EXIT
# makes a broken seed fail this service rather than leave a half-seeded schema behind.
- "sqlplus -S -L otopcua/\"$${APP_USER_PASSWORD}\"@//oracle:1521/FREEPDB1 @/seed.sql"
@@ -0,0 +1,76 @@
-- Seed for the Sql driver's Oracle live gate (OracleReadTests), applied once by the compose
-- `oracle-seed` one-shot AS THE APPLICATION USER (otopcua) against the OWNED `otopcua-sql-oracle`
-- container. Oracle equates a schema with a user, so everything below lands in the OTOPCUA schema.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ⚠️ EVERY IDENTIFIER HERE IS UNQUOTED, AND THAT IS THE POINT. Oracle upper-cases an unquoted
-- identifier, so the catalog stores OTOPCUA / TAG_VALUES / TAG_NAME while an operator authoring a
-- tag in the AdminUI naturally types lower case. The suite's case-folding leg authors the lower-case
-- spelling and expects `SqlCatalogGate` to resolve it through `SqlCatalog.TryMatch` (exact-ordinal
-- first, then a UNIQUE case-insensitive match) and substitute the catalog's own UPPER-case spelling
-- before `OracleDialect.QuoteIdentifier` double-quotes it — which is load-bearing, because a quoted
-- "tag_values" and a quoted "TAG_VALUES" are DIFFERENT objects to Oracle. Quoting a name here would
-- make that leg meaningless.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET FEEDBACK OFF
SET DEFINE OFF
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. ORA-00942 (table
-- does not exist) is the expected outcome on a first run and is the only error swallowed.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE tag_values PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE latest_status PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
-- num_value is NUMBER, Oracle's one numeric family — which OracleDialect.MapColumnType folds to
-- Float64 (the documented under-specification; see that method's remarks).
CREATE TABLE tag_values (
tag_name VARCHAR2(128) NOT NULL,
num_value NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its FETCH FIRST 1 ROWS ONLY) returns a different, wrong value rather than a
-- coincidentally right one.
CREATE TABLE latest_status (
station_id NUMBER(10) NOT NULL,
oven_temp NUMBER,
pressure NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
COMMIT;
EXIT;
@@ -0,0 +1,78 @@
# Dedicated, DISPOSABLE PostgreSQL for the Sql driver's Postgres live gate (PostgresReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN postgres container, `otopcua-sql-pg`, on host port 15432.
# Nothing else on the rig runs PostgreSQL, so there is no shared-server hazard of the
# kind the mssql blackhole fixture guards against — but the same discipline applies:
# a distinct container_name and a distinct host port, and `restart: "no"` so a killed
# container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-pg # rsync this dir → /opt/otopcua-sql-pg/
# lmxopcua-fix up sql-pg # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-pg/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-pg && SQL_PG_PASSWORD=... docker compose up -d'
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_PG_ENDPOINT=10.100.0.35:15432
# export SQL_PG_USER=otopcua # optional; this is the default
# export SQL_PG_PASSWORD=... # the same value this compose was brought up with
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~PostgresReadTests"
#
# The one-shot `postgres-seed` service applies seed-pg.sql once the server is healthy, so
# the stack is self-seeding. Unlike the mssql fixture the suite does NOT seed defensively
# on connect: the seed creates ROLES (an owner role that is not the connecting principal),
# which the read-only fixture login deliberately cannot do — see seed-pg.sql.
name: otopcua-sql-pg
services:
postgres:
image: postgres:17-alpine
container_name: otopcua-sql-pg
labels:
project: lmxopcua
restart: "no"
environment:
# Dev-only sandbox password for a throwaway container; the seed gives the unprivileged
# `otopcua` login the SAME value, which is what the suite reads from SQL_PG_PASSWORD.
POSTGRES_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
POSTGRES_DB: sqlpollfixture
ports:
- "15432:5432"
healthcheck:
# pg_isready against the fixture database: the server accepting a connection to the
# database the seed targets is the readiness signal, not merely a listening socket.
test:
- "CMD-SHELL"
- "pg_isready -U postgres -d sqlpollfixture || exit 1"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
# One-shot seed: waits for the server to pass its healthcheck, applies seed-pg.sql, exits.
postgres-seed:
image: postgres:17-alpine
labels:
project: lmxopcua
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
APP_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
volumes:
- ./seed-pg.sql:/seed.sql:ro
entrypoint:
- "/bin/sh"
- "-c"
# ON_ERROR_STOP=1 so a broken seed fails the service rather than leaving a half-seeded
# database that makes a later test failure look like a driver bug.
- "psql -v ON_ERROR_STOP=1 -v app_password=\"$${APP_PASSWORD}\" -h postgres -U postgres -d sqlpollfixture -f /seed.sql"
@@ -0,0 +1,87 @@
-- Seed for the Sql driver's PostgreSQL live gate (PostgresReadTests), applied once by the
-- compose `postgres-seed` one-shot as the superuser against the OWNED `otopcua-sql-pg` container.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ── TWO THINGS HERE ARE LOAD-BEARING, NOT SCAFFOLDING ─────────────────────────────────────────
--
-- 1. The schema is owned by `sqlpoll_owner` and the suite connects as `otopcua`, which holds
-- USAGE + SELECT and nothing else. That is the exact provisioning `PostgresDialect.ListSchemasSql`
-- was written for: `information_schema.schemata` lists only schemas the current role OWNS, so a
-- granted-not-owning read-only poller would see an empty schema list, `SqlCatalogLoader` would
-- throw its "the catalog returned no schemas at all" grant diagnosis, and every tag would be
-- rejected. Sourcing the schema list from `information_schema.tables` instead is what makes this
-- work — and a fixture where the poller owned its own schema would pass either way, proving
-- nothing. Do not "simplify" this by creating the tables as `otopcua`.
--
-- 2. Every identifier is created UNQUOTED, so Postgres folds it to lower case in the catalog. The
-- suite's case-folding leg authors the same names in UPPER case and expects them to resolve
-- through `SqlCatalog.TryMatch` (exact-ordinal first, then a UNIQUE case-insensitive match, which
-- substitutes the catalog's own spelling). Quoting a name here would make the catalog spelling
-- mixed case and that leg would stop meaning anything.
\set ON_ERROR_STOP on
-- ── roles ─────────────────────────────────────────────────────────────────────────────────────
-- \gexec runs the row the SELECT produced, and produces no row when the role already exists —
-- the idempotent CREATE ROLE idiom. A DO block cannot be used for the login role: psql does not
-- interpolate :'app_password' inside a dollar-quoted body.
SELECT 'CREATE ROLE sqlpoll_owner NOLOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sqlpoll_owner')
\gexec
SELECT 'CREATE ROLE otopcua LOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'otopcua')
\gexec
-- Set (or re-set) the password every run, so re-seeding with a different SQL_PG_PASSWORD converges
-- rather than leaving the suite unable to log in.
ALTER ROLE otopcua LOGIN PASSWORD :'app_password';
-- ── schema ────────────────────────────────────────────────────────────────────────────────────
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. The blast radius
-- is bounded to this fixture-owned schema.
DROP SCHEMA IF EXISTS sqlpoll CASCADE;
CREATE SCHEMA sqlpoll AUTHORIZATION sqlpoll_owner;
-- Everything below is created AS THE OWNER, not as the superuser — see note 1 above.
SET ROLE sqlpoll_owner;
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
CREATE TABLE sqlpoll.tag_values (
tag_name varchar(128) NOT NULL,
num_value double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42.0, TIMESTAMP '2026-07-24 10:00:00'),
('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01'),
('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its LIMIT 1) returns a different, wrong value rather than a coincidentally
-- right one.
CREATE TABLE sqlpoll.latest_status (
station_id integer NOT NULL,
oven_temp double precision NULL,
pressure double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
RESET ROLE;
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
GRANT USAGE ON SCHEMA sqlpoll TO otopcua;
GRANT SELECT ON ALL TABLES IN SCHEMA sqlpoll TO otopcua;
@@ -0,0 +1,545 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real Oracle Database</b>, over the fully managed ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
/// per provider.</para>
/// </summary>
[Collection(OracleCollection.Name)]
public sealed class OracleReadTests
{
private readonly OracleFixture _ora;
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
public OracleReadTests(OracleFixture ora) => _ora = ora;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
/// described credential-free.
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
/// loader expects.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. Oracle's connection string
// names the database inside Data Source (host:port/service), so the service is what must survive —
// and the credential must not.
driver.Endpoint.ShouldContain(_ora.ServiceName);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
/// explicit authored <c>"type"</c>.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.PresentKeyTimestampUtc);
}
/// <summary>
/// Two keys on the same table fold into ONE query — <c>… WHERE "TAG_NAME" IN (:k0, :k1)</c> — and are
/// sliced back to the right tags.
/// <para><b>This is the live falsification of positional binding.</b> With
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
/// parameter collection regardless of their names, and the failure mode is not an exception but a
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
{
SkipUnlessLive();
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", OracleFixture.SecondPresentKey));
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.SecondPresentValue);
}
// ---- wide-row model: the Oracle-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
/// different, wrong value here rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
/// <summary>
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
/// <c>OTOPCUA.TAG_VALUES</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
/// identifiers by hand.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
/// pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
{
SkipUnlessLive();
var lower = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
["keyValue"] = OracleFixture.PresentKey,
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
});
await using var driver = await StartAsync(lower);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
/// binding. Reaching a non-empty table list here is therefore evidence that
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
/// seeded schema is one of many.</para>
/// </summary>
[Fact]
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Oracle),
["connectionString"] = _ora.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == OracleFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(OracleFixture.Schema);
address["table"].ShouldBe(OracleFixture.KeyValueTable);
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
/// precisely what makes this suite exercise <c>OracleClientFactory.Instance</c>, the provider the
/// dialect ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(15),
OperationTimeout = TimeSpan.FromSeconds(20),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-oracle-integration",
dialect: new OracleDialect(),
connectionString: _ora.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table, authored in the catalog's own spelling.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
["keyColumn"] = OracleFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = OracleFixture.ValueColumn,
["timestampColumn"] = OracleFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = OracleFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = OracleFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
/// the Oracle counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent, it
/// records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
/// land in the <c>OTOPCUA</c> schema.</para>
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
/// place that deliberately authors the lower-case form an operator would actually type.</para>
/// </summary>
public sealed class OracleFixture : IAsyncLifetime
{
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
public const string UserEnvVar = "SQL_ORACLE_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
/// <summary>
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
/// </summary>
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
public const string DefaultUser = "otopcua";
/// <summary>The image's pluggable-database service name.</summary>
public const string DefaultService = "FREEPDB1";
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 1521;
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
public const string Schema = "OTOPCUA";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 10;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string KeyValueTable = "TAG_VALUES";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "TAG_NAME";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "NUM_VALUE";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "SAMPLE_TS";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/> — different from
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string WideRowTable = "LATEST_STATUS";
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
public const string OvenTempColumn = "OVEN_TEMP";
/// <summary>
/// The <see cref="OvenTempColumn"/> of the row holding the <b>newest</b> <c>SAMPLE_TS</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its
/// <c>FETCH FIRST 1 ROWS ONLY</c> picks a different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The resolved service name, for the endpoint-description assertion.</summary>
public string ServiceName { get; private set; } = DefaultService;
/// <summary>The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or seeded
/// here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
"Sql driver's live Oracle suite. Bring the fixture up from " +
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
return;
}
ConnectionString = resolved;
var dialect = new OracleDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("ODP.NET returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
"stack is up (its first boot takes minutes), or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via the
/// abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct ODP.NET
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
/// be accepted through that indexer, so no provider-specific property is needed.</para>
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
/// install and no Oracle configuration files anywhere near the test host.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, and Oracle locks an account after a handful of those.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
? service
: DefaultService;
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
builder["User Id"] = user;
builder["Password"] = password;
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
return builder.ConnectionString;
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class OracleCollection : ICollectionFixture<OracleFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlOracleServer";
}
@@ -0,0 +1,511 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
/// catalog SQL — including the deliberate choice to derive the schema list from
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
/// granted-but-not-owning principal can prove.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> for the
/// gate. With it unset nothing here opens a socket, so the suite is instant and green offline — which
/// is the point: a live test that goes red on a laptop teaches people to ignore red.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
/// walk an operator authors through.</para>
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class PostgresReadTests
{
private readonly PostgresFixture _pg;
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
/// reports Healthy with the endpoint described credential-free.
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
/// would have answered empty for.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. It must name the
// database and must not carry the credential that reached the provider.
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
[Fact]
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
}
// ---- wide-row model: the Postgres-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
/// first-inserted nor the lowest-keyed one, so losing either the limit or the
/// <c>ORDER BY … DESC</c> yields a different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realPostgres_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
/// <summary>
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
{
SkipUnlessLive();
var upper = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
["keyValue"] = PostgresFixture.PresentKey,
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(),
});
await using var driver = await StartAsync(upper);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
/// browse session reads every catalog column by name, so an unquoted alias would fold to
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
/// list.</para>
/// </summary>
[Fact]
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Postgres),
["connectionString"] = _pg.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
var schema = schemas.ShouldHaveSingleItem(
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(PostgresFixture.Schema);
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
/// ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(10),
OperationTimeout = TimeSpan.FromSeconds(15),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-postgres-integration",
dialect: new PostgresDialect(),
connectionString: _pg.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
["keyColumn"] = PostgresFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = PostgresFixture.ValueColumn,
["timestampColumn"] = PostgresFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = PostgresFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = PostgresFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent,
/// it records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>. That is what keeps
/// <c>dotnet test</c> genuinely green (and instant) on the macOS dev box this is usually run from.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
/// superuser, and the granted-not-owning provisioning that
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
public const string UserEnvVar = "SQL_PG_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
public const string DefaultUser = "otopcua";
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 5432;
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
public const string FixtureDatabase = "sqlpollfixture";
/// <summary>
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
/// seed's drop-and-recreate is bounded to objects this fixture owns.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string KeyValueTable = "tag_values";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string WideRowTable = "latest_status";
/// <summary>
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
/// different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
/// seeded here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
"without these.";
return;
}
ConnectionString = resolved;
var dialect = new PostgresDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("Npgsql returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
/// provider-specific property is needed.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, which is how accounts get locked out on a server that is not a throwaway.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
builder["Host"] = host;
builder["Port"] = port;
builder["Database"] = FixtureDatabase;
builder["Username"] = user;
builder["Password"] = password;
builder["Timeout"] = ProbeConnectTimeoutSeconds;
builder["Application Name"] = DriverApplicationName;
return builder.ConnectionString;
}
/// <summary>
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
/// </summary>
/// <param name="endpoint">The authored endpoint.</param>
/// <param name="defaultPort">The port to use when none is carried.</param>
/// <returns>The host and port.</returns>
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
{
var separator = endpoint.LastIndexOf(':');
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
var host = endpoint[..separator];
return int.TryParse(
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
? (host, port)
: (endpoint, defaultPort);
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPostgresServer";
}
@@ -27,12 +27,20 @@
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
No Microsoft.Data.SqlClient / Npgsql / Oracle.ManagedDataAccess.Core PackageReference here on purpose:
this suite must exercise EXACTLY the providers the driver ships, and they arrive transitively through
Driver.Sql. Adding one here would let the two versions drift and the suite would then prove something
about a provider nobody deploys. The Postgres and Oracle suites therefore never name a provider type:
they compose their connection strings through `dialect.Factory.CreateConnectionStringBuilder()` and the
abstract DbConnectionStringBuilder indexer.
Driver.Sql.Browser is referenced for the browse-session walk (schema → table → column) the Postgres and
Oracle suites drive through the AdminUI's real seam, SqlDriverBrowser — the only place the dialects'
catalog SQL is exercised as the picker actually calls it.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
@@ -0,0 +1,116 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A <b>test-only</b> dialect that spells parameters the ODP.NET way — <c>:name</c> in the SQL text and a
/// <b>bare</b> <c>name</c> as the <see cref="DbParameter.ParameterName"/> — over the SQLite fixture. It is
/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded
/// <c>"@"</c> before, and a planner or loader that still hardcodes it emits <c>@k0</c> against this
/// dialect and fails loudly.
/// <para><b>Why the two halves must differ.</b> A dialect whose marker and bind name are the same string
/// (SQL Server, Npgsql, SQLite) cannot tell a call site that <em>copies</em> the plan's bind name apart
/// from one that reuses the marker. Oracle is the provider where they genuinely diverge, so this fake
/// reproduces that divergence rather than merely changing the prefix.</para>
/// <para><b>It really executes.</b> Everything but the parameter naming (and the catalog SQL's markers)
/// delegates to <see cref="SqliteDialect"/>, and Microsoft.Data.Sqlite accepts both a <c>:</c>-prefixed
/// marker and a prefix-less parameter name — so a poll or a catalog load driven through this dialect runs
/// against the real fixture database rather than being asserted on as text.</para>
/// <para>A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and
/// <c>new</c>-hiding a member would leave interface dispatch calling the original — the substitution
/// would silently not happen and the control would pass for the wrong reason.</para>
/// </summary>
public sealed class ColonMarkerDialect : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
/// <summary>Every command handed to <see cref="ConfigureCommand"/>, kept so its binds can be read back.</summary>
private readonly List<DbCommand> _commands = [];
/// <summary>How many times <see cref="ConfigureCommand"/> has been called on this instance.</summary>
/// <remarks>
/// Oracle's <c>BindByName</c> is set here, and a command created without it mis-binds by position
/// rather than failing — so "was every command configured" has to be observable to be testable.
/// </remarks>
public int ConfiguredCommands
{
get { lock (_commands) return _commands.Count; }
}
/// <summary>
/// The <see cref="DbParameter.ParameterName"/> of every parameter bound on every configured command,
/// read after the fact.
/// <para><b>Why this and not the query's own success.</b> Microsoft.Data.Sqlite resolves a parameter
/// whether it is named <c>:k0</c> or bare <c>k0</c>, so a poll that returns the right values proves
/// the SQL parsed — not that the caller bound the dialect's bind name. Only the name actually put on
/// the parameter distinguishes the two, and on ODP.NET the wrong one is a hard bind failure.</para>
/// </summary>
public IReadOnlyList<string> BoundParameterNames
{
get
{
lock (_commands)
return _commands
.SelectMany(c => c.Parameters.Cast<DbParameter>())
.Select(p => p.ParameterName)
.ToList();
}
}
/// <inheritdoc/>
public SqlProvider Provider => Inner.Provider;
/// <inheritdoc/>
public DbProviderFactory Factory => Inner.Factory;
/// <inheritdoc/>
public string LivenessSql => Inner.LivenessSql;
/// <inheritdoc/>
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
/// <inheritdoc/>
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
/// <inheritdoc/>
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
/// <inheritdoc/>
public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql);
/// <inheritdoc/>
public string ListTablesSql => Colonize(Inner.ListTablesSql);
/// <inheritdoc/>
public string ListColumnsSql => Colonize(Inner.ListColumnsSql);
/// <inheritdoc/>
public string ParameterMarker(string baseName) => ":" + baseName;
/// <summary>The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker.</summary>
/// <param name="baseName">The planner's/loader's base name.</param>
/// <returns>The bind name.</returns>
public string ParameterBindName(string baseName) => baseName;
/// <inheritdoc/>
public void ConfigureCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
lock (_commands) _commands.Add(command);
}
/// <inheritdoc/>
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
/// <inheritdoc/>
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
/// <summary>
/// Rewrites the SQLite dialect's <c>@</c> markers as <c>:</c> markers. The catalog SQL is dialect-owned
/// text, so a colon-marker dialect must carry colon markers in it — that is precisely the coupling the
/// catalog loader's bind names have to stay in step with.
/// </summary>
private static string Colonize(string sql) => sql.Replace('@', ':');
}
@@ -0,0 +1,298 @@
using Microsoft.Data.Sqlite;
using Oracle.ManagedDataAccess.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Golden tests for <see cref="OracleDialect"/>, mirroring <see cref="SqlServerDialectTests"/>: the
/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
/// Oracle needs, pinning <c>OracleCommand.BindByName</c>.
/// </summary>
public class OracleDialectTests
{
// ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
/// <summary>
/// The sharpest correctness edge on the whole provider seam. A real <see cref="OracleCommand"/> — no
/// connection needed — must report <c>BindByName == false</c> before
/// <see cref="OracleDialect.ConfigureCommand"/> touches it and <c>true</c> afterwards.
/// <para><b>The false-by-default half is the falsifiability control.</b> Asserting only the post-state
/// would pass identically against an ODP.NET release that had flipped the default — i.e. it would prove
/// nothing about whether this dialect's call is load-bearing. Pinning both halves means the day the
/// provider default changes, this test says so instead of quietly becoming a tautology.</para>
/// </summary>
[Fact]
public void ConfigureCommand_turnsOnBindByName_whichOdpNetLeavesOffByDefault()
{
using var command = new OracleCommand();
command.BindByName.ShouldBeFalse(
"ODP.NET binds by position by default — if this ever changes, the dialect's ConfigureCommand " +
"is no longer what makes named binding work and this test must be re-reasoned, not deleted.");
new OracleDialect().ConfigureCommand(command);
command.BindByName.ShouldBeTrue();
}
/// <summary>
/// A command from another provider passes through untouched and, crucially, does <b>not</b> throw:
/// the dialect can legitimately meet a foreign <c>DbCommand</c> (an instrumented or substituted
/// factory in a shared test harness), and an <c>InvalidCastException</c> there would fail a poll for a
/// reason that has nothing to do with the database.
/// </summary>
[Fact]
public void ConfigureCommand_leavesAForeignCommandUntouched()
{
using var command = new SqliteCommand("SELECT 1");
Should.NotThrow(() => new OracleDialect().ConfigureCommand(command));
command.CommandText.ShouldBe("SELECT 1");
command.Parameters.Count.ShouldBe(0);
}
[Fact]
public void ParameterMarker_isColonPrefixed_andTheBindNameIsTheSameNameWithoutTheColon()
{
var d = new OracleDialect();
// The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings.
d.ParameterMarker("k0").ShouldBe(":p_k0");
d.ParameterBindName("k0").ShouldBe("p_k0");
d.ParameterMarker("schema").ShouldBe(":p_schema");
d.ParameterBindName("schema").ShouldBe("p_schema");
}
/// <summary>
/// No bind name may be spelled as its bare base name — the <c>p_</c> namespace is what stops one
/// colliding with an Oracle reserved word.
/// </summary>
/// <remarks>
/// <b>This is a regression test for a live-found, provider-fatal defect.</b> The catalog readers bind
/// a parameter whose base name is <c>table</c>, and Oracle rejects a bind variable named after a
/// reserved word outright: <c>ORA-01745: invalid host/bind variable name</c>. With the un-prefixed
/// names this dialect first shipped with, <c>ListColumnsSql</c> could not execute at all — so 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. It was invisible to every offline test (only a
/// server parses the SQL) and was caught by <c>OracleReadTests</c> against a real database.
/// <para><c>table</c> is asserted explicitly because it is the one base name in use today that is
/// actually reserved; the others guard the general rule.</para>
/// </remarks>
[Theory]
[InlineData("table")]
[InlineData("schema")]
[InlineData("k0")]
public void ParameterBindName_neverSurfacesTheBareBaseName_soItCannotBeAReservedWord(string baseName)
{
var d = new OracleDialect();
d.ParameterBindName(baseName).ShouldNotBe(baseName);
d.ParameterMarker(baseName).ShouldNotBe(":" + baseName);
// The two must stay derived from one another, or a statement's marker and its bind drift apart and
// ODP.NET silently leaves the parameter unbound.
d.ParameterMarker(baseName).ShouldBe(":" + d.ParameterBindName(baseName));
}
// ---- identity ----
[Fact]
public void Dialect_identifiesItselfAsOracle_andExposesTheAbstractFactory()
{
var d = new OracleDialect();
d.Provider.ShouldBe(SqlProvider.Oracle);
d.Factory.ShouldNotBeNull();
d.Factory.ShouldBeOfType<OracleClientFactory>();
}
// ---- quoting / injection boundary ----
[Fact]
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
{
var d = new OracleDialect();
d.QuoteIdentifier("SPEED").ShouldBe("\"SPEED\"");
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); // " doubled, per Oracle's quoted-identifier rule
}
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
=> new OracleDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\"");
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// The only metacharacter that could terminate a quoted identifier early is ", and it is doubled.
// The result is a single (nonexistent) identifier, never executable SQL.
new OracleDialect().QuoteIdentifier("x\" ; DROP TABLE Users --")
.ShouldBe("\"x\"\" ; DROP TABLE Users --\"");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident!));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
[Theory]
[InlineData("x\0y")]
[InlineData("x\u0001y")] // C0 SOH
[InlineData("x\u001By")] // C0 ESC
[InlineData("x\u007Fy")] // DEL
[InlineData("x\u0085y")] // C1 NEL
[InlineData("x\ty")]
[InlineData("x\ny")]
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData("SP\u200BEED")] // zero-width space
[InlineData("\u202Ediop.selbat")] // right-to-left override
[InlineData("x\u2066y\u2069")] // bidi isolates
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
[InlineData("x\u00ADy")] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
[Fact]
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
{
var d = new OracleDialect();
d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
// ---- liveness + row limit ----
[Fact]
public void LivenessSql_selectsFromDual()
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
[Fact]
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
{
var d = new OracleDialect();
// Oracle 12c+ spells the limit at the END of the statement, so the prefix must be empty — a
// non-empty one would land between SELECT and the column list and produce invalid SQL.
d.SingleRowLimitPrefix.ShouldBe("");
d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"A\" FROM \"T\"", d.SingleRowLimitSuffix)
.ShouldBe("SELECT \"A\" FROM \"T\" FETCH FIRST 1 ROWS ONLY");
}
// ---- catalog SQL ----
[Fact]
public void CatalogSql_readsTheAllViews_neverInformationSchema()
{
var d = new OracleDialect();
// Oracle has no INFORMATION_SCHEMA at all; the ALL_* views are the portable equivalent.
d.ListSchemasSql.ShouldContain("ALL_USERS");
d.ListTablesSql.ShouldContain("ALL_TABLES");
d.ListTablesSql.ShouldContain("ALL_VIEWS"); // INFORMATION_SCHEMA.TABLES includes views
d.ListColumnsSql.ShouldContain("ALL_TAB_COLUMNS");
d.DefaultSchemaSql.ShouldBe("SELECT sys_context('userenv','current_schema') FROM dual");
d.ListSchemasSql.ShouldNotContain("INFORMATION_SCHEMA");
d.ListTablesSql.ShouldNotContain("INFORMATION_SCHEMA");
d.ListColumnsSql.ShouldNotContain("INFORMATION_SCHEMA");
}
[Fact]
public void CatalogSql_isParameterizedWithColonMarkers_matchingParameterMarker()
{
var d = new OracleDialect();
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); // never string-interpolated
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
// A leftover @ marker would bind on SqlClient and silently be a literal here.
d.ListTablesSql.ShouldNotContain("@");
d.ListColumnsSql.ShouldNotContain("@");
}
/// <summary>
/// <c>SqlCatalogLoader</c>/<c>SqlBrowseSession</c> project catalog columns by the
/// <c>INFORMATION_SCHEMA</c> names, so every dialect must alias its columns to them or
/// <c>GetOrdinal</c> throws at runtime — a failure the SQL text alone would not reveal.
/// </summary>
[Fact]
public void CatalogSql_aliasesItsColumnsToTheInformationSchemaNamesTheReadersProjectBy()
{
var d = new OracleDialect();
d.ListSchemasSql.ShouldContain("TABLE_SCHEMA");
d.ListTablesSql.ShouldContain("TABLE_NAME");
d.ListTablesSql.ShouldContain("TABLE_TYPE");
d.ListColumnsSql.ShouldContain("COLUMN_NAME");
d.ListColumnsSql.ShouldContain("DATA_TYPE");
// SqlBrowseSession compares TABLE_TYPE against the literal "VIEW" to mark a view node.
d.ListTablesSql.ShouldContain("'VIEW'");
d.ListTablesSql.ShouldContain("'BASE TABLE'");
}
// ---- type map ----
[Theory]
[InlineData("NUMBER", DriverDataType.Float64)]
[InlineData("FLOAT", DriverDataType.Float64)]
[InlineData("BINARY_DOUBLE", DriverDataType.Float64)]
[InlineData("BINARY_FLOAT", DriverDataType.Float32)]
[InlineData("INTEGER", DriverDataType.Float64)]
[InlineData("VARCHAR2", DriverDataType.String)]
[InlineData("NVARCHAR2", DriverDataType.String)]
[InlineData("CHAR", DriverDataType.String)]
[InlineData("NCHAR", DriverDataType.String)]
[InlineData("CLOB", DriverDataType.String)]
[InlineData("NCLOB", DriverDataType.String)]
[InlineData("DATE", DriverDataType.DateTime)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
/// <summary>
/// <c>ALL_TAB_COLUMNS.DATA_TYPE</c> carries the fractional-seconds precision and the time-zone
/// qualifier <em>inside</em> the family name, unlike <c>INFORMATION_SCHEMA</c>. An exact-match table
/// would send every one of these to the String fallback.
/// </summary>
[Theory]
[InlineData("TIMESTAMP")]
[InlineData("TIMESTAMP(6)")]
[InlineData("TIMESTAMP(3)")]
[InlineData("TIMESTAMP(6) WITH TIME ZONE")]
[InlineData("TIMESTAMP(6) WITH LOCAL TIME ZONE")]
public void MapColumnType_matchesTheTimestampFamilyByPrefix(string sql)
=> new OracleDialect().MapColumnType(sql).ShouldBe(DriverDataType.DateTime);
[Theory]
[InlineData("number", DriverDataType.Float64)]
[InlineData("VarChar2", DriverDataType.String)]
[InlineData("timestamp(6)", DriverDataType.DateTime)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("RAW")] // binary, like SQL Server's varbinary
[InlineData("BLOB")]
[InlineData("LONG RAW")]
[InlineData("ROWID")]
[InlineData("XMLTYPE")]
[InlineData("INTERVAL DAY(3) TO SECOND(6)")] // a duration, deliberately NOT a DateTime
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new OracleDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
}
@@ -0,0 +1,229 @@
using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> rule for
/// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline,
/// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a
/// 63-character identifier ceiling, the end-of-statement <c>LIMIT 1</c>, and <c>bit</c> being a bit string
/// rather than a boolean — the divergence is pinned here rather than left to reading.
/// </summary>
public class PostgresDialectTests
{
[Fact]
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
{
var d = new PostgresDialect();
d.QuoteIdentifier("Speed").ShouldBe("\"Speed\"");
// " doubled, per the SQL-standard rule Postgres follows.
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier("x\0y"));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The
// code points are spelled numerically so the offending characters never appear raw in this source file.
[Theory]
[InlineData(0x0001)] // C0 SOH
[InlineData(0x001B)] // C0 ESC
[InlineData(0x007F)] // DEL
[InlineData(0x0085)] // C1 NEL
[InlineData(0x0009)] // TAB
[InlineData(0x000A)] // LF
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint)
=> Should.Throw<ArgumentException>(
() => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y"));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData(0x200B)] // zero-width space
[InlineData(0x202E)] // right-to-left override
[InlineData(0x2066)] // left-to-right isolate
[InlineData(0xFEFF)] // BOM / zero-width no-break space
[InlineData(0x00AD)] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
=> Should.Throw<ArgumentException>(
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier(ident!));
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
=> new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name "
// NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it
// is a config mistake rather than something to quote and send.
[Fact]
public void QuoteIdentifier_acceptsExactly63Chars_andRejects64()
{
var d = new PostgresDialect();
d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it
// is doubled. The result is a single (nonexistent) identifier, never executable SQL.
new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --")
.ShouldBe("\"x\"\" ; DROP TABLE users --\"");
}
[Theory]
[InlineData("boolean", DriverDataType.Boolean)]
[InlineData("smallint", DriverDataType.Int16)]
[InlineData("integer", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("double precision", DriverDataType.Float64)]
[InlineData("numeric", DriverDataType.Float64)]
[InlineData("text", DriverDataType.String)]
[InlineData("character varying", DriverDataType.String)]
[InlineData("character", DriverDataType.String)]
[InlineData("uuid", DriverDataType.String)]
[InlineData("timestamp without time zone", DriverDataType.DateTime)]
[InlineData("timestamp with time zone", DriverDataType.DateTime)]
[InlineData("date", DriverDataType.DateTime)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("BOOLEAN", DriverDataType.Boolean)]
[InlineData("Double Precision", DriverDataType.Float64)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back
[InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean
[InlineData("bit varying")]
[InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`)
[InlineData("interval")]
[InlineData("json")]
[InlineData("ARRAY")]
[InlineData("USER-DEFINED")]
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new PostgresDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.DefaultSchemaSql.ShouldBe("SELECT current_schema()");
d.ListTablesSql.ShouldContain("information_schema.tables");
d.ListColumnsSql.ShouldContain("information_schema.columns");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@schema");
d.ListColumnsSql.ShouldContain("@table");
}
// The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the
// same string, or the loader binds a name the statement never mentions.
[Fact]
public void CatalogSql_markersAgreeWithParameterMarker()
{
var d = new PostgresDialect();
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
}
[Fact]
public void ListSchemasSql_excludesTheSystemSchemas()
{
var sql = new PostgresDialect().ListSchemasSql;
sql.ShouldContain("'pg_catalog'");
sql.ShouldContain("'information_schema'");
// The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so
// they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail
// if someone "tidies" the backslash away and silently widens the exclusion.
sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'");
sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'");
}
// The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and
// Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and
// this pins the exact ones the consumers ask for.
[Fact]
public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName()
{
var d = new PostgresDialect();
d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\"");
d.ListTablesSql.ShouldContain("\"TABLE_NAME\"");
d.ListTablesSql.ShouldContain("\"TABLE_TYPE\"");
d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\"");
d.ListColumnsSql.ShouldContain("\"DATA_TYPE\"");
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
}
[Fact]
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
{
var d = new PostgresDialect();
// Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The
// planner appends the suffix straight onto the finished statement, so the space belongs to it.
d.SingleRowLimitPrefix.ShouldBe("");
d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix)
.ShouldBe("SELECT \"a\" LIMIT 1");
}
[Fact]
public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory()
{
var d = new PostgresDialect();
d.Provider.ShouldBe(SqlProvider.Postgres);
d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance);
// The seam must not leak Npgsql into its signature.
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
}
[Theory]
[InlineData("k0")]
[InlineData("w")]
[InlineData("schema")]
public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName)
{
var d = new PostgresDialect();
d.ParameterMarker(baseName).ShouldBe("@" + baseName);
d.ParameterBindName(baseName).ShouldBe("@" + baseName);
}
// Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a
// command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory).
[Fact]
public void ConfigureCommand_leavesAForeignCommandUntouched()
{
using var command = new SqliteCommand("SELECT 1");
command.Parameters.AddWithValue("@k0", 7);
Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command));
command.CommandText.ShouldBe("SELECT 1");
command.Parameters.Count.ShouldBe(1);
command.Parameters[0].ParameterName.ShouldBe("@k0");
command.Parameters[0].Value.ShouldBe(7);
}
}
@@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
/// carries.
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
/// </summary>
[Fact]
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags =
[
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
dialect,
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The catalog queries bound the bare names, and every catalog command went through the hook.
dialect.BoundParameterNames.ShouldContain("schema");
dialect.BoundParameterNames.ShouldContain("table");
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
}
// ---- helpers ----
/// <summary>
@@ -260,6 +304,12 @@ public sealed class SqlCatalogGateDriverTests
public string ListColumnsSql => Inner.ListColumnsSql;
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
@@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests
// ---- provider gate ----
/// <summary>
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> is still a
/// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that
/// had quietly become "construct anything" would pass every test below.
/// </summary>
[Fact]
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
{
@@ -130,12 +135,73 @@ public sealed class SqlDriverFactoryTests
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
"d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("MySql");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
/// </summary>
[Fact]
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
}
/// <summary>
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
/// T-SQL at a Postgres server.
/// </summary>
[Theory]
[InlineData("SqlServer", typeof(SqlServerDialect))]
[InlineData("Postgres", typeof(PostgresDialect))]
[InlineData("Oracle", typeof(OracleDialect))]
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var binding = SqlDriverFactoryExtensions.ParseBinding(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
binding.Dialect.ShouldBeOfType(expected);
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
}
/// <summary>
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
/// bootstrapper actually calls — not only on the internal parse.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
driver.ShouldNotBeNull();
}
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
[Fact]
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
}
// ---- dialect-owned parameter markers ----
/// <summary>
/// The falsifiability control for dialect-owned markers: against a <c>:</c>-marker dialect the emitted
/// statement must carry <c>:k0</c>, and no <c>@</c> at all. A planner holding the T-SQL prefix in a
/// const passes every SQL Server test in this class and fails only here.
/// </summary>
[Fact]
public void KeyValueMarkers_comeFromTheDialect_ratherThanAHardcodedAtSign()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new ColonMarkerDialect()).ToList();
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
plans[0].SqlText.ShouldNotContain("@");
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
}
[Fact]
public void TheWideRowSelectorMarker_comesFromTheDialect_ratherThanAHardcodedAtSign()
{
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
new ColonMarkerDialect()).ToList();
plans[0].SqlText.ShouldEndWith("= :w");
plans[0].SqlText.ShouldNotContain("@");
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
}
/// <summary>
/// The bind names ride the plan <em>separately</em> from the markers, because on ODP.NET they are a
/// different string. A plan that carried only markers would leave the reader deriving the bind name —
/// the convention it is not entitled to know.
/// </summary>
[Fact]
public void ParameterBindNames_areTheDialects_andNeedNotEqualTheMarkers()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new ColonMarkerDialect()).ToList();
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" }); // in the SQL text
plans[0].ParameterBindNames.ShouldBe(new[] { "k0", "k1" }); // on the DbParameter
plans[0].ParameterBindNames.Count.ShouldBe(plans[0].Parameters.Count);
}
[Fact]
public void TheWideRowSelectorBindName_isTheDialects_andNeedNotEqualTheMarker()
{
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
new ColonMarkerDialect()).ToList();
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
plans[0].ParameterBindNames.ShouldBe(new[] { "w" });
}
/// <summary>
/// On a SqlClient-style dialect the two lists coincide — which is exactly why the colon control above
/// exists, and why this one cannot stand in for it.
/// </summary>
[Fact]
public void OnSqlServer_theBindNamesAreTheMarkers()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].ParameterBindNames.ShouldBe(plans[0].ParameterNames);
plans[0].ParameterBindNames.ShouldBe(new[] { "@k0" });
}
// ---- wide row ----
[Fact]
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
}
// ---- dialect-owned parameter naming ----
/// <summary>
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
/// </summary>
[Fact]
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, dialect,
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
var snapshots = await reader.ReadAsync(
["Speed", "Pressure", "Oven"], CancellationToken.None);
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
// absence mis-binds an IN list by position instead of failing.
dialect.ConfiguredCommands.ShouldBe(2);
}
// ---- connection lifecycle ----
[Theory]
@@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests
public void Plan_doesNotAliasTheCallersLists()
{
var names = new List<string> { "@k0" };
var bindNames = new List<string> { "@k0" };
var values = new List<object?> { "v" };
var members = new List<SqlTagDefinition>
{
@@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests
};
var selected = new List<string> { "k", "c" };
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
var plan = new SqlQueryPlan(
SqlTagModel.KeyValue, "gk", "SELECT 1", names, bindNames, values, members, selected);
names.Add("@k1");
bindNames.Add("@k1");
values.Add("other");
members.Add(members[0]);
selected.Add("extra");
plan.ParameterNames.Count.ShouldBe(1);
plan.ParameterBindNames.Count.ShouldBe(1);
plan.Parameters.Count.ShouldBe(1);
plan.Members.Count.ShouldBe(1);
plan.SelectedColumns.Count.ShouldBe(2);
@@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <summary>
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>Identical to the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>No-op: <c>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
@@ -86,4 +87,47 @@ public sealed class SqlDriverFormContractTests
json.ShouldContain("\"SqlServer\"");
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
}
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
/// <summary>
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
/// the next modal open.
/// </summary>
[Theory]
[InlineData(SqlProvider.SqlServer, "SqlServer")]
[InlineData(SqlProvider.Postgres, "Postgres")]
[InlineData(SqlProvider.Oracle, "Oracle")]
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
{
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{name}\"");
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
var reloaded = SqlDriverForm.FormModel.FromDto(
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
reloaded.Provider.ShouldBe(provider);
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
}
/// <summary>
/// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer
/// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has
/// five members, the dropdown should too" edit has to argue with a test.
/// </summary>
[Theory]
[InlineData(SqlProvider.MySql)]
[InlineData(SqlProvider.Odbc)]
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
{
var json = JsonSerializer.Serialize(
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{reserved}\"");
}
}