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;
@@ -0,0 +1,78 @@
# Dedicated, DISPOSABLE PostgreSQL for the Sql driver's Postgres live gate (PostgresReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN postgres container, `otopcua-sql-pg`, on host port 15432.
# Nothing else on the rig runs PostgreSQL, so there is no shared-server hazard of the
# kind the mssql blackhole fixture guards against — but the same discipline applies:
# a distinct container_name and a distinct host port, and `restart: "no"` so a killed
# container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-pg # rsync this dir → /opt/otopcua-sql-pg/
# lmxopcua-fix up sql-pg # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-pg/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-pg && SQL_PG_PASSWORD=... docker compose up -d'
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_PG_ENDPOINT=10.100.0.35:15432
# export SQL_PG_USER=otopcua # optional; this is the default
# export SQL_PG_PASSWORD=... # the same value this compose was brought up with
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~PostgresReadTests"
#
# The one-shot `postgres-seed` service applies seed-pg.sql once the server is healthy, so
# the stack is self-seeding. Unlike the mssql fixture the suite does NOT seed defensively
# on connect: the seed creates ROLES (an owner role that is not the connecting principal),
# which the read-only fixture login deliberately cannot do — see seed-pg.sql.
name: otopcua-sql-pg
services:
postgres:
image: postgres:17-alpine
container_name: otopcua-sql-pg
labels:
project: lmxopcua
restart: "no"
environment:
# Dev-only sandbox password for a throwaway container; the seed gives the unprivileged
# `otopcua` login the SAME value, which is what the suite reads from SQL_PG_PASSWORD.
POSTGRES_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
POSTGRES_DB: sqlpollfixture
ports:
- "15432:5432"
healthcheck:
# pg_isready against the fixture database: the server accepting a connection to the
# database the seed targets is the readiness signal, not merely a listening socket.
test:
- "CMD-SHELL"
- "pg_isready -U postgres -d sqlpollfixture || exit 1"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
# One-shot seed: waits for the server to pass its healthcheck, applies seed-pg.sql, exits.
postgres-seed:
image: postgres:17-alpine
labels:
project: lmxopcua
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
APP_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
volumes:
- ./seed-pg.sql:/seed.sql:ro
entrypoint:
- "/bin/sh"
- "-c"
# ON_ERROR_STOP=1 so a broken seed fails the service rather than leaving a half-seeded
# database that makes a later test failure look like a driver bug.
- "psql -v ON_ERROR_STOP=1 -v app_password=\"$${APP_PASSWORD}\" -h postgres -U postgres -d sqlpollfixture -f /seed.sql"
@@ -0,0 +1,87 @@
-- Seed for the Sql driver's PostgreSQL live gate (PostgresReadTests), applied once by the
-- compose `postgres-seed` one-shot as the superuser against the OWNED `otopcua-sql-pg` container.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ── TWO THINGS HERE ARE LOAD-BEARING, NOT SCAFFOLDING ─────────────────────────────────────────
--
-- 1. The schema is owned by `sqlpoll_owner` and the suite connects as `otopcua`, which holds
-- USAGE + SELECT and nothing else. That is the exact provisioning `PostgresDialect.ListSchemasSql`
-- was written for: `information_schema.schemata` lists only schemas the current role OWNS, so a
-- granted-not-owning read-only poller would see an empty schema list, `SqlCatalogLoader` would
-- throw its "the catalog returned no schemas at all" grant diagnosis, and every tag would be
-- rejected. Sourcing the schema list from `information_schema.tables` instead is what makes this
-- work — and a fixture where the poller owned its own schema would pass either way, proving
-- nothing. Do not "simplify" this by creating the tables as `otopcua`.
--
-- 2. Every identifier is created UNQUOTED, so Postgres folds it to lower case in the catalog. The
-- suite's case-folding leg authors the same names in UPPER case and expects them to resolve
-- through `SqlCatalog.TryMatch` (exact-ordinal first, then a UNIQUE case-insensitive match, which
-- substitutes the catalog's own spelling). Quoting a name here would make the catalog spelling
-- mixed case and that leg would stop meaning anything.
\set ON_ERROR_STOP on
-- ── roles ─────────────────────────────────────────────────────────────────────────────────────
-- \gexec runs the row the SELECT produced, and produces no row when the role already exists —
-- the idempotent CREATE ROLE idiom. A DO block cannot be used for the login role: psql does not
-- interpolate :'app_password' inside a dollar-quoted body.
SELECT 'CREATE ROLE sqlpoll_owner NOLOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sqlpoll_owner')
\gexec
SELECT 'CREATE ROLE otopcua LOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'otopcua')
\gexec
-- Set (or re-set) the password every run, so re-seeding with a different SQL_PG_PASSWORD converges
-- rather than leaving the suite unable to log in.
ALTER ROLE otopcua LOGIN PASSWORD :'app_password';
-- ── schema ────────────────────────────────────────────────────────────────────────────────────
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. The blast radius
-- is bounded to this fixture-owned schema.
DROP SCHEMA IF EXISTS sqlpoll CASCADE;
CREATE SCHEMA sqlpoll AUTHORIZATION sqlpoll_owner;
-- Everything below is created AS THE OWNER, not as the superuser — see note 1 above.
SET ROLE sqlpoll_owner;
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
CREATE TABLE sqlpoll.tag_values (
tag_name varchar(128) NOT NULL,
num_value double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42.0, TIMESTAMP '2026-07-24 10:00:00'),
('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01'),
('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its LIMIT 1) returns a different, wrong value rather than a coincidentally
-- right one.
CREATE TABLE sqlpoll.latest_status (
station_id integer NOT NULL,
oven_temp double precision NULL,
pressure double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
RESET ROLE;
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
GRANT USAGE ON SCHEMA sqlpoll TO otopcua;
GRANT SELECT ON ALL TABLES IN SCHEMA sqlpoll TO otopcua;
@@ -0,0 +1,545 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real Oracle Database</b>, over the fully managed ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
/// per provider.</para>
/// </summary>
[Collection(OracleCollection.Name)]
public sealed class OracleReadTests
{
private readonly OracleFixture _ora;
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
public OracleReadTests(OracleFixture ora) => _ora = ora;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
/// described credential-free.
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
/// loader expects.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. Oracle's connection string
// names the database inside Data Source (host:port/service), so the service is what must survive —
// and the credential must not.
driver.Endpoint.ShouldContain(_ora.ServiceName);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
/// explicit authored <c>"type"</c>.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.PresentKeyTimestampUtc);
}
/// <summary>
/// Two keys on the same table fold into ONE query — <c>… WHERE "TAG_NAME" IN (:k0, :k1)</c> — and are
/// sliced back to the right tags.
/// <para><b>This is the live falsification of positional binding.</b> With
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
/// parameter collection regardless of their names, and the failure mode is not an exception but a
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
{
SkipUnlessLive();
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", OracleFixture.SecondPresentKey));
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.SecondPresentValue);
}
// ---- wide-row model: the Oracle-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
/// different, wrong value here rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
/// <summary>
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
/// <c>OTOPCUA.TAG_VALUES</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
/// identifiers by hand.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
/// pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
{
SkipUnlessLive();
var lower = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
["keyValue"] = OracleFixture.PresentKey,
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
});
await using var driver = await StartAsync(lower);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
/// binding. Reaching a non-empty table list here is therefore evidence that
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
/// seeded schema is one of many.</para>
/// </summary>
[Fact]
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Oracle),
["connectionString"] = _ora.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == OracleFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(OracleFixture.Schema);
address["table"].ShouldBe(OracleFixture.KeyValueTable);
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
/// precisely what makes this suite exercise <c>OracleClientFactory.Instance</c>, the provider the
/// dialect ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(15),
OperationTimeout = TimeSpan.FromSeconds(20),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-oracle-integration",
dialect: new OracleDialect(),
connectionString: _ora.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table, authored in the catalog's own spelling.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
["keyColumn"] = OracleFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = OracleFixture.ValueColumn,
["timestampColumn"] = OracleFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = OracleFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = OracleFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
/// the Oracle counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent, it
/// records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
/// land in the <c>OTOPCUA</c> schema.</para>
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
/// place that deliberately authors the lower-case form an operator would actually type.</para>
/// </summary>
public sealed class OracleFixture : IAsyncLifetime
{
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
public const string UserEnvVar = "SQL_ORACLE_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
/// <summary>
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
/// </summary>
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
public const string DefaultUser = "otopcua";
/// <summary>The image's pluggable-database service name.</summary>
public const string DefaultService = "FREEPDB1";
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 1521;
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
public const string Schema = "OTOPCUA";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 10;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string KeyValueTable = "TAG_VALUES";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "TAG_NAME";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "NUM_VALUE";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "SAMPLE_TS";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/> — different from
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string WideRowTable = "LATEST_STATUS";
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
public const string OvenTempColumn = "OVEN_TEMP";
/// <summary>
/// The <see cref="OvenTempColumn"/> of the row holding the <b>newest</b> <c>SAMPLE_TS</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its
/// <c>FETCH FIRST 1 ROWS ONLY</c> picks a different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The resolved service name, for the endpoint-description assertion.</summary>
public string ServiceName { get; private set; } = DefaultService;
/// <summary>The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or seeded
/// here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
"Sql driver's live Oracle suite. Bring the fixture up from " +
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
return;
}
ConnectionString = resolved;
var dialect = new OracleDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("ODP.NET returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
"stack is up (its first boot takes minutes), or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via the
/// abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct ODP.NET
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
/// be accepted through that indexer, so no provider-specific property is needed.</para>
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
/// install and no Oracle configuration files anywhere near the test host.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, and Oracle locks an account after a handful of those.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
? service
: DefaultService;
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
builder["User Id"] = user;
builder["Password"] = password;
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
return builder.ConnectionString;
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class OracleCollection : ICollectionFixture<OracleFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlOracleServer";
}
@@ -0,0 +1,511 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
/// catalog SQL — including the deliberate choice to derive the schema list from
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
/// granted-but-not-owning principal can prove.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> for the
/// gate. With it unset nothing here opens a socket, so the suite is instant and green offline — which
/// is the point: a live test that goes red on a laptop teaches people to ignore red.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
/// walk an operator authors through.</para>
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class PostgresReadTests
{
private readonly PostgresFixture _pg;
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
/// reports Healthy with the endpoint described credential-free.
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
/// would have answered empty for.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. It must name the
// database and must not carry the credential that reached the provider.
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
[Fact]
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
}
// ---- wide-row model: the Postgres-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
/// first-inserted nor the lowest-keyed one, so losing either the limit or the
/// <c>ORDER BY … DESC</c> yields a different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realPostgres_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
/// <summary>
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
{
SkipUnlessLive();
var upper = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
["keyValue"] = PostgresFixture.PresentKey,
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(),
});
await using var driver = await StartAsync(upper);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
/// browse session reads every catalog column by name, so an unquoted alias would fold to
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
/// list.</para>
/// </summary>
[Fact]
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Postgres),
["connectionString"] = _pg.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
var schema = schemas.ShouldHaveSingleItem(
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(PostgresFixture.Schema);
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
/// ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(10),
OperationTimeout = TimeSpan.FromSeconds(15),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-postgres-integration",
dialect: new PostgresDialect(),
connectionString: _pg.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
["keyColumn"] = PostgresFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = PostgresFixture.ValueColumn,
["timestampColumn"] = PostgresFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = PostgresFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = PostgresFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent,
/// it records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>. That is what keeps
/// <c>dotnet test</c> genuinely green (and instant) on the macOS dev box this is usually run from.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
/// superuser, and the granted-not-owning provisioning that
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
public const string UserEnvVar = "SQL_PG_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
public const string DefaultUser = "otopcua";
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 5432;
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
public const string FixtureDatabase = "sqlpollfixture";
/// <summary>
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
/// seed's drop-and-recreate is bounded to objects this fixture owns.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string KeyValueTable = "tag_values";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string WideRowTable = "latest_status";
/// <summary>
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
/// different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
/// seeded here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
"without these.";
return;
}
ConnectionString = resolved;
var dialect = new PostgresDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("Npgsql returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
/// provider-specific property is needed.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, which is how accounts get locked out on a server that is not a throwaway.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
builder["Host"] = host;
builder["Port"] = port;
builder["Database"] = FixtureDatabase;
builder["Username"] = user;
builder["Password"] = password;
builder["Timeout"] = ProbeConnectTimeoutSeconds;
builder["Application Name"] = DriverApplicationName;
return builder.ConnectionString;
}
/// <summary>
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
/// </summary>
/// <param name="endpoint">The authored endpoint.</param>
/// <param name="defaultPort">The port to use when none is carried.</param>
/// <returns>The host and port.</returns>
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
{
var separator = endpoint.LastIndexOf(':');
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
var host = endpoint[..separator];
return int.TryParse(
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
? (host, port)
: (endpoint, defaultPort);
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPostgresServer";
}
@@ -27,12 +27,20 @@
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
No Microsoft.Data.SqlClient / Npgsql / Oracle.ManagedDataAccess.Core PackageReference here on purpose:
this suite must exercise EXACTLY the providers the driver ships, and they arrive transitively through
Driver.Sql. Adding one here would let the two versions drift and the suite would then prove something
about a provider nobody deploys. The Postgres and Oracle suites therefore never name a provider type:
they compose their connection strings through `dialect.Factory.CreateConnectionStringBuilder()` and the
abstract DbConnectionStringBuilder indexer.
Driver.Sql.Browser is referenced for the browse-session walk (schema → table → column) the Postgres and
Oracle suites drive through the AdminUI's real seam, SqlDriverBrowser — the only place the dialects'
catalog SQL is exercised as the picker actually calls it.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>