From 8ee5019191d4a5113f41df16236c56892aa11b94 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 4 Aug 2026 20:21:53 -0500 Subject: [PATCH] ci: wire up extension-update-test (0.1.0 -> stable) via bin/test_existing Adds the committed install->guard->update->assert->run-suite script (bin/test_existing, modeled on cat_tools's bin/test_existing) plus a generic per-extension structural-diff tool (bin/structural_diff[.sql], copied near-verbatim from cat_tools -- it's already written generically off pg_depend's deptype='e' membership edge) and a new CI job that exercises the 0.1.0->stable update path end to end: install 0.1.0, plant + prove the dependency guard, ALTER EXTENSION UPDATE, structurally compare against a fresh "stable" install, then run the full suite in existing mode. No binary pg_upgrade job is added: object_reference has no view/function that SELECTs * over a system catalog in either its current or 0.1.0 install script (checked directly), so the cross-PostgreSQL-major risk that job protects against is low here. Left as noted future work rather than built preemptively -- see the ci.yml "Test strategy" comment and the PR description. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 90 ++++++++++- bin/structural_diff | 85 +++++++++++ bin/structural_diff.sql | 179 ++++++++++++++++++++++ bin/test_existing | 314 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 667 insertions(+), 1 deletion(-) create mode 100755 bin/structural_diff create mode 100644 bin/structural_diff.sql create mode 100755 bin/test_existing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f5d8f..1993a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,55 @@ +# =========================================================================== +# Test strategy +# +# An object_reference install can be arrived at more than one way, each of +# which can break differently, so each is exercised by its own job below: +# +# - `lint`: the cheapest possible check (no database, no container beyond +# a plain checkout, seconds to run) -- gates everything else so a broken +# style baseline never ties up runner slots on the heavier jobs below. +# +# - `changes`: cheap docs-only gate, PLUS the single source of truth for +# the supported-PostgreSQL-major list every other job's matrix derives +# from (see its own "Derive ..." step). +# +# - `test`: FRESH install (CREATE EXTENSION at the current "stable" +# version) via `make test`, on every supported PostgreSQL major. The +# baseline a brand-new user gets. +# +# - `extension-update-test`: UPDATE TO CURRENT. Installs the one real +# historical PGXN release (0.1.0), ALTER EXTENSION UPDATEs it to +# "stable" via bin/test_existing's update-scenario, structurally +# compares the result against a fresh "stable" install +# (bin/structural_diff -- so a divergent function/view body produced +# only by the update path, and never by a fresh install, cannot slip +# through silently), then runs the full pgTAP suite against that real +# updated database in `existing` mode (TEST_LOAD_SOURCE=existing, +# --use-existing). A planted dependency guard (a view hard-referencing +# _object_reference.object's row type) blocks a stray non-CASCADE DROP +# EXTENSION throughout, and is re-proved present after every step -- see +# bin/test_existing's own header for the full rationale. Runs on a +# SINGLE PostgreSQL major (the newest supported), not the full matrix: +# 0.1.0's install script has no identified PostgreSQL-version floor (no +# SELECT * over a system catalog, no ALTER TYPE ... ADD VALUE), so +# crossing this axis against every major would just multiply job count +# for no added coverage. +# +# - No binary pg_upgrade job (the cat_tools reference this effort is +# modeled on has `pg-upgrade-test` / `pg-upgrade-stepwise`) exists yet. +# Deliberate, not an oversight: that job exists to catch a view/function +# that breaks across a PostgreSQL major specifically because it touches +# catalog internals (SELECT * over a system catalog whose columns get +# added/exposed/removed between majors). object_reference has no such +# construct in either its current or 0.1.0 install script (checked +# directly -- no view or function selects * from a system catalog; every +# object_reference table/view is an ordinary user object), so the risk +# that job protects against is correspondingly low here. Left as +# explicitly-noted future work rather than built preemptively; revisit +# if/when object_reference grows a catalog-touching view or function. +# +# - `all-checks-passed`: single stable required-status-check name; see its +# own comment below. +# =========================================================================== name: CI on: push: @@ -145,6 +197,42 @@ jobs: - name: Test on PostgreSQL ${{ matrix.pg }} run: make test + # Extension UPDATE path: install the one real historical PGXN release + # (0.1.0), ALTER EXTENSION UPDATE to the current version ("stable"), and + # run the full suite against the real updated database in `existing` mode + # -- see the "Test strategy" comment at the top of this file and + # bin/test_existing's own header for the full flow and dependency-guard + # rationale. + extension-update-test: + # Gated behind lint+test (not just changes): this job installs a second, + # older extension version and runs the update path, which is wasted + # effort against a baseline that's already broken by a style violation + # or a failing fresh-install test. success() must be written explicitly + # -- GitHub only assumes success() as a job's default when it has no + # if: at all. + needs: [changes, lint, test] + if: success() && needs.changes.outputs.docs_only != 'true' + name: ⬆️ Extension update test (0.1.0 → stable) + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + steps: + # A single PostgreSQL major (the newest supported, from the `changes` + # job's single source of truth) -- see the Test strategy comment at + # the top of this file for why this isn't crossed against the full PG + # matrix. + - name: Start PostgreSQL ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }} + run: pg-start ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }} + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install object_reference + its 0.1.0-only test dependency (count_nulls) + # TEST_LOAD_SOURCE=update activates the Makefile's conditional + # `install: count_nulls` prerequisite (see the Makefile's own + # comment on it): 0.1.0's install script needs count_nulls even + # though current object_reference.control no longer declares it. + run: make install TEST_LOAD_SOURCE=update + - name: Update 0.1.0 -> stable, structurally compare, run the suite (existing mode) + run: bin/test_existing update-scenario object_reference_update 0.1.0 + # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14" # which would all need to be listed individually and updated whenever the @@ -152,7 +240,7 @@ jobs: # (e.g. test, on a docs-only push), and fails if any failed or were # cancelled. all-checks-passed: - needs: [changes, lint, test] + needs: [changes, lint, test, extension-update-test] if: always() runs-on: ubuntu-latest steps: diff --git a/bin/structural_diff b/bin/structural_diff new file mode 100755 index 0000000..fc3eef1 --- /dev/null +++ b/bin/structural_diff @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Structurally compare, object-by-object, every member of the +# object_reference extension in two databases: function bodies +# (pg_get_functiondef), view definitions (pg_get_viewdef), table/composite +# column lists, comments, and ACLs. A nonempty diff is a bug -- the whole +# point of an extension UPDATE script is that it reaches the SAME objects a +# fresh install of the target version would. +# +# Modeled on cat_tools's bin/structural_diff (Postgres-Extensions/cat_tools), +# which generalized a manual comparison that found a real fresh-vs-update +# divergence in cat_tools#46. bin/structural_diff.sql here is a near-verbatim +# copy of that file -- it is already written generically (parameterized on +# :extname, driven entirely off pg_depend's deptype='e' membership edge, with +# no cat_tools-specific object names), so it applies to any extension as-is. +# +# USAGE: bin/structural_diff [args] +# +# dump DB [EXTNAME] +# Print the signature of every EXTNAME member object in DB (EXTNAME +# defaults to object_reference). Useful on its own for eyeballing one +# database's structure, and it's what `compare` diffs under the hood. +# +# compare DB1 DB2 [EXTNAME] +# Dump both databases and diff them. Prints a unified diff and exits +# non-zero if they differ; exits 0 (and prints an OK line) if +# identical. +# +# See bin/structural_diff.sql for the query that defines "signature" (and how +# it decides which object kinds get a real structural definition vs. falling +# back to just identity/comment/ACL). +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +dump() { + local db=$1 extname=${2:-object_reference} + psql -d "$db" -v extname="'$extname'" -f "$SCRIPT_DIR/structural_diff.sql" +} + +compare() { + local db1=$1 db2=$2 extname=${3:-object_reference} + # Run in a subshell so the EXIT trap (temp-file cleanup) is scoped to this + # comparison only. A trap set with plain `trap ... RETURN` is NOT scoped to + # the function that set it -- it re-fires on every later function return in + # the same shell, including main()'s, by which point f1/f2 no longer exist. + ( + f1=$(mktemp) + f2=$(mktemp) + trap 'rm -f "$f1" "$f2"' EXIT + dump "$db1" "$extname" > "$f1" + dump "$db2" "$extname" > "$f2" + if diff -u --label "$db1" --label "$db2" "$f1" "$f2"; then + echo "OK: '$db1' and '$db2' are structurally identical for extension '$extname'" + else + echo "FAIL: structural diff between '$db1' and '$db2' for extension '$extname' (see diff above) -- an update path reached objects that differ from a fresh install" >&2 + exit 1 + fi + ) +} + +usage() { + echo "usage: bin/structural_diff [args]" >&2 + echo " dump DB [EXTNAME]" >&2 + echo " compare DB1 DB2 [EXTNAME]" >&2 + exit 2 +} + +# Explicit subcommand dispatch on $1, matching bin/test_existing's +# convention. +main() { + local cmd=${1:-} + shift || true + case "$cmd" in + dump) dump "$@" ;; + compare) compare "$@" ;; + *) usage ;; + esac +} + +main "$@" diff --git a/bin/structural_diff.sql b/bin/structural_diff.sql new file mode 100644 index 0000000..a68ebce --- /dev/null +++ b/bin/structural_diff.sql @@ -0,0 +1,179 @@ +/* + * Structural signature dump for every object that belongs to an extension + * (pg_depend deptype = 'e'), used by bin/structural_diff to compare a + * database reached via an extension UPDATE against a FRESH install of the + * same target version. Any nonempty diff between two runs of this query is a + * bug: the two paths are supposed to produce byte-identical objects. + * + * Run via: psql -d DBNAME -v extname="'object_reference'" -f bin/structural_diff.sql + * + * Modeled on cat_tools's bin/structural_diff.sql (Postgres-Extensions/ + * cat_tools), which generalized a manual comparison technique (diffing + * pg_get_functiondef / pg_get_viewdef / type labels / comments / ACLs / + * extension membership between a fresh install and an updated database) used + * to find a real fresh-vs-update divergence in that extension. This file is + * copied near-verbatim -- it is written generically off pg_depend's deptype + * = 'e' membership edge, with no cat_tools-specific object names, so it + * applies to object_reference (and any other extension) as-is; only the + * default :extname in bin/structural_diff's wrapper differs. + * + * Emits one text block per member object, ordered by its pg_describe_object() + * identity so the SAME object sorts to the SAME position regardless of the + * OIDs assigned along each installation path. Each block covers: + * - a structural definition, using pg_get_functiondef/pg_get_viewdef for + * routines/views, an ordered column dump for a plain table or standalone + * composite type, an ordered label list for enums, or a cast/domain + * summary -- whichever a member's catalog/kind actually calls for. + * object_reference currently has no enums, domains or casts of its own; + * those branches are kept anyway (harmless no-ops today) so a future + * member of one of those kinds is compared structurally too, without + * needing to remember to add it. Row types implicitly created BY a + * member relation, and the array type shadowing any other member type, + * are skipped: their structure is fully captured by the relation/base- + * type entry already, so listing them again would just duplicate that + * comparison under a second identity. + * - its comment (pg_description), generically via obj_description(). + * - its ACL, generically via whichever ACL column its catalog has (proacl / + * typacl / relacl / nspacl); sorted, since grant order is not meaningful. + * + * This is deliberately NOT specific to object_reference's current object + * list: any object kind this extension does not (yet) use falls through to + * the ELSE branch below, which still includes it (via its + * pg_describe_object identity, comment and ACL) so a future new member is + * compared at least at that level rather than silently skipped, even though + * this file does not (yet) know how to render a structural definition for + * it. + */ +\set ON_ERROR_STOP on +\pset format unaligned +\pset tuples_only on +\pset fieldsep '' + +WITH ext AS ( + SELECT oid FROM pg_extension WHERE extname = :extname +), members AS ( + SELECT d.classid, d.objid + FROM pg_depend d, ext + WHERE d.refclassid = 'pg_extension'::regclass + AND d.refobjid = ext.oid + AND d.deptype = 'e' +), skip_shadow AS ( + /* Implicit row type of a member relation: same structure as the relation + * itself, so comparing it too would just duplicate that check. */ + SELECT t.oid + FROM pg_type t + JOIN members rel ON rel.classid = 'pg_class'::regclass AND rel.objid = t.typrelid + WHERE t.typtype = 'c' + UNION + /* Array type shadowing another member type: same element type, no + * independent structure of its own. */ + SELECT t.oid + FROM pg_type t + JOIN members base ON base.classid = 'pg_type'::regclass AND base.objid = t.typelem + WHERE t.typelem <> 0 +), acl AS ( + SELECT m.classid, m.objid, + ( + SELECT array_to_string(array_agg(a::text ORDER BY a::text), ',') + FROM unnest( + CASE m.classid + WHEN 'pg_proc'::regclass THEN (SELECT proacl FROM pg_proc WHERE oid = m.objid) + WHEN 'pg_type'::regclass THEN (SELECT typacl FROM pg_type WHERE oid = m.objid) + WHEN 'pg_class'::regclass THEN (SELECT relacl FROM pg_class WHERE oid = m.objid) + WHEN 'pg_namespace'::regclass THEN (SELECT nspacl FROM pg_namespace WHERE oid = m.objid) + ELSE NULL + END + ) a + ) AS acl_text + FROM members m +), relation_cols AS ( + /* Ordered column dump, shared by the plain-table case (pg_class relkind + * 'r') and the standalone-composite-type case (pg_type typtype 'c' whose + * typrelid is NOT a member relation, i.e. survived skip_shadow) -- both + * describe a set of (name, type, not-null, default) columns identically. */ + SELECT m.classid, m.objid, + ( + SELECT string_agg( + format( + '%s %s%s%s' + , a.attname + , format_type(a.atttypid, a.atttypmod) + , CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END + , COALESCE(' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid), '') + ) + , E'\n' ORDER BY a.attnum + ) + FROM pg_attribute a + LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + WHERE a.attrelid = CASE m.classid + WHEN 'pg_class'::regclass THEN m.objid + WHEN 'pg_type'::regclass THEN (SELECT typrelid FROM pg_type WHERE oid = m.objid) + END + AND a.attnum > 0 + AND NOT a.attisdropped + ) + /* Table constraints (PK/UNIQUE/CHECK/FK) have no equivalent on a + * standalone composite type, so this is NULL there and simply appends + * nothing. */ + || COALESCE( + E'\n' || ( + SELECT string_agg(pg_get_constraintdef(c.oid), E'\n' ORDER BY c.conname) + FROM pg_constraint c + WHERE m.classid = 'pg_class'::regclass AND c.conrelid = m.objid + ) + , '' + ) AS cols + FROM members m + WHERE (m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r') + OR (m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c') +) +SELECT + '=== ' || pg_describe_object(m.classid, m.objid, 0) || E' ===\n' + || 'DEFINITION:' || E'\n' || COALESCE( + CASE + WHEN m.classid = 'pg_proc'::regclass + THEN pg_get_functiondef(m.objid) + WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) IN ('v', 'm') + THEN pg_get_viewdef(m.objid, true) + WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r' + THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'e' + THEN (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c' + THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'd' + THEN ( + SELECT format( + 'base=%s notnull=%s default=%s check=%s' + , t.typbasetype::regtype, t.typnotnull, t.typdefault + , (SELECT string_agg(pg_get_constraintdef(c.oid), ' AND ' ORDER BY c.oid) + FROM pg_constraint c WHERE c.contypid = m.objid) + ) + FROM pg_type t WHERE t.oid = m.objid + ) + WHEN m.classid = 'pg_cast'::regclass + THEN ( + SELECT format( + 'CAST (%s AS %s) METHOD %s CONTEXT %s' + , ct.castsource::regtype, ct.casttarget::regtype + , CASE ct.castmethod + WHEN 'f' THEN 'FUNCTION ' || ct.castfunc::regprocedure::text + WHEN 'i' THEN 'INOUT' + WHEN 'b' THEN 'BINARY COERCION' + END + , ct.castcontext + ) + FROM pg_cast ct WHERE ct.oid = m.objid + ) + ELSE NULL + END + , '(no structural definition rendered for this object kind -- see identity/comment/ACL below)' + ) + || E'\n' || 'COMMENT: ' || COALESCE(obj_description(m.objid, m.classid::regclass::text), '(none)') + || E'\n' || 'ACL: ' || COALESCE((SELECT acl_text FROM acl WHERE acl.classid = m.classid AND acl.objid = m.objid), '(none)') + || E'\n' + AS block + FROM members m + LEFT JOIN skip_shadow s ON m.classid = 'pg_type'::regclass AND s.oid = m.objid + WHERE s.oid IS NULL + ORDER BY pg_describe_object(m.classid, m.objid, 0); diff --git a/bin/test_existing b/bin/test_existing new file mode 100755 index 0000000..019872e --- /dev/null +++ b/bin/test_existing @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# +# Modeled on cat_tools's bin/test_existing (Postgres-Extensions/cat_tools) -- +# exercises the object_reference test suite against a REAL database whose +# extension was installed/updated OUTSIDE the suite ("existing" mode). The +# extension-update-test CI job repeats the same sequence: +# +# install -> plant dependency guard -> update -> assert version +# -> run the suite in existing mode +# +# so it lives here once instead of duplicated inline YAML. Not CI-only: a +# developer can run any subcommand locally against a scratch database. +# +# USAGE: bin/test_existing [args] +# +# plant-guard DB +# Plant + prove the dependency guard (extension must already be +# installed in DB). +# +# update DB [TO_VERSION] +# ALTER EXTENSION object_reference UPDATE [TO 'TO_VERSION'] (empty => +# the current default_version, "stable"). +# +# run-suite DB +# Run the suite in existing mode (extension must be at the current +# version). +# +# update-scenario DB FROM_VERSION +# Create DB + extension at FROM_VERSION (bringing in count_nulls, the +# 0.1.0-only dependency object_reference.control no longer declares), +# plant + prove the guard, update to the current version, structurally +# compare the result against a fresh install of the current version +# (see diff-fresh), then run the suite against that real updated +# database. +# +# diff-fresh DB VERSION +# Structurally compare DB's object_reference objects against a +# throwaway fresh install of VERSION (function/view/table definitions, +# comments, ACLs -- see bin/structural_diff.sql). Fails loudly on any +# nonempty diff. update-scenario calls this automatically; also useful +# standalone against any already-populated database. +# +# object_reference has only ONE real historical PGXN release (0.1.0) and only +# ONE update script (0.1.0--stable.sql, a direct hop with no already-tagged +# intermediate landing version), so cat_tools's prepare-old / update-check / +# update-check-version subcommands don't apply here: +# - prepare-old exists to bridge an old, pg_upgrade-unsafe version to a +# safe one before a binary pg_upgrade leg. No pg_upgrade CI job exists yet +# for object_reference (see the containing PR description for that +# decision), and 0.1.0 has no identified pg_upgrade-unsafe construct (no +# SELECT * over a system catalog in any view -- checked directly), so +# there is nothing to bridge. +# - update-check / update-check-version exist to assert an update script +# applying to an ALREADY-TAGGED intermediate version, where full +# fresh-vs-update parity is known to be permanently unattainable (the +# frozen version-specific file can never be edited to match). 0.1.0 only +# ever updates straight to "stable", the still-unpublished current +# version -- exactly the case diff-fresh/update-scenario already cover +# with a REAL parity guarantee (not a permanently-failing-by-design one). +# +# Why the dependency guard: "existing" mode must run the suite against the +# ACTUAL updated objects. If anything silently dropped + reinstalled the +# extension, the suite would test a FRESH install and hide a regression. As +# belt-and-suspenders to test/install/load.sql's guarantee (existing mode +# never drops the extension), we plant an object that HARD-references an +# object_reference member so a non-CASCADE DROP EXTENSION fails, and we +# actively PROVE that here (see plant_guard): if the drop unexpectedly +# succeeds, this script fails CI. +# +# See test/install/load.sql's own "existing mode" comment for the guard +# anchor rationale (_object_reference.object's row type). +set -euo pipefail + +# Run from the repository root (where `make` works and test paths resolve), +# regardless of the caller's cwd. bin/ sits directly under the repo root, so +# its parent is the root. readlink -f resolves any path the script was +# invoked through. +cd "$(dirname "$(readlink -f "$0")")/.." + +# A view whose output column has object_reference's own core row type creates +# a pg_depend edge to that extension member, so a non-CASCADE DROP EXTENSION +# cannot succeed. _object_reference.object is never dropped/redefined by the +# 0.1.0--stable update script, so the guard survives an update to the current +# version. +GUARD_SCHEMA=object_reference_drop_guard +GUARD_VIEW=guard + +# --------------------------------------------------------------------------- +# psql helpers +# --------------------------------------------------------------------------- + +# Capture the single-value (-tAc) output of a query against a database. Used +# for the many "read one value back out" calls below so the psql flags live +# in one place. +psql_value() { + local db=$1 sql=$2 + psql -d "$db" -tAc "$sql" +} + +# Run SQL that must succeed, aborting the whole script on any error +# (ON_ERROR_STOP). Extra args pass through to psql, so callers use either +# `psql_do DB -c '...'` or a heredoc (`psql_do DB <stable update script's own header) built + # from control.mk's EXTENSION_object_reference_VERSION variable. That is + # NOT the same thing as PGXNVERSION (meta.mk): PGXNVERSION is frozen at + # 0.1.0, the last real numbered PGXN release used for git tagging/dist, and + # does not track the current build's default_version at all once "stable" + # is in play. print-EXTENSION_object_reference_VERSION -- not + # print-PGXNVERSION -- is the correct dynamic derivation for "the version a + # fresh/updated install should currently land on" in this repo. + make -s print-EXTENSION_object_reference_VERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p' +} + +installed_version() { + psql_value "$1" \ + "SELECT extversion FROM pg_extension WHERE extname = 'object_reference'" +} + +guard_present() { + test "$(psql_value "$1" \ + "SELECT count(*) FROM pg_views WHERE schemaname = '$GUARD_SCHEMA' AND viewname = '$GUARD_VIEW'")" = 1 +} + +# Plant the guard and PROVE it blocks a non-CASCADE drop. Call right after +# CREATE EXTENSION (and before any update) so it persists through it. +plant_guard() { + local db=$1 + psql_do "$db" </dev/null 2>&1; then + echo "FAIL: DROP EXTENSION object_reference (non-CASCADE) unexpectedly SUCCEEDED in '$db' -- dependency guard is ineffective" >&2 + exit 1 + fi + guard_present "$db" \ + || { echo "FAIL: dependency guard missing from '$db' after the drop attempt" >&2; exit 1; } + test -n "$(installed_version "$db")" \ + || { echo "FAIL: object_reference extension missing from '$db' after the drop attempt" >&2; exit 1; } + echo "OK: non-CASCADE DROP EXTENSION is blocked in '$db' (dependency guard effective)" +} + +assert_version() { + local db=$1 expected=$2 installed + [ "$expected" = current ] && expected=$(current_version) + installed=$(installed_version "$db") + echo "version check '$db': installed='$installed' expected='$expected'" + if [ -z "$installed" ] || [ -z "$expected" ] || [ "$installed" != "$expected" ]; then + echo "FAIL: object_reference in '$db' is '$installed', expected '$expected'" >&2 + exit 1 + fi +} + +update_ext() { + local db=$1 to=${2:-} + # Prepend "TO " only when a target version is given, so a single statement + # covers both cases (empty $to => bare "ALTER EXTENSION ... UPDATE" to the + # current default_version). Use `if`, not `&&`: a false test under `set -e` + # would abort the script. + if [ -n "$to" ]; then to="TO '$to'"; fi + psql_do "$db" -c "ALTER EXTENSION object_reference UPDATE $to" +} + +# Structurally compare DB's object_reference objects (function/view/table +# definitions, comments, ACLs -- see bin/structural_diff.sql) against a FRESH +# install of VERSION. An update script only really "supports" a version if +# updating to it reaches the SAME objects a fresh install would -- see +# bin/structural_diff's header for the cat_tools bug this general technique +# was built to catch (a divergent function body that only an update path, +# never a fresh install, ever produced). +# +# Runs in a subshell so the EXIT trap dropping the throwaway reference +# database is scoped to THIS call, not the whole script: a plain +# `trap ... RETURN` re-fires on every later function return in the same +# shell (not just this one), and by the time some LATER function in this +# script returns, $fresh_db would be stale/wrong. A `RETURN` trap also does +# not fire at all under `set -e` if a later command in the *enclosing* +# function fails -- only `EXIT` is guaranteed to run on every path out of the +# process, including an errexit-triggered abort. See the containing PR +# description for how this was verified (deliberately forcing a failure and +# confirming the scratch database was still dropped). +assert_matches_fresh() { + local db=$1 version=$2 + # Separate statement: a self-referencing `local a=1 b=$a` is bash-version- + # dependent on whether $a is visible yet while computing b's value. + local fresh_db="${db}__fresh_ref" + ( + # Double-quoted so $fresh_db expands NOW, baking the actual database name + # into the trap as a literal -- the trap then no longer references the + # variable at all, so it is unaffected by anything that happens to it + # afterward. Also deliberately NOT `local fresh_db` above: an EXIT trap is + # process-global and only runs once the (sub)shell truly exits, so a + # `local` variable that had already gone out of scope by then would make + # the trap fail with "unbound variable" under `set -u`. Baking the value + # in at registration time sidesteps that entirely. + trap "dropdb --if-exists '$fresh_db'" EXIT + createdb "$fresh_db" + psql_do "$fresh_db" -c "CREATE EXTENSION object_reference VERSION '$version' CASCADE" + bin/structural_diff compare "$db" "$fresh_db" + ) +} + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +# update-scenario DB FROM_VERSION +# Full extension-update flow that runs the existing-mode suite: create the +# DB and extension at FROM_VERSION, plant + prove the guard, update to the +# current version, then run the suite against that real updated database. +update_scenario() { + local db=$1 from=$2 + createdb "$db" + # 0.1.0's own install script creates a trigger that calls count_nulls' + # not_null_count_trigger() -- a dependency object_reference.control no + # longer declares in `requires` now that the reg* pseudotype removal made + # it unnecessary, so the CASCADE below will NOT bring count_nulls in + # automatically. Install it explicitly first, matching + # test/install/load.sql's own update-mode handling; the caller is + # responsible for having `make install`ed it onto disk first (the + # Makefile's conditional `install: count_nulls` prerequisite, + # TEST_LOAD_SOURCE=update only, handles that -- see the CI job). + psql_do "$db" -c "CREATE EXTENSION IF NOT EXISTS count_nulls" + psql_do "$db" -c "CREATE EXTENSION object_reference VERSION '$from' CASCADE" + plant_guard "$db" + update_ext "$db" + assert_matches_fresh "$db" "$(current_version)" + run_suite "$db" +} + +# Run the pgTAP suite against an already-populated database in existing mode. +# Verifies the extension is at the current version, re-proves the guard +# still blocks a drop (i.e. it survived the update), runs the suite via +# --use-existing so pg_regress does NOT drop/recreate the database, then +# confirms the guard is still present (a CASCADE drop+reinstall would have +# removed it). +run_suite() { + local db=$1 + assert_version "$db" current + assert_drop_blocked "$db" + # In existing mode pg_regress runs against $db via --use-existing and must + # NOT create/drop its own database. Two consequences drive the make args + # below (see the advanced-extension-testing doc's §5): + # 1. PGXNTOOL_ENABLE_TEST_BUILD=no: base.mk auto-enables the test-build + # sanity check (test/build/*.sql exist in this repo) as a `test` + # prerequisite. test-build spawns a recursive `installcheck` that + # INHERITS this call's --use-existing (a command-line var propagates + # to sub-makes) but targets a fresh `regression` DB it cannot create + # under --use-existing, so it dies with "database regression does not + # exist". test-build is a fresh-install check already run by the + # `test` job on every supported PostgreSQL major, so it adds nothing + # here -- disable it. + # 2. verify-results depends on `test`, so it re-runs the suite; it must + # carry the SAME existing-mode overrides or it would re-run FRESH + # (against a new throwaway regression DB) instead of verifying THIS + # existing database. + local existing_args=(TEST_LOAD_SOURCE=existing "CONTRIB_TESTDB=$db" EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no) + make verify-results "${existing_args[@]}" + # Post-suite guard assertion: a CASCADE drop+reinstall during the run would + # have removed the guard view, meaning the suite tested a fresh install, + # not $db. + guard_present "$db" \ + || { echo "FAIL: dependency guard vanished during the suite run on '$db' -- extension was dropped+reinstalled (CASCADE)?" >&2; exit 1; } +} + +usage() { + echo "usage: bin/test_existing [args]" >&2 + echo " plant-guard DB" >&2 + echo " update DB [TO_VERSION]" >&2 + echo " run-suite DB" >&2 + echo " update-scenario DB FROM_VERSION" >&2 + echo " diff-fresh DB VERSION" >&2 + exit 2 +} + +# Explicit subcommand dispatch on $1. Defined first for readability; INVOKED +# at the very bottom, after every helper it calls is defined (bash resolves +# calls at runtime, so main() appearing first is fine). +main() { + local cmd=${1:-} + shift || true + case "$cmd" in + plant-guard) plant_guard "$@" ;; + update) update_ext "$@" ;; + run-suite) run_suite "$@" ;; + update-scenario) update_scenario "$@" ;; + diff-fresh) assert_matches_fresh "$@" ;; + *) usage ;; + esac +} + +main "$@"