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 @@ +