From 8fca3045ec04e1ccae19dfe9217ab88a7848eb50 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 27 Oct 2026 13:11:18 -0500 Subject: [PATCH 1/5] Test the update path with pgxntool's fresh/update pattern The extension is now installed once, committed, by `test/install/load.sql`. `TEST_LOAD_SOURCE=update` (`make test-update`) runs the same suite and expected output against an install updated from `TEST_UPDATE_FROM` (0.2.0), and `bin/compare_fresh_vs_update` diffs every extension member against a fresh install; CI runs both. In update mode, `test/helpers/update_with_data.sql` stores a test language and templates under the old version, runs the update, checks that they still process by name and by `template_id`, and removes the language, leaving nothing behind. The language and template setup moves from `test/core/functions.sql` to `test/helpers/language_factory.sql` so the suite and the helper share it. --- .github/workflows/ci.yml | 8 ++ Makefile | 34 ++++++++ bin/compare_fresh_vs_update | 117 ++++++++++++++++++++++++++ test/core/functions.sql | 127 +--------------------------- test/helpers/language_factory.sql | 132 ++++++++++++++++++++++++++++++ test/helpers/setup.sql | 9 +- test/helpers/update_with_data.sql | 74 +++++++++++++++++ test/install/load.sql | 59 +++++++++++++ test/sql/build.sql | 3 + test/sql/search_path.sql | 3 + 10 files changed, 433 insertions(+), 133 deletions(-) create mode 100755 bin/compare_fresh_vs_update create mode 100644 test/helpers/language_factory.sql create mode 100644 test/helpers/update_with_data.sql create mode 100644 test/install/load.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29aa0b..99bfd78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,14 @@ jobs: # to yes, PGXNTOOL_VERIFY_RESULTS_MODE to pgtap) and exits non-zero on # failure. run: make verify-results + - name: Test the update path on PostgreSQL ${{ matrix.pg }} + # Same suite and expected output, but test/install/load.sql installs + # the Makefile's TEST_UPDATE_FROM version and runs ALTER EXTENSION + # UPDATE. + run: make verify-results TEST_LOAD_SOURCE=update + - name: Compare fresh vs updated install on PostgreSQL ${{ matrix.pg }} + # Diffs every extension member against a fresh install. + run: make compare-fresh-vs-update # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14" diff --git a/Makefile b/Makefile index 7af5536..13e1162 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +# Explicit, not auto-detected: an empty test/install/ gives one clear make-time +# error instead of a pile of test failures. +PGXNTOOL_ENABLE_TEST_INSTALL = yes + include pgxntool/base.mk # @@ -6,3 +10,33 @@ include pgxntool/base.mk test_core_files = $(wildcard $(TESTDIR)/core/*.sql) testdeps: $(test_core_files) + +# TEST_LOAD_SOURCE selects how test/install/load.sql installs trunklet, once and +# committed, for the whole test run: +# - fresh (default): CREATE EXTENSION at the current version. +# - update: CREATE EXTENSION at TEST_UPDATE_FROM, store a test language and +# templates, then ALTER EXTENSION UPDATE to the current version and check +# them (test/helpers/update_with_data.sql). The suite and its expected +# output are the same, so a pass means the updated install behaves like a +# fresh one. +# The settings reach load.sql as placeholder GUCs via PGOPTIONS, which the psql +# processes pg_regress spawns inherit. Both are always exported so load.sql can +# fail if they didn't propagate; test_update_from is only read in update mode. +# There is no `existing` mode: trunklet has no pg_upgrade job. +TEST_LOAD_SOURCE ?= fresh +ifeq ($(filter $(TEST_LOAD_SOURCE),fresh update),) +$(error TEST_LOAD_SOURCE must be 'fresh' or 'update', got '$(TEST_LOAD_SOURCE)') +endif +TEST_UPDATE_FROM ?= 0.2.0 +export PGOPTIONS := $(PGOPTIONS) -c trunklet.test_load_mode=$(TEST_LOAD_SOURCE) -c trunklet.test_update_from=$(TEST_UPDATE_FROM) + +# Recurses so the PGOPTIONS export above is re-evaluated with update set. +.PHONY: test-update +test-update: + $(MAKE) test TEST_LOAD_SOURCE=update + +# Diffs every extension member's definition, comments and ACLs between a fresh +# install and one updated from TEST_UPDATE_FROM; see bin/compare_fresh_vs_update. +.PHONY: compare-fresh-vs-update +compare-fresh-vs-update: install + bin/compare_fresh_vs_update $(TEST_UPDATE_FROM) diff --git a/bin/compare_fresh_vs_update b/bin/compare_fresh_vs_update new file mode 100755 index 0000000..8a7c79b --- /dev/null +++ b/bin/compare_fresh_vs_update @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# USAGE: bin/compare_fresh_vs_update FROM_VERSION +# +# Fails, printing a diff, if any trunklet extension member differs between a +# fresh install and one created at FROM_VERSION then ALTER EXTENSION UPDATEd. +# The test suite only proves the behavior it asserts; this catches an update +# script that leaves a member defined differently from a fresh install. +# +# Members come from pg_depend, so new objects are covered without editing this. +# Each one is rendered as its identity, its definition (functions, views, and +# table columns and constraints), its comments and its object-level ACL. +# Not compared: sequence settings (identity, comments and ACL only; the query +# must run on 9.5-18, where sequence metadata lives in different places) and +# column-level ACLs (pg_attribute.attacl; trunklet grants none). Both installs run +# in scratch databases on the server the usual PG* environment points at, and +# are dropped on exit. +set -euo pipefail + +from_version=${1:?usage: $0 FROM_VERSION} + +suffix="$$_$RANDOM" +fresh_db="trunklet_compare_fresh_$suffix" +update_db="trunklet_compare_update_$suffix" +tmp=$(mktemp -d) + +cleanup() { + dropdb --if-exists "$fresh_db" + dropdb --if-exists "$update_db" + rm -rf "$tmp" +} +trap cleanup EXIT + +run_psql() { + local db=$1 + shift + psql -X -q -v ON_ERROR_STOP=1 -d "$db" "$@" +} + +snapshot() { + run_psql "$1" -tA <<'SQL' +SELECT 'extension trunklet version ' || extversion FROM pg_extension WHERE extname = 'trunklet'; + +WITH member AS ( + SELECT d.classid, d.objid, pg_describe_object(d.classid, d.objid, 0) AS identity + FROM pg_depend d + WHERE d.refclassid = 'pg_extension'::regclass + AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'trunklet') + AND d.deptype = 'e' +) +SELECT concat_ws(E'\n' + , '== ' || m.identity + , CASE WHEN m.classid = 'pg_proc'::regclass THEN pg_get_functiondef(m.objid) END + , (SELECT pg_get_viewdef(c.oid) FROM pg_class c WHERE m.classid = 'pg_class'::regclass AND c.oid = m.objid AND c.relkind = 'v') + , (SELECT string_agg( + format('column %I %s%s%s' + , a.attname + , format_type(a.atttypid, a.atttypmod) + , CASE WHEN a.attnotnull THEN ' NOT NULL' END + , ' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid) + ) + , E'\n' ORDER BY a.attnum + ) + FROM pg_class c + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + WHERE m.classid = 'pg_class'::regclass AND c.oid = m.objid AND c.relkind = 'r' + ) + , (SELECT string_agg(format('constraint %I %s', conname, pg_get_constraintdef(oid)), E'\n' ORDER BY conname) + FROM pg_constraint + WHERE m.classid = 'pg_class'::regclass AND conrelid = m.objid + ) + , (SELECT string_agg( + format('comment on %s: %s', pg_describe_object(classoid, objoid, objsubid), description) + , E'\n' ORDER BY objsubid + ) + FROM pg_description + WHERE classoid = m.classid AND objoid = m.objid + ) + , 'acl: ' || coalesce( + CASE + WHEN m.classid = 'pg_proc'::regclass THEN (SELECT proacl FROM pg_proc WHERE oid = m.objid) + WHEN m.classid = 'pg_class'::regclass THEN (SELECT relacl FROM pg_class WHERE oid = m.objid) + WHEN m.classid = 'pg_namespace'::regclass THEN (SELECT nspacl FROM pg_namespace WHERE oid = m.objid) + WHEN m.classid = 'pg_type'::regclass THEN (SELECT typacl FROM pg_type WHERE oid = m.objid) + END::text + , '(default)' + ) + ) + FROM member m + ORDER BY m.identity +; +SQL +} + +createdb "$fresh_db" +run_psql "$fresh_db" <<'SQL' +SET client_min_messages = warning; +CREATE EXTENSION trunklet; +SQL + +createdb "$update_db" +run_psql "$update_db" -v from_version="$from_version" <<'SQL' +SET client_min_messages = warning; +CREATE EXTENSION trunklet VERSION :'from_version'; +ALTER EXTENSION trunklet UPDATE; +SQL + +snapshot "$fresh_db" > "$tmp/fresh" +snapshot "$update_db" > "$tmp/update" + +if diff -u --label "fresh install" --label "updated from $from_version" "$tmp/fresh" "$tmp/update"; then + echo "OK: an install updated from $from_version matches a fresh install" +else + echo "FAIL: an install updated from $from_version differs from a fresh install (diff above)" >&2 + exit 1 +fi diff --git a/test/core/functions.sql b/test/core/functions.sql index 0f05330..76bb94f 100644 --- a/test/core/functions.sql +++ b/test/core/functions.sql @@ -151,107 +151,8 @@ BEGIN END $body$; -/* - * LANGUAGE FACTORY - */ -CREATE FUNCTION bogus_language_name( -) RETURNS text LANGUAGE sql AS $$SELECT 'bogus template language that does not exist'::text$$; -CREATE FUNCTION get_test_language_name( - langtype text DEFAULT 'text' -) RETURNS text LANGUAGE sql AS $$SELECT format('Our internal %s test language', langtype)$$; -CREATE FUNCTION get_test_language_id( -) RETURNS int LANGUAGE plpgsql AS $body$ -BEGIN - BEGIN - --SET LOCAL client_min_messages = debug; - PERFORM trunklet.template_language__add( - get_test_language_name() - , parameter_type := 'text[]' - , template_type := 'text' - , process_function_options := 'LANGUAGE plpgsql' - , process_function_body := -$process$ -DECLARE - -- Convert parameters into a string of ', , , ...' - v_args CONSTANT text := array_to_string( - array( - SELECT ', ' || quote_nullable(a) - FROM unnest(parameters) a(a) - ) - , '' - ); - sql CONSTANT text := format( 'SELECT format( %L%s )', template, v_args ); - v_return text; -BEGIN - RAISE DEBUG 'EXECUTE INTO using sql %', sql; - EXECUTE sql INTO v_return; - RETURN v_return; -END -$process$ - , extract_parameters_options := 'LANGUAGE sql' - , extract_parameters_body := -$extract$ -SELECT array( - SELECT parameters[i] - FROM generate_subscripts( parameters, 1 ) i - WHERE i = ANY( extract_list::int[] ) - ) -$extract$ - ); - EXCEPTION - -- TODO: incorrect return value - WHEN no_data_found OR unique_violation THEN - NULL; - END; - RETURN _trunklet.language__get_id( get_test_language_name() ); -END -$body$; - -CREATE FUNCTION get_json_language_id( -) RETURNS int LANGUAGE plpgsql AS $body$ -BEGIN - BEGIN - --SET LOCAL client_min_messages = debug; - PERFORM trunklet.template_language__add( - get_test_language_name('json') - , parameter_type := 'jsonb' - , template_type := 'json' - , process_function_options := 'LANGUAGE plpgsql' - , process_function_body := $process$ -DECLARE - k text; - v text; - - v_replace text; - v_return text := template::text; -BEGIN - FOR k,v IN - SELECT key, parameters->>key FROM jsonb_object_keys(parameters) key - LOOP - v_replace := format('!%s!', k); - RAISE DEBUG 'replacing all occurences of % with %', v_replace, v; - v_return := replace( v_return, v_replace, v ); - END LOOP; - RETURN v_return::json; -END -$process$ - , extract_parameters_options := 'LANGUAGE sql' - , extract_parameters_body := $extract$ -SELECT jsonb_object(keys, values) FROM ( - SELECT array_agg(k) AS keys, array_agg(parameters->>k) AS values - FROM jsonb_object_keys(parameters) k - WHERE k = ANY( extract_list ) - ) a -$extract$ - ); - EXCEPTION - -- TODO: incorrect return value - WHEN no_data_found OR unique_violation THEN - NULL; - END; - RETURN _trunklet.language__get_id( get_test_language_name() ); -END -$body$; +-- LANGUAGE FACTORY +\i test/helpers/language_factory.sql /* * FUNCTION _trunklet.language__get_id @@ -627,30 +528,6 @@ CREATE FUNCTION any_to_trunklet_template( CREATE FUNCTION any_to_trunklet_parameter( anyelement ) RETURNS text LANGUAGE plpgsql VOLATILE AS $$BEGIN RETURN quote_literal($1::text) || '::' || pg_typeof($1); END$$; -CREATE FUNCTION get_test_templates( -) RETURNS int[] LANGUAGE plpgsql AS $body$ -DECLARE - ids int[]; -BEGIN - -- Do this even if the test_templates table already exists - PERFORM get_test_language_id(); - - BEGIN - SELECT ids INTO ids FROM test_templates; - EXCEPTION - WHEN undefined_table THEN - ids[1] := trunklet.template__add( get_test_language_name(), 'test template', --any_to_trunklet_template - ('test 1'::text) ); - ids[2] := trunklet.template__add( get_test_language_name(), 'test template', 2, --any_to_trunklet_template - ('test 2'::text) ); - - -- See also test_template__remove - CREATE TEMP TABLE test_templates AS VALUES(ids); - END; - - RETURN ids; -END -$body$; CREATE FUNCTION test_template__add () RETURNS SETOF text LANGUAGE plpgsql AS $body$ diff --git a/test/helpers/language_factory.sql b/test/helpers/language_factory.sql new file mode 100644 index 0000000..1f5651d --- /dev/null +++ b/test/helpers/language_factory.sql @@ -0,0 +1,132 @@ +/* + * LANGUAGE FACTORY: creates the test languages and templates, in the first + * schema on search_path. + * + * test/helpers/update_with_data.sql also runs this under TEST_UPDATE_FROM, so + * stick to the 0.2.0 API and don't use pgTAP. + */ +CREATE FUNCTION bogus_language_name( +) RETURNS text LANGUAGE sql AS $$SELECT 'bogus template language that does not exist'::text$$; +CREATE FUNCTION get_test_language_name( + langtype text DEFAULT 'text' +) RETURNS text LANGUAGE sql AS $$SELECT format('Our internal %s test language', langtype)$$; +CREATE FUNCTION get_test_language_id( +) RETURNS int LANGUAGE plpgsql AS $body$ +BEGIN + BEGIN + --SET LOCAL client_min_messages = debug; + PERFORM trunklet.template_language__add( + get_test_language_name() + , parameter_type := 'text[]' + , template_type := 'text' + , process_function_options := 'LANGUAGE plpgsql' + , process_function_body := +$process$ +DECLARE + -- Convert parameters into a string of ', , , ...' + v_args CONSTANT text := array_to_string( + array( + SELECT ', ' || quote_nullable(a) + FROM unnest(parameters) a(a) + ) + , '' + ); + sql CONSTANT text := format( 'SELECT format( %L%s )', template, v_args ); + v_return text; +BEGIN + RAISE DEBUG 'EXECUTE INTO using sql %', sql; + EXECUTE sql INTO v_return; + RETURN v_return; +END +$process$ + , extract_parameters_options := 'LANGUAGE sql' + , extract_parameters_body := +$extract$ +SELECT array( + SELECT parameters[i] + FROM generate_subscripts( parameters, 1 ) i + WHERE i = ANY( extract_list::int[] ) + ) +$extract$ + ); + EXCEPTION + -- TODO: incorrect return value + WHEN no_data_found OR unique_violation THEN + NULL; + END; + RETURN _trunklet.language__get_id( get_test_language_name() ); +END +$body$; + +CREATE FUNCTION get_json_language_id( +) RETURNS int LANGUAGE plpgsql AS $body$ +BEGIN + BEGIN + --SET LOCAL client_min_messages = debug; + PERFORM trunklet.template_language__add( + get_test_language_name('json') + , parameter_type := 'jsonb' + , template_type := 'json' + , process_function_options := 'LANGUAGE plpgsql' + , process_function_body := $process$ +DECLARE + k text; + v text; + + v_replace text; + v_return text := template::text; +BEGIN + FOR k,v IN + SELECT key, parameters->>key FROM jsonb_object_keys(parameters) key + LOOP + v_replace := format('!%s!', k); + RAISE DEBUG 'replacing all occurences of % with %', v_replace, v; + v_return := replace( v_return, v_replace, v ); + END LOOP; + RETURN v_return::json; +END +$process$ + , extract_parameters_options := 'LANGUAGE sql' + , extract_parameters_body := $extract$ +SELECT jsonb_object(keys, values) FROM ( + SELECT array_agg(k) AS keys, array_agg(parameters->>k) AS values + FROM jsonb_object_keys(parameters) k + WHERE k = ANY( extract_list ) + ) a +$extract$ + ); + EXCEPTION + -- TODO: incorrect return value + WHEN no_data_found OR unique_violation THEN + NULL; + END; + RETURN _trunklet.language__get_id( get_test_language_name() ); +END +$body$; + +CREATE FUNCTION get_test_templates( +) RETURNS int[] LANGUAGE plpgsql AS $body$ +DECLARE + ids int[]; +BEGIN + -- Do this even if the test_templates table already exists + PERFORM get_test_language_id(); + + BEGIN + SELECT ids INTO ids FROM test_templates; + EXCEPTION + WHEN undefined_table THEN + ids[1] := trunklet.template__add( get_test_language_name(), 'test template', --any_to_trunklet_template + ('test 1'::text) ); + ids[2] := trunklet.template__add( get_test_language_name(), 'test template', 2, --any_to_trunklet_template + ('test 2'::text) ); + + -- See also test_template__remove + CREATE TEMP TABLE test_templates AS VALUES(ids); + END; + + RETURN ids; +END +$body$; + +-- vi: expandtab ts=2 sw=2 diff --git a/test/helpers/setup.sql b/test/helpers/setup.sql index 764b6ee..fded349 100644 --- a/test/helpers/setup.sql +++ b/test/helpers/setup.sql @@ -2,13 +2,6 @@ BEGIN; \i test/helpers/tap_setup.sql --CREATE EXTENSION IF NOT EXISTS variant; --- No IF NOT EXISTS because we'll be confused if we're not loading the new stuff ---\i sql/trunklet.sql -CREATE EXTENSION trunklet; - -/* -CREATE EXTENSION trunklet VERSION '0.2.1'; -ALTER EXTENSION trunklet UPDATE; - */ +-- trunklet itself is installed by test/install/load.sql \i test/core/functions.sql diff --git a/test/helpers/update_with_data.sql b/test/helpers/update_with_data.sql new file mode 100644 index 0000000..bb350fc --- /dev/null +++ b/test/helpers/update_with_data.sql @@ -0,0 +1,74 @@ +/* + * Update mode's ALTER EXTENSION UPDATE, run by test/install/load.sql with a + * test language and templates stored under TEST_UPDATE_FROM first. After the + * update it checks they still process, removes them, and leaves nothing behind, + * so the suite's expected output is the same as in fresh mode. + */ +CREATE SCHEMA _trunklet_test; +SET search_path = _trunklet_test; +\i test/helpers/language_factory.sql + +-- Committed before the update, as stored data would be in production +CREATE TABLE seeded_template_ids AS SELECT get_test_templates() AS ids; + +ALTER EXTENSION trunklet UPDATE; + +-- Updating from before 0.3.0 changes search_path, so qualify from here on + +DO $body$ +DECLARE + c_ids CONSTANT int[] := (SELECT ids FROM _trunklet_test.seeded_template_ids); + c_parameters CONSTANT text[] := '{}'; + r record; +BEGIN + -- get_test_templates() stores 'test ' as versions 1 and 2 of 'test template' + FOR r IN + SELECT format('process(%L, %s, ...)', 'test template', template_version) AS call + , trunklet.process('test template'::text, template_version, c_parameters) AS actual + , 'test ' || template_version AS expected + FROM generate_series(1, 2) template_version + UNION ALL + SELECT format('process(template_id %s, ...)', c_ids[template_version]) + , trunklet.process(c_ids[template_version], c_parameters) + , 'test ' || template_version + FROM generate_series(1, 2) template_version + LOOP + IF r.actual IS DISTINCT FROM r.expected THEN + RAISE EXCEPTION 'trunklet.% returned %, expected %' + , r.call, quote_nullable(r.actual), quote_literal(r.expected) + ; + END IF; + END LOOP; +END +$body$; + +-- The language's functions were created by TEST_UPDATE_FROM's code +SELECT trunklet.template_language__remove(_trunklet_test.get_test_language_name(), cascade => true); + +DO $body$ +DECLARE + c_functions CONSTANT text := ( + SELECT string_agg(oid::regprocedure::text, ', ') + FROM pg_proc + WHERE pronamespace = '_trunklet_functions'::regnamespace + ); +BEGIN + IF c_functions IS NOT NULL THEN + RAISE EXCEPTION 'template_language__remove() left functions in schema _trunklet_functions' + USING DETAIL = c_functions + ; + END IF; + IF EXISTS(SELECT 1 FROM _trunklet.language) OR EXISTS(SELECT 1 FROM _trunklet.template) THEN + RAISE EXCEPTION 'template_language__remove() left rows in _trunklet.language or _trunklet.template'; + END IF; +END +$body$; + +-- Back to the never-used state of a fresh install +SELECT setval(pg_get_serial_sequence('_trunklet.language', 'language_id'), 1, false) + , setval(pg_get_serial_sequence('_trunklet.template', 'template_id'), 1, false) +; +DROP SCHEMA _trunklet_test CASCADE; +RESET search_path; + +-- vi: expandtab ts=2 sw=2 diff --git a/test/install/load.sql b/test/install/load.sql new file mode 100644 index 0000000..c8f8ad7 --- /dev/null +++ b/test/install/load.sql @@ -0,0 +1,59 @@ +/* + * Installs trunklet once, committed, before the test/sql/ suite runs, so every + * test file finds it already present (see pgxntool/README.asc's "test/install" + * and "Update & Upgrade (U&U) Testing" sections). The mode comes from the + * Makefile's TEST_LOAD_SOURCE / TEST_UPDATE_FROM via PGOPTIONS. + * + * This file's output is never compared against anything; an error has to abort + * the run instead. + */ +\set ON_ERROR_STOP on + +-- Branches server-side rather than with psql's \if, which needs psql 10+ +DO $body$ +DECLARE + -- No missing_ok: a setting that didn't propagate must fail here + c_mode CONSTANT text := current_setting('trunklet.test_load_mode'); +BEGIN + IF c_mode = 'fresh' THEN + CREATE EXTENSION trunklet; + ELSIF c_mode = 'update' THEN + EXECUTE format( + 'CREATE EXTENSION trunklet VERSION %L' + , current_setting('trunklet.test_update_from') + ); + ELSE + RAISE EXCEPTION 'unknown trunklet.test_load_mode "%"', c_mode; + END IF; +END +$body$; + +/* + * Update mode runs ALTER EXTENSION UPDATE in this helper, with data stored + * first. psql's \if needs psql 10+, so pick the file to \i instead. + */ +SELECT CASE current_setting('trunklet.test_load_mode') + WHEN 'update' THEN 'test/helpers/update_with_data.sql' + ELSE '/dev/null' + END AS update_script +\gset +\i :update_script + +DO $body$ +DECLARE + v_installed text; + v_default text; +BEGIN + IF current_setting('trunklet.test_load_mode') = 'update' THEN + SELECT extversion INTO v_installed FROM pg_extension WHERE extname = 'trunklet'; + SELECT default_version INTO v_default FROM pg_available_extensions WHERE name = 'trunklet'; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'trunklet is at version % after ALTER EXTENSION UPDATE, not default_version %' + , v_installed, v_default + ; + END IF; + END IF; +END +$body$; + +-- vi: expandtab ts=2 sw=2 diff --git a/test/sql/build.sql b/test/sql/build.sql index 5b592e3..581cc4d 100644 --- a/test/sql/build.sql +++ b/test/sql/build.sql @@ -3,11 +3,14 @@ \i test/helpers/psql.sql +-- Both blocks drop test/install/load.sql's committed install first; ROLLBACK restores it BEGIN; +DROP EXTENSION trunklet; \i sql/trunklet.sql ROLLBACK; BEGIN; +DROP EXTENSION trunklet; \i test/helpers/tap_setup.sql SELECT plan(4); diff --git a/test/sql/search_path.sql b/test/sql/search_path.sql index 4240a29..83d56cb 100644 --- a/test/sql/search_path.sql +++ b/test/sql/search_path.sql @@ -7,6 +7,9 @@ SELECT plan( +1 -- verify search_path ); +-- Drop test/install/load.sql's committed install; this test's rollback restores it +DROP EXTENSION trunklet; + CREATE TEMP TABLE before AS SELECT current_setting('search_path') ; From c0f5eb8a830eb173c7832db0f8575addc176fb50 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 27 Oct 2026 15:52:08 -0500 Subject: [PATCH 2/5] Move the build test to test/build and tighten the update-mode helpers test/build/build.sql runs in pgxntool's isolated test-build pass, so it no longer drops the committed install or fights test/install. Check the post-update version in update_with_data.sql right after ALTER EXTENSION UPDATE, so a wrong version fails the run on every PG version. Assert that the test language's functions exist before template_language__remove(). Fix header and comment wording, and gate compare-fresh-vs-update's install dependency on PGXNTOOL_ENABLE_FS_INSTALL. --- .github/workflows/ci.yml | 4 ++-- Makefile | 2 +- bin/compare_fresh_vs_update | 8 ++++---- test/{sql => build}/build.sql | 3 --- test/{ => build}/expected/build.out | 0 test/helpers/language_factory.sql | 7 ++++--- test/helpers/update_with_data.sql | 31 +++++++++++++++++++++++++---- test/install/load.sql | 28 ++++---------------------- 8 files changed, 42 insertions(+), 41 deletions(-) rename test/{sql => build}/build.sql (80%) rename test/{ => build}/expected/build.out (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99bfd78..4c36a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,8 +121,8 @@ jobs: run: make verify-results - name: Test the update path on PostgreSQL ${{ matrix.pg }} # Same suite and expected output, but test/install/load.sql installs - # the Makefile's TEST_UPDATE_FROM version and runs ALTER EXTENSION - # UPDATE. + # the Makefile's TEST_UPDATE_FROM version and test/helpers/update_with_data.sql + # runs ALTER EXTENSION UPDATE. run: make verify-results TEST_LOAD_SOURCE=update - name: Compare fresh vs updated install on PostgreSQL ${{ matrix.pg }} # Diffs every extension member against a fresh install. diff --git a/Makefile b/Makefile index 13e1162..5b2e3fc 100644 --- a/Makefile +++ b/Makefile @@ -38,5 +38,5 @@ test-update: # Diffs every extension member's definition, comments and ACLs between a fresh # install and one updated from TEST_UPDATE_FROM; see bin/compare_fresh_vs_update. .PHONY: compare-fresh-vs-update -compare-fresh-vs-update: install +compare-fresh-vs-update: $(if $(filter yes,$(PGXNTOOL_ENABLE_FS_INSTALL)),install) bin/compare_fresh_vs_update $(TEST_UPDATE_FROM) diff --git a/bin/compare_fresh_vs_update b/bin/compare_fresh_vs_update index 8a7c79b..309adff 100755 --- a/bin/compare_fresh_vs_update +++ b/bin/compare_fresh_vs_update @@ -11,10 +11,10 @@ # Each one is rendered as its identity, its definition (functions, views, and # table columns and constraints), its comments and its object-level ACL. # Not compared: sequence settings (identity, comments and ACL only; the query -# must run on 9.5-18, where sequence metadata lives in different places) and -# column-level ACLs (pg_attribute.attacl; trunklet grants none). Both installs run -# in scratch databases on the server the usual PG* environment points at, and -# are dropped on exit. +# must run on every supported version, where sequence metadata lives in +# different places) and column-level ACLs (pg_attribute.attacl; trunklet grants +# none). Both installs run in scratch databases on the server the usual PG* +# environment points at, and are dropped on exit. set -euo pipefail from_version=${1:?usage: $0 FROM_VERSION} diff --git a/test/sql/build.sql b/test/build/build.sql similarity index 80% rename from test/sql/build.sql rename to test/build/build.sql index 581cc4d..5b592e3 100644 --- a/test/sql/build.sql +++ b/test/build/build.sql @@ -3,14 +3,11 @@ \i test/helpers/psql.sql --- Both blocks drop test/install/load.sql's committed install first; ROLLBACK restores it BEGIN; -DROP EXTENSION trunklet; \i sql/trunklet.sql ROLLBACK; BEGIN; -DROP EXTENSION trunklet; \i test/helpers/tap_setup.sql SELECT plan(4); diff --git a/test/expected/build.out b/test/build/expected/build.out similarity index 100% rename from test/expected/build.out rename to test/build/expected/build.out diff --git a/test/helpers/language_factory.sql b/test/helpers/language_factory.sql index 1f5651d..6ccbf66 100644 --- a/test/helpers/language_factory.sql +++ b/test/helpers/language_factory.sql @@ -1,9 +1,10 @@ /* - * LANGUAGE FACTORY: creates the test languages and templates, in the first - * schema on search_path. + * LANGUAGE FACTORY: defines, in the first schema on search_path, functions that + * create the test languages and templates on demand. * * test/helpers/update_with_data.sql also runs this under TEST_UPDATE_FROM, so - * stick to the 0.2.0 API and don't use pgTAP. + * stick to the API of the oldest version TEST_UPDATE_FROM may be set to, and + * don't use pgTAP. */ CREATE FUNCTION bogus_language_name( ) RETURNS text LANGUAGE sql AS $$SELECT 'bogus template language that does not exist'::text$$; diff --git a/test/helpers/update_with_data.sql b/test/helpers/update_with_data.sql index bb350fc..f28fd24 100644 --- a/test/helpers/update_with_data.sql +++ b/test/helpers/update_with_data.sql @@ -1,8 +1,8 @@ /* - * Update mode's ALTER EXTENSION UPDATE, run by test/install/load.sql with a - * test language and templates stored under TEST_UPDATE_FROM first. After the - * update it checks they still process, removes them, and leaves nothing behind, - * so the suite's expected output is the same as in fresh mode. + * Update mode's ALTER EXTENSION UPDATE, run by test/install/load.sql. Stores a + * test language and templates under TEST_UPDATE_FROM, updates, checks the + * version and that the templates still process, then removes them and leaves + * nothing behind, so the suite's expected output matches fresh mode. */ CREATE SCHEMA _trunklet_test; SET search_path = _trunklet_test; @@ -13,6 +13,21 @@ CREATE TABLE seeded_template_ids AS SELECT get_test_templates() AS ids; ALTER EXTENSION trunklet UPDATE; +DO $body$ +DECLARE + v_installed text; + v_default text; +BEGIN + SELECT extversion INTO v_installed FROM pg_extension WHERE extname = 'trunklet'; + SELECT default_version INTO v_default FROM pg_available_extensions WHERE name = 'trunklet'; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'trunklet is at version % after ALTER EXTENSION UPDATE, not default_version %' + , v_installed, v_default + ; + END IF; +END +$body$; + -- Updating from before 0.3.0 changes search_path, so qualify from here on DO $body$ @@ -42,6 +57,14 @@ BEGIN END $body$; +DO $body$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_proc WHERE pronamespace = '_trunklet_functions'::regnamespace) THEN + RAISE EXCEPTION 'no functions in schema _trunklet_functions before template_language__remove()'; + END IF; +END +$body$; + -- The language's functions were created by TEST_UPDATE_FROM's code SELECT trunklet.template_language__remove(_trunklet_test.get_test_language_name(), cascade => true); diff --git a/test/install/load.sql b/test/install/load.sql index c8f8ad7..b6da732 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -4,12 +4,12 @@ * and "Update & Upgrade (U&U) Testing" sections). The mode comes from the * Makefile's TEST_LOAD_SOURCE / TEST_UPDATE_FROM via PGOPTIONS. * - * This file's output is never compared against anything; an error has to abort - * the run instead. + * This file's output is never compared against anything. Its \set ON_ERROR_STOP + * makes psql exit non-zero on an error, which fails the run only on PG16+. */ \set ON_ERROR_STOP on --- Branches server-side rather than with psql's \if, which needs psql 10+ +-- Branches server-side because \if needs psql 10+ DO $body$ DECLARE -- No missing_ok: a setting that didn't propagate must fail here @@ -28,10 +28,7 @@ BEGIN END $body$; -/* - * Update mode runs ALTER EXTENSION UPDATE in this helper, with data stored - * first. psql's \if needs psql 10+, so pick the file to \i instead. - */ +-- Update mode only: update_with_data.sql does the ALTER EXTENSION UPDATE. SELECT CASE current_setting('trunklet.test_load_mode') WHEN 'update' THEN 'test/helpers/update_with_data.sql' ELSE '/dev/null' @@ -39,21 +36,4 @@ SELECT CASE current_setting('trunklet.test_load_mode') \gset \i :update_script -DO $body$ -DECLARE - v_installed text; - v_default text; -BEGIN - IF current_setting('trunklet.test_load_mode') = 'update' THEN - SELECT extversion INTO v_installed FROM pg_extension WHERE extname = 'trunklet'; - SELECT default_version INTO v_default FROM pg_available_extensions WHERE name = 'trunklet'; - IF v_installed IS DISTINCT FROM v_default THEN - RAISE EXCEPTION 'trunklet is at version % after ALTER EXTENSION UPDATE, not default_version %' - , v_installed, v_default - ; - END IF; - END IF; -END -$body$; - -- vi: expandtab ts=2 sw=2 From 9e3aecd474809eb75de7955a314cc95c6e0e59d0 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 27 Oct 2026 15:56:04 -0500 Subject: [PATCH 3/5] Install rsync in CI for pgxntool's test-build The pgxn/pgxn-tools image lacks rsync, which test-build needs. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c36a80..0491b1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,10 @@ jobs: run: pg-start ${{ matrix.pg }} - name: Check out the repo uses: actions/checkout@v4 + # pgxntool's test-build (test/build/build.sql) syncs test/build/*.sql via + # rsync (pgxntool/run-test-build.sh); the pgxn/pgxn-tools image lacks it. + - name: Install rsync + run: apt-get install -y rsync - name: Test on PostgreSQL ${{ matrix.pg }} # verify-results runs the same TEST_DEPS chain as `make test`, then # inspects the pgTAP results (PGXNTOOL_ENABLE_VERIFY_RESULTS defaults From bafef658e3791b0f4edda16e383d26786f49aa8b Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 27 Oct 2026 16:04:36 -0500 Subject: [PATCH 4/5] Tighten update-helper checks and comments --- test/helpers/language_factory.sql | 2 +- test/helpers/update_with_data.sql | 21 +++++++++++++++------ test/install/load.sql | 6 ++++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/test/helpers/language_factory.sql b/test/helpers/language_factory.sql index 6ccbf66..f3e02e9 100644 --- a/test/helpers/language_factory.sql +++ b/test/helpers/language_factory.sql @@ -3,7 +3,7 @@ * create the test languages and templates on demand. * * test/helpers/update_with_data.sql also runs this under TEST_UPDATE_FROM, so - * stick to the API of the oldest version TEST_UPDATE_FROM may be set to, and + * stick to the API of the oldest install script in sql/ (currently 0.2.0), and * don't use pgTAP. */ CREATE FUNCTION bogus_language_name( diff --git a/test/helpers/update_with_data.sql b/test/helpers/update_with_data.sql index f28fd24..6bca2fd 100644 --- a/test/helpers/update_with_data.sql +++ b/test/helpers/update_with_data.sql @@ -1,8 +1,10 @@ /* - * Update mode's ALTER EXTENSION UPDATE, run by test/install/load.sql. Stores a - * test language and templates under TEST_UPDATE_FROM, updates, checks the - * version and that the templates still process, then removes them and leaves - * nothing behind, so the suite's expected output matches fresh mode. + * Seeds a test language and a test template under TEST_UPDATE_FROM, runs ALTER + * EXTENSION UPDATE, and checks the version and that the template still + * processes. It then removes the language and checks that nothing is left + * behind, so the suite's expected output matches fresh mode. + * + * test/install/load.sql runs it in update mode. */ CREATE SCHEMA _trunklet_test; SET search_path = _trunklet_test; @@ -59,8 +61,15 @@ $body$; DO $body$ BEGIN - IF NOT EXISTS(SELECT 1 FROM pg_proc WHERE pronamespace = '_trunklet_functions'::regnamespace) THEN - RAISE EXCEPTION 'no functions in schema _trunklet_functions before template_language__remove()'; + -- Keeps the emptiness check below from passing vacuously; the comment text is the same in every version + IF (SELECT count(DISTINCT split_part(obj_description(oid, 'pg_proc'), ' ', 1)) + FROM pg_proc + WHERE pronamespace = '_trunklet_functions'::regnamespace + AND obj_description(oid, 'pg_proc') LIKE '% function for trunklet language %' + AND split_part(obj_description(oid, 'pg_proc'), ' ', 1) IN ('process', 'extract_parameters') + ) <> 2 + THEN + RAISE EXCEPTION 'test language lacks its process or extract_parameters function in _trunklet_functions before template_language__remove()'; END IF; END $body$; diff --git a/test/install/load.sql b/test/install/load.sql index b6da732..6e50390 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -5,7 +5,8 @@ * Makefile's TEST_LOAD_SOURCE / TEST_UPDATE_FROM via PGOPTIONS. * * This file's output is never compared against anything. Its \set ON_ERROR_STOP - * makes psql exit non-zero on an error, which fails the run only on PG16+. + * makes psql exit non-zero on an error, which fails the run only on PG16+. On + * PG 9.5-15 a failure surfaces only through the downstream tests. */ \set ON_ERROR_STOP on @@ -28,7 +29,8 @@ BEGIN END $body$; --- Update mode only: update_with_data.sql does the ALTER EXTENSION UPDATE. +-- Update mode only: update_with_data.sql does the ALTER EXTENSION UPDATE. Branches +-- server-side for the same reason as above. SELECT CASE current_setting('trunklet.test_load_mode') WHEN 'update' THEN 'test/helpers/update_with_data.sql' ELSE '/dev/null' From da8dce5dc521d62e2b3196f7120a03a041a1a8d0 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 27 Oct 2026 17:55:38 -0500 Subject: [PATCH 5/5] Fold fresh-vs-update comparison into test-update --- .github/workflows/ci.yml | 12 +++++------- Makefile | 9 +++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0491b1f..4a37fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,13 +124,11 @@ jobs: # failure. run: make verify-results - name: Test the update path on PostgreSQL ${{ matrix.pg }} - # Same suite and expected output, but test/install/load.sql installs - # the Makefile's TEST_UPDATE_FROM version and test/helpers/update_with_data.sql - # runs ALTER EXTENSION UPDATE. - run: make verify-results TEST_LOAD_SOURCE=update - - name: Compare fresh vs updated install on PostgreSQL ${{ matrix.pg }} - # Diffs every extension member against a fresh install. - run: make compare-fresh-vs-update + # Update-path suite (test/install/load.sql installs TEST_UPDATE_FROM, + # then runs ALTER EXTENSION UPDATE) plus the fresh-vs-update comparison. + # Plain `test` suffices: the fresh verify-results step above already + # checks the same expected output. + run: make test-update # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14" diff --git a/Makefile b/Makefile index 5b2e3fc..104bac7 100644 --- a/Makefile +++ b/Makefile @@ -30,13 +30,10 @@ endif TEST_UPDATE_FROM ?= 0.2.0 export PGOPTIONS := $(PGOPTIONS) -c trunklet.test_load_mode=$(TEST_LOAD_SOURCE) -c trunklet.test_update_from=$(TEST_UPDATE_FROM) -# Recurses so the PGOPTIONS export above is re-evaluated with update set. +# Runs the suite against an updated install, then diffs it against a fresh one +# (bin/compare_fresh_vs_update). Recurses so the PGOPTIONS export above is +# re-evaluated with update set. .PHONY: test-update test-update: $(MAKE) test TEST_LOAD_SOURCE=update - -# Diffs every extension member's definition, comments and ACLs between a fresh -# install and one updated from TEST_UPDATE_FROM; see bin/compare_fresh_vs_update. -.PHONY: compare-fresh-vs-update -compare-fresh-vs-update: $(if $(filter yes,$(PGXNTOOL_ENABLE_FS_INSTALL)),install) bin/compare_fresh_vs_update $(TEST_UPDATE_FROM)