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