diff --git a/sql/object_reference--0.1.0--stable.sql b/sql/object_reference--0.1.0--stable.sql index 18915fb..312cf69 100644 --- a/sql/object_reference--0.1.0--stable.sql +++ b/sql/object_reference--0.1.0--stable.sql @@ -1,3 +1,26 @@ +/* + * Immediately disable zzz_object_reference_capture and + * zzz_object_reference__fix_identity (raw ALTER EVENT TRIGGER -- nothing + * else, not even the __object_reference bootstrap schema below, exists yet + * to route through) for the handful of bootstrap statements that follow: + * their OLD (0.1.0) bodies have no way to recognize "this is our own + * update script" until their guarded replacements are installed a few + * statements down, and CREATE SCHEMA __object_reference is exactly the + * kind of CREATE-tagged statement _etg_capture would otherwise try (and + * fail) to register into any capture group active during this update. + * Their actual prior state is captured into a temp table (rather than + * assumed 'O') and restored below, so a DBA's own prior DISABLE of either + * trigger survives this update instead of being silently overwritten. + */ +CREATE TEMP TABLE __object_reference__bootstrap_event_trigger_state AS + SELECT evtname, evtenabled + FROM pg_catalog.pg_event_trigger + WHERE evtname IN ('zzz_object_reference_capture', 'zzz_object_reference__fix_identity') +; + +ALTER EVENT TRIGGER zzz_object_reference_capture DISABLE; +ALTER EVENT TRIGGER zzz_object_reference__fix_identity DISABLE; + /* * Uses a private __object_reference schema, mirroring * sql/object_reference.sql's own bootstrap/teardown convention, so every @@ -104,34 +127,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 +167,399 @@ $body$ , 'Is the object a member of the object_reference extension itself? (pg_depend deptype = e membership, not just co-installation.)' ); +/* + * _etg_fix_identity/_etg_capture: gain a self-recognition guard so they skip + * work while this extension's own event_trigger__disable() call is in + * effect for this session (checked via to_regclass() on the temp table + * event_trigger__disable() creates below) -- i.e. this extension's own + * install/update script is doing delicate internal restructuring right + * now. Installed here, ahead of the structural section below, specifically + * so the guard is already active by the time event_trigger__disable() is + * first called a few statements down: recreating them any later would + * leave the OLD (0.1.0), unguarded bodies live for that whole window -- + * which mattered in practice for _etg_capture, which would otherwise try + * to call _object_reference._object_v__for_update() (the FUNCTION) for any + * CREATE-tagged command in this script if a capture happened to be active, + * including a moment where that function has been dropped and not yet + * recreated, which would fail outright. Same signatures as 0.1.0, so a + * plain CREATE OR REPLACE (via create_function) is enough -- no DROP + * needed. + */ +SELECT __object_reference.create_function( + '_object_reference._etg_capture' + , '' + , 'event_trigger SECURITY DEFINER LANGUAGE plpgsql' + , $body$ +DECLARE + c_group_id CONSTANT int := object_group_id FROM object_reference.capture__get_current(); + r record; +BEGIN + /* + * Self-recognition: skip while this extension's own event_trigger__disable() + * is in effect (see below) -- i.e. this extension's own install/update + * script is doing delicate internal restructuring right now. Checked via + * to_regclass() rather than a catalog lookup that would error if the temp + * table doesn't exist, which is the common case. + */ + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RETURN; + END IF; + + IF c_group_id IS NOT NULL THEN -- Would be NULL if table is empty + RAISE DEBUG E'\n\n*** START ***'; + BEGIN + FOR r IN + SELECT classid, objid, objsubid, command_tag, object_type, schema_name, object_identity, in_extension + -- Have to manually exclude command field :/ + FROM pg_catalog.pg_event_trigger_ddl_commands() + LOOP + RAISE DEBUG 'ddl: %', row_to_json(r); + END LOOP; + END; + + FOR r IN SELECT + _object_reference._object_v__for_update( + object_type::cat_tools.object_type + , objid, objsubid + , c_group_id + , classid + ) + , classid, objid, objsubid, command_tag, object_type, schema_name, object_identity, in_extension + FROM pg_catalog.pg_event_trigger_ddl_commands() + WHERE command_tag ~ '^CREATE' --'^(ALTER|CREATE)' + AND NOT object_reference.unsupported(object_type::cat_tools.object_type) + AND (schema_name IS NULL + OR schema_name NOT LIKE 'pg_temp%' -- pg_my_temp_schema() doesn't seem worth it... + ) + /* + * __object_reference is this extension's own scratch install/update + * schema (created and dropped within a single script, never an + * extension member) -- self-recognition via the temp table above + * can't cover the handful of bootstrap statements that run before + * that table exists, so exclude it here too (object_identity + * carries the name for the CREATE SCHEMA statement itself, where + * schema_name is null). + * + * object_reference/_object_reference are excluded outright rather + * than relying on _object_v__for_update()'s own _is_own_object() + * guard: a brand-new object created by this extension's own + * update/install script isn't yet recorded as an 'e' member in + * pg_depend at the point its CREATE fires ddl_command_end (that + * happens once the surrounding CREATE/ALTER EXTENSION completes), + * so _is_own_object() can't see it as self-owned yet either -- + * confirmed by running into it: an active capture group during + * ALTER EXTENSION UPDATE otherwise ends up with this extension's + * own new functions as members. + * + * The schema-creation statement itself (CREATE SCHEMA + * __object_reference/etc.) has schema_name = NULL, with the name + * only available via object_identity -- checked separately, and + * restricted to object_type = 'schema', so an unrelated object of + * some other type whose identity happens to match one of these + * three exact strings (e.g. a same-named extension) isn't caught + * by this fallback. + */ + AND coalesce(schema_name, '') NOT IN ('__object_reference', 'object_reference', '_object_reference') + AND NOT (object_type = 'schema' AND object_identity IN ('__object_reference', 'object_reference', '_object_reference')) + LOOP + RAISE DEBUG 'registered %', row_to_json(r); + END LOOP; + RAISE DEBUG E'*** END ***\n\n'; + END IF; +END +$body$ + , 'Event trigger function to capture newly created objects in an object group.' +); +SELECT __object_reference.create_function( + '_object_reference._etg_fix_identity' + , '' + , 'event_trigger SECURITY DEFINER LANGUAGE plpgsql' + , $body$ +DECLARE + r_ddl record; + r record; +BEGIN + /* + * Self-recognition: skip while this extension's own event_trigger__disable() + * is in effect (see below) -- i.e. this extension's own install/update + * script is doing delicate internal restructuring right now. Checked via + * to_regclass() rather than a catalog lookup that would error if the temp + * table doesn't exist, which is the common case. + */ + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RETURN; + END IF; + + /* + * It's tempting to use pg_event_trigger_ddl_commands() to find exactly what + * items have changed and worry about only those. That won't work because an + * object_names array can depend on multiple names (ie: a column depends on + * the name of it's table, as well as the name of the schema the table is in. + * You might think we could simply recurse through pg_depend to handle this, + * but not every name dependency gets enumerated that way. For example, + * columns are not marked as dependent on their table. + * + * Rather than trying to be cute about this, we just do a brute-force check + * for any names that have changed. + */ + + /* + * Presumably there's no way for an objects type/classid to change, but be + * safe and attempt the update to object_type. If it actually does change the + * constraint on the table should catch it. + */ + FOR r IN + UPDATE _object_reference.object + SET object_type = (pg_catalog.pg_identify_object_as_address(classid, objid, objsubid)).type::cat_tools.object_type + , object_names = (pg_catalog.pg_identify_object_as_address(classid, objid, objsubid)).object_names + , object_args = (pg_catalog.pg_identify_object_as_address(classid, objid, objsubid)).object_args + FROM _object_reference._object_oid oo + WHERE + oo.object_id = object.object_id + AND (object_type::text, object_names, object_args) IS DISTINCT FROM + (pg_catalog.pg_identify_object_as_address(classid, objid, objsubid)) + RETURNING * + LOOP + RAISE DEBUG 'modified_objects(): %', r; + END LOOP; +END +$body$ + , 'Event trigger function to update any records with object names or args that have changed.' +); + +/* + * Restore each trigger's actual prior state (captured above) now that the + * guarded bodies above are live -- self-recognition (checking for + * event_trigger__disable()'s temp table, created below) takes over from + * here for the rest of this script. + */ +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT evtname, evtenabled FROM pg_temp.__object_reference__bootstrap_event_trigger_state LOOP + EXECUTE format( + 'ALTER EVENT TRIGGER %I %s' + , r.evtname + , CASE r.evtenabled + WHEN 'O' THEN 'ENABLE' + WHEN 'R' THEN 'ENABLE REPLICA' + WHEN 'A' THEN 'ENABLE ALWAYS' + WHEN 'D' THEN 'DISABLE' + END + ); + END LOOP; +END +$$; +DROP TABLE pg_temp.__object_reference__bootstrap_event_trigger_state; + +-- Helper for event_trigger__disable(); see the WARNING below. +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_oid pg_catalog.pg_event_trigger.oid%TYPE; + 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 (e.g. managed services): an advisory lock only serializes other callers of this function. + SELECT oid INTO v_oid FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + IF FOUND THEN + PERFORM pg_catalog.pg_advisory_xact_lock( + 'pg_catalog.pg_event_trigger'::pg_catalog.regclass::pg_catalog.oid::int + , v_oid::int + ); + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + END IF; + END; + + -- NULL if the trigger does not exist + RETURN v_enabled; +END +$body$ + , 'Lock one event trigger against concurrent callers and return its current evtenabled (NULL if it does not exist).' +); + +/* + * WARNING: avoid disabling event triggers at all where any other option + * exists. ALTER EVENT TRIGGER is ordinary transactional DDL -- like any + * other catalog write, it's invisible to other sessions until commit (no + * special database-wide/immediate effect: verified empirically that a + * concurrent session's DDL neither blocks on, nor is otherwise affected by, + * another session's still-uncommitted DISABLE) and it takes no lock at all + * on the event trigger itself. The real risk is TWO SESSIONS both trying to + * alter the SAME event trigger concurrently: a second writer blocks on the + * first the way any two concurrent writes to the same catalog row would, + * and without care, the one that unblocks second can record and later + * restore a "prior state" that was never actually the trigger's state + * immediately before it acted. disable() calls _event_trigger__lock() to + * close that gap: with catalog UPDATE privilege it locks the trigger's + * pg_event_trigger row FOR UPDATE, which also blocks a raw ALTER EVENT + * TRIGGER by another session. Without that privilege (e.g. managed + * services) it falls back to an advisory lock, which serializes only other + * callers of disable(), not a raw ALTER EVENT TRIGGER elsewhere. Prefer a + * self-recognition check (a session-local flag, checked from inside the + * trigger's own body, as _etg_fix_identity/_etg_capture above do) over + * calling this at all; reach for it only when nothing else can make the + * trigger stay quiet, as is currently true for + * zzz__object_reference_drop. + * + * General-purpose event-trigger disable/enable mechanism, replacing the + * session_replication_role trick 0.1.0 had no equivalent of. 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. It can't self-recognize the way _etg_fix_identity/ + * _etg_capture above do without also touching that same view from inside + * its own body, so it must be truly disabled for the duration of this + * script's structural section. + * + * These are created now, ahead of the structural section, specifically so + * this script itself can call event_trigger__disable() below -- a fresh + * install only ever needs these for FUTURE update scripts, or anywhere + * else a future need to safely quiet an event trigger comes up (hence the + * mechanism-focused name, not one tied to "being mid-update"). + */ +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; +BEGIN + /* + * WARNING: avoid disabling event triggers at all where any other option + * exists -- this is a database-wide change with real race-condition risk + * against concurrent sessions' DDL. See the warning above this function. + */ + + -- Validate everything before any side effect. + 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'; + END IF; + + IF array_position(event_trigger_names, NULL) IS NOT NULL THEN + RAISE 'event_trigger_names contains a NULL name' USING 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 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) + ORDER BY n COLLATE "C" + LIMIT 1 + ; + IF FOUND THEN + RAISE 'event trigger "%" does not exist', v_name; + 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 HINT = 'A previous event_trigger__enable() call may have been skipped.' + ; + END IF; + + -- Save old trigger state + 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); + + -- Fixed lock order across all callers, to avoid deadlock. + FOR v_name IN SELECT n FROM unnest(event_trigger_names) n ORDER BY n COLLATE "C" LOOP + /* + * The lock must be held before we read evtenabled, so no other session's + * ALTER EVENT TRIGGER on the same trigger can land between our read and + * our DISABLE below -- otherwise we could record (and later restore) a + * state that was never the trigger's state immediately before we + * disabled it. + */ + 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 "%" does not exist', v_name; + END IF; + + INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) + VALUES (v_name, v_enabled); + + PERFORM _object_reference.exec(format('ALTER EVENT TRIGGER %I DISABLE', v_name)); + END LOOP; +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 + 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()'; + END IF; + + -- Same order disable() locked in + 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 + ; + + /* + * Drop our own bookkeeping table BEFORE re-enabling anything below: + * dropping it is itself DDL, and if zzz__object_reference_drop is one of + * the triggers being restored here, re-enabling it first would make this + * DROP immediately fire it -- reacting to our own internal cleanup, + * exactly the hazard this whole mechanism exists to avoid. (Confirmed by + * running into it: with the table dropped after, an active capture group + * elsewhere left a stale tracked row that this DROP's cascade into + * post_restore() then found and errored on.) + */ + 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; +END +$body$ + , 'Restore event triggers disabled by event_trigger__disable() to their exact prior state.' +); + +SELECT _object_reference.event_trigger__disable('{zzz__object_reference_drop}'); + /* * _object_reference.object: no column changes, just a missing * extension_config_dump marking on its sequence (added alongside the table @@ -727,10 +1115,10 @@ 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. + * Re-enable zzz__object_reference_drop (to its actual prior state, saved by + * event_trigger__disable() 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); +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..a7c035f 100644 --- a/sql/object_reference.sql +++ b/sql/object_reference.sql @@ -1428,6 +1428,16 @@ DECLARE c_group_id CONSTANT int := object_group_id FROM object_reference.capture__get_current(); r record; BEGIN + /* + * Self-recognition: skip while this extension's own event_trigger__disable() + * is in effect (see below) -- i.e. this extension's own install/update + * script is doing delicate internal restructuring right now. Checked via + * to_regclass() rather than a catalog lookup that would error if the temp + * table doesn't exist, which is the common case. + */ + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RETURN; + END IF; IF c_group_id IS NOT NULL THEN -- Would be NULL if table is empty RAISE DEBUG E'\n\n*** START ***'; @@ -1441,7 +1451,7 @@ BEGIN END LOOP; END; - FOR r IN SELECT + FOR r IN SELECT _object_reference._object_v__for_update( object_type::cat_tools.object_type , objid, objsubid @@ -1455,6 +1465,36 @@ BEGIN AND (schema_name IS NULL OR schema_name NOT LIKE 'pg_temp%' -- pg_my_temp_schema() doesn't seem worth it... ) + /* + * __object_reference is this extension's own scratch install/update + * schema (created and dropped within a single script, never an + * extension member) -- self-recognition via the temp table above + * can't cover the handful of bootstrap statements that run before + * that table exists, so exclude it here too (object_identity + * carries the name for the CREATE SCHEMA statement itself, where + * schema_name is null). + * + * object_reference/_object_reference are excluded outright rather + * than relying on _object_v__for_update()'s own _is_own_object() + * guard: a brand-new object created by this extension's own + * update/install script isn't yet recorded as an 'e' member in + * pg_depend at the point its CREATE fires ddl_command_end (that + * happens once the surrounding CREATE/ALTER EXTENSION completes), + * so _is_own_object() can't see it as self-owned yet either -- + * confirmed by running into it: an active capture group during + * ALTER EXTENSION UPDATE otherwise ends up with this extension's + * own new functions as members. + * + * The schema-creation statement itself (CREATE SCHEMA + * __object_reference/etc.) has schema_name = NULL, with the name + * only available via object_identity -- checked separately, and + * restricted to object_type = 'schema', so an unrelated object of + * some other type whose identity happens to match one of these + * three exact strings (e.g. a same-named extension) isn't caught + * by this fallback. + */ + AND coalesce(schema_name, '') NOT IN ('__object_reference', 'object_reference', '_object_reference') + AND NOT (object_type = 'schema' AND object_identity IN ('__object_reference', 'object_reference', '_object_reference')) LOOP RAISE DEBUG 'registered %', row_to_json(r); END LOOP; @@ -1475,6 +1515,17 @@ DECLARE r_ddl record; r record; BEGIN + /* + * Self-recognition: skip while this extension's own event_trigger__disable() + * is in effect (see below) -- i.e. this extension's own install/update + * script is doing delicate internal restructuring right now. Checked via + * to_regclass() rather than a catalog lookup that would error if the temp + * table doesn't exist, which is the common case. + */ + IF to_regclass('pg_temp.__object_reference__event_trigger_state') IS NOT NULL THEN + RETURN; + END IF; + /* * It's tempting to use pg_event_trigger_ddl_commands() to find exactly what * items have changed and worry about only those. That won't work because an @@ -1560,6 +1611,211 @@ $body$ , 'Event trigger function to drop object records when objects are removed.' ); +-- Helper for event_trigger__disable(); see the WARNING below. +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_oid pg_catalog.pg_event_trigger.oid%TYPE; + 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 (e.g. managed services): an advisory lock only serializes other callers of this function. + SELECT oid INTO v_oid FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + IF FOUND THEN + PERFORM pg_catalog.pg_advisory_xact_lock( + 'pg_catalog.pg_event_trigger'::pg_catalog.regclass::pg_catalog.oid::int + , v_oid::int + ); + SELECT evtenabled INTO v_enabled FROM pg_catalog.pg_event_trigger WHERE evtname = event_trigger_name; + END IF; + END; + + -- NULL if the trigger does not exist + RETURN v_enabled; +END +$body$ + , 'Lock one event trigger against concurrent callers and return its current evtenabled (NULL if it does not exist).' +); + +/* + * WARNING: avoid disabling event triggers at all where any other option + * exists. ALTER EVENT TRIGGER is ordinary transactional DDL -- like any + * other catalog write, it's invisible to other sessions until commit (no + * special database-wide/immediate effect: verified empirically that a + * concurrent session's DDL neither blocks on, nor is otherwise affected by, + * another session's still-uncommitted DISABLE) and it takes no lock at all + * on the event trigger itself. The real risk is TWO SESSIONS both trying to + * alter the SAME event trigger concurrently: a second writer blocks on the + * first the way any two concurrent writes to the same catalog row would, + * and without care, the one that unblocks second can record and later + * restore a "prior state" that was never actually the trigger's state + * immediately before it acted. disable() calls _event_trigger__lock() to + * close that gap: with catalog UPDATE privilege it locks the trigger's + * pg_event_trigger row FOR UPDATE, which also blocks a raw ALTER EVENT + * TRIGGER by another session. Without that privilege (e.g. managed + * services) it falls back to an advisory lock, which serializes only other + * callers of disable(), not a raw ALTER EVENT TRIGGER elsewhere. Prefer a + * self-recognition check (a session-local flag, checked from inside the + * trigger's own body) over calling this at all; reach for it only when + * nothing else can make the trigger stay quiet, as is currently true for + * zzz__object_reference_drop. + * + * General-purpose event-trigger disable/enable mechanism, for use by this + * extension's OWN install/update scripts only (not part of the public API). + * Not tied to "being mid-update" specifically -- it's a plain disable-with- + * restore primitive for any event trigger that can't self-recognize that it + * should stay quiet. + * + * zzz_object_reference__fix_identity and zzz_object_reference_capture check + * whether this call is currently in effect for their OWN session (via + * to_regclass() on the temp table below) and skip if so, so they never need + * to be disabled this way. zzz__object_reference_drop cannot self-recognize + * the same way without also touching _object_reference._object_v -- a view + * an update script may itself be dropping and recreating -- from inside its + * own body, so it must be truly disabled for the duration of such a + * script's structural section. + * + * ALTER EVENT TRIGGER is ordinary transactional DDL, so if the calling + * script's transaction rolls back, the DISABLE (and any ENABLE already run) + * rolls back with it -- no separate cleanup-on-error logic is needed here. + */ +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; +BEGIN + /* + * WARNING: avoid disabling event triggers at all where any other option + * exists -- this is a database-wide change with real race-condition risk + * against concurrent sessions' DDL. See the warning above this function. + */ + + -- Validate everything before any side effect. + 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'; + END IF; + + IF array_position(event_trigger_names, NULL) IS NOT NULL THEN + RAISE 'event_trigger_names contains a NULL name' USING 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 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) + ORDER BY n COLLATE "C" + LIMIT 1 + ; + IF FOUND THEN + RAISE 'event trigger "%" does not exist', v_name; + 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 HINT = 'A previous event_trigger__enable() call may have been skipped.' + ; + END IF; + + -- Save old trigger state + 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); + + -- Fixed lock order across all callers, to avoid deadlock. + FOR v_name IN SELECT n FROM unnest(event_trigger_names) n ORDER BY n COLLATE "C" LOOP + /* + * The lock must be held before we read evtenabled, so no other session's + * ALTER EVENT TRIGGER on the same trigger can land between our read and + * our DISABLE below -- otherwise we could record (and later restore) a + * state that was never the trigger's state immediately before we + * disabled it. + */ + 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 "%" does not exist', v_name; + END IF; + + INSERT INTO pg_temp.__object_reference__event_trigger_state(evtname, evtenabled) + VALUES (v_name, v_enabled); + + PERFORM _object_reference.exec(format('ALTER EVENT TRIGGER %I DISABLE', v_name)); + END LOOP; +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 + 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()'; + END IF; + + -- Same order disable() locked in + 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 + ; + + /* + * Drop our own bookkeeping table BEFORE re-enabling anything below: + * dropping it is itself DDL, and if zzz__object_reference_drop is one of + * the triggers being restored here, re-enabling it first would make this + * DROP immediately fire it -- reacting to our own internal cleanup, + * exactly the hazard this whole mechanism exists to avoid. (Confirmed by + * running into it: with the table dropped after, an active capture group + * elsewhere left a stale tracked row that this DROP's cascade into + * post_restore() then found and errored on.) + */ + 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; +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..3e6e155 100644 --- a/test/build/expected/build.out +++ b/test/build/expected/build.out @@ -51,6 +51,9 @@ psql:test/temp_load.not_sql:580: WARNING: I promise you will be sorry if you tr + + + diff --git a/test/expected/event_trigger_disable.out b/test/expected/event_trigger_disable.out new file mode 100644 index 0000000..9e38888 --- /dev/null +++ b/test/expected/event_trigger_disable.out @@ -0,0 +1,41 @@ +\set ECHO none +1..38 +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 - enable() restores both +ok 6 - test trigger a is back to its original (origin) state +ok 7 - test trigger b is still disabled -- its prior state was preserved, not assumed enabled +ok 8 - disable() the first time +ok 9 - a second disable() without enable() in between is rejected +ok 10 - enable() cleans up so later tests are unaffected +ok 11 - enable() without disable() is rejected +ok 12 - disable() rejects an unknown event trigger name +ok 13 - disable() rejects a duplicate name in its own argument list +ok 14 - disable() rejects a NULL array +ok 15 - disable() rejects an empty array +ok 16 - disable() rejects a NULL element +ok 17 - disable() so a call is in effect +ok 18 - duplicate name is reported before the in-effect error +ok 19 - unknown name is reported before the in-effect error +ok 20 - NULL array is reported before the in-effect error +ok 21 - empty array is reported before the in-effect error +ok 22 - NULL element is reported before the in-effect error +ok 23 - the call still in effect kept its own state +ok 24 - enable() ends that call +ok 25 - disable() with unsorted names +ok 26 - names were processed in "C" collation order, not input order +ok 27 - enable() after unsorted disable() +ok 28 - all three restored +ok 29 - helper does not fail for a role that cannot row-lock the catalog +ok 30 - helper still returns the trigger's evtenabled +ok 31 - helper took the advisory lock keyed on catalog and trigger oid +ok 32 - start a capture group +ok 33 - disable() (any trigger) also signals self-recognizing triggers to stand down +ok 34 - enable() ends that window +ok 35 - the table created while disable() was in effect was NOT captured +ok 36 - stop the capture group +ok 37 - object_reference schema must not be part of the resolved search_path +ok 38 - _object_reference schema must not be part of the resolved search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/install/load.sql b/test/install/load.sql index fc3a98a..b552ddd 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -176,7 +176,147 @@ SELECT CASE WHEN :'object_reference_test_update_to' = '' THEN '' */ CREATE EXTENSION IF NOT EXISTS count_nulls; +/* + * Update scenarios. Each installs the old version inside its own + * transaction and rolls back, so nothing leaks into the committed install + * below; that includes sequence values, which a rollback alone would not + * reset on an install committed beforehand. Each one starts a capture group, + * 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. + */ +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 +$$; + +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 + OR to_regclass('pg_temp.__object_reference__bootstrap_event_trigger_state') IS NOT NULL + THEN + RAISE EXCEPTION 'event trigger state table 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 +$$; + +SET client_min_messages = ERROR; + +BEGIN; +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; +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; +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; + +SET client_min_messages = WARNING; + CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; + +/* + * Fail unless the update restores each of the extension's event triggers to + * the exact state it had before, starting each from a distinct non-default + * state. A failed check exits psql nonzero (ON_ERROR_STOP), but that does not + * fail the make run on any PostgreSQL version: pg_regress 16+ prints + * "not ok" and PG12's prints "ok", and make exits 0 either way. This + * file's own output is never compared against anything. All three are reset + * to ENABLE afterwards, so the rest of the suite runs as usual. + */ +BEGIN; +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; + /* * Suppress the deprecation NOTICEs the update script emits (e.g. the reg* * pseudotype removal), matching cat_tools' own install/load.sql. @@ -184,6 +324,36 @@ CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' C SET client_min_messages = ERROR; ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; SET client_min_messages = WARNING; + +DO $DO$ +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, 'D'::"char") + , ('zzz_object_reference__fix_identity', 'R') + , ('zzz__object_reference_drop', 'A') + ) 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 'ALTER EXTENSION UPDATE did not restore event trigger state' + USING DETAIL = v_mismatches + ; + END IF; +END +$DO$; + +ALTER EVENT TRIGGER zzz_object_reference_capture ENABLE; +ALTER EVENT TRIGGER zzz_object_reference__fix_identity ENABLE; +ALTER EVENT TRIGGER zzz__object_reference_drop ENABLE; +COMMIT; \else CREATE EXTENSION object_reference CASCADE; \endif diff --git a/test/sql/event_trigger_disable.sql b/test/sql/event_trigger_disable.sql new file mode 100644 index 0000000..3c0368b --- /dev/null +++ b/test/sql/event_trigger_disable.sql @@ -0,0 +1,281 @@ +\set ECHO none + +\i test/load.sql + +/* + * event_trigger__disable()/__enable() are ALTER EVENT TRIGGER under the + * hood -- ordinary transactional DDL, invisible to other sessions until + * commit, same as any other catalog change (verified empirically: a + * concurrent session neither blocks on, nor otherwise sees, another + * session's still-uncommitted DISABLE). The real risk is two sessions both + * altering the SAME event trigger concurrently, so this test exercises the + * mechanism against its OWN dummy event triggers rather than the real zzz_* + * ones, to keep it independent of whatever else happens to run concurrently + * in this same parallel test batch. + */ +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 + +3 -- nested disable() without an intervening enable() is rejected + +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 + +8 -- argument errors are reported in preference to the "previous call in effect" error + +4 -- names passed unsorted are processed in "C" order + +3 -- _event_trigger__lock() falls back to an advisory lock for a role that cannot row-lock the catalog + +5 -- zzz_object_reference_capture self-recognizes and stands down while a disable() is in effect + +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 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' +); + +-- Nested disable() without an intervening enable() +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , 'disable() the first time' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , NULL + , 'event_trigger__disable() called while a previous call is still in effect' + , 'a second disable() without enable() in between is rejected' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() cleans up so later tests are unaffected' +); + +-- enable() without a matching disable() +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , NULL + , '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}')$$ + , NULL + , '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}')$$ + , NULL + , '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. What they can observe + * is which error wins when several apply (see the precedence tests below). + */ +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable(NULL)$$ + , NULL + , 'event_trigger_names must be a non-empty array' + , 'disable() rejects a NULL array' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{}')$$ + , NULL + , '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}')$$ + , NULL + , 'event_trigger_names contains a NULL name' + , 'disable() rejects a NULL element' +); + +-- Argument errors are reported even while a previous call is in effect +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__a}')$$ + , 'disable() so a call is in effect' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__b,event_trigger_disable_test__b}')$$ + , NULL + , 'event_trigger_names contains a duplicate name' + , 'duplicate name is reported before the in-effect error' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{no_such_event_trigger}')$$ + , NULL + , 'event trigger "no_such_event_trigger" does not exist' + , 'unknown name is reported before the in-effect error' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable(NULL)$$ + , NULL + , 'event_trigger_names must be a non-empty array' + , 'NULL array is reported before the in-effect error' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{}')$$ + , NULL + , 'event_trigger_names must be a non-empty array' + , 'empty array is reported before the in-effect error' +); +SELECT throws_ok( + $$SELECT _object_reference.event_trigger__disable('{event_trigger_disable_test__b,NULL}')$$ + , NULL + , 'event_trigger_names contains a NULL name' + , 'NULL element is reported before the in-effect error' +); +SELECT is( + (SELECT array_agg(evtname) FROM pg_temp.__object_reference__event_trigger_state) + , '{event_trigger_disable_test__a}'::name[] + , 'the call still in effect kept its own state' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() ends that call' +); + +-- Unsorted input is processed in "C" order +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 is( + (SELECT array_agg(evtname ORDER BY ctid) FROM pg_temp.__object_reference__event_trigger_state) + , '{event_trigger_disable_test__B,event_trigger_disable_test__a,event_trigger_disable_test__z}'::name[] + , 'names were processed in "C" collation order, not input order' +); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() after unsorted disable()' +); +SELECT is( + (SELECT array_agg(evtenabled ORDER BY evtname COLLATE "C") FROM pg_catalog.pg_event_trigger WHERE evtname LIKE 'event\_trigger\_disable\_test\_\_%' AND evtname IN ('event_trigger_disable_test__B', 'event_trigger_disable_test__a', 'event_trigger_disable_test__z')) + , '{O,O,O}'::"char"[] + , 'all three restored' +); + +/* + * A role that cannot row-lock pg_event_trigger (as on managed services + * without true superuser) gets an advisory lock instead of an error. The DO + * block runs as that role, and first proves the role really cannot FOR UPDATE + * the catalog. + */ +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' +); +SELECT is( + current_setting('event_trigger_disable_test.enabled') + , 'O' + , 'helper still returns the trigger''s evtenabled' +); +SELECT is( + (SELECT count(*)::int FROM pg_locks + WHERE locktype = 'advisory' + AND pid = pg_backend_pid() + AND classid::int = 'pg_catalog.pg_event_trigger'::regclass::oid::int + AND objid::int = (SELECT oid::int FROM pg_catalog.pg_event_trigger WHERE evtname = 'event_trigger_disable_test__a') + AND objsubid = 2) + , 1 + , 'helper took the advisory lock keyed on catalog and trigger oid' +); + +/* + * zzz_object_reference_capture / zzz_object_reference__fix_identity check + * for an event_trigger__disable() call currently in effect for THIS + * session (regardless of which trigger names it names) and stand down -- + * verify that here for capture, since it's directly observable. + */ +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('{event_trigger_disable_test__a}')$$ + , 'disable() (any trigger) also signals self-recognizing triggers to stand down' +); +CREATE TABLE event_trigger_disable_test_table(); +SELECT lives_ok( + $$SELECT _object_reference.event_trigger__enable()$$ + , 'enable() ends that window' +); +SELECT is_empty( + $$ + SELECT 1 + FROM _object_reference.object_group__object + WHERE object_group_id = (object_reference.object_group__get('event_trigger_disable_test_group')).object_group_id + $$ + , 'the table created while disable() was in effect was NOT captured' +); +SELECT lives_ok( + $$SELECT object_reference.capture__stop('event_trigger_disable_test_group')$$ + , 'stop the capture group' +); + +\i test/finish.sql + +-- vi: expandtab sw=2 ts=2