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