From e74ba8bca92cd6b3946b31ae1246d3c717285ecd Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 30 Jul 2026 14:40:23 -0500 Subject: [PATCH] Add update+upgrade (U&U) test foundation: test/install, load-mode switch, TEST_SCHEMA, dependency guard, and the missing 0.1.0->0.2.0 update script Modeled on cat_tools PR #16/#46's test/install/load.sql pattern (fresh / update / existing load modes via a test_load_mode GUC propagated through PGOPTIONS), adapted to object_reference's own conventions rather than copied wholesale: - test/install/load.sql: committed-once installer, replacing the per-test CREATE EXTENSION that used to live in test/load.sql. No test/roles.sql: object_reference's own tests never create actor roles to test permission boundaries (grepped test/sql and test/helpers to confirm), so there's nothing to centralize. - TEST_LOAD_SOURCE/TEST_UPDATE_FROM/TEST_UPDATE_TO GUCs + a test-update wrapper target, same propagation mechanism as cat_tools. - TEST_SCHEMA (doc's own guidance, not something cat_tools implements yet): a second, independent GUC for where the test session's ambient search_path points, read via test/schema.sql and applied both at install time and per-test (test/deps.sql), since search_path is session-local. - sql/object_reference--0.1.0--0.2.0.sql: object_reference had no update path from 0.1.0 at all; this backfills one, converging the reg*-pseudotype removal and object__cleanup trigger forward from the frozen 0.1.0/0.2.0 sources (never edited directly). - test/guard.sql: a dependency-guard view anchored on _object_reference.object.object_id, proven (manually) to block a non-CASCADE DROP EXTENSION. Not wired into `make test` -- it's for a follow-up CI PR's existing-mode flow. Fixed along the way (surfaced by actually running fresh/update/existing locally, not just reviewing the diff): - zzz_build.sql's raw-source-load sanity check collided with the now- persistent object_reference schema/extension; it drops its own session-local copy first (transaction is never committed). - test/dump/load_all.sql exercises a separate, non-pg_regress database and needs its own explicit CREATE EXTENSION now that test/load.sql itself no longer installs one. - test/install/load.sql needs \i test/pgxntool/psql.sql for ON_ERROR_STOP: its expected-output path collapses to a self- comparison (../install/load resolves to the same file under both --inputdir/expected and --outputdir/results), so only a non-zero psql exit code -- not a content diff -- can actually fail it. - the update script must disable the zzz__object_reference_drop event trigger for its own duration; _etg_drop() queries the very view the script drops and recreates. Verified locally on PG12 and PG17: fresh, update (test-update), and existing (against both a fresh install and deliberately broken states) all behave correctly, and the TEST_SCHEMA switch was exercised with a mixed-case value to confirm quoting works. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 + Makefile | 88 ++++- sql/object_reference--0.1.0--0.2.0.sql | 508 +++++++++++++++++++++++++ test/deps.sql | 17 +- test/dump/load_all.sql | 8 + test/expected/zzz_build.out | 1 + test/guard.sql | 38 ++ test/install/load.sql | 165 ++++++++ test/load.sql | 25 +- test/schema.sql | 26 ++ test/sql/zzz_build.sql | 29 +- 11 files changed, 902 insertions(+), 10 deletions(-) create mode 100644 sql/object_reference--0.1.0--0.2.0.sql create mode 100644 test/guard.sql create mode 100644 test/install/load.sql create mode 100644 test/schema.sql diff --git a/.gitignore b/.gitignore index 999da21..4759f32 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,13 @@ test/build/sql/ # Created by make when test/install/*.sql files exist. test/install/schedule +# pg_regress writes these here because the "../install/load" schedule entry +# resolves to the same path for both --inputdir/expected and +# --outputdir/results (see the comment at the top of test/install/load.sql) +# -- transient run artifacts, not committed expected output. +test/install/load.out +test/install/load.diff + # Misc tmp/ .DS_Store diff --git a/Makefile b/Makefile index 7eec41f..7394440 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,87 @@ +# Committed-once install of the extension (see test/install/load.sql). +# pgxntool's native test/install feature runs it COMMITTED, before the suite, +# in its own pg_regress session; state persists into every (rolled-back) +# test/sql/ file. Set explicitly to `yes` (not auto-detected) so an emptied +# test/install/ becomes a hard error instead of silently turning this off. +PGXNTOOL_ENABLE_TEST_INSTALL = yes + +# TEST_LOAD_SOURCE selects how test/install/load.sql installs the extension: +# - fresh (default): CREATE EXTENSION object_reference CASCADE (current +# version). +# - update: CREATE EXTENSION at TEST_UPDATE_FROM (default 0.1.0 -- the only +# released version older than current) then ALTER EXTENSION UPDATE -- to +# TEST_UPDATE_TO if set, otherwise to the current version. Running the +# SAME suite with the SAME expected output against the updated database +# verifies it behaves identically to a fresh install. +# - existing: the extension is ALREADY installed in the target database (by +# a binary pg_upgrade, or an ALTER EXTENSION UPDATE done outside the +# suite). load.sql does not touch it; it only asserts presence + current +# version. Pair with CONTRIB_TESTDB= and +# EXTRA_REGRESS_OPTS=--use-existing so pg_regress runs against that +# database instead of dropping and recreating a throwaway one. +# +# The mode (and the update from/to versions, and TEST_SCHEMA below) are +# signalled to test/install/load.sql and test/deps.sql by placeholder GUCs. +# pg_regress does not forward make variables, but the psql processes it spawns +# inherit the environment, so PGOPTIONS reaches them. +# +# The GUCs are exported UNCONDITIONALLY, so the SQL side can read them WITHOUT +# missing_ok and fail loudly if they did not propagate. Relying on an absent +# GUC to mean "fresh"/"empty" is unsafe: a silent break anywhere in the +# make -> PGOPTIONS -> env -> psql chain would quietly run the wrong mode. +# +# TEST_LOAD_SOURCE must be exactly `fresh`, `update` or `existing`; anything +# else is a hard error at parse time (so e.g. `make test TEST_LOAD_SOURCE=typo` +# fails fast rather than defaulting). +TEST_LOAD_SOURCE ?= fresh +ifeq ($(filter $(TEST_LOAD_SOURCE),fresh update existing),) +$(error TEST_LOAD_SOURCE must be 'fresh', 'update' or 'existing', got '$(TEST_LOAD_SOURCE)') +endif + +# update-mode version range (read by test/install/load.sql only in update +# mode). Empty TEST_UPDATE_TO means "update to the current default_version". +# 0.1.0 is the only released version older than the current default (0.2.0), +# so it's the only floor there is to test right now -- no multiple-origin +# update-path duplicity to worry about yet (see +# sql/object_reference--0.1.0--0.2.0.sql, and PostgreSQL takes the SHORTEST +# update path, so that's the only script a plain `ALTER EXTENSION UPDATE` from +# 0.1.0 can ever take anyway). +TEST_UPDATE_FROM ?= 0.1.0 +TEST_UPDATE_TO ?= + +# TEST_SCHEMA: independent of TEST_LOAD_SOURCE above -- not *how* the +# extension got installed, but *where* the test session's ambient +# search_path targets while doing it. Empty (the default) means "don't target +# any schema at all" -- let the ambient search_path resolve naturally; a +# non-empty value creates and targets that schema first. See test/schema.sql. +TEST_SCHEMA ?= + +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) -c object_reference.test_schema=$(TEST_SCHEMA) + +# Convenience wrapper: `make test-update` == `make test TEST_LOAD_SOURCE=update`. +# 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: + $(MAKE) test TEST_LOAD_SOURCE=update + +# Safeguard for `make results`: refuses to copy test/results/ over +# test/expected/ while the suite shows real failures, so a stale/incorrect +# expected output can't get baked in silently. Bypass for one already-reviewed +# run with PGXNTOOL_ENABLE_VERIFY_RESULTS=no. +PGXNTOOL_ENABLE_VERIFY_RESULTS = yes + include pgxntool/base.mk +# sql/object_reference--0.1.0.sql is a frozen historical version file, not the +# current default_version -- the DATA wildcard above only picks up the CURRENT +# version file plus update-diff scripts (sql/*--*--*.sql), so a historical +# single-version file silently never gets installed unless listed explicitly +# (Postgres-Extensions/pgxntool#48). Without this, TEST_LOAD_SOURCE=update's +# `CREATE EXTENSION object_reference VERSION '0.1.0'` fails with "extension +# ... is not available". +DATA += sql/object_reference--0.1.0.sql + testdeps: $(wildcard test/*.sql test/helpers/*.sql) # Be careful not to include directories in this testdeps: test_factory @@ -12,6 +94,11 @@ install: cat_tools # to require install locally until that's fixed upstream. installcheck: install +# test/install/load.out (and its .diff, on a mismatch) are transient run +# artifacts, not committed expected output -- see the comment at the top of +# test/install/load.sql for why pg_regress writes them where it does. +EXTRA_CLEAN += test/install/load.out test/install/load.diff + test: dump_test extra_clean += $(wildcard test/dump/*.log) dump_test: test/dump/run.sh test/helpers/object_table.sql $(wildcard test/dump/*.sql) @@ -27,4 +114,3 @@ $(DESTDIR)$(datadir)/extension/cat_tools.control: test_factory: $(DESTDIR)$(datadir)/extension/test_factory.control $(DESTDIR)$(datadir)/extension/test_factory.control: pgxn install test_factory - diff --git a/sql/object_reference--0.1.0--0.2.0.sql b/sql/object_reference--0.1.0--0.2.0.sql new file mode 100644 index 0000000..b36aa6c --- /dev/null +++ b/sql/object_reference--0.1.0--0.2.0.sql @@ -0,0 +1,508 @@ +/* + * 0.1.0 -> 0.2.0 + * + * Two independent changes, bundled into one release: + * + * 1. Stop storing reg* pseudotypes (regclass, regconfig, regdictionary, + * regnamespace, regoperator, regprocedure, regtype) in + * _object_reference._object_oid. A single plain `object_oid oid` column + * (already present, previously optional) now always holds the oid, + * regardless of object type; classid changes from regclass to oid to + * match. The old per-reg-type CHECK constraints and partial unique + * indexes existed only to keep "exactly one reg column is set" true; + * with one column instead of eight, that invariant is enforced simply by + * making object_oid NOT NULL. The null_count trigger enforced the same + * "exactly one set" invariant across the reg columns, so it goes with + * them. + * 2. Add automatic object cleanup on group membership removal: + * object_reference.object__cleanup() (and the trigger that fires it) lets + * the tracking table release rows for objects no longer referenced by any + * group, once nothing else references them either. + * + * _object_reference._object_v and _object_v__for_update select every + * _object_oid column, so PostgreSQL refuses to drop any of them out from + * under the views, and CREATE OR REPLACE VIEW cannot remove columns either -- + * both views must be dropped and recreated around the column changes below. + * Neither view is referenced by any other view, and nothing is granted on + * them directly (privileges here come from the containing _object_reference + * schema), so no grants need restoring afterward -- but two functions + * (_object_reference._object_oid__add and the getsert core function, both + * confusingly also named _object_reference._object_v__for_update) declare + * RETURNS _object_reference._object_v, so dropping that view needs CASCADE. + * Both functions are recreated further down (their bodies changed too), so + * losing them here is fine. + */ + +/* + * zzz__object_reference_drop fires _etg_drop() on every sql_drop event in + * this session (including the ones this update script itself is about to + * make), and _etg_drop() queries _object_reference._object_v to find + * tracked objects to clean up after whatever was just dropped. That view is + * exactly what the DROP VIEW below removes, so the trigger would fail + * looking up a view that, at that instant, no longer exists. Disable it for + * the duration of this script and re-enable it at the end, once the view + * (and everything else _etg_drop depends on) is back. + */ +ALTER EVENT TRIGGER zzz__object_reference_drop DISABLE; + +DROP VIEW _object_reference._object_v__for_update; +DROP VIEW _object_reference._object_v CASCADE; + +/* + * The old objid_must_match CHECK constraint already guarantees, for every + * existing row, that objid equals whichever single reg-type (or plain object_oid) column + * was actually populated for that object type -- so backfilling object_oid + * FROM objid is exact (and far simpler than re-deriving it from whichever + * reg* column happens to be set), and is exactly what the new + * objid_must_match constraint below requires regardless of which reg type + * (if any) a given row originally used. + */ +UPDATE _object_reference._object_oid SET object_oid = objid WHERE object_oid IS NULL; + +ALTER TABLE _object_reference._object_oid + DROP COLUMN regclass + , DROP COLUMN regconfig + , DROP COLUMN regdictionary + , DROP COLUMN regnamespace + , DROP COLUMN regoperator + , DROP COLUMN regprocedure + , DROP COLUMN regtype + , ALTER COLUMN classid TYPE oid USING classid::oid + , ALTER COLUMN object_oid SET NOT NULL +; + +/* + * Dropping the reg* columns took the old objid_must_match CHECK (its + * coalesce(...) expression referenced them) and the per-reg-type partial + * unique indexes with it. Recreate the constraint in its simplified form, + * and drop the now-pointless null_count trigger: with object_oid NOT NULL, + * "exactly one of the optional reference columns is set" is no longer a + * meaningful invariant to enforce. + */ +ALTER TABLE _object_reference._object_oid + ADD CONSTRAINT objid_must_match CHECK ( objid IS NOT DISTINCT FROM object_oid ) +; +DROP TRIGGER null_count ON _object_reference._object_oid; + +CREATE VIEW _object_reference._object_v AS + SELECT + o.object_id + , o.object_type + , o.object_names + , o.object_args + , i.classid + , i.objid + , i.objsubid + , i.object_oid + , s.* + FROM _object_reference.object o + LEFT JOIN _object_reference._object_oid i USING(object_id) + , _object_reference._sanity(o, i) s +; +CREATE VIEW _object_reference._object_v__for_update AS + SELECT + o.object_id + , o.object_type + , o.object_names + , o.object_args + , i.classid + , i.objid + , i.objsubid + , i.object_oid + , s.* + FROM _object_reference.object o + LEFT JOIN _object_reference._object_oid i USING(object_id) + , _object_reference._sanity(o, i) s + FOR UPDATE OF o +; + +/* + * The remaining changes touch function bodies (and add new functions), all + * created via the __object_reference.create_function() helper so they get + * the same REVOKE-from-PUBLIC / GRANT / COMMENT treatment a fresh install + * gives them, instead of drifting from it. The helper (and the two smaller + * functions it depends on) is normally created and dropped within a single + * install script's own run (see the "temporary" schema note below); an + * update script has to bring it back to reuse it, then drop it again + * afterward, same as a fresh install does. + */ +CREATE SCHEMA __object_reference; + +CREATE FUNCTION __object_reference.exec( + sql text +) RETURNS void LANGUAGE plpgsql AS $body$ +BEGIN + RAISE DEBUG 'sql = %', sql; + EXECUTE sql; +END +$body$; + +CREATE FUNCTION __object_reference.create_function( + function_name text + , args text + , options text + , body text + , comment text + , grants text DEFAULT NULL +) RETURNS void LANGUAGE plpgsql AS $body$ +DECLARE + c_clean_args text := cat_tools.routine__parse_arg_types_text(args); + + create_template CONSTANT text := $template$ +CREATE OR REPLACE FUNCTION %s( +%s +) RETURNS %s AS +%L +$template$ + ; + + revoke_template CONSTANT text := $template$ +REVOKE ALL ON FUNCTION %s( +%s +) FROM public; +$template$ + ; + + grant_template CONSTANT text := $template$ +GRANT EXECUTE ON FUNCTION %s( +%s +) TO %s; +$template$ + ; + + comment_template CONSTANT text := $template$ +COMMENT ON FUNCTION %s( +%s +) IS %L; +$template$ + ; + +BEGIN + PERFORM __object_reference.exec( format( + create_template + , function_name + , args + , options -- TODO: Force search_path if options ~* 'definer' + , body + ) ) + ; + PERFORM __object_reference.exec( format( + revoke_template + , function_name + , c_clean_args + ) ) + ; + + IF grants IS NOT NULL THEN + PERFORM __object_reference.exec( format( + grant_template + , function_name + , c_clean_args + , grants + ) ) + ; + END IF; + + IF comment IS NOT NULL THEN + PERFORM __object_reference.exec( format( + comment_template + , function_name + , c_clean_args + , comment + ) ) + ; + END IF; +END +$body$; + +SELECT __object_reference.create_function( + '_object_reference._object_oid__add' + , $args$ + object_id _object_reference._object_oid.object_id%TYPE + , object_type _object_reference.object.object_type%TYPE DEFAULT NULL + , classid _object_reference._object_oid.classid%TYPE DEFAULT NULL + , objid _object_reference._object_oid.objid%TYPE DEFAULT NULL + , objsubid _object_reference._object_oid.objsubid%TYPE DEFAULT NULL +$args$ + , '_object_reference._object_v LANGUAGE plpgsql' + , $body$ +DECLARE + r_object_v _object_reference._object_v; +BEGIN + IF object_type IS NULL THEN + -- Should definitely exist + SELECT INTO STRICT object_type, classid, objid, objsubid + o.object_type, a.classid, a.objid, a.objsubid + FROM _object_reference.object o + , pg_catalog.pg_get_object_address(o.object_type::text, o.object_names, o.object_args) a + WHERE o.object_id = _object_oid__add.object_id + ; + END IF; + BEGIN + INSERT INTO _object_reference._object_oid(object_id, classid, objid, objsubid, object_oid) + VALUES (object_id, classid, objid, objsubid, objid); + + SELECT INTO STRICT r_object_v -- Record better exist! + * + FROM _object_reference._object_v__for_update o + WHERE o.object_id = _object_oid__add.object_id + ; + END; + + IF NOT r_object_v.ids_ok THEN + RAISE 'id mismatch for object_id %', object_id + USING + DETAIL = '_object_reference._object_v = ' || pg_catalog.row_to_json(r_object_v) + , HINT = 'this should not be possible' + ; + END IF; + + RETURN r_object_v; +END +$body$ + , 'Check the sanity of object and _object_oid' +); + +SELECT __object_reference.create_function( + 'object_reference.unsupported' + , '' + , 'cat_tools.object_type[] LANGUAGE sql IMMUTABLE' + , $body$ +SELECT cat_tools.objects__shared() + || cat_tools.objects__address_unsupported() + || '{event trigger, partitioned table, partitioned index}' +$body$ + , 'Returns array of object types that are not supported.' + , 'object_reference__usage' +); + +SELECT __object_reference.create_function( + '_object_reference._object_v__for_update' + , $args$ + object_type _object_reference.object.object_type%TYPE + , objid _object_reference._object_oid.objid%TYPE + , objsubid _object_reference._object_oid.objsubid%TYPE + , object_group_id int DEFAULT NULL + , class_id regclass DEFAULT NULL +$args$ + , '_object_reference._object_v LANGUAGE plpgsql' + , $body$ +DECLARE + c_classid CONSTANT regclass := cat_tools.object__address_classid(object_type); + + r_object_v _object_reference._object_v; + r_address record; + r_identity record; + + did_insert boolean := false; + + i smallint; + sql text; +BEGIN + ASSERT class_id IS NULL OR class_id = c_classid, format( + 'cat_tools.object__address_classid(object_type) %L <> class_id %L' + , c_classid + , class_id + ); + IF object_reference.unsupported(object_type) THEN + RAISE 'object_type % is not supported', object_type; + END IF; + + SELECT INTO r_address * FROM pg_catalog.pg_identify_object_as_address(c_classid, objid, objsubid); + + IF r_address IS NULL THEN + RAISE 'unable to find object' + USING DETAIL = format( + 'pg_identify_object_as_address(%s, %s, %s) returned NULL' + , c_classid + , objid + , objsubid + ) + ; + END IF; + + -- Refuse to track objects in temporary schemas + SELECT INTO r_identity * FROM pg_catalog.pg_identify_object(c_classid, objid, objsubid); + IF r_identity.schema IS NOT NULL AND (r_identity.schema LIKE 'pg_temp%' OR r_identity.schema LIKE 'pg_toast_temp%') THEN + RAISE 'cannot track temporary object' + USING DETAIL = format('object %s is in temporary schema %s', r_identity.identity, r_identity.schema) + , ERRCODE = 'feature_not_supported' + ; + END IF; + + -- Ensure the object record exists + SELECT INTO r_object_v + * + FROM _object_reference._object_v__for_update o + WHERE (o.object_type, o.object_names, o.object_args) = (_object_v__for_update.object_type, r_address.object_names, r_address.object_args) + ; + IF NOT FOUND THEN + FOR i IN 1..10 LOOP + did_insert := true; + INSERT INTO _object_reference.object(object_type, object_names, object_args) + VALUES(_object_v__for_update.object_type, r_address.object_names, r_address.object_args) + ON CONFLICT ON CONSTRAINT object__u_object_names__object_args DO NOTHING + ; + -- Still a small race condition here... + SELECT INTO r_object_v + * + FROM _object_reference._object_v__for_update o + WHERE (o.object_type, o.object_names, o.object_args) = (_object_v__for_update.object_type, r_address.object_names, r_address.object_args) + ; + EXIT WHEN FOUND; + END LOOP; + IF NOT FOUND THEN + RAISE 'fell out of loop!' USING HINT = 'This should never happen.'; + END IF; + END IF; + + ASSERT r_object_v.names_ok, 'names do not match (should not be possible)' ; + + IF object_group_id IS NOT NULL THEN + PERFORM object_reference.object_group__object__add(object_group_id, r_object_v.object_id); + END IF; + + -- Handle _object_oid table + CASE + WHEN r_object_v.ids_ok THEN + RETURN r_object_v; + + WHEN NOT r_object_v.ids_exist THEN + /* + * Just need to create IDs record. + */ + + /* + * This shouldn't normally happen, but could occur if a restore didn't + * finish cleanly. We know it's safe to do this because names_ok is true. + */ + IF NOT did_insert THEN + RAISE WARNING 'missing record in _object_reference._object_oid for object_id %', r_object_v.object_id + USING HINT = 'This indicates a restore did not finish cleanly.' + ; + END IF; + r_object_v := _object_reference._object_oid__add(r_object_v.object_id, object_type, c_classid, objid, objsubid); + + WHEN r_object_v.ids_exist THEN + RAISE 'ids are out of sync for object_id %', r_object_v.object_id + USING DETAIL = format( + E'_object_reference._object_v = %L,\n arguments (%L, %s, %s, %s)' + , pg_catalog.row_to_json(r_object_v, true) + , object_type + , objid + , objsubid + , object_group_id + ) + , HINT = 'this shoud not happen if event trigger "zzz_object_reference_end" is working' + ; + ELSE + RAISE 'unknown condition'; + END CASE; + + RETURN r_object_v; +END +$body$ + , 'Return details of a object record, creating a new record if one does not exist.' +); + +/* + * OBJECT INFO FUNCTIONS (new in 0.2.0) + */ +SELECT __object_reference.create_function( + 'object_reference.object__describe' + , $args$ + object_id int +$args$ + , 'text LANGUAGE sql' + , $body$ +SELECT pg_catalog.pg_describe_object( + o.classid, + o.objid, + o.objsubid +) +FROM _object_reference._object_oid o +WHERE o.object_id = $1 +$body$ + , 'Return a human-readable description of the object, matching pg_describe_object() format.' + , 'object_reference__usage' +); + +SELECT __object_reference.create_function( + 'object_reference.object__identity' + , $args$ + object_id int + , OUT type text + , OUT schema text + , OUT name text + , OUT identity text +$args$ + , 'record LANGUAGE sql' + , $body$ +SELECT + i.type::text, + i.schema::text, + i.name::text, + i.identity::text +FROM _object_reference._object_oid o, + LATERAL pg_catalog.pg_identify_object(o.classid, o.objid, o.objsubid) i +WHERE o.object_id = $1 +$body$ + , 'Return object identification information matching pg_identify_object() format.' + , 'object_reference__usage' +); + +SELECT __object_reference.create_function( + 'object_reference.object__cleanup' + , $args$ + object_id int +$args$ + , 'void LANGUAGE plpgsql' + , $body$ +BEGIN + DELETE FROM _object_reference.object WHERE object.object_id = object__cleanup.object_id; +EXCEPTION WHEN foreign_key_violation THEN + -- Object is still referenced elsewhere, ignore the error + NULL; +END +$body$ + , 'Attempts to delete an object from the tracking system. Silently returns if the object is still referenced by other tables.' + , 'object_reference__usage' +); + +-- Trigger function for automatic object cleanup +SELECT __object_reference.create_function( + '_object_reference._object_group__object__cleanup_trigger' + , '' + , 'trigger LANGUAGE plpgsql' + , $body$ +BEGIN + PERFORM object_reference.object__cleanup(OLD.object_id); + RETURN OLD; +END +$body$ + , 'Trigger function to automatically attempt cleanup of objects when removed from groups.' +); +CREATE TRIGGER object_group__object__cleanup + AFTER DELETE ON _object_reference.object_group__object + FOR EACH ROW + EXECUTE FUNCTION _object_reference._object_group__object__cleanup_trigger(); + +/* + * Drop "temporary" objects (see the note above CREATE SCHEMA __object_reference). + */ +DROP FUNCTION __object_reference.create_function( + function_name text + , args text + , options text + , body text + , comment text + , grants text +); +DROP FUNCTION __object_reference.exec( + sql text +); +DROP SCHEMA __object_reference; + +ALTER EVENT TRIGGER zzz__object_reference_drop ENABLE; + +-- vi: expandtab sw=2 ts=2 diff --git a/test/deps.sql b/test/deps.sql index d13a016..16b77d9 100644 --- a/test/deps.sql +++ b/test/deps.sql @@ -1,6 +1,21 @@ -- Note: pgTap is loaded by setup.sql --- Add any test dependency statements here +/* + * TEST_SCHEMA (see test/schema.sql and test/install/load.sql): re-applied + * here per test session, since search_path is session-local and does not + * carry over from test/install/load.sql's own (committed, separate) session. + * tap must stay reachable for pgTAP's own unqualified functions + * (plan(), finish(), ...), so it's appended rather than replacing + * tap_setup.sql's search_path outright. Empty test_schema leaves search_path + * exactly as tap_setup.sql already set it (tap, public) -- no CREATE SCHEMA, + * no SET search_path -- so the empty leg still proves nothing is hardcoded to + * a particular target schema. + */ +\i test/schema.sql +\if :object_reference_has_test_schema +CREATE SCHEMA IF NOT EXISTS :"object_reference_test_schema"; +SET search_path = :"object_reference_test_schema", tap, public; +\endif /* * Normally these should be loaded by the cascade! diff --git a/test/dump/load_all.sql b/test/dump/load_all.sql index 5b73421..d6601d9 100644 --- a/test/dump/load_all.sql +++ b/test/dump/load_all.sql @@ -2,6 +2,14 @@ \i test/load.sql +/* + * This runs against test_dump, a plain createdb'd database (see + * test/dump/run.sh) -- entirely outside pg_regress and its test/install + * schedule, so the extension isn't already installed here the way it is for + * the main suite. Install it explicitly. + */ +CREATE EXTENSION object_reference CASCADE; + /* * SEE ALSO sql/all.sql! */ diff --git a/test/expected/zzz_build.out b/test/expected/zzz_build.out index 0996ed0..3be1094 100644 --- a/test/expected/zzz_build.out +++ b/test/expected/zzz_build.out @@ -1,4 +1,5 @@ \set ECHO none +NOTICE: extension "cat_tools" already exists, skipping This extension must be loaded via CREATE EXTENSION object_reference; You really, REALLY do NOT want to try and load this via psql!!! diff --git a/test/guard.sql b/test/guard.sql new file mode 100644 index 0000000..fa9a5c3 --- /dev/null +++ b/test/guard.sql @@ -0,0 +1,38 @@ +/* + * Dependency guard for "existing" mode (TEST_LOAD_SOURCE=existing): plants an + * object that hard-depends on a stable, foundational object_reference member, + * so an accidental non-CASCADE `DROP EXTENSION object_reference` fails + * instead of silently succeeding and letting a subsequent fresh reinstall + * quietly pass "existing"-mode CI against a database that was never actually + * carried through pg_upgrade/update. + * + * NOT wired into `make test` -- this file is intentionally outside + * test/install/ (whose *.sql files pgxntool auto-schedules into every + * `make test` run) so it doesn't affect fresh/update-mode runs, which have no + * need for it. It's meant to be invoked directly with psql, as one step of a + * future existing-mode CI flow (plant it right after installing/upgrading, + * assert the guarded DROP fails, re-assert after every subsequent step -- see + * the doc comment above the anchor below for why this table was chosen). + * + * Usage: psql -f test/guard.sql + * + * To prove it: after running this file, + * DROP EXTENSION object_reference; -- must fail + * DROP EXTENSION object_reference CASCADE; -- succeeds, and takes + * object_reference_drop_guard.guard with it + */ +CREATE SCHEMA IF NOT EXISTS object_reference_drop_guard; + +/* + * Anchor: _object_reference.object.object_id. This table is the extension's + * own identity registry -- every other table (_object_oid, object_group__object, + * ...) exists to attach more information to a row already present here, so an + * update path that dropped or renamed it would no longer be recognizable as + * this extension at all. object_id specifically (rather than the whole + * table, or one of its other columns) is the narrowest possible anchor: it's + * the surrogate key every foreign reference into this table already depends + * on, so it's guaranteed stable for as long as the extension's core identity + * model exists in any recognizable form. + */ +CREATE OR REPLACE VIEW object_reference_drop_guard.guard AS + SELECT object_id FROM _object_reference.object WHERE false; diff --git a/test/install/load.sql b/test/install/load.sql new file mode 100644 index 0000000..66a6a54 --- /dev/null +++ b/test/install/load.sql @@ -0,0 +1,165 @@ +/* + * ON_ERROR_STOP matters here more than in a normal test/sql/ file: pg_regress + * resolves this schedule entry's expected-output path via "../install/load" + * relative to both --inputdir/expected and --outputdir/results, which + * collapse to the SAME file (test/install/load.out) -- so content diffing + * against it is a no-op self-comparison that can never fail. Without + * ON_ERROR_STOP, an error here (e.g. the existing-mode assert below, or a + * broken update script) would print an ERROR and then just keep going, + * leaving pg_regress with nothing to detect it by. ON_ERROR_STOP makes psql + * itself exit non-zero instead, which pg_regress DOES check independent of + * any output diff. + */ +\i test/pgxntool/psql.sql + +/* + * Single, committed-once installer for the test suite's dependency: the + * object_reference extension itself (see the modes below). + * + * pgxntool's test/install feature runs this file COMMITTED, in its own + * pg_regress session, BEFORE the main pgTAP suite. Because its state is + * committed it persists into every test and runs ONCE instead of per-test + * (pgTAP rolls back each test/sql/ file, so tests read these objects but + * never modify them). test/load.sql (\i'd per test) no longer installs the + * extension itself -- only this file does. + * + * Three modes, selected by the object_reference.test_load_mode placeholder + * GUC, which the Makefile TEST_LOAD_SOURCE block sets via PGOPTIONS (fresh is + * the default): + * - fresh (default): CREATE EXTENSION object_reference CASCADE (current + * version). CASCADE is required because object_reference requires + * cat_tools and count_nulls, neither of which is installed yet here. + * - update: CREATE EXTENSION at an older version + * (object_reference.test_update_from, default 0.1.0 -- the only released + * version older than current) then ALTER EXTENSION UPDATE -- to + * object_reference.test_update_to when that GUC is non-empty, otherwise + * to the current default_version. Reusing the SAME suite and expected + * output asserts an updated database behaves identically to a fresh + * install. + * - existing: the extension is ALREADY installed (by binary pg_upgrade, or + * an ALTER EXTENSION UPDATE performed outside the suite). load.sql must + * NOT drop/create/update it -- that would destroy exactly what the suite + * validates. It only asserts presence + current version. + * + * object_reference's own role-creation (object_reference__usage, + * object_reference__dependency) already tolerates being re-run -- both + * versions wrap CREATE ROLE in a DO block that swallows duplicate_object -- + * so unlike some other extensions in this family, no separate role-drop step + * is needed here before a fresh/update re-install; ordinary + * DROP EXTENSION ... CASCADE plus the extension script's own idempotent role + * creation is sufficient. + */ +SET client_min_messages = WARNING; + +/* + * TEST_SCHEMA (see test/schema.sql): independent of load mode -- targets the + * session's ambient search_path before the extension is (re)installed below, + * so a fresh/update install can be proven not to secretly depend on schema + * ordering. Applied uniformly across all three modes; in existing mode it + * only affects this session's own search_path, since the extension itself is + * untouched. + */ +\i test/schema.sql +\if :object_reference_has_test_schema +CREATE SCHEMA IF NOT EXISTS :"object_reference_test_schema"; +SET search_path = :"object_reference_test_schema"; +\endif + +/* + * Mode selection. The Makefile always exports object_reference.test_load_mode + * via PGOPTIONS. Read it WITHOUT missing_ok: if the GUC did not propagate (a + * break anywhere in make -> PGOPTIONS -> env -> psql), current_setting errors + * here and the whole install step fails loudly, instead of silently falling + * back to a default and running the wrong suite. The DO block then rejects + * any value other than fresh/update/existing with a clear message. + */ +SELECT current_setting('object_reference.test_load_mode') AS object_reference_test_load_mode +\gset + +DO $DO$ +BEGIN + IF current_setting('object_reference.test_load_mode') NOT IN ('fresh', 'update', 'existing') THEN + RAISE EXCEPTION + 'object_reference.test_load_mode must be ''fresh'', ''update'' or ''existing'', got ''%''' + , current_setting('object_reference.test_load_mode') + ; + END IF; +END +$DO$; + +SELECT + :'object_reference_test_load_mode' = 'update' AS object_reference_mode_update + , :'object_reference_test_load_mode' = 'existing' AS object_reference_mode_existing +\gset + +\if :object_reference_mode_existing +/* + * existing mode: do NOT touch the extension. Assert it is installed and at + * the current default_version -- the pg_upgrade / external update the + * database just went through is exactly what the suite is validating, so + * dropping or reinstalling it would defeat the test. Fail loudly on absence + * or mismatch. + */ +DO $DO$ +DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'object_reference'); + v_default text := (SELECT default_version FROM pg_available_extensions WHERE name = 'object_reference'); +BEGIN + IF v_installed IS NULL THEN + RAISE EXCEPTION 'test_load_mode=existing but the object_reference extension is not installed'; + END IF; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION + 'object_reference is installed at version % but the current default_version is %' + , v_installed, v_default + ; + END IF; +END +$DO$; +\else +/* + * fresh / update: (re)install from scratch. Drop-first so a re-run on a + * persistent cluster installs the newest build instead of reusing stale + * objects. CASCADE both drops and (re)creates cat_tools/count_nulls along + * with object_reference; that's fine here -- they're separate extensions + * with their own independent lifecycle, and object_reference's test suite + * only ever needs whatever their own current default_version provides. + */ +DROP EXTENSION IF EXISTS object_reference CASCADE; + +\if :object_reference_mode_update +/* + * update mode: install an older version, then ALTER EXTENSION UPDATE. The + * from/to versions come from the Makefile (TEST_UPDATE_FROM / TEST_UPDATE_TO, + * exported as GUCs). An empty test_update_to means "update to the current + * default_version" (the widest path); a non-empty value targets a specific + * version. + */ +SELECT current_setting('object_reference.test_update_from') AS object_reference_test_update_from \gset +SELECT current_setting('object_reference.test_update_to') AS object_reference_test_update_to \gset +/* + * Build the optional target clause once so a SINGLE ALTER EXTENSION covers + * both cases: an empty test_update_to yields '' (update to the current + * default_version -- the widest path); a non-empty value yields "TO ''". + * format(%L) quotes the version literal safely; the bare :clause + * interpolation below then drops it in verbatim. + */ +SELECT CASE WHEN :'object_reference_test_update_to' = '' THEN '' + ELSE format('TO %L', :'object_reference_test_update_to') END + AS object_reference_update_to_clause \gset + +CREATE EXTENSION object_reference VERSION :'object_reference_test_update_from' CASCADE; +/* + * Suppress the deprecation NOTICEs an update script may emit. + */ +SET client_min_messages = ERROR; +ALTER EXTENSION object_reference UPDATE :object_reference_update_to_clause; +SET client_min_messages = WARNING; +\else +CREATE EXTENSION object_reference CASCADE; +\endif +-- end \if :object_reference_mode_update (fresh vs. update install branch) +\endif +-- end \if :object_reference_mode_existing (existing mode skips the whole (re)install block) + +-- vi: expandtab ts=2 sw=2 diff --git a/test/load.sql b/test/load.sql index f1b267f..337e2d3 100644 --- a/test/load.sql +++ b/test/load.sql @@ -1,8 +1,21 @@ +/* + * Per-test setup, \i'd from every test/sql file inside its own rolled-back + * transaction (see test/pgxntool/setup.sql). The object_reference + * extension itself is no longer (re)installed here -- test/install/load.sql + * installs it ONCE, committed, before any test/sql/ file runs (see that file + * for the fresh/update/existing modes); state from that install persists + * into every test. This file's \i chain (test/pgxntool/setup.sql -> + * test/deps.sql) still handles per-test, session-local setup (pgTAP, + * search_path -- see test/deps.sql for the TEST_SCHEMA handling). + */ \i test/pgxntool/setup.sql -SET search_path = tap, public; - --- Don't use IF NOT EXISTS here; we want to ensure we always have the latest code -SET client_min_messages = WARNING; -- Squelch notices about dependent extensions -CREATE EXTENSION object_reference CASCADE; ---SET client_min_messages = NOTICE; +/* + * Squelch NOTICEs for the rest of this test. Previously set right before the + * per-test CREATE EXTENSION (to quiet its dependency-install chatter) and + * left in place afterward, so every later statement in the same test file + * ran quiet too; test/install/load.sql now owns the CREATE EXTENSION step, + * but test output still expects the same lowered level for everything after + * this point. + */ +SET client_min_messages = WARNING; diff --git a/test/schema.sql b/test/schema.sql new file mode 100644 index 0000000..8c1d77b --- /dev/null +++ b/test/schema.sql @@ -0,0 +1,26 @@ +/* + * TEST_SCHEMA (see Makefile): single source of truth for reading the + * schema-matrix GUC, \i'd by both test/install/load.sql (once, before + * installing the extension) and test/deps.sql (every per-test session). + * It has to be re-read per test session, not just once at install time, + * because search_path is session-local -- it does not carry over from the + * committed install session into each test/sql session's own connection. + * + * This file only reads the GUC and computes whether it's set; each \i site + * decides what to actually do with object_reference_test_schema (the + * object_reference extension's own schema is fixed via object_reference.control + * and unaffected by search_path, so this is about where the *test session's* + * ambient search_path points -- e.g. for CREATE EXTENSION to prove it doesn't + * secretly depend on schema ordering, or for a test's own unqualified scratch + * objects to land somewhere other than the default). + * + * Read WITHOUT missing_ok and exported unconditionally by the Makefile, same + * as object_reference.test_load_mode: relying on an absent GUC to mean "empty" + * would let a silent break anywhere in the make -> PGOPTIONS -> env -> psql + * chain go unnoticed. + */ +SELECT current_setting('object_reference.test_schema') AS object_reference_test_schema +\gset + +SELECT :'object_reference_test_schema' <> '' AS object_reference_has_test_schema +\gset diff --git a/test/sql/zzz_build.sql b/test/sql/zzz_build.sql index 4fc0628..3a57485 100644 --- a/test/sql/zzz_build.sql +++ b/test/sql/zzz_build.sql @@ -6,13 +6,38 @@ -- Loads deps, but not extension itself \i test/pgxntool/setup.sql +-- Need to do this now (rather than just before temp_load.not_sql, as +-- before) so that the "cat_tools already exists" NOTICE below -- a new +-- side effect of test/install/load.sql now installing cat_tools once, +-- persistently, ahead of every test -- is also stable across versions (no +-- line #s from ereport messages; see the comment further down for the +-- original rationale, which applies here for the same reason). +\set VERBOSITY default + CREATE EXTENSION IF NOT EXISTS cat_tools; +/* + * test/install/load.sql installs object_reference once, committed, before + * this (and every other) test runs -- but this test intentionally bypasses + * CREATE EXTENSION to sanity-check the raw, unwrapped source file (see the + * \echo warnings at its own top: it is NOT meant to be loaded this way). + * Drop the committed install first so CREATE SCHEMA object_reference below + * doesn't collide with the schema the extension already owns; this DROP + * lives inside this test's own transaction, which is never committed (see + * "TRANSACTION INTENTIONALLY LEFT OPEN" below), so it doesn't affect the + * persistent install other test files still rely on. DROP EXTENSION does + * NOT drop the schema it auto-created (PostgreSQL never implicitly drops a + * schema named in a .control file's "schema" setting, even one it created + * itself), so the schema -- now empty -- needs dropping too before + * recreating it below. + */ +DROP EXTENSION IF EXISTS object_reference CASCADE; +DROP SCHEMA IF EXISTS object_reference; + CREATE SCHEMA object_reference; -- doesn't work :/ SET client_min_messages = FATAL; -- Need to surpress WARNING or turn down verbosity. Suppressing WARNING seems the better idea... --- Need to do this instead so that results are stable across versions (no line #s from ereport messages) -\set VERBOSITY default +-- (VERBOSITY already set to default above) \i test/temp_load.not_sql \echo Loaded OK!