f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
Item 2: a tag's optional `write` block picks the statement the driver builds from its own catalog-validated identifiers (no authored SQL; values always bound): - Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData) - Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag) - Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL - Procedure: named stored procedure resolved against the routine catalog by the catalog gate (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional) - stampTimestamp sets the tag's timestamp column to the write time (UTC) Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else, permission denied included, stays BadCommunicationError. WriteIdempotent stays false. The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI editor surfaces the same message. Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login limited to their write targets. Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end), DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server, PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
130 lines
7.3 KiB
PL/PgSQL
130 lines
7.3 KiB
PL/PgSQL
-- 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');
|
|
|
|
-- Integer-keyed EAV source (STAB-24): the key column is `integer`, so a key bound as the default
|
|
-- `text` fails with "operator does not exist: integer = text". Pins the untyped-bind fix in
|
|
-- PostgresDialect.ConfigureParameter for the key-value IN-list path (the wide-row `whereColumn`
|
|
-- path is covered by latest_status.station_id above).
|
|
CREATE TABLE sqlpoll.int_tag_values (
|
|
tag_id integer NOT NULL,
|
|
num_value double precision NULL,
|
|
sample_ts timestamp(3) NOT NULL
|
|
);
|
|
|
|
INSERT INTO sqlpoll.int_tag_values (tag_id, num_value, sample_ts)
|
|
VALUES (101, 77.5, TIMESTAMP '2026-07-24 10:00:00'),
|
|
(102, 12.0, TIMESTAMP '2026-07-24 10:00:01');
|
|
|
|
-- Write-mode targets (lmxopcua#781, SqlWriteModesLiveTests): a primary-keyed setpoint table — the unique key is
|
|
-- what INSERT … ON CONFLICT (tag_name) needs — and a SECURITY DEFINER procedure that journals its arguments, so the
|
|
-- login needs only EXECUTE on it. p_ts is timestamptz because a procedure argument resolves by IMPLICIT cast and the
|
|
-- driver binds the write time as timestamptz.
|
|
CREATE TABLE sqlpoll.write_targets (
|
|
tag_name varchar(128) NOT NULL PRIMARY KEY,
|
|
sp_value double precision NOT NULL,
|
|
stamp_ts timestamp(3) NULL
|
|
);
|
|
|
|
INSERT INTO sqlpoll.write_targets (tag_name, sp_value, stamp_ts) VALUES ('Present', 1.0, NULL);
|
|
|
|
CREATE TABLE sqlpoll.proc_calls (
|
|
tag_name varchar(128) NOT NULL,
|
|
sp_value double precision NULL,
|
|
stamp_ts timestamptz NULL
|
|
);
|
|
|
|
CREATE PROCEDURE sqlpoll.set_target(p_value double precision, p_key varchar, p_ts timestamptz)
|
|
LANGUAGE sql SECURITY DEFINER
|
|
AS $$ INSERT INTO sqlpoll.proc_calls (tag_name, sp_value, stamp_ts) VALUES (p_key, p_value, p_ts) $$;
|
|
|
|
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;
|
|
|
|
-- …except the write-mode targets (lmxopcua#781): INSERT + UPDATE on the one setpoint table, EXECUTE on the one
|
|
-- procedure. Every other table stays SELECT-only, so a write pointed there is refused by the server's own grant.
|
|
GRANT INSERT, UPDATE ON sqlpoll.write_targets TO otopcua;
|
|
REVOKE EXECUTE ON PROCEDURE sqlpoll.set_target FROM PUBLIC;
|
|
GRANT EXECUTE ON PROCEDURE sqlpoll.set_target TO otopcua;
|