merge: Sql driver Postgres + Oracle providers (managed Npgsql/ODP.NET, dialect-owned binding, live-gated fixtures)
This commit is contained in:
@@ -89,6 +89,12 @@
|
||||
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
|
||||
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
|
||||
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
<!-- Fully managed PostgreSQL ADO.NET provider for the Sql poll driver's PostgresDialect
|
||||
(NpgsqlFactory.Instance). No native libpq / client install — the same "managed only"
|
||||
constraint that rules out Odbc. Referenced by Driver.Sql.Dialect alongside
|
||||
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
|
||||
native lib/net10.0 TFM. -->
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
@@ -97,6 +103,11 @@
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<!-- Fully managed ODP.NET Core for the Sql poll driver's OracleDialect
|
||||
(OracleClientFactory.Instance). The ".Core" package is the 100%-managed driver — no Oracle
|
||||
Instant Client / OCI native install, matching the Npgsql constraint above. Latest stable of
|
||||
the 23.x (Oracle 23ai) line, which ships lib/net8.0 + netstandard2.1. -->
|
||||
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.300" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
is pulled transitively at 1.9.0; pin to 1.15.3 (matches the Extensions.Hosting pin below),
|
||||
applied via a direct PackageReference in the Cluster project so the server stack inherits it. -->
|
||||
|
||||
+22
-5
@@ -12,9 +12,20 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th
|
||||
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
|
||||
`SELECT`s on a timer and turns cells into tag values.
|
||||
|
||||
**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares
|
||||
`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends** —
|
||||
authoring one fails the driver rather than silently falling back.
|
||||
**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
|
||||
(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
|
||||
`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain
|
||||
**reserved names, not working backends** — authoring one fails the driver rather than silently
|
||||
falling back.
|
||||
|
||||
Two provider-specific things are worth knowing before you author against them:
|
||||
|
||||
- **Identifier case folds, and the catalog gate absorbs it.** Postgres lower-cases and Oracle
|
||||
upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own
|
||||
spelling, so an authored `widgets` binds to Oracle's `WIDGETS` rather than failing.
|
||||
- **Oracle `NUMBER` infers as `Float64`.** Column metadata does not carry precision/scale to the
|
||||
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
|
||||
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
|
||||
|
||||
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
|
||||
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
|
||||
@@ -94,7 +105,7 @@ ignoring it later.
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `provider` | `SqlServer` | The only provider v1 constructs. |
|
||||
| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. |
|
||||
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
|
||||
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
|
||||
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
|
||||
@@ -132,7 +143,9 @@ The two classes of authored string are handled differently, and deliberately so:
|
||||
concatenated into command text.
|
||||
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
|
||||
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
|
||||
against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command.
|
||||
against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`**
|
||||
views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and
|
||||
dialect-quoted before they reach a command.
|
||||
|
||||
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
|
||||
legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports
|
||||
@@ -152,6 +165,10 @@ can commit columns as tags. A committed leaf carries **schema, table and column*
|
||||
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
|
||||
typed editor opened empty.
|
||||
|
||||
Browse works against all three constructed providers; the catalog query is the dialect's own
|
||||
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
|
||||
themselves excluded — and the `ALL_*` views on Oracle).
|
||||
|
||||
## Authoring in the AdminUI
|
||||
|
||||
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
|
||||
|
||||
@@ -154,12 +154,12 @@ SQL poll reuses the always-on central SQL Server. **Both shipped.**
|
||||
|
||||
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped. ~~Postgres/ODBC remain deferred to P2/P3 behind the same seam.~~ **Postgres + Oracle shipped 2026-08-05** via managed providers (Npgsql 10.0.3, ODP.NET Core 23.26.300) — dialect-owned parameter markers/bind names + Oracle `BindByName`, `INFORMATION_SCHEMA` vs `ALL_*` catalog SQL, provider gates widened, AdminUI dropdown, env-gated docker suites (postgres:17-alpine / gvenzl/oracle-free:23-slim). The live suites found + fixed ORA-01745 (Oracle rejects reserved-word bind names — `:table` — so the first Oracle build was structurally broken until binds were `p_`-prefixed). MySql/Odbc stay reserved. See [`2026-08-05-sql-postgres-oracle-design.md`](2026-08-05-sql-postgres-oracle-design.md).
|
||||
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
|
||||
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
|
||||
persisted `connectionString` (`1919a8e5`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
|
||||
shipped; Postgres/ODBC deferred behind `ISqlDialect`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server,
|
||||
**PostgreSQL and Oracle** shipped behind `ISqlDialect`; MySql/Odbc reserved).
|
||||
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
|
||||
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
|
||||
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
|
||||
@@ -312,7 +312,8 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
pass-through) CLOSED 2026-08-05 — see the Wave 0 section above.
|
||||
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
|
||||
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
|
||||
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
|
||||
`28c28667`). **Postgres + Oracle dialects shipped 2026-08-05** (managed providers, see the SQL
|
||||
poll section above); remaining SQL work is optional follow-on — MySql/Odbc dialects behind
|
||||
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
|
||||
3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and
|
||||
live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506).
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Sql driver: Postgres + Oracle providers (managed drivers only) — design
|
||||
|
||||
**Status:** DONE 2026-08-05 — all eight tasks delivered and reviewed. Suites: Driver.Sql.Tests
|
||||
355/355, Browser.Tests 75/75, IntegrationTests 33 skip-clean offline; **both new integration suites
|
||||
also ran LIVE during development** against real local containers (postgres:17-alpine 5/5,
|
||||
gvenzl/oracle-free:23-slim 6/6), which surfaced and fixed a provider-fatal defect no offline test
|
||||
could see: **ORA-01745** — Oracle rejects a bind variable named after a reserved word (`:table`),
|
||||
so every Oracle catalog load failed until OracleDialect namespaced all binds with a `p_` prefix
|
||||
(`ec90b7c4`, pinned by a never-bare-base-name invariant theory). §4.3's chrome/AdminUI gate remains
|
||||
with the operator; the shared-host fixture deploy + live run is recorded below when complete.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
`docs/drivers/Sql.md:15-18`: v1 constructs SQL Server only; `SqlProvider` declares `Postgres`,
|
||||
`MySql`, `Odbc`, `Oracle` as reserved names that fail the driver. This branch makes **Postgres and
|
||||
Oracle** working backends via fully managed ADO.NET providers — `Npgsql` and
|
||||
`Oracle.ManagedDataAccess.Core` — no native/thin-client installs. `MySql`/`Odbc` stay reserved.
|
||||
|
||||
The seam already exists and is proven: `ISqlDialect` exposes an abstract `DbProviderFactory`
|
||||
(`ISqlDialect.cs:38`), all runtime I/O goes through `DbConnection`/`DbCommand`/`DbParameter`
|
||||
(`SqlPollReader.cs:379-389`), and the test-only `SqliteDialect` is the standing falsifiability
|
||||
control that the planner emits dialect SQL rather than T-SQL. The concrete gaps:
|
||||
|
||||
1. **Parameter markers are hardcoded `@`** in two places: `SqlGroupPlanner.cs:21-25` (`@k`/`@w`)
|
||||
and the catalog path (`SqlServerDialect.cs:56/:61` SQL text + `SqlCatalogLoader.cs:97-113` bind
|
||||
names). Npgsql happens to accept `@`; Oracle requires `:`.
|
||||
2. **Two provider gates** reject everything but SqlServer: `CreateDialect`
|
||||
(`SqlDriverFactoryExtensions.cs:208-215`) and the browser's `DefaultDialectSelector`
|
||||
(`SqlDriverBrowser.cs:126-127`).
|
||||
3. **Oracle has no `INFORMATION_SCHEMA`** — catalog SQL must come from `ALL_USERS` /
|
||||
`ALL_TABLES` + `ALL_VIEWS` / `ALL_TAB_COLUMNS`; liveness is `SELECT 1 FROM DUAL`; row-limit is
|
||||
`FETCH FIRST 1 ROWS ONLY`.
|
||||
4. **AdminUI** provider dropdown is single-option (`SqlDriverForm.razor:18-25`).
|
||||
5. **Packages** — `Npgsql` and `Oracle.ManagedDataAccess.Core` are absent from
|
||||
`Directory.Packages.props`.
|
||||
|
||||
## 2. Decisions
|
||||
|
||||
- **Both dialects live in the existing `ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` project**, which
|
||||
exists precisely because provider packages are unavoidable there (`.Dialect.csproj:20-26`). Its
|
||||
csproj gains the two `PackageReference`s; consumers get them transitively exactly as
|
||||
`Microsoft.Data.SqlClient` flows today. No new projects, no `.slnx` churn.
|
||||
- **Dialect-owned parameter naming.** `ISqlDialect` gains three members with SqlServer-compatible
|
||||
semantics implemented on every dialect:
|
||||
- `string ParameterMarker(string baseName)` — the token in SQL text (`"@k0"` / `":k0"`).
|
||||
- `string ParameterBindName(string baseName)` — the `DbParameter.ParameterName` value (`"@k0"`
|
||||
for SqlClient/Npgsql; bare `"k0"` for ODP.NET, its documented convention).
|
||||
- `void ConfigureCommand(DbCommand command)` — default no-op; **OracleDialect sets
|
||||
`OracleCommand.BindByName = true`** (ODP.NET defaults to bind-by-position, which would silently
|
||||
mis-bind the planner's multi-parameter `IN` lists — this is the sharpest correctness edge in
|
||||
the branch).
|
||||
`SqlGroupPlanner` drops its `@k`/`@w` consts and takes markers from the dialect (bind names ride
|
||||
`SqlQueryPlan` alongside, keeping `SqlPollReader.cs:398-405`'s copy-don't-derive discipline).
|
||||
`SqlCatalogLoader` binds `schema`/`table` through the dialect instead of the literal `"@schema"`.
|
||||
Every command creation site (`SqlPollReader`, `SqlCatalogLoader`, `SqlDriverProbe`,
|
||||
`SqlBrowseSession`) calls `ConfigureCommand` after `CreateCommand`.
|
||||
- **`PostgresDialect`** (`NpgsqlFactory.Instance`): `@` markers; `"`-quoting with `""` escape, max
|
||||
identifier 63; `LivenessSql = "SELECT 1"`; limit prefix `""` / suffix `" LIMIT 1"`;
|
||||
`INFORMATION_SCHEMA` catalog SQL with `pg_catalog`/`information_schema` schemas excluded and
|
||||
`DefaultSchemaSql = "SELECT current_schema()"`; type map over Postgres names (`integer`,
|
||||
`bigint`, `smallint`, `real`, `double precision`, `numeric`, `boolean`, `text`,
|
||||
`character varying`, `timestamp*`, `uuid`, `bytea`, …).
|
||||
- **`OracleDialect`** (`OracleClientFactory.Instance`): `:` markers, bare bind names, BindByName;
|
||||
`"`-quoting with `""` escape, max identifier 128 (declares Oracle 12.2+ as the supported floor —
|
||||
`FETCH FIRST` needs 12c anyway); `LivenessSql = "SELECT 1 FROM DUAL"`; limit suffix
|
||||
`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL from `ALL_USERS` (schemas),
|
||||
`ALL_TABLES` ∪ `ALL_VIEWS` (tables — matching `INFORMATION_SCHEMA.TABLES`' view inclusion),
|
||||
`ALL_TAB_COLUMNS` (columns), `DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"`;
|
||||
type map over Oracle names — **`NUMBER` maps to `Float64`** (precision/scale are not visible to
|
||||
`MapColumnType(string)`; the per-tag `DeclaredType` override is the documented escape hatch),
|
||||
plus `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`, `DATE`/`TIMESTAMP*`,
|
||||
`CLOB`, `RAW`.
|
||||
- **Case-folding rides the existing catalog gate.** Postgres lower-cases and Oracle upper-cases
|
||||
unquoted identifiers; `SqlCatalogGate` already resolves every authored identifier to the
|
||||
catalog's own spelling, so authored `widgets` binds to Oracle `WIDGETS`. The implementer verifies
|
||||
the gate's comparison is case-insensitive and pins it per-dialect with tests.
|
||||
- **Gates widen to exactly these two.** `CreateDialect` and `DefaultDialectSelector` add
|
||||
`Postgres`/`Oracle`; `MySql`/`Odbc` keep the existing loud rejection (tests updated: the
|
||||
"Postgres is rejected" assertions at `SqlDriverFactoryTests.cs:133-135` and
|
||||
`SqlDriverBrowserTests.cs:188-196` flip to `MySql` as the still-reserved control).
|
||||
- **AdminUI**: `SqlDriverForm.razor` dropdown gains `Postgres` and `Oracle` options; helper text
|
||||
updated. No other UI change — Sql stays a single-connection driver with no device form
|
||||
(`DriverFormMaps.cs:81-89`), `connectionStringRef` + `Sql__ConnectionStrings__<ref>` env
|
||||
resolution is provider-agnostic already, and the `SqlConnectionStringPersisted` deploy gate needs
|
||||
no change (it keys on the literal `connectionString` key, not the provider).
|
||||
- **Packages**: `Directory.Packages.props` gains `Npgsql` (between `MQTTnet` and `Novell.*`) and
|
||||
`Oracle.ManagedDataAccess.Core` (between the `OPCFoundation.*` block and `OpenTelemetry`), latest
|
||||
stable versions on the feed. `CentralPackageTransitivePinningEnabled` is OFF by design, so if
|
||||
restore surfaces an NU1903 in either package's transitive tree, the fix is the established
|
||||
surgical direct-pin pattern (`Directory.Packages.props:118-145`), never a suppress.
|
||||
- **Credential discipline holds automatically**: `SqlDriver` already renders endpoints via the
|
||||
provider-neutral `DbConnectionStringBuilder` reading `host`/`data source` keys
|
||||
(`SqlDriver.cs:47-51, :952-968`) and logs exception types only.
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- `MySql`/`Odbc` dialects, write support (`IWritable` stays structurally absent, `allowWrites`
|
||||
stays inert), the deferred `Query` tag model, connection pooling knobs, and Kerberos/wallet
|
||||
auth schemes (connection-string concerns, owned by the operator's `Sql__ConnectionStrings__*`
|
||||
env value).
|
||||
|
||||
## 4. Testing
|
||||
|
||||
1. **Unit.**
|
||||
- `PostgresDialectTests` / `OracleDialectTests`: quoting (escape doubling, length caps,
|
||||
control-char rejection — mirror `SqlServerDialectTests`), liveness/limit/catalog SQL text,
|
||||
type maps, markers/bind names, and `ConfigureCommand` (Oracle: a real `OracleCommand` has
|
||||
`BindByName == true` after the call; others: no-op).
|
||||
- Planner/loader marker plumbing: a colon-marker fake dialect (or `SqliteDialect` extended)
|
||||
proves the planner emits dialect markers and the reader binds the dialect's bind names —
|
||||
the falsifiability control for gap 1.
|
||||
- Gate tests updated as in §2 (MySql becomes the rejected control).
|
||||
- The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism.
|
||||
2. **Integration (env-gated, skip-clean, docker).** Two new fixtures under
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/`, deployed to the shared
|
||||
docker host per the established pattern:
|
||||
- `postgres:17-alpine` (project `otopcua-sql-pg`, host port 15432) — env
|
||||
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`.
|
||||
- `gvenzl/oracle-free:23-slim` (project `otopcua-sql-oracle`, host port 11521) — env
|
||||
`SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/`SQL_ORACLE_PASSWORD`.
|
||||
Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded
|
||||
schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog),
|
||||
browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider-
|
||||
reference discipline (`.IntegrationTests.csproj:29-33`) — packages arrive transitively.
|
||||
3. **Live `/run` chrome gate — DEFERRED, run separately by the operator.** AdminUI legs (provider
|
||||
dropdown authoring, Test-connect, `/raw` browse against the Postgres and Oracle fixtures,
|
||||
browse-commit of a column). The executing session must NOT run this; it hands the operator the
|
||||
command at the end.
|
||||
|
||||
## 5. Bookkeeping
|
||||
|
||||
`docs/drivers/Sql.md` (§providers table, config table, browse section),
|
||||
`SqlProvider.cs` doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and
|
||||
`docs/research/drivers/sql-poll.md` stays as-is (research is a record, not living doc).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Sql Postgres + Oracle providers Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||||
> (opus implementers per user instruction).
|
||||
|
||||
**Goal:** Make `SqlProvider.Postgres` and `SqlProvider.Oracle` working backends via managed
|
||||
ADO.NET providers (Npgsql, Oracle.ManagedDataAccess.Core).
|
||||
|
||||
**Architecture:** See `docs/plans/2026-08-05-sql-postgres-oracle-design.md` (this branch). Two new
|
||||
dialects in the existing `.Dialect` project; dialect-owned parameter markers/bind-names +
|
||||
`ConfigureCommand` (Oracle BindByName); provider gates widened; AdminUI dropdown; env-gated docker
|
||||
integration suites. Chrome/AdminUI live gate is DEFERRED to the operator.
|
||||
|
||||
**Tech Stack:** .NET 10, central package management (`Directory.Packages.props`), xUnit v3 +
|
||||
Shouldly, SQLite falsifiability control.
|
||||
|
||||
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/sql-multi-provider`
|
||||
(branch `feature/sql-multi-provider`). All paths below relative to it.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Package pins
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj`
|
||||
|
||||
Add `PackageVersion` entries for `Npgsql` (alphabetical slot after `MQTTnet`) and
|
||||
`Oracle.ManagedDataAccess.Core` (after the `OPCFoundation.*` block) — latest stable on the feed
|
||||
(`dotnet package search <id> --exact-match` to confirm; Npgsql 9.x line, ODP.NET Core 23.x line;
|
||||
both must target net8.0+). Add both `PackageReference`s (version-less) to the `.Dialect` csproj
|
||||
under the existing `Microsoft.Data.SqlClient` reference, extending that ItemGroup's
|
||||
rationale comment. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` must come back clean WITH the audit
|
||||
active — if NU1903 fires on a transitive dep, apply the surgical direct-pin pattern documented at
|
||||
`Directory.Packages.props:118-145`, never a suppress. Commit
|
||||
`build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only)`.
|
||||
|
||||
### Task 1: Dialect-owned parameter naming + `ConfigureCommand`
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs` (:21-25 consts,
|
||||
:143, :151, :188)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs` (bind names alongside
|
||||
markers if the plan record needs both)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs` (:379-405 — ConfigureCommand
|
||||
call + bind-name copy)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs` (:97-113, `Bind` :181-188)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs` (ConfigureCommand)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs` (ConfigureCommand)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs` (implement new
|
||||
members; keep linked copy in Browser.Tests compiling)
|
||||
- Test: `SqlGroupPlannerTests.cs`, `SqlPollReaderTests.cs`, `SqlCatalogGateDriverTests.cs` (+ a
|
||||
colon-marker fake dialect in the test tree)
|
||||
|
||||
Per design §2 bullet 2. `ISqlDialect` gains `ParameterMarker(string baseName)`,
|
||||
`ParameterBindName(string baseName)`, `ConfigureCommand(DbCommand)`; SqlServerDialect returns
|
||||
`"@"+name` / `"@"+name` / no-op — **behaviour today must be byte-identical** (existing planner
|
||||
SQL-text assertions must pass untouched; if any needs editing, that is a regression, stop).
|
||||
Planner takes markers from its dialect; catalog SQL text and loader bind names route through the
|
||||
dialect so the two can never disagree. TDD: the colon-marker fake proves markers flow (observed
|
||||
red against the `@k` consts first). Every `CreateCommand` site calls `ConfigureCommand`
|
||||
immediately after creation. Commit
|
||||
`refactor(sql): dialect-owned parameter markers, bind names and command configuration`.
|
||||
|
||||
### Task 2: `PostgresDialect`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs` (new)
|
||||
|
||||
Per design §2 Postgres bullet. `Factory => NpgsqlFactory.Instance`; `Provider =>
|
||||
SqlProvider.Postgres`; `@` markers/bind names; `"`-quoting `""`-escaped, MaxIdentifierLength 63,
|
||||
same control-char/Unicode-Format rejection discipline as `SqlServerDialect.QuoteIdentifier`
|
||||
(:88-114); `LivenessSql "SELECT 1"`; limit `""`/`" LIMIT 1"`; catalog SQL over
|
||||
`information_schema.schemata/tables/columns` excluding `pg_catalog` + `information_schema`,
|
||||
`DefaultSchemaSql "SELECT current_schema()"` (parameter markers in catalog SQL via the Task 1
|
||||
members); `MapColumnType` per design (unknown → the same fallback SqlServerDialect uses — read it
|
||||
first). Tests mirror `SqlServerDialectTests` structure: quoting theory, SQL text, type-map theory,
|
||||
marker members. Commit `feat(sql): PostgresDialect (Npgsql)`.
|
||||
|
||||
### Task 3: `OracleDialect`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 5
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs` (new)
|
||||
|
||||
Per design §2 Oracle bullet. `OracleClientFactory.Instance`; `:` markers, **bare** bind names;
|
||||
`ConfigureCommand` casts to `OracleCommand` and sets `BindByName = true` (the test constructs a
|
||||
real `OracleCommand` and asserts the flag — this is the load-bearing test of the whole branch;
|
||||
also assert a foreign `DbCommand` passes through untouched). Quoting `"`/`""`, max 128;
|
||||
`LivenessSql "SELECT 1 FROM DUAL"`; limit `""`/`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL:
|
||||
`ALL_USERS` schemas, `ALL_TABLES` ∪ `ALL_VIEWS` tables, `ALL_TAB_COLUMNS` columns,
|
||||
`sys_context('userenv','current_schema')` default schema — markers via `:schema`/`:table` through
|
||||
the Task 1 members; `MapColumnType`: `NUMBER`→Float64 (doc the DeclaredType escape hatch in the
|
||||
XML doc comment), `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`/`CLOB`,
|
||||
`DATE`/`TIMESTAMP*`, `RAW`, plus `FLOAT`/`INTEGER` synonyms. Commit
|
||||
`feat(sql): OracleDialect (ODP.NET managed, BindByName)`.
|
||||
|
||||
### Task 4: Widen the provider gates
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs` (:208-215)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs` (:126-127,
|
||||
:146-151 comment)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs` (doc comments)
|
||||
- Test: `SqlDriverFactoryTests.cs` (:133-135 → MySql as rejected control; new: Postgres/Oracle
|
||||
construct with their dialects), `SqlDriverBrowserTests.cs` (:188-196 same flip)
|
||||
|
||||
TDD: red first — author `{"provider":"Postgres"}` and assert construction reaches the
|
||||
connection-string resolver (or dialect type assertion via the binding) instead of the
|
||||
not-available throw. Rejection message updated to name the three supported providers. Commit
|
||||
`feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved`.
|
||||
|
||||
### Task 5: AdminUI dropdown + docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor`
|
||||
(:18-25)
|
||||
- Modify: `docs/drivers/Sql.md` (:15-18, :96 provider row, browse section)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend the existing Sql driver-form
|
||||
model test file (find `SqlDriverFormModelTests` or nearest equivalent) with round-trip of
|
||||
`provider: Postgres` / `Oracle`
|
||||
|
||||
Dropdown gains `Postgres` + `Oracle` options; helper text → "SQL Server, PostgreSQL and Oracle
|
||||
are supported; MySql/Odbc are reserved." Sql.md rewritten per design §5 including the Oracle
|
||||
`NUMBER`→Float64 note and the 12.2+ floor. Commit
|
||||
`feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs`.
|
||||
|
||||
### Task 6: Integration fixtures + env-gated suites
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml`
|
||||
+ `seed-pg.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml`
|
||||
+ `seed-oracle.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs`,
|
||||
`OracleReadTests.cs`
|
||||
|
||||
Per design §4.2. Compose shapes mirror the existing mssql fixture (`Docker/docker-compose.yml`:
|
||||
project label `project: lmxopcua`, one-shot seed service, healthcheck): `postgres:17-alpine`
|
||||
port `15432:5432` env `POSTGRES_PASSWORD=${SQL_PG_PASSWORD}`; `gvenzl/oracle-free:23-slim` port
|
||||
`11521:1521` env `ORACLE_PASSWORD=${SQL_ORACLE_PASSWORD}` (gvenzl images take `APP_USER`/
|
||||
`APP_USER_PASSWORD` for a non-SYS user — use `otopcua`/the password env). Seeds create one
|
||||
KeyValue table + one WideRow table with a timestamp column, plus (Oracle) a deliberately
|
||||
UPPER-CASE table authored lower-case in the test to pin catalog case-folding. Test classes follow
|
||||
`SqlServerReadTests`/`SqlBlackholeTimeoutTests` conventions exactly: env vars
|
||||
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`, `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/
|
||||
`SQL_ORACLE_PASSWORD`; `Assert.SkipWhen` absent; connection strings via each provider's builder
|
||||
obtained FROM THE FACTORY (`dialect.Factory.CreateConnectionStringBuilder()`) so the csproj keeps
|
||||
its no-direct-provider-reference discipline (`.IntegrationTests.csproj:29-33`). Legs per suite:
|
||||
initialize (liveness + catalog), KeyValue read, WideRow topByTimestamp read, browse-session
|
||||
walk, case-folding leg. Commit `test(sql): env-gated Postgres + Oracle integration suites`.
|
||||
|
||||
### Task 7: Full-suite verification + bookkeeping
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors), full Sql unit + Browser.Tests + AdminUI.Tests +
|
||||
Configuration.Tests (the SqlCredentialGuard suites must stay green), integration suites compile +
|
||||
skip clean offline. Update `docs/plans/2026-07-24-driver-expansion-tracking.md` Sql row/deferral
|
||||
note. Commit.
|
||||
|
||||
### Task 8: Fixture deploy + live integration run (main session)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~15 min (main session, after merge)
|
||||
|
||||
Main session, not a subagent: rsync the two fixture dirs to `dohertj2@10.100.0.35:/opt/otopcua-sql-pg/`
|
||||
and `/opt/otopcua-sql-oracle/`, `docker compose up -d` each over ssh, then run
|
||||
`dotnet test --filter "FullyQualifiedName~PostgresRead|FullyQualifiedName~OracleRead"` with the
|
||||
env vars set. (oracle-free first pull is ~1.5 GB — allow time; if the host can't take it, record
|
||||
the suite as authored-but-not-yet-run.) Record results in the design doc §Outcome.
|
||||
|
||||
**Chrome/AdminUI live gate — DEFERRED to the operator.** After merge the operator runs, in a
|
||||
chrome-capable session:
|
||||
`/superpowers-extended-cc:executing-plans docs/plans/2026-08-05-sql-postgres-oracle.md` — gate
|
||||
section: provider dropdown authoring, Test-connect, `/raw` browse + browse-commit against both
|
||||
fixtures at `http://localhost:9200`.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-05-sql-postgres-oracle.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 0: Package pins", "status": "completed" },
|
||||
{ "id": 1, "subject": "Task 1: Dialect-owned parameter naming + ConfigureCommand", "status": "completed" },
|
||||
{ "id": 2, "subject": "Task 2: PostgresDialect", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 3, "subject": "Task 3: OracleDialect", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 4, "subject": "Task 4: Widen the provider gates", "status": "completed", "blockedBy": [2, 3] },
|
||||
{ "id": 5, "subject": "Task 5: AdminUI dropdown + docs", "status": "completed" },
|
||||
{ "id": 6, "subject": "Task 6: Integration fixtures + env-gated suites", "status": "completed", "blockedBy": [4], "note": "found+fixed ORA-01745 (ec90b7c4); both suites ran live locally (pg 5/5, oracle 6/6)" },
|
||||
{ "id": 7, "subject": "Task 7: Full-suite verification + bookkeeping", "status": "completed", "blockedBy": [5, 6] },
|
||||
{ "id": 8, "subject": "Task 8: Fixture deploy + live integration run (main session); chrome gate deferred to operator", "status": "pending", "blockedBy": [7] }
|
||||
],
|
||||
"lastUpdated": "2026-08-05T00:00:00Z"
|
||||
}
|
||||
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
|
||||
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
|
||||
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
|
||||
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
|
||||
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
|
||||
/// read verbatim and its <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/> — so the bind names cannot drift from the markers the
|
||||
/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a
|
||||
/// hostile or merely awkward catalog name is inert here.
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
|
||||
/// never needs an identifier in text.</para>
|
||||
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
|
||||
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <summary>
|
||||
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
|
||||
/// (design §4.3), so there is nothing else a picked column could be.
|
||||
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
{
|
||||
var rows = await QueryAsync(
|
||||
_dialect.ListTablesSql,
|
||||
command => Bind(command, "@schema", schema),
|
||||
command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
|
||||
static reader => (
|
||||
Name: ReadString(reader, TableNameColumn),
|
||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
_dialect.ListColumnsSql,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, _dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
|
||||
var results = new List<T>();
|
||||
await using var command = _connection.CreateCommand();
|
||||
// Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
|
||||
// column query is exactly where positional binding swaps the schema and the table silently.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
bind(command);
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
/// <param name="dialectSelector">
|
||||
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
|
||||
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
|
||||
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
|
||||
/// provider gets added without touching this class.
|
||||
/// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how
|
||||
/// a further provider gets added without touching this class.
|
||||
/// </param>
|
||||
/// <param name="environmentReader">
|
||||
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
|
||||
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
ConnectionStringEnvironmentPrefix + connectionStringRef;
|
||||
|
||||
/// <summary>
|
||||
/// The providers this build can browse. v1 constructs SQL Server only; every other
|
||||
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
|
||||
/// the caller reports it as unavailable rather than crashing on a null dialect.
|
||||
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
|
||||
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
|
||||
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
|
||||
/// unavailable rather than crashing on a null dialect.
|
||||
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
|
||||
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
|
||||
/// against a database the AdminUI refuses to show them.</para>
|
||||
/// </summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
|
||||
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
@@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
var literal = ReadPastedConnectionString(configJson);
|
||||
var config = Deserialize(configJson, literal);
|
||||
|
||||
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
|
||||
// Provider first: "this build cannot browse MySql" is true regardless of how the database is
|
||||
// named, and answering it before touching the environment keeps the two failures from masking.
|
||||
var dialect = _dialectSelector(config.Provider)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
|
||||
+ $"'{SqlProvider.SqlServer}' only.");
|
||||
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
|
||||
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
|
||||
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
/// </summary>
|
||||
public sealed class SqlDriverConfigDto
|
||||
{
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
|
||||
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
/// <summary>
|
||||
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
|
||||
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
|
||||
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
|
||||
/// </summary>
|
||||
public enum SqlProvider
|
||||
{
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
|
||||
SqlServer,
|
||||
|
||||
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
|
||||
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
MySql,
|
||||
|
||||
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
Odbc,
|
||||
|
||||
/// <summary>Oracle — reserved; not constructed in v1.</summary>
|
||||
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
|
||||
Oracle,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// </summary>
|
||||
public interface ISqlDialect
|
||||
{
|
||||
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
|
||||
/// <summary>
|
||||
/// Which backend this dialect speaks. One implementation ships per constructed
|
||||
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
|
||||
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
|
||||
/// </summary>
|
||||
SqlProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -50,6 +54,58 @@ public interface ISqlDialect
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||
string QuoteIdentifier(string ident);
|
||||
|
||||
/// <summary>
|
||||
/// The token that stands for a bound parameter <b>in the command text</b> — <c>"@k0"</c> for
|
||||
/// SqlClient/Npgsql, <c>":p_k0"</c> for ODP.NET — given the caller's bare base name (<c>"k0"</c>,
|
||||
/// <c>"w"</c>, <c>"schema"</c>).
|
||||
/// <para><b>Why the marker and the bind name are two members.</b> They are the same string on every
|
||||
/// provider but Oracle, where the SQL text carries <c>:p_schema</c> while
|
||||
/// <see cref="DbParameter.ParameterName"/> is the bare <c>schema</c>. Modelling one and deriving the
|
||||
/// other means a call site guessing at a convention it does not own — the exact defect this seam
|
||||
/// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
|
||||
/// bind-by-position, as a <em>silently mis-bound value</em>) rather than a compile error.</para>
|
||||
/// <para>The base name is caller-owned and never operator input: the poll planner mints
|
||||
/// <c>k0…kN</c>/<c>w</c>, the catalog path uses <c>schema</c>/<c>table</c>. Authored <em>values</em>
|
||||
/// ride the parameter; authored <em>identifiers</em> go through <see cref="QuoteIdentifier"/>. Nothing
|
||||
/// else reaches a command text.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The marker to embed in the SQL text.</returns>
|
||||
string ParameterMarker(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// The value to assign to <see cref="DbParameter.ParameterName"/> for the same
|
||||
/// <paramref name="baseName"/> — <c>"@k0"</c> for SqlClient-style providers (which expect the marker
|
||||
/// verbatim), the bare <c>"k0"</c> for ODP.NET-style ones (whose documented convention is a
|
||||
/// prefix-less bind name).
|
||||
/// <para>Always paired with <see cref="ParameterMarker"/> over the <em>same</em> base name: a plan
|
||||
/// carries both lists and every binding site <b>copies</b> them index-for-index rather than
|
||||
/// re-deriving either from the other.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The parameter name to bind under.</returns>
|
||||
string ParameterBindName(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// Applies provider-specific setup to a freshly created <see cref="DbCommand"/>, <b>before</b> its
|
||||
/// parameters are added or it is executed. A no-op for providers that need none.
|
||||
/// <para><b>Oracle uses this to set <c>OracleCommand.BindByName = true</c></b>, and that call is the
|
||||
/// sharpest correctness edge on this seam: ODP.NET binds <em>by position</em> by default, so a
|
||||
/// multi-parameter statement (the planner's <c>IN (:p_k0, :p_k1, …)</c> list, the catalog path's
|
||||
/// <c>schema</c> + <c>table</c> pair) would bind values to the wrong markers whenever the parameter
|
||||
/// order and the marker order differ — publishing one tag's value onto another tag's node with no
|
||||
/// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
|
||||
/// browse path — therefore calls this immediately after <c>CreateCommand()</c> and <b>before</b> the
|
||||
/// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
|
||||
/// ones are indifferent: <c>SqlDriverProbe</c> calls it anyway for uniformity,
|
||||
/// <c>SqlDriver.PingAsync</c> does not.)</para>
|
||||
/// <para>Implementations must tolerate <em>any</em> <see cref="DbCommand"/> they are handed: the
|
||||
/// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
|
||||
/// provider type must do so defensively rather than throwing.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure.</param>
|
||||
void ConfigureCommand(DbCommand command);
|
||||
|
||||
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||
string LivenessSql { get; }
|
||||
|
||||
@@ -105,10 +161,19 @@ public interface ISqlDialect
|
||||
/// </remarks>
|
||||
string DefaultSchemaSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||
/// <summary>
|
||||
/// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
|
||||
/// parameter under the base name <c>schema</c>, spelled with this dialect's own
|
||||
/// <see cref="ParameterMarker"/> (<c>@schema</c> on SqlClient, <c>:p_schema</c> on ODP.NET); callers bind
|
||||
/// it under <see cref="ParameterBindName"/> of the same base name.
|
||||
/// </summary>
|
||||
string ListTablesSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
|
||||
/// <summary>
|
||||
/// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
|
||||
/// parameters under the base names <c>schema</c> and <c>table</c>, spelled and bound exactly as
|
||||
/// <see cref="ListTablesSql"/> describes.
|
||||
/// </summary>
|
||||
string ListColumnsSql { get; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
|
||||
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
|
||||
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>colon-less</b> bind names, the
|
||||
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
|
||||
/// <c>FETCH FIRST</c> row limiting.
|
||||
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> arrived
|
||||
/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a
|
||||
/// long-but-legal-here identifier would be rejected by the server rather than by
|
||||
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session.</para>
|
||||
/// </summary>
|
||||
public sealed class OracleDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog
|
||||
/// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 128;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Oracle;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => OracleClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
|
||||
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
|
||||
/// <see cref="ParameterMarker"/>.
|
||||
/// </summary>
|
||||
private const string BindPrefix = "p_";
|
||||
|
||||
/// <summary>
|
||||
/// Oracle markers are <c>:</c>-prefixed <b>and namespaced</b> — <c>ParameterMarker("k0")</c> is
|
||||
/// <c>":p_k0"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The <c>p_</c> is load-bearing, and was found live.</b> Oracle rejects a bind variable
|
||||
/// whose name is a reserved word with <c>ORA-01745: invalid host/bind variable name</c> — and the
|
||||
/// catalog readers' own base names include <c>table</c> (<c>SqlCatalogLoader</c> /
|
||||
/// <c>SqlBrowseSession</c> bind <c>schema</c> + <c>table</c>). So an un-prefixed
|
||||
/// <see cref="ListColumnsSql"/> could not execute at all: every Oracle driver instance failed its
|
||||
/// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's
|
||||
/// columns threw. Caught by <c>OracleReadTests</c> against a real database; invisible to any offline
|
||||
/// test, because the SQL is only ever parsed by a server.</para>
|
||||
/// <para>Prefixing <em>everything</em> rather than maintaining a reserved-word list is deliberate:
|
||||
/// the list is long, version-dependent, and a future base name (<c>date</c>, <c>user</c>,
|
||||
/// <c>comment</c>, <c>level</c>…) would re-open the same hole silently. A prefixed name can never be
|
||||
/// a reserved word, and the marker and the bind name are derived from the same constant, so they
|
||||
/// cannot drift apart.</para>
|
||||
/// </remarks>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>:</c>-prefixed, namespaced marker.</returns>
|
||||
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// The same namespaced name <b>without the <c>:</c></b> — ODP.NET's documented convention for
|
||||
/// <see cref="DbParameter.ParameterName"/>, and deliberately <em>not</em>
|
||||
/// <see cref="ParameterMarker"/>. This is the divergence that makes the two members exist at all
|
||||
/// (see <see cref="ISqlDialect.ParameterBindName"/>).
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The colon-less bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => BindPrefix + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Sets <c>OracleCommand.BindByName = true</c>. <b>This is the sharpest correctness edge in the
|
||||
/// driver's provider support.</b>
|
||||
/// <para>ODP.NET binds <em>by position</em> by default: parameter 0 fills the first marker in the
|
||||
/// statement, parameter 1 the second, and so on, <em>regardless of the names</em>. Every statement this
|
||||
/// driver builds with more than one marker would then be a coin flip — the planner's
|
||||
/// <c>IN (:p_k0, :p_k1, …)</c> key list, and <see cref="ListColumnsSql"/>'s <c>schema</c> + <c>table</c>
|
||||
/// pair — and the failure mode is not an exception but a <em>successful query over the wrong rows</em>,
|
||||
/// publishing one tag's value onto another tag's node. Worse still,
|
||||
/// <see cref="ListTablesSql"/> names <c>:p_schema</c> <b>twice</b> (once per branch of its
|
||||
/// <c>UNION ALL</c>) and is bound once: under positional binding that statement cannot even execute,
|
||||
/// because the second occurrence has no parameter to take.</para>
|
||||
/// <para>Must be called while the parameter collection is still empty — which is exactly what every
|
||||
/// call site does (immediately after <c>CreateCommand()</c>, before the first bind).</para>
|
||||
/// <para><b>A foreign command passes through untouched rather than throwing.</b> The dialect can be
|
||||
/// handed a <see cref="DbCommand"/> from another provider — an instrumented or substituted factory in
|
||||
/// a shared test harness is the realistic case — and an <c>InvalidCastException</c> there would fail a
|
||||
/// poll for a reason that has nothing to do with the database. There is nothing to configure on a
|
||||
/// non-ODP.NET command anyway: every other provider this repo speaks binds by name already.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; only an <see cref="OracleCommand"/> is touched.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
if (command is OracleCommand oracle) oracle.BindByName = true;
|
||||
}
|
||||
|
||||
/// <summary>Oracle has no bare <c>SELECT</c>; every projection needs a table, and <c>DUAL</c> is the one-row one.</summary>
|
||||
public string LivenessSql => "SELECT 1 FROM DUAL";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Oracle 12c+ spells the row limit at the <em>end</em> of the statement (see
|
||||
/// <see cref="SingleRowLimitSuffix"/>). Anything here would land between <c>SELECT</c> and the column
|
||||
/// list, where Oracle has no limit syntax at all.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
|
||||
/// finished statement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately <b>not</b> the older <c>WHERE ROWNUM = 1</c>: <c>ROWNUM</c> is assigned <em>before</em>
|
||||
/// <c>ORDER BY</c> runs, so it would take an arbitrary row and then sort it — silently returning the
|
||||
/// wrong row for the <c>topByTimestamp</c> selector, whose entire purpose is "the newest one". Making
|
||||
/// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the
|
||||
/// statement shape, which this seam deliberately leaves with the planner.
|
||||
/// </remarks>
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <summary>
|
||||
/// Schemas visible to the connecting principal, from <c>ALL_USERS</c> (Oracle equates a schema with a
|
||||
/// user), aliased to the <c>TABLE_SCHEMA</c> column name the catalog readers project by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Broader than the SQL Server dialect's <c>DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES</c>,
|
||||
/// which lists only schemas that own an object: <c>ALL_USERS</c> includes empty ones. Harmless here —
|
||||
/// the list is used to resolve an authored schema name to the catalog's own spelling, and a schema
|
||||
/// holding no tables simply resolves and then matches no relation.
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
|
||||
|
||||
/// <summary>
|
||||
/// <c>sys_context('userenv','current_schema')</c> is the schema an unqualified name resolves in for
|
||||
/// this session — which is what the catalog gate asks, and it correctly follows an
|
||||
/// <c>ALTER SESSION SET CURRENT_SCHEMA</c> rather than reporting the login name.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
|
||||
|
||||
/// <summary>
|
||||
/// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
|
||||
/// <c>INFORMATION_SCHEMA.TABLES</c> carries both. The synthesised <c>TABLE_TYPE</c> literals are the
|
||||
/// <c>INFORMATION_SCHEMA</c> spellings the browser compares against to mark a view node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>UNION ALL</c> rather than <c>UNION</c>: the two sources are disjoint by construction (a name
|
||||
/// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort.
|
||||
/// <para>Note this statement names <c>:p_schema</c> twice and is bound <b>once</b> — see
|
||||
/// <see cref="ConfigureCommand"/>.</para>
|
||||
/// </remarks>
|
||||
public string ListTablesSql =>
|
||||
"SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " +
|
||||
"UNION ALL " +
|
||||
"SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " +
|
||||
"ORDER BY TABLE_NAME";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one relation, from <c>ALL_TAB_COLUMNS</c> — which covers views as well as tables, so it
|
||||
/// serves both halves of <see cref="ListTablesSql"/>. <c>column_id</c> is Oracle's
|
||||
/// <c>ORDINAL_POSITION</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ALL_TAB_COLUMNS.NULLABLE</c> is <c>Y</c>/<c>N</c> where <c>INFORMATION_SCHEMA.IS_NULLABLE</c> is
|
||||
/// <c>YES</c>/<c>NO</c>, so it is translated rather than merely aliased: a column that carries a
|
||||
/// shared name across dialects has to carry shared <em>values</em> too, or the first consumer to read
|
||||
/// it gets a different answer per provider.
|
||||
/// </remarks>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " +
|
||||
"CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " +
|
||||
"FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the Oracle escape rule, and the
|
||||
/// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the
|
||||
/// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Case matters once an identifier is quoted.</b> Oracle upper-cases unquoted identifiers but
|
||||
/// takes a quoted one literally, so <c>"speed"</c> and <c>"SPEED"</c> are different columns. This is
|
||||
/// safe here only because the catalog gate has already substituted the catalog's own spelling
|
||||
/// (<c>ALL_TAB_COLUMNS.column_name</c>, i.e. what the server actually stored) before anything reaches
|
||||
/// this method — an operator who authored <c>speed</c> against an Oracle <c>SPEED</c> column resolves
|
||||
/// to <c>SPEED</c> at Initialize, and it is <c>SPEED</c> that gets quoted.</para>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
|
||||
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
|
||||
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
||||
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
||||
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> text of a log line or
|
||||
/// an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source class,
|
||||
/// CVE-2021-42574) — the same log/audit-integrity concern that motivates the control-character rule.</para>
|
||||
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
||||
/// input and this exception's text reaches the driver log.</para>
|
||||
/// <para><b>This is a backstop, not the only defence</b> — see
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> on the catalog gate. The rules below still run, and must
|
||||
/// hold for any future caller that has no catalog to check against.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The double-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid Oracle identifier.</exception>
|
||||
public string QuoteIdentifier(string ident)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ident))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
|
||||
|
||||
if (ident.Length > MaxIdentifierLength)
|
||||
throw new ArgumentException(
|
||||
$"An Oracle identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
|
||||
nameof(ident));
|
||||
|
||||
foreach (var ch in ident)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
|
||||
|
||||
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
|
||||
// zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain Unicode format characters "
|
||||
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
|
||||
}
|
||||
|
||||
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds an <c>ALL_TAB_COLUMNS.DATA_TYPE</c> family name onto a <see cref="DriverDataType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b><c>NUMBER</c> maps to <see cref="DriverDataType.Float64"/>, and that is a deliberate
|
||||
/// under-specification.</b> Oracle has one numeric type: an integer column, a currency column and a
|
||||
/// scientific measurement are all <c>NUMBER</c>, differing only in the precision/scale this method
|
||||
/// never sees — its whole input is the family name. Choosing an integral driver type would truncate
|
||||
/// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a
|
||||
/// <c>NUMBER(38)</c> beyond 2^53 loses low-order digits in a <c>double</c>, and an integer-valued
|
||||
/// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
|
||||
/// an explicit <c>"type"</c> on the tag (<c>Int64</c> for a true integer key, <c>String</c> to keep an
|
||||
/// exact high-precision decimal), which overrides this inference.</para>
|
||||
/// <para><b><c>TIMESTAMP</c> is matched by prefix</b>, unlike every other entry. Oracle reports the
|
||||
/// fractional-seconds precision and the time-zone qualifier <em>inside</em> the family name —
|
||||
/// <c>TIMESTAMP(6)</c>, <c>TIMESTAMP(6) WITH TIME ZONE</c>, <c>TIMESTAMP(6) WITH LOCAL TIME ZONE</c> —
|
||||
/// where <c>INFORMATION_SCHEMA</c> would report a bare <c>timestamp</c>. An exact-match table would
|
||||
/// send all of them to the String fallback.</para>
|
||||
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
|
||||
/// throws — a schema browse must render a table containing an exotic column (<c>RAW</c>,
|
||||
/// <c>LONG RAW</c>, <c>BLOB</c>, <c>BFILE</c>, <c>ROWID</c>, <c>XMLTYPE</c>, an object type) rather
|
||||
/// than crash on it. Null/blank input takes the same fallback. <c>RAW</c>/<c>BLOB</c> land here for
|
||||
/// the same reason SQL Server's <c>varbinary</c> does: there is no binary driver type.</para>
|
||||
/// <para><b>Deliberate non-mapping:</b> the <c>INTERVAL</c> types are durations, not points in time,
|
||||
/// and take the String fallback rather than being mistaken for a
|
||||
/// <see cref="DriverDataType.DateTime"/> — the same call the SQL Server dialect makes for
|
||||
/// <c>time</c>.</para>
|
||||
/// <para>Oracle's <c>DATE</c> carries a time-of-day component (it is not a date-only type), so
|
||||
/// <see cref="DriverDataType.DateTime"/> is the faithful mapping rather than a widening.</para>
|
||||
/// </remarks>
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
public DriverDataType MapColumnType(string sqlDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
||||
|
||||
// ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server
|
||||
// dialect does) keeps the arms readable as the names an operator sees in the browse tree.
|
||||
var family = sqlDataType.Trim().ToUpperInvariant();
|
||||
|
||||
// Prefix, not equality: the precision and time-zone qualifier live inside the family name.
|
||||
if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime;
|
||||
|
||||
return family switch
|
||||
{
|
||||
// One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are
|
||||
// resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are
|
||||
// here so a server or gateway that does surface one is not silently mapped to String.
|
||||
"NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION"
|
||||
or "INTEGER" or "INT" or "SMALLINT"
|
||||
or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64,
|
||||
|
||||
// The only single-precision storage type Oracle has.
|
||||
"BINARY_FLOAT" => DriverDataType.Float32,
|
||||
|
||||
"VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR"
|
||||
or "CLOB" or "NCLOB" => DriverDataType.String,
|
||||
|
||||
// Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as
|
||||
// NUMBER(1) or CHAR(1), which are invisible here and map by their own family.
|
||||
"BOOLEAN" => DriverDataType.Boolean,
|
||||
|
||||
"DATE" => DriverDataType.DateTime,
|
||||
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
|
||||
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
|
||||
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
|
||||
/// <c>information_schema.columns.data_type</c> actually reports.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
|
||||
/// </summary>
|
||||
public sealed class PostgresDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
|
||||
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
|
||||
/// a different object than it spells; rejecting it surfaces the config mistake instead.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 63;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Postgres;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => NpgsqlFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
|
||||
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
|
||||
/// catalog SQL below and the planner's markers can never drift apart.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> is matched against
|
||||
/// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps
|
||||
/// the parameter collection readable in a log and matches what the SQL text says.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
|
||||
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
|
||||
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
|
||||
/// untouched rather than throwing.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Postgres spells its row limit at the end of the statement, in
|
||||
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
|
||||
/// the seam carries both ends.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
|
||||
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <summary>
|
||||
/// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
|
||||
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
|
||||
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
|
||||
/// account granted <c>USAGE</c> + <c>SELECT</c> on someone else's schema — the normal way a read-only
|
||||
/// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving
|
||||
/// the list from the relations the principal can see answers the question the catalog gate is really
|
||||
/// asking: "which schemas hold something I may read?"</para>
|
||||
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
|
||||
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
|
||||
/// <c>pg_toast_temp_N</c> namespaces are excluded by prefix as well: they hold storage internals and
|
||||
/// session-local temp objects, can never contain an authored poll target, and a <c>pg_temp_N</c> in
|
||||
/// particular names a different thing per session — offering it in the browse tree would present a
|
||||
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
|
||||
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
|
||||
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " +
|
||||
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
|
||||
"AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " +
|
||||
"AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " +
|
||||
"ORDER BY \"TABLE_SCHEMA\"";
|
||||
|
||||
/// <summary>
|
||||
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
|
||||
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT current_schema()";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
|
||||
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
|
||||
/// <c>SqlBrowseSession</c> both read every catalog column by name
|
||||
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> output
|
||||
/// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those
|
||||
/// consumers ask for rather than relying on a provider's case-insensitive <c>GetOrdinal</c>
|
||||
/// fallback.</para>
|
||||
/// <para>The <c>@schema</c> marker is written literally here because
|
||||
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
|
||||
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
|
||||
/// </remarks>
|
||||
public string ListTablesSql =>
|
||||
"SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " +
|
||||
"FROM information_schema.tables " +
|
||||
"WHERE table_schema = @schema ORDER BY \"TABLE_NAME\"";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one table in ordinal order — the same three columns
|
||||
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
|
||||
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
|
||||
/// </summary>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " +
|
||||
"is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " +
|
||||
"WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the SQL-standard escape rule
|
||||
/// Postgres follows, and the only metacharacter that could terminate a quoted identifier early.
|
||||
/// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
|
||||
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
|
||||
/// against the other): null / empty / all-whitespace; longer than
|
||||
/// <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL and the C0/C1
|
||||
/// ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
||||
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
||||
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> text of a log line
|
||||
/// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source
|
||||
/// class, CVE-2021-42574).</para>
|
||||
/// <para>NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string
|
||||
/// outright, so passing one through would fail the whole statement rather than just the one
|
||||
/// identifier.</para>
|
||||
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
||||
/// input and this exception's text reaches the driver log.</para>
|
||||
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
|
||||
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
|
||||
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
|
||||
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
|
||||
/// read back out of the catalog.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The double-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid PostgreSQL identifier.</exception>
|
||||
public string QuoteIdentifier(string ident)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ident))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
|
||||
|
||||
if (ident.Length > MaxIdentifierLength)
|
||||
throw new ArgumentException(
|
||||
$"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
|
||||
nameof(ident));
|
||||
|
||||
foreach (var ch in ident)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
|
||||
|
||||
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
|
||||
// zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain Unicode format characters "
|
||||
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
|
||||
}
|
||||
|
||||
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds an <c>information_schema.columns.data_type</c> family name onto a
|
||||
/// <see cref="DriverDataType"/> per design §3.7.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The input is the canonical, spelled-out name Postgres reports</b> —
|
||||
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
|
||||
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
|
||||
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
|
||||
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
|
||||
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
|
||||
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
|
||||
/// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
|
||||
/// <c>"type": "String"</c> on the tag preserves the exact textual value when that matters.</para>
|
||||
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
|
||||
/// throws — a schema browse must render a table containing an exotic column (<c>json</c>,
|
||||
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
|
||||
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
|
||||
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
|
||||
/// falls back rather than pretending binary is a scalar.</para>
|
||||
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
|
||||
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
|
||||
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
|
||||
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
|
||||
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
|
||||
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
|
||||
/// <c>uniqueidentifier</c> is handled.</para>
|
||||
/// </remarks>
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
public DriverDataType MapColumnType(string sqlDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
||||
|
||||
return sqlDataType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => DriverDataType.Boolean,
|
||||
"smallint" or "int2" => DriverDataType.Int16,
|
||||
"integer" or "int" or "int4" => DriverDataType.Int32,
|
||||
"bigint" or "int8" => DriverDataType.Int64,
|
||||
"real" or "float4" => DriverDataType.Float32,
|
||||
"double precision" or "float8"
|
||||
or "numeric" or "decimal" or "money" => DriverDataType.Float64,
|
||||
"text" or "character varying" or "varchar" or "character" or "char" or "bpchar"
|
||||
or "name" or "uuid" => DriverDataType.String,
|
||||
"date"
|
||||
or "timestamp without time zone" or "timestamp with time zone"
|
||||
or "timestamp" or "timestamptz" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
|
||||
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session.</para>
|
||||
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL markers are <c>@</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>"@k0"</c>.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
|
||||
/// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
|
||||
/// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
|
||||
/// collection readable in a profiler trace and matches what the SQL text says.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>SqlCommand</c> binds by name out of the box, so there is nothing SQL Server needs set on a
|
||||
/// fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>).
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
|
||||
+6
-1
@@ -21,8 +21,13 @@
|
||||
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
|
||||
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
|
||||
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type.
|
||||
PostgresDialect and OracleDialect land here for the same reason, each returning its
|
||||
provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully
|
||||
managed ADO.NET drivers, so adding them costs no native client install anywhere. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
|
||||
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
|
||||
/// warehouse.</para>
|
||||
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
||||
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
|
||||
/// <c>schema</c>/<c>table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
|
||||
/// The bind names come from <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
|
||||
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
|
||||
/// Server, <c>:p_schema</c> on Oracle).</para>
|
||||
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
|
||||
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
|
||||
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
|
||||
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||
/// </summary>
|
||||
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
var schemas = await QueryColumnAsync(
|
||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
||||
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
|
||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
||||
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
|
||||
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||
{
|
||||
tables = await QueryColumnAsync(
|
||||
connection, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, "@schema", schema),
|
||||
connection, dialect, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
tablesBySchema[schema] = tables;
|
||||
}
|
||||
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
|
||||
if (columnsByTable.ContainsKey(key)) continue;
|
||||
|
||||
columnsByTable[key] = await QueryColumnAsync(
|
||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.DefaultSchemaSql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
string sql,
|
||||
string columnName,
|
||||
Action<DbCommand> bind,
|
||||
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
bind(command);
|
||||
|
||||
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provider seam.
|
||||
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
|
||||
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
|
||||
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
|
||||
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
|
||||
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
|
||||
/// <see cref="SqlProvider.Odbc"/> remain reserved names on the shipped enum; authoring one is a config
|
||||
/// mistake that must fail at construction with a message that says so, rather than silently degrading
|
||||
/// to another backend's SQL dialect against a database that does not speak it.</para>
|
||||
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
|
||||
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
|
||||
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
|
||||
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
|
||||
=> provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
|
||||
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
|
||||
$"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
|
||||
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
|
||||
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Uniformly applied at every command site, even where this statement takes no
|
||||
// parameters — a provider hook that is only *sometimes* honoured is the harder bug.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// <c>SqlGroupPlannerTests</c>.</para>
|
||||
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
|
||||
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
|
||||
/// interpolation of tag content into SQL anywhere in this type.</para>
|
||||
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
|
||||
/// (<c>@k0</c> on SQL Server, <c>:p_k0</c> on Oracle) — with the value carried in
|
||||
/// <see cref="SqlQueryPlan.Parameters"/>. There is no interpolation of tag content into SQL anywhere in
|
||||
/// this type.</para>
|
||||
/// <para><b>The base names are this planner's, the spelling is the dialect's.</b> Each plan carries the
|
||||
/// markers it emitted <em>and</em> the matching <see cref="ISqlDialect.ParameterBindName"/>s, so the
|
||||
/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
|
||||
/// ODP.NET (<c>:p_k0</c> in the text, bare <c>p_k0</c> on the parameter).</para>
|
||||
/// </summary>
|
||||
public static class SqlGroupPlanner
|
||||
{
|
||||
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
|
||||
private const string KeyMarkerPrefix = "@k";
|
||||
/// <summary>The base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
|
||||
private const string KeyBaseNamePrefix = "k";
|
||||
|
||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorMarker = "@w";
|
||||
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorBaseName = "w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
|
||||
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
|
||||
|
||||
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
|
||||
var names = new List<string>(members.Count);
|
||||
var markers = new List<string>(members.Count);
|
||||
var bindNames = new List<string>(members.Count);
|
||||
var values = new List<object?>(members.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
|
||||
var keyValue = member.KeyValue
|
||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||
if (!seen.Add(keyValue)) continue;
|
||||
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
|
||||
var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture);
|
||||
markers.Add(dialect.ParameterMarker(baseName));
|
||||
bindNames.Add(dialect.ParameterBindName(baseName));
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", names).Append(')')
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
|
||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||
dialect.SingleRowLimitSuffix);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
|
||||
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before any parameter is added: on ODP.NET this is what turns bind-by-position off, and
|
||||
// a positionally-bound IN list feeds one tag's value to another tag's node in silence.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = plan.SqlText;
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
for (var i = 0; i < plan.ParameterNames.Count; i++)
|
||||
for (var i = 0; i < plan.ParameterBindNames.Count; i++)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
|
||||
parameter.ParameterName = plan.ParameterNames[i];
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
|
||||
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
|
||||
parameter.ParameterName = plan.ParameterBindNames[i];
|
||||
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// per group rather than one query per tag.
|
||||
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
|
||||
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
|
||||
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
|
||||
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
|
||||
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
|
||||
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
|
||||
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</para>
|
||||
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
|
||||
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
|
||||
/// </summary>
|
||||
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||
/// convention.
|
||||
/// </param>
|
||||
/// <param name="ParameterBindNames">
|
||||
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
|
||||
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
|
||||
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
|
||||
/// </param>
|
||||
/// <param name="Parameters">
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
|
||||
/// — a hostile value is inert here because it is data, not text.
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
|
||||
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
|
||||
/// </param>
|
||||
/// <param name="Members">
|
||||
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
|
||||
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
|
||||
object GroupKey,
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterNames,
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
|
||||
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
||||
|
||||
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
|
||||
|
||||
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
||||
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
||||
|
||||
|
||||
+5
-2
@@ -18,10 +18,13 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Provider</label>
|
||||
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
|
||||
@* SqlServer, Postgres and Oracle construct a dialect; MySql/Odbc stay reserved
|
||||
SqlProvider members and are deliberately not offered (authoring one fails the driver). *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
|
||||
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
|
||||
<div class="form-text">SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests
|
||||
await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
||||
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
|
||||
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
|
||||
/// names) — ODP.NET's shape, and the only place the two spellings differ.
|
||||
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
|
||||
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
|
||||
/// recorded names would carry the prefix.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
await using var session = new SqlBrowseSession(db.Connection, dialect);
|
||||
|
||||
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
|
||||
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
|
||||
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
|
||||
|
||||
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
|
||||
dialect.BoundParameterNames.ShouldContain("schema");
|
||||
dialect.BoundParameterNames.ShouldContain("table");
|
||||
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
|
||||
dialect.ConfiguredCommands.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests
|
||||
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
|
||||
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
|
||||
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
|
||||
/// member fail here rather than silently browse as SQL Server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultDialectSelector_carriesSqlServerOnly()
|
||||
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
|
||||
{
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
|
||||
|
||||
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
|
||||
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
|
||||
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
|
||||
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
// ---- provider availability ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
|
||||
/// reserved and must be reported as unavailable, naming itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
|
||||
{
|
||||
@@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("Postgres");
|
||||
ex.Message.ShouldContain("MySql");
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider availability is answered before the environment is touched: "this build cannot browse
|
||||
/// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
|
||||
/// MySql" is true whatever the ref resolves to, and the two failures must not mask each other.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
|
||||
@@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A carried provider must get <b>past</b> the provider gate and on to the environment, so that a
|
||||
/// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability.
|
||||
/// Asserted through the reads list: reaching the environment reader is the observable proof the gate
|
||||
/// let the provider through.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Postgres")]
|
||||
[InlineData("Oracle")]
|
||||
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
|
||||
{
|
||||
var reads = new List<string>();
|
||||
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldNotContain("not available in this build");
|
||||
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
|
||||
}
|
||||
|
||||
// ---- malformed configuration ----
|
||||
|
||||
[Theory]
|
||||
|
||||
+7
@@ -45,6 +45,13 @@
|
||||
— see its class docs.
|
||||
-->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
|
||||
<!--
|
||||
Linked for the same reason and on the same terms: ColonMarkerDialect is the control for
|
||||
dialect-owned parameter naming (":schema" in the text, bare "schema" on the parameter — ODP.NET's
|
||||
shape), and the browse session binds those names. It decorates SqliteDialect, which is already
|
||||
linked above, so the pair travels together.
|
||||
-->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\ColonMarkerDialect.cs" Link="ColonMarkerDialect.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+81
@@ -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"
|
||||
+76
@@ -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;
|
||||
+78
@@ -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"
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
-- Seed for the Sql driver's PostgreSQL live gate (PostgresReadTests), applied once by the
|
||||
-- compose `postgres-seed` one-shot as the superuser against the OWNED `otopcua-sql-pg` container.
|
||||
--
|
||||
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
|
||||
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
|
||||
-- its first — so the same assertions can be made against both backends and a divergence between
|
||||
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
|
||||
-- test different things.
|
||||
--
|
||||
-- ── TWO THINGS HERE ARE LOAD-BEARING, NOT SCAFFOLDING ─────────────────────────────────────────
|
||||
--
|
||||
-- 1. The schema is owned by `sqlpoll_owner` and the suite connects as `otopcua`, which holds
|
||||
-- USAGE + SELECT and nothing else. That is the exact provisioning `PostgresDialect.ListSchemasSql`
|
||||
-- was written for: `information_schema.schemata` lists only schemas the current role OWNS, so a
|
||||
-- granted-not-owning read-only poller would see an empty schema list, `SqlCatalogLoader` would
|
||||
-- throw its "the catalog returned no schemas at all" grant diagnosis, and every tag would be
|
||||
-- rejected. Sourcing the schema list from `information_schema.tables` instead is what makes this
|
||||
-- work — and a fixture where the poller owned its own schema would pass either way, proving
|
||||
-- nothing. Do not "simplify" this by creating the tables as `otopcua`.
|
||||
--
|
||||
-- 2. Every identifier is created UNQUOTED, so Postgres folds it to lower case in the catalog. The
|
||||
-- suite's case-folding leg authors the same names in UPPER case and expects them to resolve
|
||||
-- through `SqlCatalog.TryMatch` (exact-ordinal first, then a UNIQUE case-insensitive match, which
|
||||
-- substitutes the catalog's own spelling). Quoting a name here would make the catalog spelling
|
||||
-- mixed case and that leg would stop meaning anything.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- ── roles ─────────────────────────────────────────────────────────────────────────────────────
|
||||
-- \gexec runs the row the SELECT produced, and produces no row when the role already exists —
|
||||
-- the idempotent CREATE ROLE idiom. A DO block cannot be used for the login role: psql does not
|
||||
-- interpolate :'app_password' inside a dollar-quoted body.
|
||||
SELECT 'CREATE ROLE sqlpoll_owner NOLOGIN'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sqlpoll_owner')
|
||||
\gexec
|
||||
|
||||
SELECT 'CREATE ROLE otopcua LOGIN'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'otopcua')
|
||||
\gexec
|
||||
|
||||
-- Set (or re-set) the password every run, so re-seeding with a different SQL_PG_PASSWORD converges
|
||||
-- rather than leaving the suite unable to log in.
|
||||
ALTER ROLE otopcua LOGIN PASSWORD :'app_password';
|
||||
|
||||
-- ── schema ────────────────────────────────────────────────────────────────────────────────────
|
||||
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
|
||||
-- row from an older revision of this file would make a passing test meaningless. The blast radius
|
||||
-- is bounded to this fixture-owned schema.
|
||||
DROP SCHEMA IF EXISTS sqlpoll CASCADE;
|
||||
CREATE SCHEMA sqlpoll AUTHORIZATION sqlpoll_owner;
|
||||
|
||||
-- Everything below is created AS THE OWNER, not as the superuser — see note 1 above.
|
||||
SET ROLE sqlpoll_owner;
|
||||
|
||||
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
|
||||
CREATE TABLE sqlpoll.tag_values (
|
||||
tag_name varchar(128) NOT NULL,
|
||||
num_value double precision NULL,
|
||||
sample_ts timestamp(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sqlpoll.tag_values (tag_name, num_value, sample_ts)
|
||||
VALUES ('Line1.Speed', 42.0, TIMESTAMP '2026-07-24 10:00:00'),
|
||||
('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01'),
|
||||
('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
|
||||
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
|
||||
-- ORDER BY … DESC (or its LIMIT 1) returns a different, wrong value rather than a coincidentally
|
||||
-- right one.
|
||||
CREATE TABLE sqlpoll.latest_status (
|
||||
station_id integer NOT NULL,
|
||||
oven_temp double precision NULL,
|
||||
pressure double precision NULL,
|
||||
sample_ts timestamp(3) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sqlpoll.latest_status (station_id, oven_temp, pressure, sample_ts)
|
||||
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
|
||||
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
|
||||
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
|
||||
GRANT USAGE ON SCHEMA sqlpoll TO otopcua;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA sqlpoll TO otopcua;
|
||||
@@ -0,0 +1,545 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver against a <b>real Oracle Database</b>, over the fully managed ODP.NET provider
|
||||
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
|
||||
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
|
||||
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
|
||||
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
|
||||
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
|
||||
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
|
||||
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
|
||||
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
|
||||
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
|
||||
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
|
||||
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
|
||||
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
|
||||
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
|
||||
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
|
||||
/// per provider.</para>
|
||||
/// </summary>
|
||||
[Collection(OracleCollection.Name)]
|
||||
public sealed class OracleReadTests
|
||||
{
|
||||
private readonly OracleFixture _ora;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
|
||||
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
|
||||
public OracleReadTests(OracleFixture ora) => _ora = ora;
|
||||
|
||||
// ---- lifecycle: liveness + catalog load ----
|
||||
|
||||
/// <summary>
|
||||
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
|
||||
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
|
||||
/// described credential-free.
|
||||
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
|
||||
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
|
||||
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
|
||||
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
|
||||
/// loader expects.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
// The endpoint description is what every log line and the Admin UI show. Oracle's connection string
|
||||
// names the database inside Data Source (host:port/service), so the service is what must survive —
|
||||
// and the credential must not.
|
||||
driver.Endpoint.ShouldContain(_ora.ServiceName);
|
||||
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
|
||||
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>
|
||||
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
|
||||
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
|
||||
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
|
||||
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
|
||||
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
|
||||
/// explicit authored <c>"type"</c>.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
|
||||
|
||||
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
|
||||
|
||||
var snapshot = snapshots.ShouldHaveSingleItem();
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
|
||||
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.PresentKeyTimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two keys on the same table fold into ONE query — <c>… WHERE "TAG_NAME" IN (:k0, :k1)</c> — and are
|
||||
/// sliced back to the right tags.
|
||||
/// <para><b>This is the live falsification of positional binding.</b> With
|
||||
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
|
||||
/// parameter collection regardless of their names, and the failure mode is not an exception but a
|
||||
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
|
||||
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(
|
||||
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
|
||||
KeyValue("Sql/Line1/Pressure", OracleFixture.SecondPresentKey));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.PresentValue);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.SecondPresentValue);
|
||||
}
|
||||
|
||||
// ---- wide-row model: the Oracle-specific row-limit syntax ----
|
||||
|
||||
/// <summary>
|
||||
/// The <c>topByTimestamp</c> selector resolves to the newest row.
|
||||
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
|
||||
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
|
||||
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
|
||||
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
|
||||
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
|
||||
/// different, wrong value here rather than a coincidentally-right one.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(OracleFixture.NewestStationOvenTemp);
|
||||
}
|
||||
|
||||
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
|
||||
|
||||
/// <summary>
|
||||
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
|
||||
/// <c>OTOPCUA.TAG_VALUES</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
|
||||
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
|
||||
/// anything is quoted into SQL.
|
||||
/// <para><b>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
|
||||
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
|
||||
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
|
||||
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
|
||||
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
|
||||
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
|
||||
/// identifiers by hand.</para>
|
||||
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
|
||||
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
|
||||
/// pinned offline in the unit suites.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var lower = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
|
||||
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
|
||||
["keyValue"] = OracleFixture.PresentKey,
|
||||
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(lower);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- browse session: the AdminUI's schema → table → column walk ----
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
|
||||
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
|
||||
/// over a real database, and the leaf's mapped driver type is the one
|
||||
/// <c>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
|
||||
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
|
||||
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
|
||||
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
|
||||
/// binding. Reaching a non-empty table list here is therefore evidence that
|
||||
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
|
||||
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
|
||||
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
|
||||
/// seeded schema is one of many.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var config = new JsonObject
|
||||
{
|
||||
["provider"] = nameof(SqlProvider.Oracle),
|
||||
["connectionString"] = _ora.ConnectionString,
|
||||
}.ToJsonString();
|
||||
|
||||
await using var session = await new SqlDriverBrowser()
|
||||
.OpenAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
|
||||
// return, which is the only useful diagnostic when a browse walk comes back short.
|
||||
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
|
||||
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
|
||||
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
|
||||
|
||||
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
|
||||
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
|
||||
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
|
||||
|
||||
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
|
||||
columns.Select(c => c.DisplayName).ShouldBe(
|
||||
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.TimestampColumn]);
|
||||
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
|
||||
|
||||
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
|
||||
// column, or the commit falls through to a generic blob the typed editor cannot read.
|
||||
var value = columns.Single(c => c.DisplayName == OracleFixture.ValueColumn);
|
||||
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
|
||||
|
||||
var address = attribute.AddressFields.ShouldNotBeNull();
|
||||
address["schema"].ShouldBe(OracleFixture.Schema);
|
||||
address["table"].ShouldBe(OracleFixture.KeyValueTable);
|
||||
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
|
||||
/// precisely what makes this suite exercise <c>OracleClientFactory.Instance</c>, the provider the
|
||||
/// dialect ships.
|
||||
/// </summary>
|
||||
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
|
||||
{
|
||||
var options = new SqlDriverOptions
|
||||
{
|
||||
RawTags = tags,
|
||||
CommandTimeout = TimeSpan.FromSeconds(15),
|
||||
OperationTimeout = TimeSpan.FromSeconds(20),
|
||||
};
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId: "sql-oracle-integration",
|
||||
dialect: new OracleDialect(),
|
||||
connectionString: _ora.ConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await driver.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>A key-value tag over the seeded EAV table, authored in the catalog's own spelling.</summary>
|
||||
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
|
||||
["keyColumn"] = OracleFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = OracleFixture.ValueColumn,
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
/// <summary>A wide-row tag selected by newest timestamp.</summary>
|
||||
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "WideRow",
|
||||
["table"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
|
||||
["columnName"] = column,
|
||||
["timestampColumn"] = OracleFixture.TimestampColumn,
|
||||
["rowSelector"] = new JsonObject
|
||||
{
|
||||
["topByTimestamp"] = OracleFixture.TimestampColumn,
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
|
||||
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
|
||||
/// <see cref="SqlServerReadTests"/> gives.
|
||||
/// </summary>
|
||||
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
|
||||
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
|
||||
/// the Oracle counterpart of <see cref="SqlPollServerFixture"/>.
|
||||
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
|
||||
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent, it
|
||||
/// records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>.</para>
|
||||
/// <para><b>This fixture does not seed — the compose stack does</b>
|
||||
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
|
||||
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
|
||||
/// land in the <c>OTOPCUA</c> schema.</para>
|
||||
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
|
||||
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
|
||||
/// place that deliberately authors the lower-case form an operator would actually type.</para>
|
||||
/// </summary>
|
||||
public sealed class OracleFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
|
||||
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
|
||||
|
||||
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
|
||||
public const string UserEnvVar = "SQL_ORACLE_USER";
|
||||
|
||||
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
|
||||
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
|
||||
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
|
||||
/// </summary>
|
||||
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
|
||||
|
||||
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
|
||||
public const string DefaultUser = "otopcua";
|
||||
|
||||
/// <summary>The image's pluggable-database service name.</summary>
|
||||
public const string DefaultService = "FREEPDB1";
|
||||
|
||||
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
|
||||
public const int DefaultPort = 1521;
|
||||
|
||||
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
|
||||
public const string Schema = "OTOPCUA";
|
||||
|
||||
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
|
||||
private const int ProbeConnectTimeoutSeconds = 10;
|
||||
|
||||
// ---- key-value (EAV) source ----
|
||||
|
||||
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
|
||||
public const string KeyValueTable = "TAG_VALUES";
|
||||
|
||||
/// <summary>The key-value table's key column.</summary>
|
||||
public const string KeyColumn = "TAG_NAME";
|
||||
|
||||
/// <summary>The key-value table's value column.</summary>
|
||||
public const string ValueColumn = "NUM_VALUE";
|
||||
|
||||
/// <summary>The source-timestamp column carried by every seeded table.</summary>
|
||||
public const string TimestampColumn = "SAMPLE_TS";
|
||||
|
||||
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
|
||||
public const string PresentKey = "Line1.Speed";
|
||||
|
||||
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
|
||||
public const double PresentValue = 42.0;
|
||||
|
||||
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
|
||||
public const string SecondPresentKey = "Line1.Pressure";
|
||||
|
||||
/// <summary>The value seeded for <see cref="SecondPresentKey"/> — different from
|
||||
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
|
||||
public const double SecondPresentValue = 3.5;
|
||||
|
||||
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
|
||||
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---- wide-row source ----
|
||||
|
||||
/// <summary>The wide-row table: <c>STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
|
||||
public const string WideRowTable = "LATEST_STATUS";
|
||||
|
||||
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
|
||||
public const string OvenTempColumn = "OVEN_TEMP";
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="OvenTempColumn"/> of the row holding the <b>newest</b> <c>SAMPLE_TS</c> — what a
|
||||
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
|
||||
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its
|
||||
/// <c>FETCH FIRST 1 ROWS ONLY</c> picks a different value and the test says so.
|
||||
/// </summary>
|
||||
public const double NewestStationOvenTemp = 210.25;
|
||||
|
||||
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
|
||||
public string ConnectionString { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>The resolved service name, for the endpoint-description assertion.</summary>
|
||||
public string ServiceName { get; private set; } = DefaultService;
|
||||
|
||||
/// <summary>The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
|
||||
public string Server { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
|
||||
/// <param name="table">The bare table or view name.</param>
|
||||
/// <returns>The <c>schema.table</c> name.</returns>
|
||||
public static string Qualified(string table) => $"{Schema}.{table}";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
|
||||
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or seeded
|
||||
/// here (the compose stack owns that).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (ResolveConnectionString() is not { } resolved)
|
||||
{
|
||||
SkipReason =
|
||||
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
|
||||
$"default '{DefaultUser}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
|
||||
"Sql driver's live Oracle suite. Bring the fixture up from " +
|
||||
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionString = resolved;
|
||||
|
||||
var dialect = new OracleDialect();
|
||||
try
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException("ODP.NET returned no connection object.");
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = ConnectionString;
|
||||
await connection.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
|
||||
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
|
||||
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
|
||||
"stack is up (its first boot takes minutes), or leave them unset to skip.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
|
||||
/// <returns>A completed task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the gate and composes the connection string <b>through the dialect's own provider
|
||||
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via the
|
||||
/// abstract <see cref="DbConnectionStringBuilder"/> indexer.
|
||||
/// <para>That indirection is deliberate: it keeps this project free of a direct ODP.NET
|
||||
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
|
||||
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
|
||||
/// <c>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
|
||||
/// be accepted through that indexer, so no provider-specific property is needed.</para>
|
||||
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
|
||||
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
|
||||
/// install and no Oracle configuration files anywhere near the test host.</para>
|
||||
/// </summary>
|
||||
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
|
||||
private string? ResolveConnectionString()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
|
||||
|
||||
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
|
||||
// failed login, and Oracle locks an account after a handful of those.
|
||||
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
|
||||
|
||||
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
|
||||
? named
|
||||
: DefaultUser;
|
||||
|
||||
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
|
||||
? service
|
||||
: DefaultService;
|
||||
|
||||
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
|
||||
Server = $"{host}:{port}";
|
||||
|
||||
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
|
||||
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
|
||||
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
|
||||
builder["User Id"] = user;
|
||||
builder["Password"] = password;
|
||||
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the gate is resolved once per test session rather than once per test
|
||||
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class OracleCollection : ICollectionFixture<OracleFixture>
|
||||
{
|
||||
/// <summary>The collection name test classes reference.</summary>
|
||||
public const string Name = "SqlOracleServer";
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
|
||||
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
|
||||
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
|
||||
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
|
||||
/// catalog SQL — including the deliberate choice to derive the schema list from
|
||||
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
|
||||
/// granted-but-not-owning principal can prove.
|
||||
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> for the
|
||||
/// gate. With it unset nothing here opens a socket, so the suite is instant and green offline — which
|
||||
/// is the point: a live test that goes red on a laptop teaches people to ignore red.</para>
|
||||
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
|
||||
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
|
||||
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
|
||||
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
|
||||
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
|
||||
/// walk an operator authors through.</para>
|
||||
/// </summary>
|
||||
[Collection(PostgresCollection.Name)]
|
||||
public sealed class PostgresReadTests
|
||||
{
|
||||
private readonly PostgresFixture _pg;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
|
||||
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
|
||||
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
|
||||
|
||||
// ---- lifecycle: liveness + catalog load ----
|
||||
|
||||
/// <summary>
|
||||
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
|
||||
/// reports Healthy with the endpoint described credential-free.
|
||||
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
|
||||
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
|
||||
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
|
||||
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
|
||||
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
|
||||
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
|
||||
/// would have answered empty for.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
// The endpoint description is what every log line and the Admin UI show. It must name the
|
||||
// database and must not carry the credential that reached the provider.
|
||||
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
|
||||
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
|
||||
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
|
||||
|
||||
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
|
||||
|
||||
var snapshot = snapshots.ShouldHaveSingleItem();
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
||||
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
|
||||
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
|
||||
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
|
||||
}
|
||||
|
||||
// ---- wide-row model: the Postgres-specific row-limit syntax ----
|
||||
|
||||
/// <summary>
|
||||
/// The <c>topByTimestamp</c> selector resolves to the newest row.
|
||||
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
|
||||
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
|
||||
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
|
||||
/// first-inserted nor the lowest-keyed one, so losing either the limit or the
|
||||
/// <c>ORDER BY … DESC</c> yields a different, wrong value rather than a coincidentally-right one.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realPostgres_topByTimestampSelectsTheNewestRow()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
|
||||
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
|
||||
}
|
||||
|
||||
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
|
||||
|
||||
/// <summary>
|
||||
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
|
||||
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
|
||||
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
|
||||
/// anything is quoted into SQL.
|
||||
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
|
||||
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
|
||||
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
|
||||
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
|
||||
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
|
||||
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
|
||||
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
|
||||
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var upper = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
|
||||
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
|
||||
["keyValue"] = PostgresFixture.PresentKey,
|
||||
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
|
||||
["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(),
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(upper);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- browse session: the AdminUI's schema → table → column walk ----
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
|
||||
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
|
||||
/// over a real database, and the leaf's mapped driver type is the one
|
||||
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
|
||||
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
|
||||
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
|
||||
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
|
||||
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
|
||||
/// browse session reads every catalog column by name, so an unquoted alias would fold to
|
||||
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
|
||||
/// list.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var config = new JsonObject
|
||||
{
|
||||
["provider"] = nameof(SqlProvider.Postgres),
|
||||
["connectionString"] = _pg.ConnectionString,
|
||||
}.ToJsonString();
|
||||
|
||||
await using var session = await new SqlDriverBrowser()
|
||||
.OpenAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
|
||||
var schema = schemas.ShouldHaveSingleItem(
|
||||
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
|
||||
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
|
||||
|
||||
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
|
||||
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
|
||||
// return, which is the only useful diagnostic when a browse walk comes back short.
|
||||
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
|
||||
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
|
||||
|
||||
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
|
||||
columns.Select(c => c.DisplayName).ShouldBe(
|
||||
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
|
||||
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
|
||||
|
||||
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
|
||||
// column, or the commit falls through to a generic blob the typed editor cannot read.
|
||||
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
|
||||
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
|
||||
|
||||
var address = attribute.AddressFields.ShouldNotBeNull();
|
||||
address["schema"].ShouldBe(PostgresFixture.Schema);
|
||||
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
|
||||
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
|
||||
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
|
||||
/// ships.
|
||||
/// </summary>
|
||||
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
|
||||
{
|
||||
var options = new SqlDriverOptions
|
||||
{
|
||||
RawTags = tags,
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId: "sql-postgres-integration",
|
||||
dialect: new PostgresDialect(),
|
||||
connectionString: _pg.ConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await driver.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>A key-value tag over the seeded EAV table.</summary>
|
||||
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
|
||||
["keyColumn"] = PostgresFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = PostgresFixture.ValueColumn,
|
||||
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
/// <summary>A wide-row tag selected by newest timestamp.</summary>
|
||||
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "WideRow",
|
||||
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
|
||||
["columnName"] = column,
|
||||
["timestampColumn"] = PostgresFixture.TimestampColumn,
|
||||
["rowSelector"] = new JsonObject
|
||||
{
|
||||
["topByTimestamp"] = PostgresFixture.TimestampColumn,
|
||||
},
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
|
||||
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
|
||||
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
|
||||
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
|
||||
/// </summary>
|
||||
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
|
||||
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
|
||||
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
|
||||
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
|
||||
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent,
|
||||
/// it records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>. That is what keeps
|
||||
/// <c>dotnet test</c> genuinely green (and instant) on the macOS dev box this is usually run from.</para>
|
||||
/// <para><b>This fixture does not seed — the compose stack does</b>
|
||||
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
|
||||
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
|
||||
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
|
||||
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
|
||||
/// superuser, and the granted-not-owning provisioning that
|
||||
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
|
||||
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
|
||||
|
||||
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
|
||||
public const string UserEnvVar = "SQL_PG_USER";
|
||||
|
||||
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
|
||||
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
|
||||
|
||||
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
|
||||
public const string DefaultUser = "otopcua";
|
||||
|
||||
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
|
||||
public const int DefaultPort = 5432;
|
||||
|
||||
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
|
||||
public const string FixtureDatabase = "sqlpollfixture";
|
||||
|
||||
/// <summary>
|
||||
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
|
||||
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
|
||||
/// seed's drop-and-recreate is bounded to objects this fixture owns.
|
||||
/// </summary>
|
||||
public const string Schema = "sqlpoll";
|
||||
|
||||
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
|
||||
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
|
||||
|
||||
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
|
||||
private const int ProbeConnectTimeoutSeconds = 5;
|
||||
|
||||
// ---- key-value (EAV) source ----
|
||||
|
||||
/// <summary>The key-value table: <c>tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
||||
public const string KeyValueTable = "tag_values";
|
||||
|
||||
/// <summary>The key-value table's key column.</summary>
|
||||
public const string KeyColumn = "tag_name";
|
||||
|
||||
/// <summary>The key-value table's value column.</summary>
|
||||
public const string ValueColumn = "num_value";
|
||||
|
||||
/// <summary>The source-timestamp column carried by every seeded table.</summary>
|
||||
public const string TimestampColumn = "sample_ts";
|
||||
|
||||
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
|
||||
public const string PresentKey = "Line1.Speed";
|
||||
|
||||
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
|
||||
public const double PresentValue = 42.0;
|
||||
|
||||
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
|
||||
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---- wide-row source ----
|
||||
|
||||
/// <summary>The wide-row table: <c>station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
|
||||
public const string WideRowTable = "latest_status";
|
||||
|
||||
/// <summary>
|
||||
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
|
||||
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
|
||||
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
|
||||
/// different value and the test says so.
|
||||
/// </summary>
|
||||
public const double NewestStationOvenTemp = 210.25;
|
||||
|
||||
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
|
||||
public string ConnectionString { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
|
||||
public string Server { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
|
||||
/// <param name="table">The bare table or view name.</param>
|
||||
/// <returns>The <c>schema.table</c> name.</returns>
|
||||
public static string Qualified(string table) => $"{Schema}.{table}";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
|
||||
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
|
||||
/// seeded here (the compose stack owns that).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (ResolveConnectionString() is not { } resolved)
|
||||
{
|
||||
SkipReason =
|
||||
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
|
||||
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
|
||||
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
|
||||
"without these.";
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionString = resolved;
|
||||
|
||||
var dialect = new PostgresDialect();
|
||||
try
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException("Npgsql returned no connection object.");
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = ConnectionString;
|
||||
await connection.OpenAsync().ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
|
||||
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
|
||||
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
|
||||
/// <returns>A completed task.</returns>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the gate and composes the connection string <b>through the dialect's own provider
|
||||
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
|
||||
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
|
||||
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
|
||||
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
|
||||
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
|
||||
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
|
||||
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
|
||||
/// provider-specific property is needed.</para>
|
||||
/// </summary>
|
||||
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
|
||||
private string? ResolveConnectionString()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
|
||||
|
||||
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
|
||||
// failed login, which is how accounts get locked out on a server that is not a throwaway.
|
||||
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
|
||||
|
||||
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
|
||||
? named
|
||||
: DefaultUser;
|
||||
|
||||
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
|
||||
Server = $"{host}:{port}";
|
||||
|
||||
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
|
||||
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
|
||||
builder["Host"] = host;
|
||||
builder["Port"] = port;
|
||||
builder["Database"] = FixtureDatabase;
|
||||
builder["Username"] = user;
|
||||
builder["Password"] = password;
|
||||
builder["Timeout"] = ProbeConnectTimeoutSeconds;
|
||||
builder["Application Name"] = DriverApplicationName;
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
|
||||
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The authored endpoint.</param>
|
||||
/// <param name="defaultPort">The port to use when none is carried.</param>
|
||||
/// <returns>The host and port.</returns>
|
||||
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
|
||||
{
|
||||
var separator = endpoint.LastIndexOf(':');
|
||||
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
|
||||
|
||||
var host = endpoint[..separator];
|
||||
return int.TryParse(
|
||||
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
|
||||
? (host, port)
|
||||
: (endpoint, defaultPort);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the gate is resolved once per test session rather than once per test
|
||||
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
|
||||
{
|
||||
/// <summary>The collection name test classes reference.</summary>
|
||||
public const string Name = "SqlPostgresServer";
|
||||
}
|
||||
+11
-3
@@ -27,12 +27,20 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
|
||||
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
|
||||
the two versions drift and the suite would then prove something about a provider nobody deploys.
|
||||
No Microsoft.Data.SqlClient / Npgsql / Oracle.ManagedDataAccess.Core PackageReference here on purpose:
|
||||
this suite must exercise EXACTLY the providers the driver ships, and they arrive transitively through
|
||||
Driver.Sql. Adding one here would let the two versions drift and the suite would then prove something
|
||||
about a provider nobody deploys. The Postgres and Oracle suites therefore never name a provider type:
|
||||
they compose their connection strings through `dialect.Factory.CreateConnectionStringBuilder()` and the
|
||||
abstract DbConnectionStringBuilder indexer.
|
||||
|
||||
Driver.Sql.Browser is referenced for the browse-session walk (schema → table → column) the Postgres and
|
||||
Oracle suites drive through the AdminUI's real seam, SqlDriverBrowser — the only place the dialects'
|
||||
catalog SQL is exercised as the picker actually calls it.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A <b>test-only</b> dialect that spells parameters the ODP.NET way — <c>:name</c> in the SQL text and a
|
||||
/// <b>bare</b> <c>name</c> as the <see cref="DbParameter.ParameterName"/> — over the SQLite fixture. It is
|
||||
/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded
|
||||
/// <c>"@"</c> before, and a planner or loader that still hardcodes it emits <c>@k0</c> against this
|
||||
/// dialect and fails loudly.
|
||||
/// <para><b>Why the two halves must differ.</b> A dialect whose marker and bind name are the same string
|
||||
/// (SQL Server, Npgsql, SQLite) cannot tell a call site that <em>copies</em> the plan's bind name apart
|
||||
/// from one that reuses the marker. Oracle is the provider where they genuinely diverge, so this fake
|
||||
/// reproduces that divergence rather than merely changing the prefix.</para>
|
||||
/// <para><b>It really executes.</b> Everything but the parameter naming (and the catalog SQL's markers)
|
||||
/// delegates to <see cref="SqliteDialect"/>, and Microsoft.Data.Sqlite accepts both a <c>:</c>-prefixed
|
||||
/// marker and a prefix-less parameter name — so a poll or a catalog load driven through this dialect runs
|
||||
/// against the real fixture database rather than being asserted on as text.</para>
|
||||
/// <para>A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and
|
||||
/// <c>new</c>-hiding a member would leave interface dispatch calling the original — the substitution
|
||||
/// would silently not happen and the control would pass for the wrong reason.</para>
|
||||
/// </summary>
|
||||
public sealed class ColonMarkerDialect : ISqlDialect
|
||||
{
|
||||
private static readonly SqliteDialect Inner = new();
|
||||
|
||||
/// <summary>Every command handed to <see cref="ConfigureCommand"/>, kept so its binds can be read back.</summary>
|
||||
private readonly List<DbCommand> _commands = [];
|
||||
|
||||
/// <summary>How many times <see cref="ConfigureCommand"/> has been called on this instance.</summary>
|
||||
/// <remarks>
|
||||
/// Oracle's <c>BindByName</c> is set here, and a command created without it mis-binds by position
|
||||
/// rather than failing — so "was every command configured" has to be observable to be testable.
|
||||
/// </remarks>
|
||||
public int ConfiguredCommands
|
||||
{
|
||||
get { lock (_commands) return _commands.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="DbParameter.ParameterName"/> of every parameter bound on every configured command,
|
||||
/// read after the fact.
|
||||
/// <para><b>Why this and not the query's own success.</b> Microsoft.Data.Sqlite resolves a parameter
|
||||
/// whether it is named <c>:k0</c> or bare <c>k0</c>, so a poll that returns the right values proves
|
||||
/// the SQL parsed — not that the caller bound the dialect's bind name. Only the name actually put on
|
||||
/// the parameter distinguishes the two, and on ODP.NET the wrong one is a hard bind failure.</para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BoundParameterNames
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_commands)
|
||||
return _commands
|
||||
.SelectMany(c => c.Parameters.Cast<DbParameter>())
|
||||
.Select(p => p.ParameterName)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => Inner.Provider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => Inner.Factory;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => Inner.LivenessSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListTablesSql => Colonize(Inner.ListTablesSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListColumnsSql => Colonize(Inner.ListColumnsSql);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ParameterMarker(string baseName) => ":" + baseName;
|
||||
|
||||
/// <summary>The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker.</summary>
|
||||
/// <param name="baseName">The planner's/loader's base name.</param>
|
||||
/// <returns>The bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => baseName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
lock (_commands) _commands.Add(command);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the SQLite dialect's <c>@</c> markers as <c>:</c> markers. The catalog SQL is dialect-owned
|
||||
/// text, so a colon-marker dialect must carry colon markers in it — that is precisely the coupling the
|
||||
/// catalog loader's bind names have to stay in step with.
|
||||
/// </summary>
|
||||
private static string Colonize(string sql) => sql.Replace('@', ':');
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden tests for <see cref="OracleDialect"/>, mirroring <see cref="SqlServerDialectTests"/>: the
|
||||
/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
|
||||
/// Oracle needs, pinning <c>OracleCommand.BindByName</c>.
|
||||
/// </summary>
|
||||
public class OracleDialectTests
|
||||
{
|
||||
// ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
|
||||
|
||||
/// <summary>
|
||||
/// The sharpest correctness edge on the whole provider seam. A real <see cref="OracleCommand"/> — no
|
||||
/// connection needed — must report <c>BindByName == false</c> before
|
||||
/// <see cref="OracleDialect.ConfigureCommand"/> touches it and <c>true</c> afterwards.
|
||||
/// <para><b>The false-by-default half is the falsifiability control.</b> Asserting only the post-state
|
||||
/// would pass identically against an ODP.NET release that had flipped the default — i.e. it would prove
|
||||
/// nothing about whether this dialect's call is load-bearing. Pinning both halves means the day the
|
||||
/// provider default changes, this test says so instead of quietly becoming a tautology.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigureCommand_turnsOnBindByName_whichOdpNetLeavesOffByDefault()
|
||||
{
|
||||
using var command = new OracleCommand();
|
||||
|
||||
command.BindByName.ShouldBeFalse(
|
||||
"ODP.NET binds by position by default — if this ever changes, the dialect's ConfigureCommand " +
|
||||
"is no longer what makes named binding work and this test must be re-reasoned, not deleted.");
|
||||
|
||||
new OracleDialect().ConfigureCommand(command);
|
||||
|
||||
command.BindByName.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A command from another provider passes through untouched and, crucially, does <b>not</b> throw:
|
||||
/// the dialect can legitimately meet a foreign <c>DbCommand</c> (an instrumented or substituted
|
||||
/// factory in a shared test harness), and an <c>InvalidCastException</c> there would fail a poll for a
|
||||
/// reason that has nothing to do with the database.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigureCommand_leavesAForeignCommandUntouched()
|
||||
{
|
||||
using var command = new SqliteCommand("SELECT 1");
|
||||
|
||||
Should.NotThrow(() => new OracleDialect().ConfigureCommand(command));
|
||||
|
||||
command.CommandText.ShouldBe("SELECT 1");
|
||||
command.Parameters.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParameterMarker_isColonPrefixed_andTheBindNameIsTheSameNameWithoutTheColon()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings.
|
||||
d.ParameterMarker("k0").ShouldBe(":p_k0");
|
||||
d.ParameterBindName("k0").ShouldBe("p_k0");
|
||||
d.ParameterMarker("schema").ShouldBe(":p_schema");
|
||||
d.ParameterBindName("schema").ShouldBe("p_schema");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No bind name may be spelled as its bare base name — the <c>p_</c> namespace is what stops one
|
||||
/// colliding with an Oracle reserved word.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is a regression test for a live-found, provider-fatal defect.</b> The catalog readers bind
|
||||
/// a parameter whose base name is <c>table</c>, and Oracle rejects a bind variable named after a
|
||||
/// reserved word outright: <c>ORA-01745: invalid host/bind variable name</c>. With the un-prefixed
|
||||
/// names this dialect first shipped with, <c>ListColumnsSql</c> could not execute at all — so every
|
||||
/// Oracle driver instance failed its Initialize-time catalog load and rejected every authored tag, and
|
||||
/// every AdminUI browse of a table's columns threw. It was invisible to every offline test (only a
|
||||
/// server parses the SQL) and was caught by <c>OracleReadTests</c> against a real database.
|
||||
/// <para><c>table</c> is asserted explicitly because it is the one base name in use today that is
|
||||
/// actually reserved; the others guard the general rule.</para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("table")]
|
||||
[InlineData("schema")]
|
||||
[InlineData("k0")]
|
||||
public void ParameterBindName_neverSurfacesTheBareBaseName_soItCannotBeAReservedWord(string baseName)
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ParameterBindName(baseName).ShouldNotBe(baseName);
|
||||
d.ParameterMarker(baseName).ShouldNotBe(":" + baseName);
|
||||
// The two must stay derived from one another, or a statement's marker and its bind drift apart and
|
||||
// ODP.NET silently leaves the parameter unbound.
|
||||
d.ParameterMarker(baseName).ShouldBe(":" + d.ParameterBindName(baseName));
|
||||
}
|
||||
|
||||
// ---- identity ----
|
||||
|
||||
[Fact]
|
||||
public void Dialect_identifiesItselfAsOracle_andExposesTheAbstractFactory()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.Provider.ShouldBe(SqlProvider.Oracle);
|
||||
d.Factory.ShouldNotBeNull();
|
||||
d.Factory.ShouldBeOfType<OracleClientFactory>();
|
||||
}
|
||||
|
||||
// ---- quoting / injection boundary ----
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.QuoteIdentifier("SPEED").ShouldBe("\"SPEED\"");
|
||||
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); // " doubled, per Oracle's quoted-identifier rule
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
|
||||
=> new OracleDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\"");
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
|
||||
{
|
||||
// The only metacharacter that could terminate a quoted identifier early is ", and it is doubled.
|
||||
// The result is a single (nonexistent) identifier, never executable SQL.
|
||||
new OracleDialect().QuoteIdentifier("x\" ; DROP TABLE Users --")
|
||||
.ShouldBe("\"x\"\" ; DROP TABLE Users --\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident!));
|
||||
|
||||
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
|
||||
[Theory]
|
||||
[InlineData("x\0y")]
|
||||
[InlineData("x\u0001y")] // C0 SOH
|
||||
[InlineData("x\u001By")] // C0 ESC
|
||||
[InlineData("x\u007Fy")] // DEL
|
||||
[InlineData("x\u0085y")] // C1 NEL
|
||||
[InlineData("x\ty")]
|
||||
[InlineData("x\ny")]
|
||||
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
|
||||
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
|
||||
|
||||
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
|
||||
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
|
||||
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
|
||||
[Theory]
|
||||
[InlineData("SP\u200BEED")] // zero-width space
|
||||
[InlineData("\u202Ediop.selbat")] // right-to-left override
|
||||
[InlineData("x\u2066y\u2069")] // bidi isolates
|
||||
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
|
||||
[InlineData("x\u00ADy")] // soft hyphen
|
||||
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
|
||||
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
|
||||
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
|
||||
}
|
||||
|
||||
// ---- liveness + row limit ----
|
||||
|
||||
[Fact]
|
||||
public void LivenessSql_selectsFromDual()
|
||||
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// Oracle 12c+ spells the limit at the END of the statement, so the prefix must be empty — a
|
||||
// non-empty one would land between SELECT and the column list and produce invalid SQL.
|
||||
d.SingleRowLimitPrefix.ShouldBe("");
|
||||
d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY");
|
||||
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"A\" FROM \"T\"", d.SingleRowLimitSuffix)
|
||||
.ShouldBe("SELECT \"A\" FROM \"T\" FETCH FIRST 1 ROWS ONLY");
|
||||
}
|
||||
|
||||
// ---- catalog SQL ----
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_readsTheAllViews_neverInformationSchema()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
// Oracle has no INFORMATION_SCHEMA at all; the ALL_* views are the portable equivalent.
|
||||
d.ListSchemasSql.ShouldContain("ALL_USERS");
|
||||
d.ListTablesSql.ShouldContain("ALL_TABLES");
|
||||
d.ListTablesSql.ShouldContain("ALL_VIEWS"); // INFORMATION_SCHEMA.TABLES includes views
|
||||
d.ListColumnsSql.ShouldContain("ALL_TAB_COLUMNS");
|
||||
d.DefaultSchemaSql.ShouldBe("SELECT sys_context('userenv','current_schema') FROM dual");
|
||||
|
||||
d.ListSchemasSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
d.ListTablesSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
d.ListColumnsSql.ShouldNotContain("INFORMATION_SCHEMA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isParameterizedWithColonMarkers_matchingParameterMarker()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
|
||||
|
||||
// A leftover @ marker would bind on SqlClient and silently be a literal here.
|
||||
d.ListTablesSql.ShouldNotContain("@");
|
||||
d.ListColumnsSql.ShouldNotContain("@");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>SqlCatalogLoader</c>/<c>SqlBrowseSession</c> project catalog columns by the
|
||||
/// <c>INFORMATION_SCHEMA</c> names, so every dialect must alias its columns to them or
|
||||
/// <c>GetOrdinal</c> throws at runtime — a failure the SQL text alone would not reveal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CatalogSql_aliasesItsColumnsToTheInformationSchemaNamesTheReadersProjectBy()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
|
||||
d.ListSchemasSql.ShouldContain("TABLE_SCHEMA");
|
||||
d.ListTablesSql.ShouldContain("TABLE_NAME");
|
||||
d.ListTablesSql.ShouldContain("TABLE_TYPE");
|
||||
d.ListColumnsSql.ShouldContain("COLUMN_NAME");
|
||||
d.ListColumnsSql.ShouldContain("DATA_TYPE");
|
||||
|
||||
// SqlBrowseSession compares TABLE_TYPE against the literal "VIEW" to mark a view node.
|
||||
d.ListTablesSql.ShouldContain("'VIEW'");
|
||||
d.ListTablesSql.ShouldContain("'BASE TABLE'");
|
||||
}
|
||||
|
||||
// ---- type map ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("NUMBER", DriverDataType.Float64)]
|
||||
[InlineData("FLOAT", DriverDataType.Float64)]
|
||||
[InlineData("BINARY_DOUBLE", DriverDataType.Float64)]
|
||||
[InlineData("BINARY_FLOAT", DriverDataType.Float32)]
|
||||
[InlineData("INTEGER", DriverDataType.Float64)]
|
||||
[InlineData("VARCHAR2", DriverDataType.String)]
|
||||
[InlineData("NVARCHAR2", DriverDataType.String)]
|
||||
[InlineData("CHAR", DriverDataType.String)]
|
||||
[InlineData("NCHAR", DriverDataType.String)]
|
||||
[InlineData("CLOB", DriverDataType.String)]
|
||||
[InlineData("NCLOB", DriverDataType.String)]
|
||||
[InlineData("DATE", DriverDataType.DateTime)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// <c>ALL_TAB_COLUMNS.DATA_TYPE</c> carries the fractional-seconds precision and the time-zone
|
||||
/// qualifier <em>inside</em> the family name, unlike <c>INFORMATION_SCHEMA</c>. An exact-match table
|
||||
/// would send every one of these to the String fallback.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("TIMESTAMP")]
|
||||
[InlineData("TIMESTAMP(6)")]
|
||||
[InlineData("TIMESTAMP(3)")]
|
||||
[InlineData("TIMESTAMP(6) WITH TIME ZONE")]
|
||||
[InlineData("TIMESTAMP(6) WITH LOCAL TIME ZONE")]
|
||||
public void MapColumnType_matchesTheTimestampFamilyByPrefix(string sql)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(DriverDataType.DateTime);
|
||||
|
||||
[Theory]
|
||||
[InlineData("number", DriverDataType.Float64)]
|
||||
[InlineData("VarChar2", DriverDataType.String)]
|
||||
[InlineData("timestamp(6)", DriverDataType.DateTime)]
|
||||
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
|
||||
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("RAW")] // binary, like SQL Server's varbinary
|
||||
[InlineData("BLOB")]
|
||||
[InlineData("LONG RAW")]
|
||||
[InlineData("ROWID")]
|
||||
[InlineData("XMLTYPE")]
|
||||
[InlineData("INTERVAL DAY(3) TO SECOND(6)")] // a duration, deliberately NOT a DateTime
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
|
||||
=> new OracleDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> rule for
|
||||
/// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline,
|
||||
/// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a
|
||||
/// 63-character identifier ceiling, the end-of-statement <c>LIMIT 1</c>, and <c>bit</c> being a bit string
|
||||
/// rather than a boolean — the divergence is pinned here rather than left to reading.
|
||||
/// </summary>
|
||||
public class PostgresDialectTests
|
||||
{
|
||||
[Fact]
|
||||
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.QuoteIdentifier("Speed").ShouldBe("\"Speed\"");
|
||||
// " doubled, per the SQL-standard rule Postgres follows.
|
||||
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_rejectsControlCharsAndNul()
|
||||
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier("x\0y"));
|
||||
|
||||
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The
|
||||
// code points are spelled numerically so the offending characters never appear raw in this source file.
|
||||
[Theory]
|
||||
[InlineData(0x0001)] // C0 SOH
|
||||
[InlineData(0x001B)] // C0 ESC
|
||||
[InlineData(0x007F)] // DEL
|
||||
[InlineData(0x0085)] // C1 NEL
|
||||
[InlineData(0x0009)] // TAB
|
||||
[InlineData(0x000A)] // LF
|
||||
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint)
|
||||
=> Should.Throw<ArgumentException>(
|
||||
() => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y"));
|
||||
|
||||
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
|
||||
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
|
||||
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
|
||||
[Theory]
|
||||
[InlineData(0x200B)] // zero-width space
|
||||
[InlineData(0x202E)] // right-to-left override
|
||||
[InlineData(0x2066)] // left-to-right isolate
|
||||
[InlineData(0xFEFF)] // BOM / zero-width no-break space
|
||||
[InlineData(0x00AD)] // soft hyphen
|
||||
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
|
||||
=> Should.Throw<ArgumentException>(
|
||||
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier(ident!));
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
|
||||
=> new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name "
|
||||
|
||||
// NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it
|
||||
// is a config mistake rather than something to quote and send.
|
||||
[Fact]
|
||||
public void QuoteIdentifier_acceptsExactly63Chars_andRejects64()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\"");
|
||||
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
|
||||
{
|
||||
// A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it
|
||||
// is doubled. The result is a single (nonexistent) identifier, never executable SQL.
|
||||
new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --")
|
||||
.ShouldBe("\"x\"\" ; DROP TABLE users --\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("boolean", DriverDataType.Boolean)]
|
||||
[InlineData("smallint", DriverDataType.Int16)]
|
||||
[InlineData("integer", DriverDataType.Int32)]
|
||||
[InlineData("bigint", DriverDataType.Int64)]
|
||||
[InlineData("real", DriverDataType.Float32)]
|
||||
[InlineData("double precision", DriverDataType.Float64)]
|
||||
[InlineData("numeric", DriverDataType.Float64)]
|
||||
[InlineData("text", DriverDataType.String)]
|
||||
[InlineData("character varying", DriverDataType.String)]
|
||||
[InlineData("character", DriverDataType.String)]
|
||||
[InlineData("uuid", DriverDataType.String)]
|
||||
[InlineData("timestamp without time zone", DriverDataType.DateTime)]
|
||||
[InlineData("timestamp with time zone", DriverDataType.DateTime)]
|
||||
[InlineData("date", DriverDataType.DateTime)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("BOOLEAN", DriverDataType.Boolean)]
|
||||
[InlineData("Double Precision", DriverDataType.Float64)]
|
||||
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
|
||||
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back
|
||||
[InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean
|
||||
[InlineData("bit varying")]
|
||||
[InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`)
|
||||
[InlineData("interval")]
|
||||
[InlineData("json")]
|
||||
[InlineData("ARRAY")]
|
||||
[InlineData("USER-DEFINED")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
|
||||
=> new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isInformationSchemaAndParameterized()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.LivenessSql.ShouldBe("SELECT 1");
|
||||
d.DefaultSchemaSql.ShouldBe("SELECT current_schema()");
|
||||
d.ListTablesSql.ShouldContain("information_schema.tables");
|
||||
d.ListColumnsSql.ShouldContain("information_schema.columns");
|
||||
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain("@schema");
|
||||
d.ListColumnsSql.ShouldContain("@table");
|
||||
}
|
||||
|
||||
// The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the
|
||||
// same string, or the loader binds a name the statement never mentions.
|
||||
[Fact]
|
||||
public void CatalogSql_markersAgreeWithParameterMarker()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
|
||||
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListSchemasSql_excludesTheSystemSchemas()
|
||||
{
|
||||
var sql = new PostgresDialect().ListSchemasSql;
|
||||
sql.ShouldContain("'pg_catalog'");
|
||||
sql.ShouldContain("'information_schema'");
|
||||
|
||||
// The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so
|
||||
// they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail
|
||||
// if someone "tidies" the backslash away and silently widens the exclusion.
|
||||
sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'");
|
||||
sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'");
|
||||
}
|
||||
|
||||
// The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and
|
||||
// Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and
|
||||
// this pins the exact ones the consumers ask for.
|
||||
[Fact]
|
||||
public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\"");
|
||||
d.ListTablesSql.ShouldContain("\"TABLE_NAME\"");
|
||||
d.ListTablesSql.ShouldContain("\"TABLE_TYPE\"");
|
||||
d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\"");
|
||||
d.ListColumnsSql.ShouldContain("\"DATA_TYPE\"");
|
||||
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
|
||||
// Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The
|
||||
// planner appends the suffix straight onto the finished statement, so the space belongs to it.
|
||||
d.SingleRowLimitPrefix.ShouldBe("");
|
||||
d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1");
|
||||
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix)
|
||||
.ShouldBe("SELECT \"a\" LIMIT 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.Provider.ShouldBe(SqlProvider.Postgres);
|
||||
d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance);
|
||||
// The seam must not leak Npgsql into its signature.
|
||||
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
|
||||
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("k0")]
|
||||
[InlineData("w")]
|
||||
[InlineData("schema")]
|
||||
public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName)
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.ParameterMarker(baseName).ShouldBe("@" + baseName);
|
||||
d.ParameterBindName(baseName).ShouldBe("@" + baseName);
|
||||
}
|
||||
|
||||
// Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a
|
||||
// command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory).
|
||||
[Fact]
|
||||
public void ConfigureCommand_leavesAForeignCommandUntouched()
|
||||
{
|
||||
using var command = new SqliteCommand("SELECT 1");
|
||||
command.Parameters.AddWithValue("@k0", 7);
|
||||
|
||||
Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command));
|
||||
|
||||
command.CommandText.ShouldBe("SELECT 1");
|
||||
command.Parameters.Count.ShouldBe(1);
|
||||
command.Parameters[0].ParameterName.ShouldBe("@k0");
|
||||
command.Parameters[0].Value.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
|
||||
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
|
||||
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
|
||||
/// carries.
|
||||
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
|
||||
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
|
||||
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags =
|
||||
[
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
|
||||
],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
dialect,
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// The catalog queries bound the bare names, and every catalog command went through the hook.
|
||||
dialect.BoundParameterNames.ShouldContain("schema");
|
||||
dialect.BoundParameterNames.ShouldContain("table");
|
||||
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
@@ -260,6 +304,12 @@ public sealed class SqlCatalogGateDriverTests
|
||||
|
||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||
|
||||
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
|
||||
|
||||
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
|
||||
|
||||
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
|
||||
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
|
||||
@@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests
|
||||
|
||||
// ---- provider gate ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> is still a
|
||||
/// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that
|
||||
/// had quietly become "construct anything" would pass every test below.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
|
||||
{
|
||||
@@ -130,12 +135,73 @@ public sealed class SqlDriverFactoryTests
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
|
||||
"d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain("Postgres");
|
||||
ex.Message.ShouldContain("MySql");
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
|
||||
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null));
|
||||
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
|
||||
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
|
||||
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
|
||||
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
|
||||
/// T-SQL at a Postgres server.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("SqlServer", typeof(SqlServerDialect))]
|
||||
[InlineData("Postgres", typeof(PostgresDialect))]
|
||||
[InlineData("Oracle", typeof(OracleDialect))]
|
||||
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var binding = SqlDriverFactoryExtensions.ParseBinding(
|
||||
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
|
||||
|
||||
binding.Dialect.ShouldBeOfType(expected);
|
||||
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
|
||||
/// bootstrapper actually calls — not only on the internal parse.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Postgres")]
|
||||
[InlineData("Oracle")]
|
||||
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
|
||||
{
|
||||
var name = NewRef();
|
||||
using var _ = new EnvironmentVariableScope(
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
|
||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||
}
|
||||
|
||||
// ---- dialect-owned parameter markers ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for dialect-owned markers: against a <c>:</c>-marker dialect the emitted
|
||||
/// statement must carry <c>:k0</c>, and no <c>@</c> at all. A planner holding the T-SQL prefix in a
|
||||
/// const passes every SQL Server test in this class and fails only here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void KeyValueMarkers_comeFromTheDialect_ratherThanAHardcodedAtSign()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWideRowSelectorMarker_comesFromTheDialect_ratherThanAHardcodedAtSign()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("= :w");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bind names ride the plan <em>separately</em> from the markers, because on ODP.NET they are a
|
||||
/// different string. A plan that carried only markers would leave the reader deriving the bind name —
|
||||
/// the convention it is not entitled to know.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParameterBindNames_areTheDialects_andNeedNotEqualTheMarkers()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" }); // in the SQL text
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "k0", "k1" }); // on the DbParameter
|
||||
plans[0].ParameterBindNames.Count.ShouldBe(plans[0].Parameters.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWideRowSelectorBindName_isTheDialects_andNeedNotEqualTheMarker()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "w" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On a SqlClient-style dialect the two lists coincide — which is exactly why the colon control above
|
||||
/// exists, and why this one cannot stand in for it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OnSqlServer_theBindNamesAreTheMarkers()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
|
||||
new SqlServerDialect()).ToList();
|
||||
|
||||
plans[0].ParameterBindNames.ShouldBe(plans[0].ParameterNames);
|
||||
plans[0].ParameterBindNames.ShouldBe(new[] { "@k0" });
|
||||
}
|
||||
|
||||
// ---- wide row ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
|
||||
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- dialect-owned parameter naming ----
|
||||
|
||||
/// <summary>
|
||||
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
|
||||
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
|
||||
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
|
||||
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
|
||||
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
|
||||
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var dialect = new ColonMarkerDialect();
|
||||
var reader = new SqlPollReader(
|
||||
fixture.Factory, fixture.ConnectionString, dialect,
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||
|
||||
var snapshots = await reader.ReadAsync(
|
||||
["Speed", "Pressure", "Oven"], CancellationToken.None);
|
||||
|
||||
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
|
||||
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
|
||||
|
||||
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
|
||||
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
|
||||
|
||||
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
|
||||
// absence mis-binds an IN list by position instead of failing.
|
||||
dialect.ConfiguredCommands.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests
|
||||
public void Plan_doesNotAliasTheCallersLists()
|
||||
{
|
||||
var names = new List<string> { "@k0" };
|
||||
var bindNames = new List<string> { "@k0" };
|
||||
var values = new List<object?> { "v" };
|
||||
var members = new List<SqlTagDefinition>
|
||||
{
|
||||
@@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests
|
||||
};
|
||||
var selected = new List<string> { "k", "c" };
|
||||
|
||||
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
|
||||
var plan = new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, "gk", "SELECT 1", names, bindNames, values, members, selected);
|
||||
|
||||
names.Add("@k1");
|
||||
bindNames.Add("@k1");
|
||||
values.Add("other");
|
||||
members.Add(members[0]);
|
||||
selected.Add("extra");
|
||||
|
||||
plan.ParameterNames.Count.ShouldBe(1);
|
||||
plan.ParameterBindNames.Count.ShouldBe(1);
|
||||
plan.Parameters.Count.ShouldBe(1);
|
||||
plan.Members.Count.ShouldBe(1);
|
||||
plan.SelectedColumns.Count.ShouldBe(2);
|
||||
|
||||
@@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqliteFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
|
||||
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
|
||||
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
|
||||
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>Identical to the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>No-op: <c>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
@@ -86,4 +87,47 @@ public sealed class SqlDriverFormContractTests
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
|
||||
}
|
||||
|
||||
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
|
||||
|
||||
/// <summary>
|
||||
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
|
||||
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
|
||||
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
|
||||
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
|
||||
/// the next modal open.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.SqlServer, "SqlServer")]
|
||||
[InlineData(SqlProvider.Postgres, "Postgres")]
|
||||
[InlineData(SqlProvider.Oracle, "Oracle")]
|
||||
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
|
||||
{
|
||||
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
|
||||
|
||||
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
|
||||
|
||||
json.ShouldContain($"\"provider\":\"{name}\"");
|
||||
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
|
||||
var reloaded = SqlDriverForm.FormModel.FromDto(
|
||||
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
|
||||
reloaded.Provider.ShouldBe(provider);
|
||||
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer
|
||||
/// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has
|
||||
/// five members, the dropdown should too" edit has to argue with a test.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.MySql)]
|
||||
[InlineData(SqlProvider.Odbc)]
|
||||
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
|
||||
|
||||
json.ShouldContain($"\"provider\":\"{reserved}\"");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user