diff --git a/Directory.Packages.props b/Directory.Packages.props index c9ad0acf..4740412e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,6 +89,12 @@ document (the XML formatter ships in a third, unreferenced package) nor frame the /sample multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. --> + + + diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md index 9d32457f..a3988fb0 100644 --- a/docs/drivers/Sql.md +++ b/docs/drivers/Sql.md @@ -12,9 +12,20 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs `SELECT`s on a timer and turns cells into tag values. -**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares -`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends** — -authoring one fails the driver rather than silently falling back. +**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres` +(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the +`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain +**reserved names, not working backends** — authoring one fails the driver rather than silently +falling back. + +Two provider-specific things are worth knowing before you author against them: + +- **Identifier case folds, and the catalog gate absorbs it.** Postgres lower-cases and Oracle + upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own + spelling, so an authored `widgets` binds to Oracle's `WIDGETS` rather than failing. +- **Oracle `NUMBER` infers as `Float64`.** Column metadata does not carry precision/scale to the + type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override + (see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters. **Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it @@ -94,7 +105,7 @@ ignoring it later. | Key | Default | Notes | |---|---|---| -| `provider` | `SqlServer` | The only provider v1 constructs. | +| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. | | `connectionStringRef` | — | **A name, never a connection string.** See below. | | `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. | | `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. | @@ -132,7 +143,9 @@ The two classes of authored string are handled differently, and deliberately so: concatenated into command text. - **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`, `columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated - against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command. + against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`** + views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and + dialect-quoted before they reach a command. The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports @@ -152,6 +165,10 @@ can commit columns as tags. A committed leaf carries **schema, table and column* cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose typed editor opened empty. +Browse works against all three constructed providers; the catalog query is the dialect's own +(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema` +themselves excluded — and the `ALL_*` views on Oracle). + ## Authoring in the AdminUI - **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md index f15a8ac4..29662df5 100644 --- a/docs/plans/2026-07-24-driver-expansion-tracking.md +++ b/docs/plans/2026-07-24-driver-expansion-tracking.md @@ -154,12 +154,12 @@ SQL poll reuses the always-on central SQL Server. **Both shipped.** ### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED) - **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md) -- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam. +- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped. ~~Postgres/ODBC remain deferred to P2/P3 behind the same seam.~~ **Postgres + Oracle shipped 2026-08-05** via managed providers (Npgsql 10.0.3, ODP.NET Core 23.26.300) — dialect-owned parameter markers/bind names + Oracle `BindByName`, `INFORMATION_SCHEMA` vs `ALL_*` catalog SQL, provider gates widened, AdminUI dropdown, env-gated docker suites (postgres:17-alpine / gvenzl/oracle-free:23-slim). The live suites found + fixed ORA-01745 (Oracle rejects reserved-word bind names — `:table` — so the first Oracle build was structurally broken until binds were `p_`-prefixed). MySql/Odbc stay reserved. See [`2026-08-05-sql-postgres-oracle-design.md`](2026-08-05-sql-postgres-oracle-design.md). - **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate (`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a persisted `connectionString` (`1919a8e5`). -- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server - shipped; Postgres/ODBC deferred behind `ISqlDialect`). +- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server, + **PostgreSQL and Oracle** shipped behind `ISqlDialect`; MySql/Odbc reserved). - **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared @@ -312,7 +312,8 @@ Both CI-simulatable on the shared docker host, no hardware. pass-through) CLOSED 2026-08-05 — see the Wave 0 section above. 2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged `0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in - `28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind + `28c28667`). **Postgres + Oracle dialects shipped 2026-08-05** (managed providers, see the SQL + poll section above); remaining SQL work is optional follow-on — MySql/Odbc dialects behind `ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design). 3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). diff --git a/docs/plans/2026-08-05-sql-postgres-oracle-design.md b/docs/plans/2026-08-05-sql-postgres-oracle-design.md new file mode 100644 index 00000000..608cba4c --- /dev/null +++ b/docs/plans/2026-08-05-sql-postgres-oracle-design.md @@ -0,0 +1,134 @@ +# Sql driver: Postgres + Oracle providers (managed drivers only) — design + +**Status:** DONE 2026-08-05 — all eight tasks delivered and reviewed. Suites: Driver.Sql.Tests +355/355, Browser.Tests 75/75, IntegrationTests 33 skip-clean offline; **both new integration suites +also ran LIVE during development** against real local containers (postgres:17-alpine 5/5, +gvenzl/oracle-free:23-slim 6/6), which surfaced and fixed a provider-fatal defect no offline test +could see: **ORA-01745** — Oracle rejects a bind variable named after a reserved word (`:table`), +so every Oracle catalog load failed until OracleDialect namespaced all binds with a `p_` prefix +(`ec90b7c4`, pinned by a never-bare-base-name invariant theory). §4.3's chrome/AdminUI gate remains +with the operator; the shared-host fixture deploy + live run is recorded below when complete. + +## 1. Problem + +`docs/drivers/Sql.md:15-18`: v1 constructs SQL Server only; `SqlProvider` declares `Postgres`, +`MySql`, `Odbc`, `Oracle` as reserved names that fail the driver. This branch makes **Postgres and +Oracle** working backends via fully managed ADO.NET providers — `Npgsql` and +`Oracle.ManagedDataAccess.Core` — no native/thin-client installs. `MySql`/`Odbc` stay reserved. + +The seam already exists and is proven: `ISqlDialect` exposes an abstract `DbProviderFactory` +(`ISqlDialect.cs:38`), all runtime I/O goes through `DbConnection`/`DbCommand`/`DbParameter` +(`SqlPollReader.cs:379-389`), and the test-only `SqliteDialect` is the standing falsifiability +control that the planner emits dialect SQL rather than T-SQL. The concrete gaps: + +1. **Parameter markers are hardcoded `@`** in two places: `SqlGroupPlanner.cs:21-25` (`@k`/`@w`) + and the catalog path (`SqlServerDialect.cs:56/:61` SQL text + `SqlCatalogLoader.cs:97-113` bind + names). Npgsql happens to accept `@`; Oracle requires `:`. +2. **Two provider gates** reject everything but SqlServer: `CreateDialect` + (`SqlDriverFactoryExtensions.cs:208-215`) and the browser's `DefaultDialectSelector` + (`SqlDriverBrowser.cs:126-127`). +3. **Oracle has no `INFORMATION_SCHEMA`** — catalog SQL must come from `ALL_USERS` / + `ALL_TABLES` + `ALL_VIEWS` / `ALL_TAB_COLUMNS`; liveness is `SELECT 1 FROM DUAL`; row-limit is + `FETCH FIRST 1 ROWS ONLY`. +4. **AdminUI** provider dropdown is single-option (`SqlDriverForm.razor:18-25`). +5. **Packages** — `Npgsql` and `Oracle.ManagedDataAccess.Core` are absent from + `Directory.Packages.props`. + +## 2. Decisions + +- **Both dialects live in the existing `ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` project**, which + exists precisely because provider packages are unavoidable there (`.Dialect.csproj:20-26`). Its + csproj gains the two `PackageReference`s; consumers get them transitively exactly as + `Microsoft.Data.SqlClient` flows today. No new projects, no `.slnx` churn. +- **Dialect-owned parameter naming.** `ISqlDialect` gains three members with SqlServer-compatible + semantics implemented on every dialect: + - `string ParameterMarker(string baseName)` — the token in SQL text (`"@k0"` / `":k0"`). + - `string ParameterBindName(string baseName)` — the `DbParameter.ParameterName` value (`"@k0"` + for SqlClient/Npgsql; bare `"k0"` for ODP.NET, its documented convention). + - `void ConfigureCommand(DbCommand command)` — default no-op; **OracleDialect sets + `OracleCommand.BindByName = true`** (ODP.NET defaults to bind-by-position, which would silently + mis-bind the planner's multi-parameter `IN` lists — this is the sharpest correctness edge in + the branch). + `SqlGroupPlanner` drops its `@k`/`@w` consts and takes markers from the dialect (bind names ride + `SqlQueryPlan` alongside, keeping `SqlPollReader.cs:398-405`'s copy-don't-derive discipline). + `SqlCatalogLoader` binds `schema`/`table` through the dialect instead of the literal `"@schema"`. + Every command creation site (`SqlPollReader`, `SqlCatalogLoader`, `SqlDriverProbe`, + `SqlBrowseSession`) calls `ConfigureCommand` after `CreateCommand`. +- **`PostgresDialect`** (`NpgsqlFactory.Instance`): `@` markers; `"`-quoting with `""` escape, max + identifier 63; `LivenessSql = "SELECT 1"`; limit prefix `""` / suffix `" LIMIT 1"`; + `INFORMATION_SCHEMA` catalog SQL with `pg_catalog`/`information_schema` schemas excluded and + `DefaultSchemaSql = "SELECT current_schema()"`; type map over Postgres names (`integer`, + `bigint`, `smallint`, `real`, `double precision`, `numeric`, `boolean`, `text`, + `character varying`, `timestamp*`, `uuid`, `bytea`, …). +- **`OracleDialect`** (`OracleClientFactory.Instance`): `:` markers, bare bind names, BindByName; + `"`-quoting with `""` escape, max identifier 128 (declares Oracle 12.2+ as the supported floor — + `FETCH FIRST` needs 12c anyway); `LivenessSql = "SELECT 1 FROM DUAL"`; limit suffix + `" FETCH FIRST 1 ROWS ONLY"`; catalog SQL from `ALL_USERS` (schemas), + `ALL_TABLES` ∪ `ALL_VIEWS` (tables — matching `INFORMATION_SCHEMA.TABLES`' view inclusion), + `ALL_TAB_COLUMNS` (columns), `DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"`; + type map over Oracle names — **`NUMBER` maps to `Float64`** (precision/scale are not visible to + `MapColumnType(string)`; the per-tag `DeclaredType` override is the documented escape hatch), + plus `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`, `DATE`/`TIMESTAMP*`, + `CLOB`, `RAW`. +- **Case-folding rides the existing catalog gate.** Postgres lower-cases and Oracle upper-cases + unquoted identifiers; `SqlCatalogGate` already resolves every authored identifier to the + catalog's own spelling, so authored `widgets` binds to Oracle `WIDGETS`. The implementer verifies + the gate's comparison is case-insensitive and pins it per-dialect with tests. +- **Gates widen to exactly these two.** `CreateDialect` and `DefaultDialectSelector` add + `Postgres`/`Oracle`; `MySql`/`Odbc` keep the existing loud rejection (tests updated: the + "Postgres is rejected" assertions at `SqlDriverFactoryTests.cs:133-135` and + `SqlDriverBrowserTests.cs:188-196` flip to `MySql` as the still-reserved control). +- **AdminUI**: `SqlDriverForm.razor` dropdown gains `Postgres` and `Oracle` options; helper text + updated. No other UI change — Sql stays a single-connection driver with no device form + (`DriverFormMaps.cs:81-89`), `connectionStringRef` + `Sql__ConnectionStrings__` env + resolution is provider-agnostic already, and the `SqlConnectionStringPersisted` deploy gate needs + no change (it keys on the literal `connectionString` key, not the provider). +- **Packages**: `Directory.Packages.props` gains `Npgsql` (between `MQTTnet` and `Novell.*`) and + `Oracle.ManagedDataAccess.Core` (between the `OPCFoundation.*` block and `OpenTelemetry`), latest + stable versions on the feed. `CentralPackageTransitivePinningEnabled` is OFF by design, so if + restore surfaces an NU1903 in either package's transitive tree, the fix is the established + surgical direct-pin pattern (`Directory.Packages.props:118-145`), never a suppress. +- **Credential discipline holds automatically**: `SqlDriver` already renders endpoints via the + provider-neutral `DbConnectionStringBuilder` reading `host`/`data source` keys + (`SqlDriver.cs:47-51, :952-968`) and logs exception types only. + +## 3. Out of scope + +- `MySql`/`Odbc` dialects, write support (`IWritable` stays structurally absent, `allowWrites` + stays inert), the deferred `Query` tag model, connection pooling knobs, and Kerberos/wallet + auth schemes (connection-string concerns, owned by the operator's `Sql__ConnectionStrings__*` + env value). + +## 4. Testing + +1. **Unit.** + - `PostgresDialectTests` / `OracleDialectTests`: quoting (escape doubling, length caps, + control-char rejection — mirror `SqlServerDialectTests`), liveness/limit/catalog SQL text, + type maps, markers/bind names, and `ConfigureCommand` (Oracle: a real `OracleCommand` has + `BindByName == true` after the call; others: no-op). + - Planner/loader marker plumbing: a colon-marker fake dialect (or `SqliteDialect` extended) + proves the planner emits dialect markers and the reader binds the dialect's bind names — + the falsifiability control for gap 1. + - Gate tests updated as in §2 (MySql becomes the rejected control). + - The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism. +2. **Integration (env-gated, skip-clean, docker).** Two new fixtures under + `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/`, deployed to the shared + docker host per the established pattern: + - `postgres:17-alpine` (project `otopcua-sql-pg`, host port 15432) — env + `SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`. + - `gvenzl/oracle-free:23-slim` (project `otopcua-sql-oracle`, host port 11521) — env + `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/`SQL_ORACLE_PASSWORD`. + Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded + schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog), + browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider- + reference discipline (`.IntegrationTests.csproj:29-33`) — packages arrive transitively. +3. **Live `/run` chrome gate — DEFERRED, run separately by the operator.** AdminUI legs (provider + dropdown authoring, Test-connect, `/raw` browse against the Postgres and Oracle fixtures, + browse-commit of a column). The executing session must NOT run this; it hands the operator the + command at the end. + +## 5. Bookkeeping + +`docs/drivers/Sql.md` (§providers table, config table, browse section), +`SqlProvider.cs` doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and +`docs/research/drivers/sql-poll.md` stays as-is (research is a record, not living doc). diff --git a/docs/plans/2026-08-05-sql-postgres-oracle.md b/docs/plans/2026-08-05-sql-postgres-oracle.md new file mode 100644 index 00000000..5ec6f518 --- /dev/null +++ b/docs/plans/2026-08-05-sql-postgres-oracle.md @@ -0,0 +1,210 @@ +# Sql Postgres + Oracle providers Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development +> (opus implementers per user instruction). + +**Goal:** Make `SqlProvider.Postgres` and `SqlProvider.Oracle` working backends via managed +ADO.NET providers (Npgsql, Oracle.ManagedDataAccess.Core). + +**Architecture:** See `docs/plans/2026-08-05-sql-postgres-oracle-design.md` (this branch). Two new +dialects in the existing `.Dialect` project; dialect-owned parameter markers/bind-names + +`ConfigureCommand` (Oracle BindByName); provider gates widened; AdminUI dropdown; env-gated docker +integration suites. Chrome/AdminUI live gate is DEFERRED to the operator. + +**Tech Stack:** .NET 10, central package management (`Directory.Packages.props`), xUnit v3 + +Shouldly, SQLite falsifiability control. + +**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/sql-multi-provider` +(branch `feature/sql-multi-provider`). All paths below relative to it. + +--- + +### Task 0: Package pins + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 5 + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj` + +Add `PackageVersion` entries for `Npgsql` (alphabetical slot after `MQTTnet`) and +`Oracle.ManagedDataAccess.Core` (after the `OPCFoundation.*` block) — latest stable on the feed +(`dotnet package search --exact-match` to confirm; Npgsql 9.x line, ODP.NET Core 23.x line; +both must target net8.0+). Add both `PackageReference`s (version-less) to the `.Dialect` csproj +under the existing `Microsoft.Data.SqlClient` reference, extending that ItemGroup's +rationale comment. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` must come back clean WITH the audit +active — if NU1903 fires on a transitive dep, apply the surgical direct-pin pattern documented at +`Directory.Packages.props:118-145`, never a suppress. Commit +`build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only)`. + +### Task 1: Dialect-owned parameter naming + `ConfigureCommand` + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 0, Task 5 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs` (:21-25 consts, + :143, :151, :188) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs` (bind names alongside + markers if the plan record needs both) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs` (:379-405 — ConfigureCommand + call + bind-name copy) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs` (:97-113, `Bind` :181-188) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs` (ConfigureCommand) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs` (ConfigureCommand) +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs` (implement new + members; keep linked copy in Browser.Tests compiling) +- Test: `SqlGroupPlannerTests.cs`, `SqlPollReaderTests.cs`, `SqlCatalogGateDriverTests.cs` (+ a + colon-marker fake dialect in the test tree) + +Per design §2 bullet 2. `ISqlDialect` gains `ParameterMarker(string baseName)`, +`ParameterBindName(string baseName)`, `ConfigureCommand(DbCommand)`; SqlServerDialect returns +`"@"+name` / `"@"+name` / no-op — **behaviour today must be byte-identical** (existing planner +SQL-text assertions must pass untouched; if any needs editing, that is a regression, stop). +Planner takes markers from its dialect; catalog SQL text and loader bind names route through the +dialect so the two can never disagree. TDD: the colon-marker fake proves markers flow (observed +red against the `@k` consts first). Every `CreateCommand` site calls `ConfigureCommand` +immediately after creation. Commit +`refactor(sql): dialect-owned parameter markers, bind names and command configuration`. + +### Task 2: `PostgresDialect` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3, Task 5 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs` (new) + +Per design §2 Postgres bullet. `Factory => NpgsqlFactory.Instance`; `Provider => +SqlProvider.Postgres`; `@` markers/bind names; `"`-quoting `""`-escaped, MaxIdentifierLength 63, +same control-char/Unicode-Format rejection discipline as `SqlServerDialect.QuoteIdentifier` +(:88-114); `LivenessSql "SELECT 1"`; limit `""`/`" LIMIT 1"`; catalog SQL over +`information_schema.schemata/tables/columns` excluding `pg_catalog` + `information_schema`, +`DefaultSchemaSql "SELECT current_schema()"` (parameter markers in catalog SQL via the Task 1 +members); `MapColumnType` per design (unknown → the same fallback SqlServerDialect uses — read it +first). Tests mirror `SqlServerDialectTests` structure: quoting theory, SQL text, type-map theory, +marker members. Commit `feat(sql): PostgresDialect (Npgsql)`. + +### Task 3: `OracleDialect` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 5 + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs` (new) + +Per design §2 Oracle bullet. `OracleClientFactory.Instance`; `:` markers, **bare** bind names; +`ConfigureCommand` casts to `OracleCommand` and sets `BindByName = true` (the test constructs a +real `OracleCommand` and asserts the flag — this is the load-bearing test of the whole branch; +also assert a foreign `DbCommand` passes through untouched). Quoting `"`/`""`, max 128; +`LivenessSql "SELECT 1 FROM DUAL"`; limit `""`/`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL: +`ALL_USERS` schemas, `ALL_TABLES` ∪ `ALL_VIEWS` tables, `ALL_TAB_COLUMNS` columns, +`sys_context('userenv','current_schema')` default schema — markers via `:schema`/`:table` through +the Task 1 members; `MapColumnType`: `NUMBER`→Float64 (doc the DeclaredType escape hatch in the +XML doc comment), `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`/`CLOB`, +`DATE`/`TIMESTAMP*`, `RAW`, plus `FLOAT`/`INTEGER` synonyms. Commit +`feat(sql): OracleDialect (ODP.NET managed, BindByName)`. + +### Task 4: Widen the provider gates + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs` (:208-215) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs` (:126-127, + :146-151 comment) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs` (doc comments) +- Test: `SqlDriverFactoryTests.cs` (:133-135 → MySql as rejected control; new: Postgres/Oracle + construct with their dialects), `SqlDriverBrowserTests.cs` (:188-196 same flip) + +TDD: red first — author `{"provider":"Postgres"}` and assert construction reaches the +connection-string resolver (or dialect type assertion via the binding) instead of the +not-available throw. Rejection message updated to name the three supported providers. Commit +`feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved`. + +### Task 5: AdminUI dropdown + docs + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 2, Task 3 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor` + (:18-25) +- Modify: `docs/drivers/Sql.md` (:15-18, :96 provider row, browse section) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend the existing Sql driver-form + model test file (find `SqlDriverFormModelTests` or nearest equivalent) with round-trip of + `provider: Postgres` / `Oracle` + +Dropdown gains `Postgres` + `Oracle` options; helper text → "SQL Server, PostgreSQL and Oracle +are supported; MySql/Odbc are reserved." Sql.md rewritten per design §5 including the Oracle +`NUMBER`→Float64 note and the 12.2+ floor. Commit +`feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs`. + +### Task 6: Integration fixtures + env-gated suites + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml` + + `seed-pg.sql` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml` + + `seed-oracle.sql` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs`, + `OracleReadTests.cs` + +Per design §4.2. Compose shapes mirror the existing mssql fixture (`Docker/docker-compose.yml`: +project label `project: lmxopcua`, one-shot seed service, healthcheck): `postgres:17-alpine` +port `15432:5432` env `POSTGRES_PASSWORD=${SQL_PG_PASSWORD}`; `gvenzl/oracle-free:23-slim` port +`11521:1521` env `ORACLE_PASSWORD=${SQL_ORACLE_PASSWORD}` (gvenzl images take `APP_USER`/ +`APP_USER_PASSWORD` for a non-SYS user — use `otopcua`/the password env). Seeds create one +KeyValue table + one WideRow table with a timestamp column, plus (Oracle) a deliberately +UPPER-CASE table authored lower-case in the test to pin catalog case-folding. Test classes follow +`SqlServerReadTests`/`SqlBlackholeTimeoutTests` conventions exactly: env vars +`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`, `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/ +`SQL_ORACLE_PASSWORD`; `Assert.SkipWhen` absent; connection strings via each provider's builder +obtained FROM THE FACTORY (`dialect.Factory.CreateConnectionStringBuilder()`) so the csproj keeps +its no-direct-provider-reference discipline (`.IntegrationTests.csproj:29-33`). Legs per suite: +initialize (liveness + catalog), KeyValue read, WideRow topByTimestamp read, browse-session +walk, case-folding leg. Commit `test(sql): env-gated Postgres + Oracle integration suites`. + +### Task 7: Full-suite verification + bookkeeping + +**Classification:** small +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors), full Sql unit + Browser.Tests + AdminUI.Tests + +Configuration.Tests (the SqlCredentialGuard suites must stay green), integration suites compile + +skip clean offline. Update `docs/plans/2026-07-24-driver-expansion-tracking.md` Sql row/deferral +note. Commit. + +### Task 8: Fixture deploy + live integration run (main session) + +**Classification:** standard +**Estimated implement time:** ~15 min (main session, after merge) + +Main session, not a subagent: rsync the two fixture dirs to `dohertj2@10.100.0.35:/opt/otopcua-sql-pg/` +and `/opt/otopcua-sql-oracle/`, `docker compose up -d` each over ssh, then run +`dotnet test --filter "FullyQualifiedName~PostgresRead|FullyQualifiedName~OracleRead"` with the +env vars set. (oracle-free first pull is ~1.5 GB — allow time; if the host can't take it, record +the suite as authored-but-not-yet-run.) Record results in the design doc §Outcome. + +**Chrome/AdminUI live gate — DEFERRED to the operator.** After merge the operator runs, in a +chrome-capable session: +`/superpowers-extended-cc:executing-plans docs/plans/2026-08-05-sql-postgres-oracle.md` — gate +section: provider dropdown authoring, Test-connect, `/raw` browse + browse-commit against both +fixtures at `http://localhost:9200`. diff --git a/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json b/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json new file mode 100644 index 00000000..de102425 --- /dev/null +++ b/docs/plans/2026-08-05-sql-postgres-oracle.md.tasks.json @@ -0,0 +1,15 @@ +{ + "planPath": "docs/plans/2026-08-05-sql-postgres-oracle.md", + "tasks": [ + { "id": 0, "subject": "Task 0: Package pins", "status": "completed" }, + { "id": 1, "subject": "Task 1: Dialect-owned parameter naming + ConfigureCommand", "status": "completed" }, + { "id": 2, "subject": "Task 2: PostgresDialect", "status": "completed", "blockedBy": [0, 1] }, + { "id": 3, "subject": "Task 3: OracleDialect", "status": "completed", "blockedBy": [0, 1] }, + { "id": 4, "subject": "Task 4: Widen the provider gates", "status": "completed", "blockedBy": [2, 3] }, + { "id": 5, "subject": "Task 5: AdminUI dropdown + docs", "status": "completed" }, + { "id": 6, "subject": "Task 6: Integration fixtures + env-gated suites", "status": "completed", "blockedBy": [4], "note": "found+fixed ORA-01745 (ec90b7c4); both suites ran live locally (pg 5/5, oracle 6/6)" }, + { "id": 7, "subject": "Task 7: Full-suite verification + bookkeeping", "status": "completed", "blockedBy": [5, 6] }, + { "id": 8, "subject": "Task 8: Fixture deploy + live integration run (main session); chrome gate deferred to operator", "status": "pending", "blockedBy": [7] } + ], + "lastUpdated": "2026-08-05T00:00:00Z" +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs index ff397fcd..6cc9236b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; /// Every level is the dialect's catalog SQL ( / /// / ) — nothing here /// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is -/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId -/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here. +/// read verbatim and its schema / table parameters are bound as parameters under +/// — so the bind names cannot drift from the markers the +/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a +/// hostile or merely awkward catalog name is inert here. /// is deliberately not used on this path — the browse /// never needs an identifier in text. /// Connection ownership: this session owns the connection it is handed and closes it on @@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession /// The TABLE_TYPE value marking a view rather than a base table. private const string ViewTableType = "VIEW"; + /// Base name of the schema parameter every dialect's catalog SQL carries. + private const string SchemaParameter = "schema"; + + /// Base name of the table parameter carries. + private const string TableParameter = "table"; + /// /// The security class every browsed column reports. The Sql driver is read-only in v1 /// (design §4.3), so there is nothing else a picked column could be. @@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession { var rows = await QueryAsync( _dialect.ListTablesSql, - command => Bind(command, "@schema", schema), + command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema), static reader => ( Name: ReadString(reader, TableNameColumn), Type: ReadOptionalString(reader, TableTypeColumn)), @@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession _dialect.ListColumnsSql, command => { - Bind(command, "@schema", schema); - Bind(command, "@table", table); + Bind(command, _dialect.ParameterBindName(SchemaParameter), schema); + Bind(command, _dialect.ParameterBindName(TableParameter), table); }, static reader => ( Name: ReadString(reader, ColumnNameColumn), @@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession var results = new List(); await using var command = _connection.CreateCommand(); + // Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter + // column query is exactly where positional binding swaps the schema and the table silently. + _dialect.ConfigureCommand(command); command.CommandText = sql; bind(command); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs index 2b0bd7a1..e67d5bd8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser /// /// Maps an authored onto the dialect that browses it, or /// for a provider this build does not carry. Defaults to - /// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2 - /// provider gets added without touching this class. + /// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how + /// a further provider gets added without touching this class. /// /// /// Reads one environment variable. Defaults to . @@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser ConnectionStringEnvironmentPrefix + connectionStringRef; /// - /// The providers this build can browse. v1 constructs SQL Server only; every other - /// member is a reserved name, so it resolves to and - /// the caller reports it as unavailable rather than crashing on a null dialect. + /// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining + /// members (, ) + /// are reserved names, so they resolve to and the caller reports them as + /// unavailable rather than crashing on a null dialect. + /// Kept in step with SqlDriverFactoryExtensions.CreateDialect: a provider the runtime + /// driver constructs but the browser cannot select would leave the operator authoring tags by hand + /// against a database the AdminUI refuses to show them. /// /// The authored provider. /// The dialect, or when this build does not carry the provider. - public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => - provider == SqlProvider.SqlServer ? new SqlServerDialect() : null; + public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch + { + SqlProvider.SqlServer => new SqlServerDialect(), + SqlProvider.Postgres => new PostgresDialect(), + SqlProvider.Oracle => new OracleDialect(), + _ => null, + }; /// /// @@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser var literal = ReadPastedConnectionString(configJson); var config = Deserialize(configJson, literal); - // Provider first: "this build cannot browse Postgres" is true regardless of how the database is + // Provider first: "this build cannot browse MySql" is true regardless of how the database is // named, and answering it before touching the environment keeps the two failures from masking. var dialect = _dialectSelector(config.Provider) ?? throw new InvalidOperationException( - $"Sql provider '{config.Provider}' is not available in this build; v1 browses " - + $"'{SqlProvider.SqlServer}' only."); + $"Sql provider '{config.Provider}' is not available in this build; browsing supports " + + $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' " + + $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names)."); var reference = config.ConnectionStringRef; var connectionString = ResolveConnectionString(literal, reference); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs index 2bca5d24..1e29f05c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs @@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; /// public sealed class SqlDriverConfigDto { - /// Which ADO.NET provider/dialect backs this instance. Defaults to — the only provider v1 constructs. + /// Which ADO.NET provider/dialect backs this instance. Defaults to ; and are also constructed. public SqlProvider Provider { get; init; } = SqlProvider.SqlServer; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs index 67879307..0dd44c55 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs @@ -1,21 +1,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; -/// Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer. +/// +/// Which ADO.NET provider/dialect backs a Sql driver instance. Constructed: SqlServer, Postgres, +/// Oracle — each over a fully managed provider package, no native client install. Reserved: MySql +/// and Odbc are declared names only; authoring one fails at driver construction and at browse. +/// public enum SqlProvider { - /// Microsoft SQL Server via Microsoft.Data.SqlClient — the only provider v1 constructs. + /// Microsoft SQL Server via Microsoft.Data.SqlClient. SqlServer, - /// PostgreSQL (Npgsql) — reserved for P2; not constructed in v1. + /// PostgreSQL via the managed Npgsql provider. Postgres, - /// MySQL / MariaDB — reserved; not constructed in v1. + /// MySQL / MariaDB — reserved; no dialect ships, so this is not constructed. MySql, - /// Generic ODBC — reserved for P2; not constructed in v1. + /// Generic ODBC — reserved; no dialect ships, so this is not constructed. Odbc, - /// Oracle — reserved; not constructed in v1. + /// Oracle Database via the fully managed Oracle.ManagedDataAccess.Core provider. Oracle, } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs index 31dae6ef..2dd04716 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs @@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// public interface ISqlDialect { - /// Which backend this dialect speaks. v1 constructs only. + /// + /// Which backend this dialect speaks. One implementation ships per constructed + /// member — , + /// and . + /// SqlProvider Provider { get; } /// @@ -50,6 +54,58 @@ public interface ISqlDialect /// The value cannot be a valid catalog identifier. string QuoteIdentifier(string ident); + /// + /// The token that stands for a bound parameter in the command text — "@k0" for + /// SqlClient/Npgsql, ":p_k0" for ODP.NET — given the caller's bare base name ("k0", + /// "w", "schema"). + /// Why the marker and the bind name are two members. They are the same string on every + /// provider but Oracle, where the SQL text carries :p_schema while + /// is the bare schema. Modelling one and deriving the + /// other means a call site guessing at a convention it does not own — the exact defect this seam + /// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under + /// bind-by-position, as a silently mis-bound value) rather than a compile error. + /// The base name is caller-owned and never operator input: the poll planner mints + /// k0…kN/w, the catalog path uses schema/table. Authored values + /// ride the parameter; authored identifiers go through . Nothing + /// else reaches a command text. + /// + /// The bare base name, without any provider prefix. + /// The marker to embed in the SQL text. + string ParameterMarker(string baseName); + + /// + /// The value to assign to for the same + /// — "@k0" for SqlClient-style providers (which expect the marker + /// verbatim), the bare "k0" for ODP.NET-style ones (whose documented convention is a + /// prefix-less bind name). + /// Always paired with over the same base name: a plan + /// carries both lists and every binding site copies them index-for-index rather than + /// re-deriving either from the other. + /// + /// The bare base name, without any provider prefix. + /// The parameter name to bind under. + string ParameterBindName(string baseName); + + /// + /// Applies provider-specific setup to a freshly created , before its + /// parameters are added or it is executed. A no-op for providers that need none. + /// Oracle uses this to set OracleCommand.BindByName = true, and that call is the + /// sharpest correctness edge on this seam: ODP.NET binds by position by default, so a + /// multi-parameter statement (the planner's IN (:p_k0, :p_k1, …) list, the catalog path's + /// schema + table pair) would bind values to the wrong markers whenever the parameter + /// order and the marker order differ — publishing one tag's value onto another tag's node with no + /// error anywhere. Every command that carries parameters — the poll path, the catalog path, the + /// browse path — therefore calls this immediately after CreateCommand() and before the + /// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness + /// ones are indifferent: SqlDriverProbe calls it anyway for uniformity, + /// SqlDriver.PingAsync does not.) + /// Implementations must tolerate any they are handed: the + /// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own + /// provider type must do so defensively rather than throwing. + /// + /// The command to configure. + void ConfigureCommand(DbCommand command); + /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL). string LivenessSql { get; } @@ -105,10 +161,19 @@ public interface ISqlDialect /// string DefaultSchemaSql { get; } - /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema. + /// + /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one + /// parameter under the base name schema, spelled with this dialect's own + /// (@schema on SqlClient, :p_schema on ODP.NET); callers bind + /// it under of the same base name. + /// string ListTablesSql { get; } - /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table. + /// + /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries + /// parameters under the base names schema and table, spelled and bound exactly as + /// describes. + /// string ListColumnsSql { get; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs new file mode 100644 index 00000000..a6fd198e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs @@ -0,0 +1,301 @@ +using System.Data.Common; +using System.Globalization; +using Oracle.ManagedDataAccess.Client; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// Oracle Database dialect over the fully managed ODP.NET provider +/// (Oracle.ManagedDataAccess.Core) — no Instant Client, no native library, no bitness constraint. +/// Double-quote identifier quoting, : parameter markers with colon-less bind names, the +/// ALL_* catalog views in place of the INFORMATION_SCHEMA Oracle does not have, and +/// FETCH FIRST row limiting. +/// Supported floor: Oracle 12.2. Two things pin it — FETCH FIRST n ROWS ONLY arrived +/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a +/// long-but-legal-here identifier would be rejected by the server rather than by +/// , which is a clear error at the right layer, not a silent wrong answer. +/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and +/// the AdminUI browse session. +/// +public sealed class OracleDialect : ISqlDialect +{ + /// + /// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog + /// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded. + /// + internal const int MaxIdentifierLength = 128; + + /// + public SqlProvider Provider => SqlProvider.Oracle; + + /// + public DbProviderFactory Factory => OracleClientFactory.Instance; + + /// + /// Prepended to every Oracle bind name. It is what keeps a bind name from colliding with an + /// Oracle reserved word, which is a hard error rather than a style problem — see + /// . + /// + private const string BindPrefix = "p_"; + + /// + /// Oracle markers are :-prefixed and namespaced — ParameterMarker("k0") is + /// ":p_k0". + /// + /// + /// The p_ is load-bearing, and was found live. Oracle rejects a bind variable + /// whose name is a reserved word with ORA-01745: invalid host/bind variable name — and the + /// catalog readers' own base names include table (SqlCatalogLoader / + /// SqlBrowseSession bind schema + table). So an un-prefixed + /// could not execute at all: every Oracle driver instance failed its + /// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's + /// columns threw. Caught by OracleReadTests against a real database; invisible to any offline + /// test, because the SQL is only ever parsed by a server. + /// Prefixing everything rather than maintaining a reserved-word list is deliberate: + /// the list is long, version-dependent, and a future base name (date, user, + /// comment, level…) would re-open the same hole silently. A prefixed name can never be + /// a reserved word, and the marker and the bind name are derived from the same constant, so they + /// cannot drift apart. + /// + /// The bare base name. + /// The :-prefixed, namespaced marker. + public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName; + + /// + /// The same namespaced name without the : — ODP.NET's documented convention for + /// , and deliberately not + /// . This is the divergence that makes the two members exist at all + /// (see ). + /// + /// The bare base name. + /// The colon-less bind name. + public string ParameterBindName(string baseName) => BindPrefix + baseName; + + /// + /// Sets OracleCommand.BindByName = true. This is the sharpest correctness edge in the + /// driver's provider support. + /// ODP.NET binds by position by default: parameter 0 fills the first marker in the + /// statement, parameter 1 the second, and so on, regardless of the names. Every statement this + /// driver builds with more than one marker would then be a coin flip — the planner's + /// IN (:p_k0, :p_k1, …) key list, and 's schema + table + /// pair — and the failure mode is not an exception but a successful query over the wrong rows, + /// publishing one tag's value onto another tag's node. Worse still, + /// names :p_schema twice (once per branch of its + /// UNION ALL) and is bound once: under positional binding that statement cannot even execute, + /// because the second occurrence has no parameter to take. + /// Must be called while the parameter collection is still empty — which is exactly what every + /// call site does (immediately after CreateCommand(), before the first bind). + /// A foreign command passes through untouched rather than throwing. The dialect can be + /// handed a from another provider — an instrumented or substituted factory in + /// a shared test harness is the realistic case — and an InvalidCastException there would fail a + /// poll for a reason that has nothing to do with the database. There is nothing to configure on a + /// non-ODP.NET command anyway: every other provider this repo speaks binds by name already. + /// + /// The command to configure; only an is touched. + public void ConfigureCommand(DbCommand command) + { + if (command is OracleCommand oracle) oracle.BindByName = true; + } + + /// Oracle has no bare SELECT; every projection needs a table, and DUAL is the one-row one. + public string LivenessSql => "SELECT 1 FROM DUAL"; + + /// + /// Empty — Oracle 12c+ spells the row limit at the end of the statement (see + /// ). Anything here would land between SELECT and the column + /// list, where Oracle has no limit syntax at all. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a + /// finished statement. + /// + /// + /// Deliberately not the older WHERE ROWNUM = 1: ROWNUM is assigned before + /// ORDER BY runs, so it would take an arbitrary row and then sort it — silently returning the + /// wrong row for the topByTimestamp selector, whose entire purpose is "the newest one". Making + /// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the + /// statement shape, which this seam deliberately leaves with the planner. + /// + public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY"; + + /// + /// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a + /// user), aliased to the TABLE_SCHEMA column name the catalog readers project by. + /// + /// + /// Broader than the SQL Server dialect's DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES, + /// which lists only schemas that own an object: ALL_USERS includes empty ones. Harmless here — + /// the list is used to resolve an authored schema name to the catalog's own spelling, and a schema + /// holding no tables simply resolves and then matches no relation. + /// + public string ListSchemasSql => + "SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username"; + + /// + /// sys_context('userenv','current_schema') is the schema an unqualified name resolves in for + /// this session — which is what the catalog gate asks, and it correctly follows an + /// ALTER SESSION SET CURRENT_SCHEMA rather than reporting the login name. + /// + public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual"; + + /// + /// Tables and views in one schema, unioned because Oracle splits them across two catalog views where + /// INFORMATION_SCHEMA.TABLES carries both. The synthesised TABLE_TYPE literals are the + /// INFORMATION_SCHEMA spellings the browser compares against to mark a view node. + /// + /// + /// UNION ALL rather than UNION: the two sources are disjoint by construction (a name + /// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort. + /// Note this statement names :p_schema twice and is bound once — see + /// . + /// + public string ListTablesSql => + "SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " + + "UNION ALL " + + "SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " + + "ORDER BY TABLE_NAME"; + + /// + /// Columns of one relation, from ALL_TAB_COLUMNS — which covers views as well as tables, so it + /// serves both halves of . column_id is Oracle's + /// ORDINAL_POSITION. + /// + /// + /// ALL_TAB_COLUMNS.NULLABLE is Y/N where INFORMATION_SCHEMA.IS_NULLABLE is + /// YES/NO, so it is translated rather than merely aliased: a column that carries a + /// shared name across dialects has to carry shared values too, or the first consumer to read + /// it gets a different answer per provider. + /// + public string ListColumnsSql => + "SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " + + "CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " + + "FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — the Oracle escape rule, and the + /// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the + /// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged. + /// + /// + /// Case matters once an identifier is quoted. Oracle upper-cases unquoted identifiers but + /// takes a quoted one literally, so "speed" and "SPEED" are different columns. This is + /// safe here only because the catalog gate has already substituted the catalog's own spelling + /// (ALL_TAB_COLUMNS.column_name, i.e. what the server actually stored) before anything reaches + /// this method — an operator who authored speed against an Oracle SPEED column resolves + /// to SPEED at Initialize, and it is SPEED that gets quoted. + /// Rejects (all as ): null / empty / all-whitespace; longer + /// than ; any control character (which covers embedded NUL, and the + /// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode + /// character — bidi overrides, zero-width spaces, soft hyphen, + /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line or + /// an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source class, + /// CVE-2021-42574) — the same log/audit-integrity concern that motivates the control-character rule. + /// The rejection messages deliberately do not echo the offending value — it is untrusted + /// input and this exception's text reaches the driver log. + /// This is a backstop, not the only defence — see + /// on the catalog gate. The rules below still run, and must + /// hold for any future caller that has no catalog to check against. + /// + /// The bare, unquoted identifier part. + /// The double-quoted identifier. + /// The value cannot be a valid Oracle identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + if (ident.Length > MaxIdentifierLength) + throw new ArgumentException( + $"An Oracle identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + + // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and + // zero-width character. They are inert inside the quotes but spoof rendered log/UI text. + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format) + throw new ArgumentException( + "A SQL identifier may not contain Unicode format characters " + + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident)); + } + + return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + } + + /// + /// Folds an ALL_TAB_COLUMNS.DATA_TYPE family name onto a . + /// + /// + /// NUMBER maps to , and that is a deliberate + /// under-specification. Oracle has one numeric type: an integer column, a currency column and a + /// scientific measurement are all NUMBER, differing only in the precision/scale this method + /// never sees — its whole input is the family name. Choosing an integral driver type would truncate + /// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a + /// NUMBER(38) beyond 2^53 loses low-order digits in a double, and an integer-valued + /// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring + /// an explicit "type" on the tag (Int64 for a true integer key, String to keep an + /// exact high-precision decimal), which overrides this inference. + /// TIMESTAMP is matched by prefix, unlike every other entry. Oracle reports the + /// fractional-seconds precision and the time-zone qualifier inside the family name — + /// TIMESTAMP(6), TIMESTAMP(6) WITH TIME ZONE, TIMESTAMP(6) WITH LOCAL TIME ZONE — + /// where INFORMATION_SCHEMA would report a bare timestamp. An exact-match table would + /// send all of them to the String fallback. + /// Fallback: an unrecognised family maps to and never + /// throws — a schema browse must render a table containing an exotic column (RAW, + /// LONG RAW, BLOB, BFILE, ROWID, XMLTYPE, an object type) rather + /// than crash on it. Null/blank input takes the same fallback. RAW/BLOB land here for + /// the same reason SQL Server's varbinary does: there is no binary driver type. + /// Deliberate non-mapping: the INTERVAL types are durations, not points in time, + /// and take the String fallback rather than being mistaken for a + /// — the same call the SQL Server dialect makes for + /// time. + /// Oracle's DATE carries a time-of-day component (it is not a date-only type), so + /// is the faithful mapping rather than a widening. + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + // ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server + // dialect does) keeps the arms readable as the names an operator sees in the browse tree. + var family = sqlDataType.Trim().ToUpperInvariant(); + + // Prefix, not equality: the precision and time-zone qualifier live inside the family name. + if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime; + + return family switch + { + // One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are + // resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are + // here so a server or gateway that does surface one is not silently mapped to String. + "NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION" + or "INTEGER" or "INT" or "SMALLINT" + or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64, + + // The only single-precision storage type Oracle has. + "BINARY_FLOAT" => DriverDataType.Float32, + + "VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR" + or "CLOB" or "NCLOB" => DriverDataType.String, + + // Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as + // NUMBER(1) or CHAR(1), which are invisible here and map by their own family. + "BOOLEAN" => DriverDataType.Boolean, + + "DATE" => DriverDataType.DateTime, + + _ => DriverDataType.String, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs new file mode 100644 index 00000000..eaa5a9b6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs @@ -0,0 +1,251 @@ +using System.Data.Common; +using System.Globalization; +using Npgsql; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// PostgreSQL dialect (design §2), over the fully managed Npgsql ADO.NET provider — no native +/// client install anywhere. Double-quote identifier quoting, information_schema catalog SQL, an +/// end-of-statement LIMIT 1, and a type map over the type names +/// information_schema.columns.data_type actually reports. +/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and +/// the AdminUI browse session — the same contract SqlServerDialect holds to. +/// +public sealed class PostgresDialect : ISqlDialect +{ + /// + /// PostgreSQL's identifier ceiling — NAMEDATALEN - 1, 63 by default. Enforced because the + /// server silently truncates anything longer, so a 70-character authored name would resolve to + /// a different object than it spells; rejecting it surfaces the config mistake instead. + /// + internal const int MaxIdentifierLength = 63; + + /// + public SqlProvider Provider => SqlProvider.Postgres; + + /// + public DbProviderFactory Factory => NpgsqlFactory.Instance; + + /// + /// Npgsql markers are @-prefixed, exactly as SQL Server's are — ParameterMarker("k0") + /// is "@k0". (Npgsql also accepts :, but this dialect commits to one spelling so the + /// catalog SQL below and the planner's markers can never drift apart.) + /// + /// The bare base name. + /// The @-prefixed marker. + public string ParameterMarker(string baseName) => "@" + baseName; + + /// + /// Identical to : NpgsqlParameter.ParameterName is matched against + /// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps + /// the parameter collection readable in a log and matches what the SQL text says. + /// + /// The bare base name. + /// The @-prefixed bind name. + public string ParameterBindName(string baseName) => "@" + baseName; + + /// + /// No-op. NpgsqlCommand binds by name out of the box, so there is nothing Postgres needs set on + /// a fresh command — the member exists for Oracle's BindByName (see + /// ). It deliberately does not cast + /// , so a substituted or instrumented factory's command passes through + /// untouched rather than throwing. + /// + /// The command to configure; untouched here. + public void ConfigureCommand(DbCommand command) + { + // Intentionally empty: see the summary. + } + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// Empty — Postgres spells its row limit at the end of the statement, in + /// . The mirror image of T-SQL's TOP 1, which is exactly why + /// the seam carries both ends. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// " LIMIT 1", leading space included so it appends straight onto the finished statement + /// (… ORDER BY "sample_ts" DESC LIMIT 1). + /// + public string SingleRowLimitSuffix => " LIMIT 1"; + + /// + /// The schemas holding relations this connection can actually see, system schemas excluded. + /// + /// + /// Sourced from information_schema.tables, not information_schema.schemata + /// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape. + /// Postgres' schemata view lists only schemas the current role owns, so a service + /// account granted USAGE + SELECT on someone else's schema — the normal way a read-only + /// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving + /// the list from the relations the principal can see answers the question the catalog gate is really + /// asking: "which schemas hold something I may read?" + /// What counts as a system schema. pg_catalog and information_schema are + /// named explicitly (design §2). pg_toast and the per-backend pg_temp_N / + /// pg_toast_temp_N namespaces are excluded by prefix as well: they hold storage internals and + /// session-local temp objects, can never contain an authored poll target, and a pg_temp_N in + /// particular names a different thing per session — offering it in the browse tree would present a + /// schema that vanishes. The ESCAPE clause makes the _ literal rather than the + /// single-character wildcard LIKE would otherwise treat it as. + /// Aliased "TABLE_SCHEMA" in double quotes — see . + /// + public string ListSchemasSql => + "SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " + + "WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " + + "AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " + + "AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " + + "ORDER BY \"TABLE_SCHEMA\""; + + /// + /// current_schema() is the first schema on the connection's search_path — the one an + /// unqualified object name resolves in, which is precisely the question the catalog gate asks. + /// + public string DefaultSchemaSql => "SELECT current_schema()"; + + /// + /// Tables + views in one schema, matching SqlServerDialect.ListTablesSql column for column + /// (TABLE_NAME, TABLE_TYPE, ordered by name) so both consumers read the same shape. + /// + /// + /// The aliases are quoted, and that is load-bearing. SqlCatalogLoader and + /// SqlBrowseSession both read every catalog column by name + /// (DbDataReader.GetOrdinal("TABLE_NAME")), while Postgres folds an unquoted output + /// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those + /// consumers ask for rather than relying on a provider's case-insensitive GetOrdinal + /// fallback. + /// The @schema marker is written literally here because + /// spells it identically; the two agree by construction on this dialect + /// (they diverge only on ODP.NET), and a test pins that they do. + /// + public string ListTablesSql => + "SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " + + "FROM information_schema.tables " + + "WHERE table_schema = @schema ORDER BY \"TABLE_NAME\""; + + /// + /// Columns of one table in ordinal order — the same three columns + /// SqlServerDialect.ListColumnsSql projects (COLUMN_NAME, DATA_TYPE, + /// IS_NULLABLE), quoted for the reason gives. + /// + public string ListColumnsSql => + "SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " + + "is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " + + "WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — the SQL-standard escape rule + /// Postgres follows, and the only metacharacter that could terminate a quoted identifier early. + /// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through + /// unchanged. + /// + /// + /// Rejects (all as , mirroring + /// SqlServerDialect.QuoteIdentifier so a test written against one dialect fails the same way + /// against the other): null / empty / all-whitespace; longer than + /// ; any control character (which covers embedded NUL and the C0/C1 + /// ranges that can truncate or corrupt a logged/audited statement); and any Unicode + /// character — bidi overrides, zero-width spaces, soft hyphen, + /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line + /// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source + /// class, CVE-2021-42574). + /// NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string + /// outright, so passing one through would fail the whole statement rather than just the one + /// identifier. + /// The rejection messages deliberately do not echo the offending value — it is untrusted + /// input and this exception's text reaches the driver log. + /// This is a backstop, not the only defence. Design §8.1's catalog gate + /// (SqlCatalogGate) resolves an authored table/column against the live catalog at Initialize + /// and substitutes the catalog's own spelling — which on Postgres is the lower-cased one an + /// unquoted CREATE TABLE produced — so on the poll path the value arriving here is a string + /// read back out of the catalog. + /// + /// The bare, unquoted identifier part. + /// The double-quoted identifier. + /// The value cannot be a valid PostgreSQL identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + if (ident.Length > MaxIdentifierLength) + throw new ArgumentException( + $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + + // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and + // zero-width character. They are inert inside the quotes but spoof rendered log/UI text. + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format) + throw new ArgumentException( + "A SQL identifier may not contain Unicode format characters " + + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident)); + } + + return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + } + + /// + /// Folds an information_schema.columns.data_type family name onto a + /// per design §3.7. + /// + /// + /// The input is the canonical, spelled-out name Postgres reports — + /// double precision, character varying, timestamp without time zone — never the + /// float8/varchar/timestamptz aliases and never a length + /// (character varying(50) is reported as character varying). The internal aliases are + /// accepted anyway, so a caller reading types from pg_catalog instead maps identically. + /// Precision caveat: numeric / decimal / money collapse to + /// , as they do on SQL Server. A Postgres numeric is + /// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit + /// "type": "String" on the tag preserves the exact textual value when that matters. + /// Fallback: an unrecognised family maps to and never + /// throws — a schema browse must render a table containing an exotic column (json, + /// jsonb, inet, tsvector, an enum or composite reported as + /// USER-DEFINED, an array reported as ARRAY) rather than crash on it. Null/blank input + /// takes the same fallback, and so does bytea — matching how SQL Server's varbinary + /// falls back rather than pretending binary is a scalar. + /// Deliberate non-mappings. Postgres bit / bit varying are bit + /// strings, not booleans — mapping bit to the way + /// T-SQL's bit demands would silently coerce a multi-bit value, so they fall back. + /// time / time with time zone are times of day rather than points in time and take the + /// same fallback as T-SQL's time; interval is a duration, likewise. + /// uuid maps to , matching how SQL Server's + /// uniqueidentifier is handled. + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "boolean" or "bool" => DriverDataType.Boolean, + "smallint" or "int2" => DriverDataType.Int16, + "integer" or "int" or "int4" => DriverDataType.Int32, + "bigint" or "int8" => DriverDataType.Int64, + "real" or "float4" => DriverDataType.Float32, + "double precision" or "float8" + or "numeric" or "decimal" or "money" => DriverDataType.Float64, + "text" or "character varying" or "varchar" or "character" or "char" or "bpchar" + or "name" or "uuid" => DriverDataType.String, + "date" + or "timestamp without time zone" or "timestamp with time zone" + or "timestamp" or "timestamptz" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index 1f9b4c5b..aafce4e7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// -/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting, +/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting, /// INFORMATION_SCHEMA catalog SQL, and the design §3.7 type-family map. /// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and /// the AdminUI browse session. @@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect /// public DbProviderFactory Factory => SqlClientFactory.Instance; + /// + /// T-SQL markers are @-prefixed — ParameterMarker("k0") is "@k0". + /// + /// The bare base name. + /// The @-prefixed marker. + public string ParameterMarker(string baseName) => "@" + baseName; + + /// + /// Identical to : Microsoft.Data.SqlClient expects + /// to carry the same @ the statement does. (It also + /// accepts a bare name and prepends the @ itself, but spelling it out keeps the parameter + /// collection readable in a profiler trace and matches what the SQL text says.) + /// + /// The bare base name. + /// The @-prefixed bind name. + public string ParameterBindName(string baseName) => "@" + baseName; + + /// + /// No-op. SqlCommand binds by name out of the box, so there is nothing SQL Server needs set on a + /// fresh command — the member exists for Oracle's BindByName (see + /// ). + /// + /// The command to configure; untouched here. + public void ConfigureCommand(DbCommand command) + { + // Intentionally empty: see the summary. + } + /// public string LivenessSql => "SELECT 1"; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj index dd9f264b..bf77262f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj @@ -21,8 +21,13 @@ + the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. + PostgresDialect and OracleDialect land here for the same reason, each returning its + provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully + managed ADO.NET drivers, so adding them costs no native client install anywhere. --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs index f0802333..7635112c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs @@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a /// warehouse. /// Every parameter is bound. Authored names reach the catalog queries as -/// @schema/@table parameters — the same discipline the schema browser follows — so loading -/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path. +/// schema/table parameters — the same discipline the schema browser follows — so loading +/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path. +/// The bind names come from over those two base names, so +/// they can never disagree with the markers in the dialect's own catalog SQL (@schema on SQL +/// Server, :p_schema on Oracle). /// Failures throw; they do not degrade to an empty catalog. An empty catalog would reject /// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all /// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has @@ -33,6 +36,12 @@ internal static class SqlCatalogLoader /// Column alias projects. private const string ColumnNameColumn = "COLUMN_NAME"; + /// Base name of the schema parameter every dialect's catalog SQL carries. + private const string SchemaParameter = "schema"; + + /// Base name of the table parameter carries. + private const string TableParameter = "table"; + /// /// Reads the catalog slice covering . /// @@ -53,7 +62,7 @@ internal static class SqlCatalogLoader var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds)); var schemas = await QueryColumnAsync( - connection, dialect.ListSchemasSql, SchemaColumn, + connection, dialect, dialect.ListSchemasSql, SchemaColumn, static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false); // Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on @@ -94,8 +103,8 @@ internal static class SqlCatalogLoader if (!tablesBySchema.TryGetValue(schema, out var tables)) { tables = await QueryColumnAsync( - connection, dialect.ListTablesSql, TableNameColumn, - command => Bind(command, "@schema", schema), + connection, dialect, dialect.ListTablesSql, TableNameColumn, + command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema), timeoutSeconds, cancellationToken).ConfigureAwait(false); tablesBySchema[schema] = tables; } @@ -106,11 +115,11 @@ internal static class SqlCatalogLoader if (columnsByTable.ContainsKey(key)) continue; columnsByTable[key] = await QueryColumnAsync( - connection, dialect.ListColumnsSql, ColumnNameColumn, + connection, dialect, dialect.ListColumnsSql, ColumnNameColumn, command => { - Bind(command, "@schema", schema); - Bind(command, "@table", table); + Bind(command, dialect.ParameterBindName(SchemaParameter), schema); + Bind(command, dialect.ParameterBindName(TableParameter), table); }, timeoutSeconds, cancellationToken).ConfigureAwait(false); } @@ -133,6 +142,7 @@ internal static class SqlCatalogLoader var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { + dialect.ConfigureCommand(command); command.CommandText = dialect.DefaultSchemaSql; command.CommandTimeout = timeoutSeconds; var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); @@ -144,6 +154,7 @@ internal static class SqlCatalogLoader /// Runs one catalog query and projects a single string column from every row. private static async Task> QueryColumnAsync( DbConnection connection, + ISqlDialect dialect, string sql, string columnName, Action bind, @@ -155,6 +166,8 @@ internal static class SqlCatalogLoader var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { + // Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty. + dialect.ConfigureCommand(command); command.CommandText = sql; command.CommandTimeout = timeoutSeconds; bind(command); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index a4ec55ef..5961e670 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions /// /// Selects the provider seam. - /// v1 constructs only. The other members exist on the shipped - /// enum as reserved names; authoring one is a config mistake that must fail at construction with a - /// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database. + /// This build constructs , + /// and . and + /// remain reserved names on the shipped enum; authoring one is a config + /// mistake that must fail at construction with a message that says so, rather than silently degrading + /// to another backend's SQL dialect against a database that does not speak it. + /// Every arm returns that provider's OWN dialect — there is no fallback arm. A default + /// that resolved to would construct happily and then emit T-SQL + /// (INFORMATION_SCHEMA, TOP 1, @ markers) at a Postgres or Oracle server, which + /// fails at poll time as an unintelligible provider error rather than here as a config error. /// private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId) => provider switch { SqlProvider.SqlServer => new SqlServerDialect(), + SqlProvider.Postgres => new PostgresDialect(), + SqlProvider.Oracle => new OracleDialect(), _ => throw new InvalidOperationException( $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " + - $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."), + $"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " + + $"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " + + $"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."), }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs index 79c5b986..95222661 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs @@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe var command = connection.CreateCommand(); await using (command.ConfigureAwait(false)) { + // Uniformly applied at every command site, even where this statement takes no + // parameters — a provider hook that is only *sometimes* honoured is the harder bug. + dialect.ConfigureCommand(command); command.CommandText = dialect.LivenessSql; await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs index 73510d1d..1dfc08bf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// SqlGroupPlannerTests. /// Injection boundary (design §8.1). Identifiers reach the command text through /// and nothing else; every authored value becomes a -/// @k0/@w marker with the value carried in . There is no -/// interpolation of tag content into SQL anywhere in this type. +/// k0/w parameter — spelled with the dialect's own +/// (@k0 on SQL Server, :p_k0 on Oracle) — with the value carried in +/// . There is no interpolation of tag content into SQL anywhere in +/// this type. +/// The base names are this planner's, the spelling is the dialect's. Each plan carries the +/// markers it emitted and the matching s, so the +/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on +/// ODP.NET (:p_k0 in the text, bare p_k0 on the parameter). /// public static class SqlGroupPlanner { - /// The parameter-marker prefix for a key-value model's IN list. - private const string KeyMarkerPrefix = "@k"; + /// The base-name prefix for a key-value model's IN-list parameters (k0…kN). + private const string KeyBaseNamePrefix = "k"; - /// The single parameter marker for a wide-row model's row selector. - private const string RowSelectorMarker = "@w"; + /// The single parameter base name for a wide-row model's row selector. + private const string RowSelectorBaseName = "w"; /// /// Groups by source and emits one per group. @@ -132,7 +138,8 @@ public static class SqlGroupPlanner if (timestampColumn is not null) AddDistinct(selected, timestampColumn); // A keyValue may legitimately be the empty string, so only null is a broken invariant. - var names = new List(members.Count); + var markers = new List(members.Count); + var bindNames = new List(members.Count); var values = new List(members.Count); var seen = new HashSet(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); } /// @@ -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( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs index ee1dce0f..a979f59a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -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); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs index f586bbf6..d9c14dbe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs @@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// per group rather than one query per tag. /// Injection boundary. is built from dialect-quoted identifiers only; /// every authored value lives in and is bound as a -/// named by the positionally-aligned . There is no -/// path by which a tag's keyValue or whereValue reaches the command text. +/// named by the positionally-aligned . There is +/// no path by which a tag's keyValue or whereValue reaches the command text. +/// Markers and bind names are carried separately because they are not the same string on +/// every provider: ODP.NET's text says :p_k0 while its is the +/// bare p_k0. Both lists are positionally aligned with , so a binding site +/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own. /// Produced only by ; consumed by the poll reader, which executes /// once and slices the rows back to per-member snapshots. /// @@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// with . Carried explicitly so the reader never has to re-derive the naming /// convention. /// +/// +/// The to bind each value under, from +/// , aligned index-for-index with and +/// with . 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. +/// /// -/// The values to bind, aligned with . Captured verbatim from the authored tags -/// — a hostile value is inert here because it is data, not text. +/// The values to bind, aligned with and . +/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text. /// /// /// 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 ParameterNames, + IReadOnlyList ParameterBindNames, IReadOnlyList Parameters, IReadOnlyList Members, IReadOnlyList SelectedColumns) @@ -57,6 +68,9 @@ public sealed record SqlQueryPlan( /// The parameter markers in , aligned with . public IReadOnlyList ParameterNames { get; } = [.. ParameterNames]; + /// The parameter names to bind under, aligned with . + public IReadOnlyList ParameterBindNames { get; } = [.. ParameterBindNames]; + /// The values to bind, aligned with . public IReadOnlyList Parameters { get; } = [.. Parameters]; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor index 744aee9f..9eb5efed 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor @@ -18,10 +18,13 @@
- @* 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). *@ + + -
Only Microsoft SQL Server is supported in v1.
+
SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs index d75ec6ec..0e24499c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs @@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests await session.AttributesAsync(columnNodeId, CancellationToken.None); session.LastUsedUtc.ShouldBeGreaterThan(afterExpand); } + + /// + /// The browse path binds the dialect's parameter names, not a hardcoded "@schema". + /// Driven through (markers :schema/:table, bare bind + /// names) — ODP.NET's shape, and the only place the two spellings differ. + /// Discriminating in two independent ways: a session still binding "@schema" leaves the + /// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the + /// recorded names would carry the prefix. + /// + [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); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs index c984c7bd..93d1f29f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs @@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName); } + /// + /// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each + /// resolve to their own dialect, and every remaining member resolves to . + /// Enumerating for the reserved half is what makes a future enum + /// member fail here rather than silently browse as SQL Server. + /// [Fact] - public void DefaultDialectSelector_carriesSqlServerOnly() + public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle() { SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType(); + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType(); + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType(); - foreach (var provider in Enum.GetValues().Where(p => p != SqlProvider.SqlServer)) + SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle]; + foreach (var provider in Enum.GetValues().Where(p => !carried.Contains(p))) SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); } @@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests // ---- provider availability ---- + /// + /// The falsifiability control for the widened browse gate: is still + /// reserved and must be reported as unavailable, naming itself. + /// [Fact] public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild() { @@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => 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"); } /// /// 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. /// [Fact] public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider() @@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => browser.OpenAsync( - ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None)); + ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None)); ex.Message.ShouldContain("not available in this build"); reads.ShouldBeEmpty(); } + /// + /// A carried provider must get past 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. + /// + [Theory] + [InlineData("Postgres")] + [InlineData("Oracle")] + public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider) + { + var reads = new List(); + var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; }); + + var ex = await Should.ThrowAsync(() => + browser.OpenAsync( + ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None)); + + ex.Message.ShouldNotContain("not available in this build"); + reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging")); + } + // ---- malformed configuration ---- [Theory] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj index b3837b17..b1f73dc6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj @@ -45,6 +45,13 @@ — see its class docs. --> + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml new file mode 100644 index 00000000..09defff3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml @@ -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" diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/seed-oracle.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/seed-oracle.sql new file mode 100644 index 00000000..28d92da1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/seed-oracle.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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml new file mode 100644 index 00000000..68bb0817 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml @@ -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" diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql new file mode 100644 index 00000000..21c1b520 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.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; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OracleReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OracleReadTests.cs new file mode 100644 index 00000000..59c29b9e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OracleReadTests.cs @@ -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; + +/// +/// The Sql driver against a real Oracle Database, over the fully managed ODP.NET provider +/// (Oracle.ManagedDataAccess.Core) OracleDialect ships. Four things are exercised nowhere +/// else in this driver's estate: the ODP.NET ADO.NET path, OracleCommand.BindByName against a +/// real multi-parameter statement, the ALL_* catalog views that stand in for the +/// INFORMATION_SCHEMA Oracle does not have, and FETCH FIRST 1 ROWS ONLY row limiting. +/// BindByName is the sharpest edge, and the browse leg below is what proves it live. +/// ODP.NET binds by position by default, and OracleDialect.ListTablesSql names +/// :schema twice (once per branch of its UNION ALL) while binding it once — under +/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a +/// real OracleCommand; this asserts the statement it protects actually runs. +/// Env-gated; skipping is the normal outcome. See for the gate. +/// With it unset nothing here opens a socket, so the suite is instant and green offline. +/// Deliberately narrower than , for the reason +/// gives: the provider-agnostic read semantics are pinned once against +/// SQL Server and by the SQLite suites, and only what a dialect can get wrong is repeated +/// per provider. +/// +[Collection(OracleCollection.Name)] +public sealed class OracleReadTests +{ + private readonly OracleFixture _ora; + + /// Initializes a new instance of the class. + /// The seeded live-server fixture (collection-scoped). + public OracleReadTests(OracleFixture ora) => _ora = ora; + + // ---- lifecycle: liveness + catalog load ---- + + /// + /// Initialize's liveness check — SELECT 1 FROM DUAL, not the bare SELECT 1 every other + /// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint + /// described credential-free. + /// The catalog load is asserted indirectly but unambiguously: SqlCatalogLoader throws + /// when the principal sees zero schemas, and a tag whose table did not resolve publishes + /// BadNodeIdUnknown instead of a value — so the Good read below is only reachable once + /// ALL_USERS / ALL_TABLES / ALL_TAB_COLUMNS have all answered the shape the + /// loader expects. + /// + [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 ---- + + /// + /// A seeded key-value row round-trips as a Good snapshot with its source timestamp. + /// The value column is a plain NUMBER — Oracle's single numeric family — which + /// OracleDialect.MapColumnType folds to . 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 "type". + /// + [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); + } + + /// + /// Two keys on the same table fold into ONE query — … WHERE "TAG_NAME" IN (:k0, :k1) — and are + /// sliced back to the right tags. + /// This is the live falsification of positional binding. With + /// OracleCommand.BindByName 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 + /// successful query over the wrong rows — 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. + /// + [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 ---- + + /// + /// The topByTimestamp selector resolves to the newest row. + /// This is the Oracle-specific leg. The planner spells the row limit through the + /// dialect's SingleRowLimitSuffix — a trailing FETCH FIRST 1 ROWS ONLY (the 12.1+ + /// ANSI clause), deliberately not WHERE ROWNUM = 1, which is assigned before + /// ORDER BY 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. + /// + [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) ---- + + /// + /// An operator who authors otopcua.tag_values against a catalog holding + /// OTOPCUA.TAG_VALUES still reads. Pinned as accepted, not rejected — that is what + /// SqlCatalog.TryMatch actually does: an exact ordinal hit wins, and failing that a + /// unique case-insensitive hit is accepted and the catalog's own spelling substituted before + /// anything is quoted into SQL. + /// On Oracle this is not tolerance, it is the only thing that works. Oracle upper-cases + /// an unquoted identifier, so the catalog holds TAG_VALUES; but + /// OracleDialect.QuoteIdentifier emits a double-quoted — i.e. case-sensitive — name, + /// and "tag_values" and "TAG_VALUES" 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. + /// The gate refuses only an ambiguous 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. + /// + [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 ---- + + /// + /// The AdminUI picker's walk — RootAsync (schemas) → ExpandAsync (tables) → + /// ExpandAsync (columns) → AttributesAsync (the side panel) — sees the seeded table + /// over a real database, and the leaf's mapped driver type is the one + /// OracleDialect.MapColumnType promises for NUMBER. + /// The table expand is the BindByName proof. It runs + /// OracleDialect.ListTablesSql, whose UNION ALL names :schema 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 + /// ConfigureCommand ran at every command site, not just in the unit test. + /// Unlike the Postgres walk, the schema list is NOT asserted to be a single item: + /// ALL_USERS lists every user in the instance (Oracle equates a schema with a user), so the + /// seeded schema is one of many. + /// + [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 ---- + + /// Skips the calling test when the live-server gate is not configured or not reachable. + private void SkipUnlessLive() + { + if (_ora.SkipReason is { } reason) Assert.Skip(reason); + } + + /// + /// Builds and initializes a driver over the seeded schema, with no injected factory — which is + /// precisely what makes this suite exercise OracleClientFactory.Instance, the provider the + /// dialect ships. + /// + private async Task 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; + } + + /// A key-value tag over the seeded EAV table, authored in the catalog's own spelling. + 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, + }); + + /// A wide-row tag selected by newest timestamp. + 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, + }, + }); + + /// + /// Wraps an authored TagConfig object as a raw tag entry. Composed as a + /// rather than as an interpolated string literal for the reason + /// gives. + /// + private static RawTagEntry Tag(string rawPath, JsonObject config) => + new(rawPath, config.ToJsonString(), WriteIdempotent: false); +} + +/// +/// A real, seeded Oracle Database schema standing in for the source the Sql driver polls — +/// the Oracle counterpart of . +/// Env-gated, and the gate is checked before any socket is touched. This fixture opens +/// nothing unless and are both set; absent, it +/// records and every test calls Assert.Skip. +/// This fixture does not seed — the compose stack does +/// (Docker/oracle/docker-compose.yml + seed-oracle.sql). Oracle equates a schema with a +/// user, so the seed runs as the same otopcua principal the suite connects as and its objects +/// land in the OTOPCUA schema. +/// Every identifier constant below is UPPER case, and that is the catalog's spelling, not a +/// style choice. 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. +/// +public sealed class OracleFixture : IAsyncLifetime +{ + /// Listener endpoint in host:port form (e.g. 10.100.0.35:11521). Absent ⇒ skip. + public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT"; + + /// The login. Defaults to , the user the compose stack creates. + public const string UserEnvVar = "SQL_ORACLE_USER"; + + /// The password. Required — never defaulted (see ). + public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD"; + + /// + /// The service name the EZ-Connect descriptor addresses. Optional: defaults to + /// , the pluggable database the gvenzl/oracle-free image creates. + /// + public const string ServiceEnvVar = "SQL_ORACLE_SERVICE"; + + /// The application user the compose stack creates (APP_USER). + public const string DefaultUser = "otopcua"; + + /// The image's pluggable-database service name. + public const string DefaultService = "FREEPDB1"; + + /// Oracle's default listener port, used when carries no port. + public const int DefaultPort = 1521; + + /// The schema the seed's objects live in — Oracle folds the unquoted user name up. + public const string Schema = "OTOPCUA"; + + /// Seconds a connect attempt may take before the fixture calls the server unreachable. + private const int ProbeConnectTimeoutSeconds = 10; + + // ---- key-value (EAV) source ---- + + /// The key-value table: TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3). + public const string KeyValueTable = "TAG_VALUES"; + + /// The key-value table's key column. + public const string KeyColumn = "TAG_NAME"; + + /// The key-value table's value column. + public const string ValueColumn = "NUM_VALUE"; + + /// The source-timestamp column carried by every seeded table. + public const string TimestampColumn = "SAMPLE_TS"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// A second present key, so a group can legitimately fold more than one member. + public const string SecondPresentKey = "Line1.Pressure"; + + /// The value seeded for — different from + /// , so a positional-binding swap is visible. + public const double SecondPresentValue = 3.5; + + /// The UTC source timestamp seeded for . + public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); + + // ---- wide-row source ---- + + /// The wide-row table: STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3). + public const string WideRowTable = "LATEST_STATUS"; + + /// The wide-row table's value column the topByTimestamp leg reads. + public const string OvenTempColumn = "OVEN_TEMP"; + + /// + /// The of the row holding the newest SAMPLE_TS — what a + /// topByTimestamp selector must resolve to. Its row is neither the first inserted nor the + /// lowest-keyed, so a plan that lost its ORDER BY … DESC or its + /// FETCH FIRST 1 ROWS ONLY picks a different value and the test says so. + /// + public const double NewestStationOvenTemp = 210.25; + + /// Why the suite is skipping, or when the server is reachable. + public string? SkipReason { get; private set; } + + /// The connection string to hand the code under test. Empty while is set. + public string ConnectionString { get; private set; } = string.Empty; + + /// The resolved service name, for the endpoint-description assertion. + public string ServiceName { get; private set; } = DefaultService; + + /// The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials. + public string Server { get; private set; } = string.Empty; + + /// Two-part name of a seeded object, in the form an authored tag's table carries. + /// The bare table or view name. + /// The schema.table name. + public static string Qualified(string table) => $"{Schema}.{table}"; + + /// + /// Resolves the gate and probes the server with the dialect's own LivenessSql. A failure to + /// connect is the offline case and becomes a ; nothing is created or seeded + /// here (the compose stack owns that). + /// + /// A task that represents the asynchronous operation. + 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."; + } + } + + /// Holds no long-lived connection, so there is nothing to tear down. + /// A completed task. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// + /// Reads the gate and composes the connection string through the dialect's own provider + /// factory (), setting keys via the + /// abstract indexer. + /// That indirection is deliberate: it keeps this project free of a direct ODP.NET + /// PackageReference, 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 — + /// Data Source, User Id, Password, Connection Timeout — was verified to + /// be accepted through that indexer, so no provider-specific property is needed. + /// The data source is an EZ-Connect descriptor (host:port/service), which needs + /// no tnsnames.ora — deliberate, since the managed provider is meant to run with no client + /// install and no Oracle configuration files anywhere near the test host. + /// + /// The connection string, or — touching no socket — when unconfigured. + 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; + } +} + +/// +/// Collection definition so the gate is resolved once per test session rather than once per test +/// class — the same shape as . +/// +[CollectionDefinition(Name)] +public sealed class OracleCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlOracleServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs new file mode 100644 index 00000000..fcf8f4a4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs @@ -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; + +/// +/// The Sql driver against a real PostgreSQL, over the Npgsql provider +/// PostgresDialect 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 LIMIT 1 +/// where T-SQL emits SELECT TOP 1), and PostgresDialect's information_schema +/// catalog SQL — including the deliberate choice to derive the schema list from +/// information_schema.tables rather than .schemata, which only a +/// granted-but-not-owning principal can prove. +/// Env-gated; skipping is the normal outcome. See 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. +/// Deliberately narrower than . 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 dialect can get +/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse +/// walk an operator authors through. +/// +[Collection(PostgresCollection.Name)] +public sealed class PostgresReadTests +{ + private readonly PostgresFixture _pg; + + /// Initializes a new instance of the class. + /// The seeded live-server fixture (collection-scoped). + public PostgresReadTests(PostgresFixture pg) => _pg = pg; + + // ---- lifecycle: liveness + catalog load ---- + + /// + /// Initialize's liveness check (SELECT 1) and its catalog load both succeed, and the driver + /// reports Healthy with the endpoint described credential-free. + /// The catalog load is the interesting half, and it is asserted indirectly but + /// unambiguously. SqlCatalogLoader throws when the connecting principal sees zero + /// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes + /// BadNodeIdUnknown. So a Good read (below) is only reachable when + /// PostgresDialect.ListSchemasSql returned the fixture schema for a role that merely holds + /// USAGE + SELECT on it — the provisioning that information_schema.schemata + /// would have answered empty for. + /// + [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 ---- + + /// A seeded key-value row round-trips as a Good snapshot with its source timestamp. + [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 ---- + + /// + /// The topByTimestamp selector resolves to the newest row. + /// This is the Postgres-specific leg. The planner spells the row limit through the + /// dialect's SingleRowLimitSuffix — a trailing LIMIT 1 — where the SQL Server suite + /// exercises a leading SELECT TOP 1. The seed makes the newest row neither the + /// first-inserted nor the lowest-keyed one, so losing either the limit or the + /// ORDER BY … DESC yields a different, wrong value rather than a coincidentally-right one. + /// + [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) ---- + + /// + /// An operator who authors SQLPOLL.TAG_VALUES against a catalog holding + /// sqlpoll.tag_values still reads. Pinned as accepted, not rejected — that is what + /// SqlCatalog.TryMatch actually does: an exact ordinal hit wins, and failing that a + /// unique case-insensitive hit is accepted and the catalog's own spelling substituted before + /// anything is quoted into SQL. + /// Why this matters more on Postgres than it looks. Postgres folds an unquoted + /// identifier to lower case, and PostgresDialect.QuoteIdentifier emits a + /// double-quoted — i.e. case-sensitive — name. Without the gate's substitution, the + /// authored "TAG_VALUES" 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. + /// The gate refuses only an ambiguous fold — two catalog columns differing solely in + /// case — which cannot be produced from this seed and is pinned offline in the unit suites. + /// + [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 ---- + + /// + /// The AdminUI picker's walk — RootAsync (schemas) → ExpandAsync (tables) → + /// ExpandAsync (columns) → AttributesAsync (the side panel) — sees the seeded table + /// over a real database, and the leaf's mapped driver type is the one + /// PostgresDialect.MapColumnType promises for double precision. + /// Driven through rather than the internal session, because + /// that is the seam the AdminUI actually calls: it also proves the provider gate admits + /// Postgres and that the dialect's quoted catalog aliases + /// (AS "TABLE_NAME") survive Postgres' lower-casing of unquoted output columns — the + /// browse session reads every catalog column by name, so an unquoted alias would fold to + /// table_name and the walk would throw on GetOrdinal rather than return an empty + /// list. + /// + [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 ---- + + /// Skips the calling test when the live-server gate is not configured or not reachable. + private void SkipUnlessLive() + { + if (_pg.SkipReason is { } reason) Assert.Skip(reason); + } + + /// + /// Builds and initializes a driver over the seeded database, with no injected factory — which is + /// precisely what makes this suite exercise NpgsqlFactory.Instance, the provider the dialect + /// ships. + /// + private async Task 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; + } + + /// A key-value tag over the seeded EAV table. + 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, + }); + + /// A wide-row tag selected by newest timestamp. + 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, + }, + }); + + /// + /// Wraps an authored TagConfig object as a raw tag entry. Composed as a + /// rather than as an interpolated string literal for the reason + /// gives: a hand-written literal that loses a brace produces a + /// config the parser silently rejects, which surfaces as a plausible-looking driver failure. + /// + private static RawTagEntry Tag(string rawPath, JsonObject config) => + new(rawPath, config.ToJsonString(), WriteIdempotent: false); +} + +/// +/// A real, seeded PostgreSQL database standing in for the source the Sql driver polls — +/// the Postgres counterpart of . +/// Env-gated, and the gate is checked before any socket is touched. This fixture opens +/// nothing unless and are both set; absent, +/// it records and every test calls Assert.Skip. That is what keeps +/// dotnet test genuinely green (and instant) on the macOS dev box this is usually run from. +/// This fixture does not seed — the compose stack does +/// (Docker/postgres/docker-compose.yml + seed-pg.sql), 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 +/// not: the connecting principal holds USAGE + SELECT 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 +/// PostgresDialect.ListSchemasSql exists for would never be exercised. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + /// Server endpoint in host:port form (e.g. 10.100.0.35:15432). Absent ⇒ skip. + public const string EndpointEnvVar = "SQL_PG_ENDPOINT"; + + /// The login. Defaults to , the user the seed grants. + public const string UserEnvVar = "SQL_PG_USER"; + + /// The password. Required — never defaulted (see ). + public const string PasswordEnvVar = "SQL_PG_PASSWORD"; + + /// The unprivileged login the seed creates and grants USAGE + SELECT to. + public const string DefaultUser = "otopcua"; + + /// Postgres' default port, used when carries no port. + public const int DefaultPort = 5432; + + /// The database the compose stack creates (POSTGRES_DB) and the seed targets. + public const string FixtureDatabase = "sqlpollfixture"; + + /// + /// The schema every seeded object lives in — not public, so every authored + /// table 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. + /// + public const string Schema = "sqlpoll"; + + /// The Application Name the driver's connections carry, for server-side attribution. + public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg"; + + /// Seconds a connect attempt may take before the fixture calls the server unreachable. + private const int ProbeConnectTimeoutSeconds = 5; + + // ---- key-value (EAV) source ---- + + /// The key-value table: tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3). + public const string KeyValueTable = "tag_values"; + + /// The key-value table's key column. + public const string KeyColumn = "tag_name"; + + /// The key-value table's value column. + public const string ValueColumn = "num_value"; + + /// The source-timestamp column carried by every seeded table. + public const string TimestampColumn = "sample_ts"; + + /// A key whose row exists and whose value cell holds . + public const string PresentKey = "Line1.Speed"; + + /// The value seeded for . + public const double PresentValue = 42.0; + + /// The UTC source timestamp seeded for . + public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); + + // ---- wide-row source ---- + + /// The wide-row table: station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3). + public const string WideRowTable = "latest_status"; + + /// + /// The oven_temp of the row holding the newest sample_ts — what a + /// topByTimestamp selector must resolve to. Its row is neither the first inserted nor the + /// lowest-keyed, so a plan that lost its ORDER BY … DESC or its LIMIT 1 picks a + /// different value and the test says so. + /// + public const double NewestStationOvenTemp = 210.25; + + /// Why the suite is skipping, or when the server is reachable. + public string? SkipReason { get; private set; } + + /// The connection string to hand the code under test. Empty while is set. + public string ConnectionString { get; private set; } = string.Empty; + + /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. + public string Server { get; private set; } = string.Empty; + + /// Two-part name of a seeded object, in the form an authored tag's table carries. + /// The bare table or view name. + /// The schema.table name. + public static string Qualified(string table) => $"{Schema}.{table}"; + + /// + /// Resolves the gate and probes the server with the dialect's own LivenessSql. A failure to + /// connect is the offline case and becomes a ; nothing is created or + /// seeded here (the compose stack owns that). + /// + /// A task that represents the asynchronous operation. + 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."; + } + } + + /// Holds no long-lived connection, so there is nothing to tear down. + /// A completed task. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// + /// Reads the gate and composes the connection string through the dialect's own provider + /// factory (), setting keys via + /// the abstract indexer. + /// That indirection is deliberate: it keeps this project free of a direct Npgsql + /// PackageReference, 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 — + /// Host, Port, Database, Username, Password, Timeout, + /// Application Name — was verified to be accepted through that indexer, so no + /// provider-specific property is needed. + /// + /// The connection string, or — touching no socket — when unconfigured. + 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; + } + + /// + /// Splits a host:port endpoint, defaulting the port when absent or unparseable. Split on the + /// LAST colon so a bracketed IPv6 literal keeps its own colons. + /// + /// The authored endpoint. + /// The port to use when none is carried. + /// The host and port. + 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); + } +} + +/// +/// Collection definition so the gate is resolved once per test session rather than once per test +/// class — the same shape as . +/// +[CollectionDefinition(Name)] +public sealed class PostgresCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlPostgresServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj index 2a1e1c4c..6663eca0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj @@ -27,12 +27,20 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs new file mode 100644 index 00000000..d8f8ae60 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ColonMarkerDialect.cs @@ -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; + +/// +/// A test-only dialect that spells parameters the ODP.NET way — :name in the SQL text and a +/// bare name as the — over the SQLite fixture. It is +/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded +/// "@" before, and a planner or loader that still hardcodes it emits @k0 against this +/// dialect and fails loudly. +/// Why the two halves must differ. A dialect whose marker and bind name are the same string +/// (SQL Server, Npgsql, SQLite) cannot tell a call site that copies 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. +/// It really executes. Everything but the parameter naming (and the catalog SQL's markers) +/// delegates to , and Microsoft.Data.Sqlite accepts both a :-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. +/// A decorator rather than a subclass: is sealed, and +/// new-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. +/// +public sealed class ColonMarkerDialect : ISqlDialect +{ + private static readonly SqliteDialect Inner = new(); + + /// Every command handed to , kept so its binds can be read back. + private readonly List _commands = []; + + /// How many times has been called on this instance. + /// + /// Oracle's BindByName 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. + /// + public int ConfiguredCommands + { + get { lock (_commands) return _commands.Count; } + } + + /// + /// The of every parameter bound on every configured command, + /// read after the fact. + /// Why this and not the query's own success. Microsoft.Data.Sqlite resolves a parameter + /// whether it is named :k0 or bare k0, 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. + /// + public IReadOnlyList BoundParameterNames + { + get + { + lock (_commands) + return _commands + .SelectMany(c => c.Parameters.Cast()) + .Select(p => p.ParameterName) + .ToList(); + } + } + + /// + public SqlProvider Provider => Inner.Provider; + + /// + public DbProviderFactory Factory => Inner.Factory; + + /// + public string LivenessSql => Inner.LivenessSql; + + /// + public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix; + + /// + public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix; + + /// + public string ListSchemasSql => Colonize(Inner.ListSchemasSql); + + /// + public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql); + + /// + public string ListTablesSql => Colonize(Inner.ListTablesSql); + + /// + public string ListColumnsSql => Colonize(Inner.ListColumnsSql); + + /// + public string ParameterMarker(string baseName) => ":" + baseName; + + /// The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker. + /// The planner's/loader's base name. + /// The bind name. + public string ParameterBindName(string baseName) => baseName; + + /// + public void ConfigureCommand(DbCommand command) + { + ArgumentNullException.ThrowIfNull(command); + lock (_commands) _commands.Add(command); + } + + /// + public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident); + + /// + public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType); + + /// + /// Rewrites the SQLite dialect's @ markers as : 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. + /// + private static string Colonize(string sql) => sql.Replace('@', ':'); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs new file mode 100644 index 00000000..e8b0c375 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs @@ -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; + +/// +/// Golden tests for , mirroring : the +/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only +/// Oracle needs, pinning OracleCommand.BindByName. +/// +public class OracleDialectTests +{ + // ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ---- + + /// + /// The sharpest correctness edge on the whole provider seam. A real — no + /// connection needed — must report BindByName == false before + /// touches it and true afterwards. + /// The false-by-default half is the falsifiability control. 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. + /// + [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(); + } + + /// + /// A command from another provider passes through untouched and, crucially, does not throw: + /// the dialect can legitimately meet a foreign DbCommand (an instrumented or substituted + /// factory in a shared test harness), and an InvalidCastException there would fail a poll for a + /// reason that has nothing to do with the database. + /// + [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"); + } + + /// + /// No bind name may be spelled as its bare base name — the p_ namespace is what stops one + /// colliding with an Oracle reserved word. + /// + /// + /// This is a regression test for a live-found, provider-fatal defect. The catalog readers bind + /// a parameter whose base name is table, and Oracle rejects a bind variable named after a + /// reserved word outright: ORA-01745: invalid host/bind variable name. With the un-prefixed + /// names this dialect first shipped with, ListColumnsSql 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 OracleReadTests against a real database. + /// table is asserted explicitly because it is the one base name in use today that is + /// actually reserved; the others guard the general rule. + /// + [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(); + } + + // ---- 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(() => 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(() => 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(() => 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(() => 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("@"); + } + + /// + /// SqlCatalogLoader/SqlBrowseSession project catalog columns by the + /// INFORMATION_SCHEMA names, so every dialect must alias its columns to them or + /// GetOrdinal throws at runtime — a failure the SQL text alone would not reveal. + /// + [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); + + /// + /// ALL_TAB_COLUMNS.DATA_TYPE carries the fractional-seconds precision and the time-zone + /// qualifier inside the family name, unlike INFORMATION_SCHEMA. An exact-match table + /// would send every one of these to the String fallback. + /// + [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); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs new file mode 100644 index 00000000..0ca35ec1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs @@ -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; + +/// +/// Golden tests for , mirroring 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 LIMIT 1, and bit being a bit string +/// rather than a boolean — the divergence is pinned here rather than left to reading. +/// +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(() => 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( + () => 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( + () => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident) + => Should.Throw(() => 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(() => 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); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs index 9236f3e3..afeb8ad1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlCatalogGateDriverTests.cs @@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests driver.GetHealth().State.ShouldBe(DriverState.Faulted); } + /// + /// The catalog path against a dialect whose markers are :schema/:table and whose bind + /// names are the bare schema/table — 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. + /// Behaviourally discriminating, not decorative: a loader still binding the literal + /// "@schema" leaves :schema unbound, the catalog query matches nothing, and + /// every tag — including the honest one — falls to BadNodeIdUnknown. + /// + [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.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 ---- /// @@ -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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs index 0fb8fdb0..65d6a281 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests // ---- provider gate ---- + /// + /// The falsifiability control for the widened gate: 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. + /// [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(() => 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"); } + /// + /// 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. + /// + [Fact] + public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets() + { + var name = NewRef(); + using var _ = new EnvironmentVariableScope( + SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); + + var ex = Should.Throw(() => 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)); + } + + /// + /// 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 SqlServerDialect would also construct without complaint and then speak + /// T-SQL at a Postgres server. + /// + [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(provider)); + } + + /// + /// 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. + /// + [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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs index 85fe8f34..344f4447 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs @@ -185,6 +185,84 @@ public class SqlGroupPlannerTests plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" }); } + // ---- dialect-owned parameter markers ---- + + /// + /// The falsifiability control for dialect-owned markers: against a :-marker dialect the emitted + /// statement must carry :k0, and no @ at all. A planner holding the T-SQL prefix in a + /// const passes every SQL Server test in this class and fails only here. + /// + [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" }); + } + + /// + /// The bind names ride the plan separately 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. + /// + [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" }); + } + + /// + /// 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. + /// + [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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs index cc203d9d..da658517 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs @@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests async () => await reader.ReadAsync(["Speed"], CancellationToken.None)); } + // ---- dialect-owned parameter naming ---- + + /// + /// Runs a real poll through a dialect whose SQL markers (:k0) and bind names (bare k0) + /// differ — ODP.NET's shape — against the SQLite fixture, and asserts both that the + /// values arrive and that the names actually put on the parameters are the dialect's bind names. + /// The name assertion is the load-bearing half. Microsoft.Data.Sqlite happily resolves + /// :k0 and bare k0 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. + /// + [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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs index b76ddd8a..23a11590 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlQueryPlanImmutabilityTests.cs @@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests public void Plan_doesNotAliasTheCallersLists() { var names = new List { "@k0" }; + var bindNames = new List { "@k0" }; var values = new List { "v" }; var members = new List { @@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests }; var selected = new List { "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); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs index a3ec1ca6..b60fdc4a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs @@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect /// public DbProviderFactory Factory => SqliteFactory.Instance; + /// + /// @-prefixed, matching SQL Server — SQLite accepts @, : and $ equally, so + /// this dialect deliberately keeps the same spelling as the production one. Divergent + /// parameter naming is the job of ColonMarkerDialect, which is the control for it; if this + /// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place. + /// + /// The bare base name. + /// The @-prefixed marker. + public string ParameterMarker(string baseName) => "@" + baseName; + + /// Identical to the marker — Microsoft.Data.Sqlite matches on the prefixed name. + /// The bare base name. + /// The @-prefixed bind name. + public string ParameterBindName(string baseName) => "@" + baseName; + + /// No-op: SqliteCommand binds by name and needs no per-command setup. + /// The command to configure; untouched here. + public void ConfigureCommand(DbCommand command) + { + // Intentionally empty: see the summary. + } + /// public string LivenessSql => "SELECT 1"; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs index 8bc28e88..492ce79c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -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(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer); } + + // ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ---- + + /// + /// The dropdown offers SqlServer + Postgres + Oracle, so each must survive + /// FormModel.ToDto() → JSON → FormModel.FromDto() 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. + /// + [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(json, FormOptions)!); + reloaded.Provider.ShouldBe(provider); + reloaded.ConnectionStringRef.ShouldBe("MesStaging"); + } + + /// + /// 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. + /// + [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}\""); + } }