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:
+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;
|
||||
Reference in New Issue
Block a user