Files
Joseph Doherty f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
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
2026-11-09 22:59:09 -05:00

112 lines
5.3 KiB
SQL

-- Seed for the Sql driver's MySQL live gate (MySqlReadTests, lmxopcua#753), applied once by the compose
-- `mysql-seed` one-shot as root against the OWNED `otopcua-sql-mysql` container. `__APP_PASSWORD__` is
-- substituted by the one-shot before this reaches the server.
--
-- Table shapes mirror the SQL Server and PostgreSQL fixtures row for row, so the same assertions hold on all
-- backends. Two things are load-bearing:
--
-- 1. The suite connects as `otopcua`, which holds SELECT on `sqlpoll` and nothing else (plus EXECUTE on the
-- side-effect function and UPDATE on the one write-target table, below). A MySQL "schema" is a database,
-- and information_schema shows a principal only the objects it holds a privilege on — so MySqlDialect.ListSchemasSql returning `sqlpoll` for this
-- login is the real provisioning check, not a superuser's view.
-- 2. Every identifier is lower case. The suite authors the table name in UPPER case and expects the catalog
-- gate to resolve it through a unique case-insensitive match: on Linux, MySQL table names are case-
-- SENSITIVE, so without the gate's substitution the quoted upper-case name would name no table.
DROP DATABASE IF EXISTS sqlpoll;
CREATE DATABASE sqlpoll;
USE sqlpoll;
-- Key-value (EAV) source.
CREATE TABLE tag_values (
tag_name varchar(128) NOT NULL,
num_value double NULL,
sample_ts datetime(3) NOT NULL
);
INSERT INTO tag_values (tag_name, num_value, sample_ts) VALUES
('Line1.Speed', 42.0, '2026-07-24 10:00:00'),
('Line1.Temp', NULL, '2026-07-24 10:00:01'),
('Line1.Pressure', 3.5, '2026-07-24 10:00:02');
-- Wide-row source. Station 8 is inserted LAST and is the newest sample; station 7 is first — so a plan that
-- lost its ORDER BY … DESC or its LIMIT 1 returns a different, wrong value.
CREATE TABLE latest_status (
station_id int NOT NULL,
oven_temp double NULL,
pressure double NULL,
sample_ts datetime(3) NOT NULL
);
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts) VALUES
(7, 180.5, 1.2, '2026-07-24 10:00:00'),
(9, NULL, 1.6, '2026-07-24 10:00:01'),
(8, 210.25, 1.4, '2026-07-24 10:00:02');
-- Integer-keyed EAV source: an authored text keyValue must compare against an INT key column.
CREATE TABLE int_tag_values (
tag_id int NOT NULL,
num_value double NULL,
sample_ts datetime(3) NOT NULL
);
INSERT INTO int_tag_values (tag_id, num_value, sample_ts) VALUES
(101, 77.5, '2026-07-24 10:00:00'),
(102, 12.0, '2026-07-24 10:00:01');
-- Named-query rollback probe: bump_counter() WRITES a row and returns 1. Called through a SELECT, it passes the
-- read-only guard (nothing lexical says "write"); the driver's always-rolled-back transaction is what must
-- discard the row. InnoDB, so the insert is transactional. SQL SECURITY DEFINER (root), so `otopcua` needs only
-- EXECUTE — it holds no INSERT of its own.
CREATE TABLE side_effects (n int NOT NULL) ENGINE = InnoDB;
DELIMITER //
CREATE FUNCTION bump_counter() RETURNS int
NOT DETERMINISTIC MODIFIES SQL DATA SQL SECURITY DEFINER
BEGIN
INSERT INTO side_effects (n) VALUES (1);
RETURN 1;
END//
DELIMITER ;
-- Write target (lmxopcua#753): the ONLY table the suite login may UPDATE. `dup` deliberately holds two rows
-- for one key, so a write keyed on it matches two rows and must be rolled back. tag_values stays SELECT-only,
-- so a write tag pointed there proves the server's own grant refuses it.
CREATE TABLE setpoints (
tag_name varchar(128) NOT NULL,
sp_value double NULL
) ENGINE = InnoDB;
INSERT INTO setpoints (tag_name, sp_value) VALUES
('Line1.SpeedSp', 10.0),
('Line1.TempSp', 20.0),
('dup', 1.0),
('dup', 2.0);
-- Write-mode targets (lmxopcua#781, SqlWriteModesLiveTests): a primary-keyed setpoint table — the key ON DUPLICATE
-- KEY UPDATE fires on — and a DEFINER procedure that journals its arguments (MySQL CALL is positional: value, key,
-- write time, the order the driver passes them).
CREATE TABLE write_targets (
tag_name varchar(128) NOT NULL PRIMARY KEY,
sp_value double NOT NULL,
stamp_ts datetime(3) NULL
) ENGINE = InnoDB;
INSERT INTO write_targets (tag_name, sp_value, stamp_ts) VALUES ('Present', 1.0, NULL);
CREATE TABLE proc_calls (
tag_name varchar(128) NOT NULL,
sp_value double NULL,
stamp_ts datetime(3) NULL
) ENGINE = InnoDB;
DELIMITER //
CREATE PROCEDURE set_target(IN p_value double, IN p_key varchar(128), IN p_ts datetime(3))
MODIFIES SQL DATA SQL SECURITY DEFINER
BEGIN
INSERT INTO proc_calls (tag_name, sp_value, stamp_ts) VALUES (p_key, p_value, p_ts);
END//
DELIMITER ;
-- ── the read-only login ──────────────────────────────────────────────────────────────────────
DROP USER IF EXISTS 'otopcua'@'%';
CREATE USER 'otopcua'@'%' IDENTIFIED BY '__APP_PASSWORD__';
GRANT SELECT ON sqlpoll.* TO 'otopcua'@'%';
GRANT EXECUTE ON FUNCTION sqlpoll.bump_counter TO 'otopcua'@'%';
GRANT UPDATE ON sqlpoll.setpoints TO 'otopcua'@'%';
GRANT INSERT, UPDATE ON sqlpoll.write_targets TO 'otopcua'@'%';
GRANT EXECUTE ON PROCEDURE sqlpoll.set_target TO 'otopcua'@'%';
FLUSH PRIVILEGES;