test(sql): env-gated Postgres + Oracle integration suites

Two disposable docker fixtures (postgres:17-alpine on 15432, gvenzl/oracle-free:
23-slim on 11521) shaped like the existing mssql one — project label, distinct
container name, healthcheck, one-shot seed service — plus a live suite per
provider, gated on SQL_PG_* / SQL_ORACLE_* and skipping clean offline.

Each suite covers what a DIALECT can get wrong, rather than re-running the
provider-agnostic read semantics already pinned against SQL Server and SQLite:
liveness + catalog load, a KeyValue read, the provider's own row-limit syntax via
topByTimestamp (LIMIT 1 / FETCH FIRST 1 ROWS ONLY, where T-SQL emits TOP 1),
identifier case-folding through the design §8.1 catalog gate, and the AdminUI
browse walk driven through SqlDriverBrowser.

Two seed decisions are load-bearing rather than scaffolding:

- The Postgres schema is owned by a role the suite is NOT; the connecting login
  holds USAGE + SELECT only. That is the exact provisioning PostgresDialect's
  ListSchemasSql exists for (information_schema.schemata lists only schemas the
  role OWNS, so a granted-not-owning poller would see none and every tag would be
  rejected). A fixture that owned its own schema would pass either way.
- Every identifier in both seeds is created UNQUOTED, so the catalogs hold
  lower-case (Postgres) and UPPER-case (Oracle) spellings. The case-folding legs
  author the opposite case and pin SqlCatalog.TryMatch's REAL contract — exact
  ordinal first, then a UNIQUE case-insensitive match whose catalog spelling is
  substituted — which on both providers is what makes mixed-case authoring work
  at all, since both dialects emit case-sensitive double-quoted identifiers.

Connection strings are composed through dialect.Factory.CreateConnectionString-
Builder() and the abstract DbConnectionStringBuilder indexer, so the csproj keeps
its no-direct-provider-reference discipline; every key used was verified accepted
through that indexer. The csproj gains only Driver.Sql.Browser, for the browse leg.

Live-run evidence (local OrbStack, both images arm64): Postgres 5/5 green;
Oracle 6/6 green after the ORA-01745 dialect fix this suite found. Offline the
whole project is 33 skipped / 0 failed.
This commit is contained in:
Joseph Doherty
2026-08-05 07:15:43 -04:00
parent ec90b7c416
commit 9675823cd6
7 changed files with 1389 additions and 3 deletions
@@ -0,0 +1,81 @@
# Dedicated, DISPOSABLE Oracle Database Free for the Sql driver's Oracle live gate
# (OracleReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN container, `otopcua-sql-oracle`, on host port 11521 — deliberately not
# the default 1521, so it can never be confused with a real Oracle listener on the rig. `restart:
# "no"` so a killed container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-oracle # rsync this dir → /opt/otopcua-sql-oracle/
# lmxopcua-fix up sql-oracle # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-oracle/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-oracle && SQL_ORACLE_PASSWORD=... docker compose up -d'
#
# ⚠️ FIRST START TAKES MINUTES, not seconds — the image creates the database on first boot. The
# healthcheck's start_period reflects that; the seed one-shot simply waits for it.
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_ORACLE_ENDPOINT=10.100.0.35:11521
# export SQL_ORACLE_USER=otopcua # optional; this is the default
# export SQL_ORACLE_PASSWORD=... # the same value this compose was brought up with
# export SQL_ORACLE_SERVICE=FREEPDB1 # optional; this is the default (the image's PDB)
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~OracleReadTests"
#
# The one-shot `oracle-seed` service applies seed-oracle.sql once the server is healthy. The
# gvenzl images also auto-run scripts mounted at /container-entrypoint-initdb.d, but that path
# runs ONLY on the very first boot of an empty data volume — a re-seed after editing the script
# would silently not happen. The one-shot service matches the mssql fixture and re-runs every
# `docker compose up`, which is why it is used here.
name: otopcua-sql-oracle
services:
oracle:
image: gvenzl/oracle-free:23-slim
container_name: otopcua-sql-oracle
labels:
project: lmxopcua
restart: "no"
environment:
# SYS/SYSTEM password. Dev-only sandbox value for a throwaway container.
ORACLE_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
# The image creates this non-SYS user (and its schema) inside the FREEPDB1 pluggable
# database. It is the principal the suite connects as — nothing here ever uses SYS.
APP_USER: otopcua
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
ports:
- "11521:1521"
healthcheck:
# healthcheck.sh ships in the image and reports the database OPEN, not merely the listener up.
test: ["CMD", "healthcheck.sh"]
interval: 15s
timeout: 10s
retries: 40
start_period: 180s
# One-shot seed: waits for the database to pass its healthcheck, applies seed-oracle.sql, exits.
# Reuses the server image purely because it already carries sqlplus.
oracle-seed:
image: gvenzl/oracle-free:23-slim
labels:
project: lmxopcua
restart: "no"
depends_on:
oracle:
condition: service_healthy
environment:
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
volumes:
- ./seed-oracle.sql:/seed.sql:ro
entrypoint:
- "/bin/bash"
- "-c"
# -S silent, -L single login attempt (never prompt). The script's WHENEVER SQLERROR EXIT
# makes a broken seed fail this service rather than leave a half-seeded schema behind.
- "sqlplus -S -L otopcua/\"$${APP_USER_PASSWORD}\"@//oracle:1521/FREEPDB1 @/seed.sql"
@@ -0,0 +1,76 @@
-- Seed for the Sql driver's Oracle live gate (OracleReadTests), applied once by the compose
-- `oracle-seed` one-shot AS THE APPLICATION USER (otopcua) against the OWNED `otopcua-sql-oracle`
-- container. Oracle equates a schema with a user, so everything below lands in the OTOPCUA schema.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ⚠️ EVERY IDENTIFIER HERE IS UNQUOTED, AND THAT IS THE POINT. Oracle upper-cases an unquoted
-- identifier, so the catalog stores OTOPCUA / TAG_VALUES / TAG_NAME while an operator authoring a
-- tag in the AdminUI naturally types lower case. The suite's case-folding leg authors the lower-case
-- spelling and expects `SqlCatalogGate` to resolve it through `SqlCatalog.TryMatch` (exact-ordinal
-- first, then a UNIQUE case-insensitive match) and substitute the catalog's own UPPER-case spelling
-- before `OracleDialect.QuoteIdentifier` double-quotes it — which is load-bearing, because a quoted
-- "tag_values" and a quoted "TAG_VALUES" are DIFFERENT objects to Oracle. Quoting a name here would
-- make that leg meaningless.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET FEEDBACK OFF
SET DEFINE OFF
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. ORA-00942 (table
-- does not exist) is the expected outcome on a first run and is the only error swallowed.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE tag_values PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE latest_status PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
-- num_value is NUMBER, Oracle's one numeric family — which OracleDialect.MapColumnType folds to
-- Float64 (the documented under-specification; see that method's remarks).
CREATE TABLE tag_values (
tag_name VARCHAR2(128) NOT NULL,
num_value NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its FETCH FIRST 1 ROWS ONLY) returns a different, wrong value rather than a
-- coincidentally right one.
CREATE TABLE latest_status (
station_id NUMBER(10) NOT NULL,
oven_temp NUMBER,
pressure NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
COMMIT;
EXIT;