diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064462c..88b5a9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,14 +135,13 @@ jobs: - name: Install rsync run: apt-get install -y rsync - name: Test on PostgreSQL ${{ matrix.pg }} - # `make test` alone never fails this step: pgxntool/base.mk (as - # vendored here, 2.2.0) marks its underlying installcheck .IGNORE, - # so a regression exits 0 and only prints the diff. verify-results is - # the real gate -- it runs the same TEST_DEPS chain, then actually - # inspects the pgTAP results (PGXNTOOL_ENABLE_VERIFY_RESULTS defaults - # to yes, PGXNTOOL_VERIFY_RESULTS_MODE to pgtap, matching this - # suite's pgTAP-based tests) and exits non-zero on failure. + # Fresh-mode run: verify-results runs the test chain, then parses the + # pgTAP results, which `make test` alone does not. run: make verify-results + - name: Test the update path on PostgreSQL ${{ matrix.pg }} + # Runs the same suite against a database installed at TEST_UPDATE_FROM + # and then ALTER EXTENSION UPDATEd. + run: make test--update # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14" diff --git a/Makefile b/Makefile index 73316b3..e6b0aa2 100644 --- a/Makefile +++ b/Makefile @@ -50,11 +50,13 @@ TEST_UPDATE_TO ?= export PGOPTIONS := $(PGOPTIONS) -c object_reference.test_load_mode=$(TEST_LOAD_SOURCE) -c object_reference.test_update_from=$(TEST_UPDATE_FROM) -c object_reference.test_update_to=$(TEST_UPDATE_TO) -# Convenience wrapper: `make test-update` == `make test TEST_LOAD_SOURCE=update`. +# `test--update` is the preferred name (`--` names a flavor of +# that target); `test-update` is a synonym for anyone who types a single dash. +# Scripts may use the underlying TEST_LOAD_SOURCE variable directly. # Must recurse (a fresh $(MAKE)) rather than depend on `test`, so the # parse-time TEST_LOAD_SOURCE conditional above re-evaluates with update set. -.PHONY: test-update -test-update: +.PHONY: test--update test-update +test--update test-update: $(MAKE) test TEST_LOAD_SOURCE=update include pgxntool/base.mk diff --git a/sql/object_reference--0.1.0--stable.sql b/sql/object_reference--0.1.0--stable.sql index 18915fb..d17a275 100644 --- a/sql/object_reference--0.1.0--stable.sql +++ b/sql/object_reference--0.1.0--stable.sql @@ -1,3 +1,40 @@ +/* + * Disables all three event triggers for this script (a hand-written copy of + * event_trigger__disable(), which 0.1.0 lacks); event_trigger__enable() at the + * end restores them. See the sentinel note on event_trigger__disable() below. + */ +-- Unlike disable(), this does not check for an existing sentinel: a committed one fails with the plain "already exists" error. +CREATE FUNCTION pg_temp.__object_reference__event_trigger_sentinel() RETURNS event_trigger LANGUAGE plpgsql AS 'BEGIN END'; +CREATE EVENT TRIGGER __object_reference__event_trigger_sentinel + ON ddl_command_end + EXECUTE PROCEDURE pg_temp.__object_reference__event_trigger_sentinel() +; +CREATE TEMP TABLE __object_reference__event_trigger_state AS + SELECT evtname, evtenabled FROM pg_catalog.pg_event_trigger WHERE false +; +ALTER TABLE pg_temp.__object_reference__event_trigger_state ADD PRIMARY KEY (evtname); +DO $$ +DECLARE + v_name pg_catalog.pg_event_trigger.evtname%TYPE; + v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; +BEGIN + FOR v_name IN + SELECT n + FROM unnest('{zzz__object_reference_drop,zzz_object_reference__fix_identity,zzz_object_reference_capture}'::name[]) n + ORDER BY n COLLATE "C" + LOOP + BEGIN + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = v_name FOR UPDATE; + EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'could not lock the pg_event_trigger row of event trigger "%"; reading its state without a lock, so races with ALTER EVENT TRIGGER are possible', v_name; + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = v_name; + END; + EXECUTE format('ALTER EVENT TRIGGER %I DISABLE', v_name); + INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) VALUES (v_name, v_enabled); + END LOOP; +END +$$; + /* * Uses a private __object_reference schema, mirroring * sql/object_reference.sql's own bootstrap/teardown convention, so every @@ -104,34 +141,6 @@ BEGIN END $body$; -/* - * 0.1.0 already installed this extension's own event triggers, and they - * stay active for the rest of THIS session while the structural changes - * below run. zzz__object_reference_drop in particular queries - * _object_reference._object_v inside its own body, so it would fire -- and - * error, since the view is momentarily gone -- the instant this script drops - * that view a few statements down. All three are default-enabled (origin), - * so setting session_replication_role = replica suppresses them for the - * structural section below. - * - * This script is not necessarily the only thing running in its transaction - * -- ALTER EXTENSION UPDATE can be issued as one statement among several in - * a caller-managed transaction -- so session_replication_role cannot simply - * be left disturbed for "the rest of the transaction" to sort out, and - * whatever it's restored to afterward must be the caller's actual prior - * value, not an assumed 'origin' default (the caller may already have it set - * to something else for their own reasons). Stashed in a placeholder GUC - * (there's no other way to carry a value between separate top-level - * statements in a plain multi-statement SQL script -- this isn't a single - * PL/pgSQL block) and restored explicitly right after the cleanup at the end - * of this script, once every object the event triggers reference is back in - * its final, current-source shape. A fresh install never hits this: it - * creates these event triggers only at the very end, once nothing they - * reference is still being modified. - */ -SELECT set_config('object_reference.saved_session_replication_role', current_setting('session_replication_role'), true); -SET LOCAL session_replication_role = replica; - /* * _is_own_object(): true for members of the object_reference extension itself; * _object_v__for_update() uses it to refuse tracking them. @@ -172,6 +181,265 @@ $body$ , 'Is the object a member of the object_reference extension itself? (pg_depend deptype = e membership, not just co-installation.)' ); +/* + * Takes the row lock event_trigger__disable() relies on to block a concurrent raw + * ALTER EVENT TRIGGER of the same trigger until commit. + */ +SELECT __object_reference.create_function( + '_object_reference._event_trigger__lock' + , $args$ + event_trigger_name pg_catalog.pg_event_trigger.evtname%TYPE +$args$ + , 'pg_catalog.pg_event_trigger.evtenabled%TYPE LANGUAGE plpgsql' + , $body$ +DECLARE + v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; +BEGIN + BEGIN + SELECT evtenabled INTO v_enabled + FROM pg_catalog.pg_event_trigger + WHERE evtname = event_trigger_name + FOR UPDATE + ; + EXCEPTION WHEN insufficient_privilege THEN + -- No catalog UPDATE rights: the row can't be locked. + RAISE WARNING 'could not lock the pg_event_trigger row of event trigger "%"; reading its state without a lock, so races with ALTER EVENT TRIGGER are possible', event_trigger_name; + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + END; + + -- NULL if the trigger does not exist + RETURN v_enabled; +END +$body$ + , 'Row-lock one event trigger where privileges allow, and return its current evtenabled (NULL if it does not exist).' +); + +SELECT __object_reference.create_function( + '_object_reference._event_trigger__state_check' + , '' + , 'trigger LANGUAGE plpgsql' + , $body$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_temp.__object_reference__event_trigger_state) THEN + RAISE 'transaction committed without event_trigger__enable() after event_trigger__disable()' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'Call _object_reference.event_trigger__enable() before committing.' + ; + END IF; + RETURN NULL; +END +$body$ + , 'Deferred constraint trigger function: fail the commit if event_trigger__enable() has not run.' +); + +/* + * event_trigger__disable(names) disables the named event triggers, and + * event_trigger__enable() restores each one's exact prior state. Both are + * internal: for this extension's own update scripts, not the public API. + * Avoid disabling event triggers wherever another option exists. + * + * The caller names the triggers, rather than this disabling all of them, so + * that no trigger is disabled (or locked) beyond what the caller needs. + * + * Call enable() in the same transaction as disable(): committing without it + * raises an error (a deferred constraint trigger on the state table, + * _event_trigger__state_check()), so the commit rolls back and the triggers + * stay as they were. The disable therefore never becomes visible to other + * sessions, and an error needs no cleanup logic. A hand-run ALTER EVENT TRIGGER + * is not covered. disable() locks each trigger's catalog row before reading + * its state (_event_trigger__lock()), which blocks a concurrent raw ALTER EVENT + * TRIGGER of the same trigger until the transaction ends. + * + * If a capture group is active, include zzz_object_reference_capture in the + * names: disable() creates the check trigger (a CREATE, which capture records + * unless disabled), and capture does not skip it. + * + * disable() first creates a no-op ddl_command_end event trigger, the sentinel, + * which enable() drops after restoring every state. While every sql_drop, + * table_rewrite and ddl_command_end event trigger is disabled, Postgres gives + * the commands nested in an extension script no event trigger state of their + * own: it collects them onto the enclosing CREATE/ALTER EXTENSION and replays + * them to that command's ddl_command_end once the triggers are back. There, + * pg_event_trigger_ddl_commands() fails on objects dropped since (before + * PostgreSQL 14) or hands capture the script's own objects. One enabled event + * trigger prevents the collection. The sentinel's function is in pg_temp, + * which capture skips. + * + * name[] and "char"[] stand in for %TYPE[], which signatures never accept and + * PL/pgSQL DECLARE accepts only from PostgreSQL 17. + */ +SELECT __object_reference.create_function( + '_object_reference.event_trigger__disable' + , $args$ + event_trigger_names name[] +$args$ + , 'void LANGUAGE plpgsql' + , $body$ +DECLARE + v_name pg_catalog.pg_event_trigger.evtname%TYPE; + v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; + -- name[] and "char"[] stand in for %TYPE[], as in the header comment. + v_names name[] := '{}'; + v_states "char"[] := '{}'; +BEGIN + IF event_trigger_names IS NULL OR coalesce(array_length(event_trigger_names, 1), 0) = 0 THEN + RAISE 'event_trigger_names must be a non-empty array' + USING ERRCODE = 'invalid_parameter_value' + ; + END IF; + + IF array_position(event_trigger_names, NULL) IS NOT NULL THEN + RAISE 'event_trigger_names contains a NULL name' + USING ERRCODE = 'invalid_parameter_value' + , DETAIL = event_trigger_names::text + ; + END IF; + + IF array_length(event_trigger_names, 1) <> (SELECT count(DISTINCT x) FROM unnest(event_trigger_names) x) THEN + RAISE 'event_trigger_names contains a duplicate name' + USING ERRCODE = 'invalid_parameter_value' + , DETAIL = event_trigger_names::text + ; + END IF; + + SELECT n INTO v_name + FROM unnest(event_trigger_names) n + WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = n) + LIMIT 1 + ; + IF FOUND THEN + RAISE 'event trigger "%" does not exist', v_name + USING ERRCODE = 'undefined_object' + ; + END IF; + + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RAISE 'event_trigger__disable() called while a previous call is still in effect' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'A previous event_trigger__enable() call may have been skipped.' + ; + END IF; + + -- A committed sentinel (another session's, or one left by a commit that bypassed the state check) would make CREATE EVENT TRIGGER below fail with a plain "already exists". + IF EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '__object_reference__event_trigger_sentinel') THEN + RAISE 'event_trigger__disable() from another session is still in effect' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'Wait for that session to finish, or drop event trigger __object_reference__event_trigger_sentinel if it was left behind without event_trigger__enable().' + ; + END IF; + + -- The sentinel, before anything is disabled + CREATE FUNCTION pg_temp.__object_reference__event_trigger_sentinel() RETURNS event_trigger LANGUAGE plpgsql AS 'BEGIN END'; + CREATE EVENT TRIGGER __object_reference__event_trigger_sentinel + ON ddl_command_end + EXECUTE PROCEDURE pg_temp.__object_reference__event_trigger_sentinel() + ; + + CREATE TEMP TABLE __object_reference__event_trigger_state AS + SELECT evtname, evtenabled FROM pg_catalog.pg_event_trigger WHERE false + ; + ALTER TABLE pg_temp.__object_reference__event_trigger_state ADD PRIMARY KEY (evtname); + + -- Sorting in "C" collation gives every caller the same lock order, so two callers (ourselves included) can't deadlock on each other's triggers. + FOR v_name IN SELECT n FROM unnest(event_trigger_names) n ORDER BY n COLLATE "C" LOOP + v_enabled := _object_reference._event_trigger__lock(v_name); + + -- Dropped by another session since the check above + IF v_enabled IS NULL THEN + RAISE 'event trigger "%" was dropped concurrently', v_name + USING ERRCODE = 'undefined_object' + ; + END IF; + + PERFORM _object_reference.exec(format('ALTER EVENT TRIGGER %I DISABLE', v_name)); + + v_names := v_names || v_name; + v_states := v_states || v_enabled; + END LOOP; + + /* + * Fails the commit while any state row remains, i.e. enable() has not run. + * ENABLE ALWAYS so session_replication_role can't skip it. + */ + CREATE CONSTRAINT TRIGGER __object_reference__event_trigger_state_check + AFTER INSERT ON pg_temp.__object_reference__event_trigger_state + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION _object_reference._event_trigger__state_check() + ; + ALTER TABLE pg_temp.__object_reference__event_trigger_state + ENABLE ALWAYS TRIGGER __object_reference__event_trigger_state_check + ; + -- A caller's earlier SET CONSTRAINTS ALL IMMEDIATE would otherwise apply to this new trigger too. + SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check DEFERRED; + INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) + SELECT * FROM unnest(v_names, v_states) + ; +END +$body$ + , 'Disable the given event triggers, remembering their exact prior state; pair with event_trigger__enable().' +); +SELECT __object_reference.create_function( + '_object_reference.event_trigger__enable' + , '' + , 'void LANGUAGE plpgsql' + , $body$ +DECLARE + -- name[] and "char"[] stand in for %TYPE[], as in disable(). + v_names name[]; + v_states "char"[]; + i int; +BEGIN + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NULL THEN + RAISE 'event_trigger__enable() called without a matching event_trigger__disable()' + USING ERRCODE = 'object_not_in_prerequisite_state' + ; + END IF; + + /* + * Same sorted order as disable(): these ALTERs take the row locks again, and + * one order for every caller prevents deadlock. + */ + SELECT array_agg(evtname ORDER BY evtname COLLATE "C"), array_agg(evtenabled ORDER BY evtname COLLATE "C") + INTO v_names, v_states + FROM pg_temp.__object_reference__event_trigger_state + ; + + /* + * Dropped before anything is restored, so this DROP fires none of the restored triggers. + * The commit check's pending events make DROP TABLE fail, so empty the table and fire them (harmlessly) first. + * The update script's hand-written disable creates the table without the check trigger. + */ + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger + WHERE tgrelid = 'pg_temp.__object_reference__event_trigger_state'::regclass + AND tgname = '__object_reference__event_trigger_state_check' + ) THEN + DELETE FROM pg_temp.__object_reference__event_trigger_state; + SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check IMMEDIATE; + END IF; + DROP TABLE pg_temp.__object_reference__event_trigger_state; + + FOR i IN 1..coalesce(array_length(v_names, 1), 0) LOOP + PERFORM _object_reference.exec(format( + 'ALTER EVENT TRIGGER %I %s' + , v_names[i] + , CASE v_states[i] + WHEN 'O' THEN 'ENABLE' + WHEN 'R' THEN 'ENABLE REPLICA' + WHEN 'A' THEN 'ENABLE ALWAYS' + WHEN 'D' THEN 'DISABLE' + END + )); + END LOOP; + + -- Only now that every state is restored (see the sentinel note above event_trigger__disable()) + DROP EVENT TRIGGER __object_reference__event_trigger_sentinel; + DROP FUNCTION pg_temp.__object_reference__event_trigger_sentinel(); +END +$body$ + , 'Restore event triggers disabled by event_trigger__disable() to their exact prior state.' +); + /* * _object_reference.object: no column changes, just a missing * extension_config_dump marking on its sequence (added alongside the table @@ -726,11 +994,7 @@ DROP FUNCTION __object_reference.exec( ); DROP SCHEMA __object_reference; -/* - * Restore session_replication_role to the caller's actual prior value - * (saved near the top of this script), now that the structural section and - * its cleanup are both done. - */ -SELECT set_config('session_replication_role', current_setting('object_reference.saved_session_replication_role'), true); +-- Restores the event triggers disabled at the top of this script. +SELECT _object_reference.event_trigger__enable(); -- vi: expandtab sw=2 ts=2 diff --git a/sql/object_reference.sql b/sql/object_reference.sql index 48591c5..3131a55 100644 --- a/sql/object_reference.sql +++ b/sql/object_reference.sql @@ -1560,6 +1560,265 @@ $body$ , 'Event trigger function to drop object records when objects are removed.' ); +/* + * Takes the row lock event_trigger__disable() relies on to block a concurrent raw + * ALTER EVENT TRIGGER of the same trigger until commit. + */ +SELECT __object_reference.create_function( + '_object_reference._event_trigger__lock' + , $args$ + event_trigger_name pg_catalog.pg_event_trigger.evtname%TYPE +$args$ + , 'pg_catalog.pg_event_trigger.evtenabled%TYPE LANGUAGE plpgsql' + , $body$ +DECLARE + v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; +BEGIN + BEGIN + SELECT evtenabled INTO v_enabled + FROM pg_catalog.pg_event_trigger + WHERE evtname = event_trigger_name + FOR UPDATE + ; + EXCEPTION WHEN insufficient_privilege THEN + -- No catalog UPDATE rights: the row can't be locked. + RAISE WARNING 'could not lock the pg_event_trigger row of event trigger "%"; reading its state without a lock, so races with ALTER EVENT TRIGGER are possible', event_trigger_name; + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + END; + + -- NULL if the trigger does not exist + RETURN v_enabled; +END +$body$ + , 'Row-lock one event trigger where privileges allow, and return its current evtenabled (NULL if it does not exist).' +); + +SELECT __object_reference.create_function( + '_object_reference._event_trigger__state_check' + , '' + , 'trigger LANGUAGE plpgsql' + , $body$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_temp.__object_reference__event_trigger_state) THEN + RAISE 'transaction committed without event_trigger__enable() after event_trigger__disable()' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'Call _object_reference.event_trigger__enable() before committing.' + ; + END IF; + RETURN NULL; +END +$body$ + , 'Deferred constraint trigger function: fail the commit if event_trigger__enable() has not run.' +); + +/* + * event_trigger__disable(names) disables the named event triggers, and + * event_trigger__enable() restores each one's exact prior state. Both are + * internal: for this extension's own update scripts, not the public API. + * Avoid disabling event triggers wherever another option exists. + * + * The caller names the triggers, rather than this disabling all of them, so + * that no trigger is disabled (or locked) beyond what the caller needs. + * + * Call enable() in the same transaction as disable(): committing without it + * raises an error (a deferred constraint trigger on the state table, + * _event_trigger__state_check()), so the commit rolls back and the triggers + * stay as they were. The disable therefore never becomes visible to other + * sessions, and an error needs no cleanup logic. A hand-run ALTER EVENT TRIGGER + * is not covered. disable() locks each trigger's catalog row before reading + * its state (_event_trigger__lock()), which blocks a concurrent raw ALTER EVENT + * TRIGGER of the same trigger until the transaction ends. + * + * If a capture group is active, include zzz_object_reference_capture in the + * names: disable() creates the check trigger (a CREATE, which capture records + * unless disabled), and capture does not skip it. + * + * disable() first creates a no-op ddl_command_end event trigger, the sentinel, + * which enable() drops after restoring every state. While every sql_drop, + * table_rewrite and ddl_command_end event trigger is disabled, Postgres gives + * the commands nested in an extension script no event trigger state of their + * own: it collects them onto the enclosing CREATE/ALTER EXTENSION and replays + * them to that command's ddl_command_end once the triggers are back. There, + * pg_event_trigger_ddl_commands() fails on objects dropped since (before + * PostgreSQL 14) or hands capture the script's own objects. One enabled event + * trigger prevents the collection. The sentinel's function is in pg_temp, + * which capture skips. + * + * name[] and "char"[] stand in for %TYPE[], which signatures never accept and + * PL/pgSQL DECLARE accepts only from PostgreSQL 17. + */ +SELECT __object_reference.create_function( + '_object_reference.event_trigger__disable' + , $args$ + event_trigger_names name[] +$args$ + , 'void LANGUAGE plpgsql' + , $body$ +DECLARE + v_name pg_catalog.pg_event_trigger.evtname%TYPE; + v_enabled pg_catalog.pg_event_trigger.evtenabled%TYPE; + -- name[] and "char"[] stand in for %TYPE[], as in the header comment. + v_names name[] := '{}'; + v_states "char"[] := '{}'; +BEGIN + IF event_trigger_names IS NULL OR coalesce(array_length(event_trigger_names, 1), 0) = 0 THEN + RAISE 'event_trigger_names must be a non-empty array' + USING ERRCODE = 'invalid_parameter_value' + ; + END IF; + + IF array_position(event_trigger_names, NULL) IS NOT NULL THEN + RAISE 'event_trigger_names contains a NULL name' + USING ERRCODE = 'invalid_parameter_value' + , DETAIL = event_trigger_names::text + ; + END IF; + + IF array_length(event_trigger_names, 1) <> (SELECT count(DISTINCT x) FROM unnest(event_trigger_names) x) THEN + RAISE 'event_trigger_names contains a duplicate name' + USING ERRCODE = 'invalid_parameter_value' + , DETAIL = event_trigger_names::text + ; + END IF; + + SELECT n INTO v_name + FROM unnest(event_trigger_names) n + WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = n) + LIMIT 1 + ; + IF FOUND THEN + RAISE 'event trigger "%" does not exist', v_name + USING ERRCODE = 'undefined_object' + ; + END IF; + + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RAISE 'event_trigger__disable() called while a previous call is still in effect' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'A previous event_trigger__enable() call may have been skipped.' + ; + END IF; + + -- A committed sentinel (another session's, or one left by a commit that bypassed the state check) would make CREATE EVENT TRIGGER below fail with a plain "already exists". + IF EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '__object_reference__event_trigger_sentinel') THEN + RAISE 'event_trigger__disable() from another session is still in effect' + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'Wait for that session to finish, or drop event trigger __object_reference__event_trigger_sentinel if it was left behind without event_trigger__enable().' + ; + END IF; + + -- The sentinel, before anything is disabled + CREATE FUNCTION pg_temp.__object_reference__event_trigger_sentinel() RETURNS event_trigger LANGUAGE plpgsql AS 'BEGIN END'; + CREATE EVENT TRIGGER __object_reference__event_trigger_sentinel + ON ddl_command_end + EXECUTE PROCEDURE pg_temp.__object_reference__event_trigger_sentinel() + ; + + CREATE TEMP TABLE __object_reference__event_trigger_state AS + SELECT evtname, evtenabled FROM pg_catalog.pg_event_trigger WHERE false + ; + ALTER TABLE pg_temp.__object_reference__event_trigger_state ADD PRIMARY KEY (evtname); + + -- Sorting in "C" collation gives every caller the same lock order, so two callers (ourselves included) can't deadlock on each other's triggers. + FOR v_name IN SELECT n FROM unnest(event_trigger_names) n ORDER BY n COLLATE "C" LOOP + v_enabled := _object_reference._event_trigger__lock(v_name); + + -- Dropped by another session since the check above + IF v_enabled IS NULL THEN + RAISE 'event trigger "%" was dropped concurrently', v_name + USING ERRCODE = 'undefined_object' + ; + END IF; + + PERFORM _object_reference.exec(format('ALTER EVENT TRIGGER %I DISABLE', v_name)); + + v_names := v_names || v_name; + v_states := v_states || v_enabled; + END LOOP; + + /* + * Fails the commit while any state row remains, i.e. enable() has not run. + * ENABLE ALWAYS so session_replication_role can't skip it. + */ + CREATE CONSTRAINT TRIGGER __object_reference__event_trigger_state_check + AFTER INSERT ON pg_temp.__object_reference__event_trigger_state + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION _object_reference._event_trigger__state_check() + ; + ALTER TABLE pg_temp.__object_reference__event_trigger_state + ENABLE ALWAYS TRIGGER __object_reference__event_trigger_state_check + ; + -- A caller's earlier SET CONSTRAINTS ALL IMMEDIATE would otherwise apply to this new trigger too. + SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check DEFERRED; + INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) + SELECT * FROM unnest(v_names, v_states) + ; +END +$body$ + , 'Disable the given event triggers, remembering their exact prior state; pair with event_trigger__enable().' +); +SELECT __object_reference.create_function( + '_object_reference.event_trigger__enable' + , '' + , 'void LANGUAGE plpgsql' + , $body$ +DECLARE + -- name[] and "char"[] stand in for %TYPE[], as in disable(). + v_names name[]; + v_states "char"[]; + i int; +BEGIN + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NULL THEN + RAISE 'event_trigger__enable() called without a matching event_trigger__disable()' + USING ERRCODE = 'object_not_in_prerequisite_state' + ; + END IF; + + /* + * Same sorted order as disable(): these ALTERs take the row locks again, and + * one order for every caller prevents deadlock. + */ + SELECT array_agg(evtname ORDER BY evtname COLLATE "C"), array_agg(evtenabled ORDER BY evtname COLLATE "C") + INTO v_names, v_states + FROM pg_temp.__object_reference__event_trigger_state + ; + + /* + * Dropped before anything is restored, so this DROP fires none of the restored triggers. + * The commit check's pending events make DROP TABLE fail, so empty the table and fire them (harmlessly) first. + * The update script's hand-written disable creates the table without the check trigger. + */ + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger + WHERE tgrelid = 'pg_temp.__object_reference__event_trigger_state'::regclass + AND tgname = '__object_reference__event_trigger_state_check' + ) THEN + DELETE FROM pg_temp.__object_reference__event_trigger_state; + SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check IMMEDIATE; + END IF; + DROP TABLE pg_temp.__object_reference__event_trigger_state; + + FOR i IN 1..coalesce(array_length(v_names, 1), 0) LOOP + PERFORM _object_reference.exec(format( + 'ALTER EVENT TRIGGER %I %s' + , v_names[i] + , CASE v_states[i] + WHEN 'O' THEN 'ENABLE' + WHEN 'R' THEN 'ENABLE REPLICA' + WHEN 'A' THEN 'ENABLE ALWAYS' + WHEN 'D' THEN 'DISABLE' + END + )); + END LOOP; + + -- Only now that every state is restored (see the sentinel note above event_trigger__disable()) + DROP EVENT TRIGGER __object_reference__event_trigger_sentinel; + DROP FUNCTION pg_temp.__object_reference__event_trigger_sentinel(); +END +$body$ + , 'Restore event triggers disabled by event_trigger__disable() to their exact prior state.' +); + SELECT __object_reference.create_function( '_object_reference.etg_raise__start' , '' diff --git a/test/build/expected/build.out b/test/build/expected/build.out index f86b240..553450f 100644 --- a/test/build/expected/build.out +++ b/test/build/expected/build.out @@ -50,6 +50,10 @@ psql:test/temp_load.not_sql:580: WARNING: I promise you will be sorry if you tr + + + + diff --git a/test/build/expected/update.out b/test/build/expected/update.out new file mode 100644 index 0000000..25fdbb1 --- /dev/null +++ b/test/build/expected/update.out @@ -0,0 +1 @@ +\set ECHO none diff --git a/test/build/update.sql b/test/build/update.sql new file mode 100644 index 0000000..8b0bbcb --- /dev/null +++ b/test/build/update.sql @@ -0,0 +1,163 @@ +\set ECHO none +/* + * Update-mode scenarios (make test--update): install the old version, then + * ALTER EXTENSION UPDATE, in rolled-back transactions, so nothing persists. + * Scenarios 1-3 start a capture group; each asserts the event trigger state it + * starts the update from, updates, and checks the result: + * 1. All three triggers at their default ENABLE; a table created after the + * update is captured. + * 2. zzz__object_reference_drop already disabled, and still disabled after. + * 3. A table captured before the update is renamed after it; the capture + * group shows the new name. + * 4. The three triggers start as DISABLE, ENABLE REPLICA and ENABLE ALWAYS + * and must be restored to exactly those states. + * + * Runs only when object_reference.test_load_mode is 'update' (the Makefile's + * TEST_LOAD_SOURCE); the output is empty in every mode, so a failed assertion + * shows up as a diff against the expected file, which fails test-build. + * count_nulls is created per scenario (the 0.1.0 install script needs it) and + * rolled back with it. + */ +\set ON_ERROR_STOP 1 + +SELECT current_setting('object_reference.test_load_mode') = 'update' AS object_reference_mode_update +\gset + +\if :object_reference_mode_update +\o /dev/null + +SELECT current_setting('object_reference.test_update_from') AS object_reference_test_update_from \gset +SELECT CASE WHEN current_setting('object_reference.test_update_to') = '' THEN '' + ELSE format('TO %L', current_setting('object_reference.test_update_to')) END + AS object_reference_update_to_clause \gset + +SET client_min_messages = ERROR; + +\i test/helpers/event_trigger_state.sql + +/* + * 0.1.0's install script sets client_min_messages = WARNING itself and calls a + * cat_tools function that has since been deprecated, so the session setting + * can't hide its WARNINGs and they would vary with the cat_tools version. A + * per-function setting can: create cat_tools (rolled back with the scenario) + * and silence every function in it. + */ +CREATE FUNCTION pg_temp.quiet_cat_tools() RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_function regprocedure; +BEGIN + CREATE EXTENSION IF NOT EXISTS cat_tools; + FOR v_function IN + SELECT p.oid::regprocedure + FROM pg_catalog.pg_proc p + WHERE p.pronamespace = 'cat_tools'::regnamespace AND p.prokind = 'f' + LOOP + EXECUTE format('ALTER FUNCTION %s SET client_min_messages = error', v_function); + END LOOP; +END +$$; + +CREATE FUNCTION pg_temp.assert_update_finished( + step text +) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_installed text := (SELECT extversion FROM pg_catalog.pg_extension WHERE extname = 'object_reference'); + v_target text := coalesce( + nullif(current_setting('object_reference.test_update_to'), '') + , (SELECT default_version FROM pg_catalog.pg_available_extensions WHERE name = 'object_reference') + ); +BEGIN + IF v_installed IS DISTINCT FROM v_target THEN + RAISE EXCEPTION 'object_reference is at version % %, expected %', v_installed, step, v_target; + END IF; + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RAISE EXCEPTION 'event trigger state table left behind %', step; + END IF; + IF EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '__object_reference__event_trigger_sentinel') + OR to_regprocedure('pg_temp.__object_reference__event_trigger_sentinel()') IS NOT NULL + THEN + RAISE EXCEPTION 'sentinel event trigger or its function left behind %', step; + END IF; +END +$$; + +CREATE FUNCTION pg_temp.assert_capture_group( + step text + , expected text[] +) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_members text[]; +BEGIN + SELECT coalesce(array_agg(array_to_string(o.object_names, '.') ORDER BY o.object_names), '{}') + INTO v_members + FROM _object_reference.object_group__object m + JOIN _object_reference.object o USING (object_id) + WHERE m.object_group_id = (object_reference.object_group__get('load_update_scenario')).object_group_id + ; + IF v_members IS DISTINCT FROM expected THEN + RAISE EXCEPTION 'unexpected capture group members %', step + USING DETAIL = format('Members are %L, expected %L.', v_members, expected) + ; + END IF; +END +$$; + +BEGIN; +SELECT pg_temp.quiet_cat_tools(); +CREATE EXTENSION IF NOT EXISTS count_nulls; +CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; +SELECT object_reference.capture__start(object_reference.object_group__create('load_update_scenario')); +SELECT pg_temp.assert_event_trigger_state('before the update in scenario 1', 'O', 'O', 'O'); +ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; +SELECT pg_temp.assert_update_finished('after the update in scenario 1'); +SELECT pg_temp.assert_event_trigger_state('after the update in scenario 1', 'O', 'O', 'O'); +SELECT pg_temp.assert_capture_group('after the update in scenario 1', '{}'); +CREATE TABLE load_update_scenario__after(); +SELECT pg_temp.assert_capture_group('after CREATE TABLE in scenario 1', '{public.load_update_scenario__after}'); +ROLLBACK; + +BEGIN; +SELECT pg_temp.quiet_cat_tools(); +CREATE EXTENSION IF NOT EXISTS count_nulls; +CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; +ALTER EVENT TRIGGER zzz__object_reference_drop DISABLE; +SELECT object_reference.capture__start(object_reference.object_group__create('load_update_scenario')); +SELECT pg_temp.assert_event_trigger_state('before the update in scenario 2', 'O', 'O', 'D'); +ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; +SELECT pg_temp.assert_update_finished('after the update in scenario 2'); +SELECT pg_temp.assert_event_trigger_state('after the update in scenario 2', 'O', 'O', 'D'); +SELECT pg_temp.assert_capture_group('after the update in scenario 2', '{}'); +ROLLBACK; + +BEGIN; +SELECT pg_temp.quiet_cat_tools(); +CREATE EXTENSION IF NOT EXISTS count_nulls; +CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; +SELECT object_reference.capture__start(object_reference.object_group__create('load_update_scenario')); +CREATE TABLE load_update_scenario__before(); +SELECT pg_temp.assert_capture_group('before the update in scenario 3', '{public.load_update_scenario__before}'); +SELECT pg_temp.assert_event_trigger_state('before the update in scenario 3', 'O', 'O', 'O'); +ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; +SELECT pg_temp.assert_update_finished('after the update in scenario 3'); +SELECT pg_temp.assert_event_trigger_state('after the update in scenario 3', 'O', 'O', 'O'); +ALTER TABLE load_update_scenario__before RENAME TO load_update_scenario__renamed; +SELECT pg_temp.assert_capture_group('after the rename in scenario 3', '{public.load_update_scenario__renamed}'); +ROLLBACK; + +BEGIN; +SELECT pg_temp.quiet_cat_tools(); +CREATE EXTENSION IF NOT EXISTS count_nulls; +CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; +ALTER EVENT TRIGGER zzz_object_reference_capture DISABLE; +ALTER EVENT TRIGGER zzz_object_reference__fix_identity ENABLE REPLICA; +ALTER EVENT TRIGGER zzz__object_reference_drop ENABLE ALWAYS; +SELECT pg_temp.assert_event_trigger_state('before the update in scenario 4', 'D', 'R', 'A'); +ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; +SELECT pg_temp.assert_update_finished('after the update in scenario 4'); +SELECT pg_temp.assert_event_trigger_state('after the update in scenario 4', 'D', 'R', 'A'); +ROLLBACK; + +\o +\endif + +-- vi: expandtab sw=2 ts=2 diff --git a/test/dump/load_all.sql b/test/dump/load_all.sql index 0d9dd26..062c6cd 100644 --- a/test/dump/load_all.sql +++ b/test/dump/load_all.sql @@ -1,5 +1,9 @@ \set ECHO none +-- This flow installs the extension itself, whatever TEST_LOAD_SOURCE says. +\set QUIET on +SET object_reference.test_load_mode = 'fresh'; +\set QUIET off \i test/load.sql /* diff --git a/test/expected/event_trigger_disable.out b/test/expected/event_trigger_disable.out new file mode 100644 index 0000000..00953d5 --- /dev/null +++ b/test/expected/event_trigger_disable.out @@ -0,0 +1,44 @@ +\set ECHO none +1..40 +ok 1 - manually disable test trigger b ahead of time +ok 2 - disable() both test triggers +ok 3 - test trigger a is disabled while a call is in effect +ok 4 - test trigger b is (still) disabled while a call is in effect +ok 5 - the sentinel event trigger is enabled on ddl_command_end while a call is in effect +ok 6 - the sentinel function exists while a call is in effect +ok 7 - enable() restores both +ok 8 - test trigger a is back to its original (origin) state +ok 9 - test trigger b is still disabled -- its prior state was preserved, not assumed enabled +ok 10 - enable() dropped the sentinel event trigger +ok 11 - enable() dropped the sentinel function +ok 12 - enable() without disable() is rejected +ok 13 - disable() rejects an unknown event trigger name +ok 14 - disable() rejects a duplicate name in its own argument list +ok 15 - disable() rejects a NULL array +ok 16 - disable() rejects an empty array +ok 17 - disable() rejects a NULL element +ok 18 - disable() with unsorted names +ok 19 - names were processed in "C" collation order, not input order +ok 20 - the commit check fails while enable() has not run +ok 21 - SET CONSTRAINTS ALL IMMEDIATE between disable() and enable() raises +ok 22 - enable() after unsorted disable() +ok 23 - all three restored +ok 24 - helper does not fail for a role that cannot row-lock the catalog +ok 25 - helper still returns the trigger's evtenabled +ok 26 - start a capture group +ok 27 - disable() zzz_object_reference_capture +ok 28 - enable() restores zzz_object_reference_capture +ok 29 - only the table created after enable() was captured +ok 30 - stop the capture group +ok 31 - disable() under session_replication_role = replica +ok 32 - the commit check also fails under session_replication_role = replica +ok 33 - enable() after the replica-role disable() +ok 34 - the commit check passes after enable() +ok 35 - enable() dropped the state table +ok 36 - disable() rejects an existing sentinel event trigger +ok 37 - disable() succeeds once the stray sentinel is gone +ok 38 - a second disable() without enable() in between is rejected +ok 39 - object_reference schema must not be part of the resolved search_path +ok 40 - _object_reference schema must not be part of the resolved search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! +# WARNING: this test assumes it runs in a transaction that is never committed; if you ran it by hand in psql, ROLLBACK now. diff --git a/test/helpers/event_trigger_state.sql b/test/helpers/event_trigger_state.sql new file mode 100644 index 0000000..b447189 --- /dev/null +++ b/test/helpers/event_trigger_state.sql @@ -0,0 +1,33 @@ +/* + * Asserts the enabled state of the extension's three event triggers (used by + * test/build/update.sql). + */ +CREATE FUNCTION pg_temp.assert_event_trigger_state( + step text + , capture_state "char" + , fix_identity_state "char" + , drop_state "char" +) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_mismatches text; +BEGIN + SELECT string_agg( + format('%I is %L, expected %L.', e.evtname, t.evtenabled, e.evtenabled) + , ' ' ORDER BY e.evtname + ) + INTO v_mismatches + FROM (VALUES + ('zzz_object_reference_capture'::name, capture_state) + , ('zzz_object_reference__fix_identity', fix_identity_state) + , ('zzz__object_reference_drop', drop_state) + ) e(evtname, evtenabled) + LEFT JOIN pg_catalog.pg_event_trigger t USING (evtname) + WHERE t.evtenabled IS DISTINCT FROM e.evtenabled + ; + IF v_mismatches IS NOT NULL THEN + RAISE EXCEPTION 'unexpected event trigger state %', step + USING DETAIL = v_mismatches + ; + END IF; +END +$$; diff --git a/test/install/load.sql b/test/install/load.sql index fc3a98a..60aaa89 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -176,7 +176,10 @@ SELECT CASE WHEN :'object_reference_test_update_to' = '' THEN '' */ CREATE EXTENSION IF NOT EXISTS count_nulls; +SET client_min_messages = WARNING; + CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; + /* * Suppress the deprecation NOTICEs the update script emits (e.g. the reg* * pseudotype removal), matching cat_tools' own install/load.sql. diff --git a/test/load.sql b/test/load.sql index 14ccd26..d203c02 100644 --- a/test/load.sql +++ b/test/load.sql @@ -2,6 +2,30 @@ SET search_path = tap, public; +/* + * In update and existing modes the extension must be installed at the version + * under test; raising here fails every test file otherwise, so `make` fails + * when test/install/load.sql stopped short. + */ +DO $$ +DECLARE + v_mode text := current_setting('object_reference.test_load_mode', true); + v_installed text := (SELECT extversion FROM pg_catalog.pg_extension WHERE extname = 'object_reference'); + v_expected text; +BEGIN + IF v_mode IN ('update', 'existing') THEN + v_expected := coalesce( + CASE WHEN v_mode = 'update' THEN nullif(current_setting('object_reference.test_update_to', true), '') END + , (SELECT default_version FROM pg_catalog.pg_available_extensions WHERE name = 'object_reference') + ); + IF v_installed IS DISTINCT FROM v_expected THEN + RAISE EXCEPTION 'object_reference is at version % in % mode, expected %' + , coalesce(v_installed, '(not installed)'), v_mode, v_expected; + END IF; + END IF; +END +$$; + /* * The extension is normally already installed -- committed, once -- by * test/install/load.sql before this file ever runs (see that file for the diff --git a/test/sql/event_trigger_disable.sql b/test/sql/event_trigger_disable.sql new file mode 100644 index 0000000..7ba206b --- /dev/null +++ b/test/sql/event_trigger_disable.sql @@ -0,0 +1,288 @@ +\set ECHO none + +\i test/load.sql + +/* + * This file assumes its transaction is never committed (test/load.sql opens + * it, test/finish.sql leaves it open) and that pg_regress runs the test files + * one at a time, so no other session ever sees an event trigger disabled here. + * Most tests use their own no-op event triggers, whose names and prior states + * they control; the capture test disables the real zzz_object_reference_capture. + */ +CREATE FUNCTION event_trigger_disable_test__noop() RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN +END +$$; +CREATE EVENT TRIGGER event_trigger_disable_test__a ON ddl_command_start EXECUTE FUNCTION event_trigger_disable_test__noop(); +CREATE EVENT TRIGGER event_trigger_disable_test__b ON ddl_command_start EXECUTE FUNCTION event_trigger_disable_test__noop(); +-- Names whose "C" order (B, a, z) differs from a typical locale order (a, B, z) +CREATE EVENT TRIGGER "event_trigger_disable_test__B" ON ddl_command_start EXECUTE FUNCTION event_trigger_disable_test__noop(); +CREATE EVENT TRIGGER event_trigger_disable_test__z ON ddl_command_start EXECUTE FUNCTION event_trigger_disable_test__noop(); + +CREATE ROLE event_trigger_disable_test__role; +GRANT USAGE ON SCHEMA _object_reference TO event_trigger_disable_test__role; +GRANT EXECUTE ON FUNCTION _object_reference._event_trigger__lock(name) TO event_trigger_disable_test__role; + +SELECT plan( + 0 + +7 -- multi-trigger disable/enable preserves each one's own prior state + +4 -- the sentinel exists while a call is in effect and is gone, function included, after enable() + +1 -- enable() without a matching disable() is rejected + +1 -- disable() rejects an unknown event trigger name + +1 -- disable() rejects a duplicate name in its own argument list + +3 -- disable() rejects a NULL array, an empty array and a NULL element + +4 -- names passed unsorted are processed in "C" order + +2 -- commit check, origin role: the check fails while enable() has not run, also via SET CONSTRAINTS ALL IMMEDIATE + +2 -- _event_trigger__lock() reads the state unlocked for a role that cannot row-lock the catalog + +5 -- disabling zzz_object_reference_capture stops capture until enable() + +5 -- commit check, replica role: the check fails while enable() has not run; after enable() it passes and the state table is gone + +3 -- closing sequence: a stray sentinel event trigger is rejected, disable() succeeds once the stray is dropped, then a nested disable() is rejected + +2 -- schema-qualification (search_path) +); + +-- Multi-trigger disable/enable, preserving each trigger's own prior state +SELECT lives_ok( + $$ALTER EVENT TRIGGER event_trigger_disable_test__b DISABLE$$ + , 'manually disable test trigger b ahead of time' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a,event_trigger_disable_test__b}')$$ + , 'disable() both test triggers' +); +SELECT is( + (SELECT evtenabled FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__a') + , 'D' + , 'test trigger a is disabled while a call is in effect' +); +SELECT is( + (SELECT evtenabled FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__b') + , 'D' + , 'test trigger b is (still) disabled while a call is in effect' +); +SELECT is( + (SELECT evtevent || ' ' || evtenabled::text FROM pg_catalog.pg_event_trigger WHERE evtname = '__object_reference__event_trigger_sentinel') + , 'ddl_command_end O' + , 'the sentinel event trigger is enabled on ddl_command_end while a call is in effect' +); +SELECT has_function( + (SELECT nspname FROM pg_catalog.pg_namespace WHERE oid = pg_catalog.pg_my_temp_schema()) + , '__object_reference__event_trigger_sentinel' + , '{}'::name[] + , 'the sentinel function exists while a call is in effect' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() restores both' +); +SELECT is( + (SELECT evtenabled FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__a') + , 'O' + , 'test trigger a is back to its original (origin) state' +); +SELECT is( + (SELECT evtenabled FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__b') + , 'D' + , 'test trigger b is still disabled -- its prior state was preserved, not assumed enabled' +); +SELECT ok( + NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '__object_reference__event_trigger_sentinel') + , 'enable() dropped the sentinel event trigger' +); +SELECT hasnt_function( + (SELECT nspname FROM pg_catalog.pg_namespace WHERE oid = pg_catalog.pg_my_temp_schema()) + , '__object_reference__event_trigger_sentinel' + , '{}'::name[] + , 'enable() dropped the sentinel function' +); + +-- enable() without a matching disable() +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , '55000' + , 'event_trigger__enable() called without a matching event_trigger__disable()' + , 'enable() without disable() is rejected' +); + +-- Unknown event trigger name +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a,no_such_event_trigger}')$$ + , '42704' + , 'event trigger "no_such_event_trigger" does not exist' + , 'disable() rejects an unknown event trigger name' +); + +-- Duplicate name in the same call +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a,event_trigger_disable_test__a}')$$ + , '22023' + , 'event_trigger_names contains a duplicate name' + , 'disable() rejects a duplicate name in its own argument list' +); + +/* + * Argument errors are reported by disable() itself; any state change made + * before the error would be rolled back with the call, so these tests cannot + * observe whether validation ran before a side effect. + */ +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable(NULL)$$ + , '22023' + , 'event_trigger_names must be a non-empty array' + , 'disable() rejects a NULL array' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{}')$$ + , '22023' + , 'event_trigger_names must be a non-empty array' + , 'disable() rejects an empty array' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a,NULL}')$$ + , '22023' + , 'event_trigger_names contains a NULL name' + , 'disable() rejects a NULL element' +); + +-- Unsorted input is processed in "C" order; the commit check (origin) piggybacks on this disable()/enable() pair +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__z,event_trigger_disable_test__a,"event_trigger_disable_test__B"}')$$ + , 'disable() with unsorted names' +); +SELECT results_eq( + $$SELECT evtname FROM pg_temp.__object_reference__event_trigger_state ORDER BY ctid$$ + , $$VALUES ('event_trigger_disable_test__B'::name), ('event_trigger_disable_test__a'), ('event_trigger_disable_test__z')$$ + , 'names were processed in "C" collation order, not input order' +); +-- SET CONSTRAINTS ... IMMEDIATE runs the same check COMMIT would +SELECT throws_ok( + $$SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check IMMEDIATE$$ + , '55000' + , 'transaction committed without event_trigger__enable() after event_trigger__disable()' + , 'the commit check fails while enable() has not run' +); +SELECT throws_ok( + $$SET CONSTRAINTS ALL IMMEDIATE$$ + , '55000' + , 'transaction committed without event_trigger__enable() after event_trigger__disable()' + , 'SET CONSTRAINTS ALL IMMEDIATE between disable() and enable() raises' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() after unsorted disable()' +); +SELECT bag_eq( + $$SELECT evtenabled FROM pg_catalog.pg_event_trigger WHERE evtname IN ('event_trigger_disable_test__B', 'event_trigger_disable_test__a', 'event_trigger_disable_test__z')$$ + , $$VALUES ('O'::"char"), ('O'), ('O')$$ + , 'all three restored' +); + +/* + * A role without UPDATE on the catalog gets the trigger's state read + * unlocked, with a WARNING naming the trigger, instead of an error. The DO + * block runs as that role, and first proves the role really cannot FOR UPDATE + * the catalog. The WARNING is suppressed to keep it out of the expected output. + */ +SELECT current_setting('client_min_messages') AS saved_client_min_messages \gset +SET client_min_messages = error; +SELECT lives_ok( + $t$DO $do$ + BEGIN + SET LOCAL ROLE event_trigger_disable_test__role; + BEGIN + PERFORM 1 FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__a' FOR UPDATE; + RAISE 'test role unexpectedly able to row-lock pg_event_trigger'; + EXCEPTION WHEN insufficient_privilege THEN NULL; + END; + PERFORM set_config('event_trigger_disable_test.enabled' + , _object_reference._event_trigger__lock('event_trigger_disable_test__a')::text, true); + RESET ROLE; + END + $do$$t$ + , 'helper does not fail for a role that cannot row-lock the catalog' +); +SET client_min_messages = :'saved_client_min_messages'; +SELECT is( + current_setting('event_trigger_disable_test.enabled') + , 'O' + , 'helper still returns the trigger''s evtenabled' +); + +-- Disabling zzz_object_reference_capture stops capture until enable() +SELECT lives_ok( + $$SELECT object_reference.capture__start(object_reference.object_group__create('event_trigger_disable_test_group'))$$ + , 'start a capture group' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{zzz_object_reference_capture}')$$ + , 'disable() zzz_object_reference_capture' +); +CREATE TABLE public.event_trigger_disable_test__while_disabled(); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() restores zzz_object_reference_capture' +); +CREATE TABLE public.event_trigger_disable_test__after_enable(); +SELECT results_eq( + $$SELECT array_to_string(o.object_names, '.') + FROM _object_reference.object_group__object m + JOIN _object_reference.object o USING (object_id) + WHERE m.object_group_id = (object_reference.object_group__get('event_trigger_disable_test_group')).object_group_id$$ + , $$VALUES ('public.event_trigger_disable_test__after_enable')$$ + , 'only the table created after enable() was captured' +); +SELECT lives_ok( + $$SELECT object_reference.capture__stop('event_trigger_disable_test_group')$$ + , 'stop the capture group' +); + +-- Commit check, continued: replica is set before disable(), so only ENABLE ALWAYS on the check trigger lets its event be queued +SET LOCAL session_replication_role = replica; +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , 'disable() under session_replication_role = replica' +); +SELECT throws_ok( + $$SET CONSTRAINTS pg_temp.__object_reference__event_trigger_state_check IMMEDIATE$$ + , '55000' + , 'transaction committed without event_trigger__enable() after event_trigger__disable()' + , 'the commit check also fails under session_replication_role = replica' +); +SET LOCAL session_replication_role = origin; +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() after the replica-role disable()' +); +SELECT lives_ok( + $$SET CONSTRAINTS ALL IMMEDIATE$$ + , 'the commit check passes after enable()' +); +SELECT hasnt_table( + '__object_reference__event_trigger_state' + , 'enable() dropped the state table' +); + +-- Must stay last: this sequence leaves a disable() call in effect. +CREATE EVENT TRIGGER __object_reference__event_trigger_sentinel ON ddl_command_end EXECUTE FUNCTION event_trigger_disable_test__noop(); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , '55000' + , 'event_trigger__disable() from another session is still in effect' + , 'disable() rejects an existing sentinel event trigger' +); +DROP EVENT TRIGGER __object_reference__event_trigger_sentinel; +-- SET CONSTRAINTS ALL IMMEDIATE has already run in this transaction: the new check trigger must still start deferred. +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , 'disable() succeeds once the stray sentinel is gone' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , '55000' + , 'event_trigger__disable() called while a previous call is still in effect' + , 'a second disable() without enable() in between is rejected' +); + +\i test/finish.sql +\echo # WARNING: this test assumes it runs in a transaction that is never committed; if you ran it by hand in psql, ROLLBACK now. + +-- vi: expandtab sw=2 ts=2