Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 343 additions & 2 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,77 @@ include pgxntool/base.mk

# Temporary hack
testdeps: $(wildcard test/*/*.sql) $(wildcard test/*.sql) # Be careful not to include directories in this

# Install the oldest historical version's full install script so the update
# test in test/build/upgrade.sql and test/deps.sql's update mode can
# CREATE EXTENSION count_nulls VERSION '0.9.6'.
# Not covered by base.mk's DATA wildcard, which only picks up upgrade scripts
# (sql/*--*--*.sql) and the current version file - a pgxntool bug, filed as
# Postgres-Extensions/pgxntool#48.
DATA += sql/count_nulls--0.9.6.sql

# TEST_LOAD_SOURCE selects how test/deps.sql installs the extension for the
# WHOLE test suite:
# - fresh (default): CREATE EXTENSION count_nulls (current version).
# - update: CREATE EXTENSION at the oldest version we still ship a full
# install script for (0.9.6), then ALTER EXTENSION UPDATE to current.
# - existing: the extension is already installed (a real `pg_upgrade` run,
# or an out-of-band update) - deps.sql only asserts it's present and
# current, it does not drop/create/update anything. Meant to be run with
# CONTRIB_TESTDB=<db> EXTRA_REGRESS_OPTS=--use-existing
# PGXNTOOL_ENABLE_TEST_BUILD=no against a real database, not via a make
# wrapper here (see the pg_upgrade CI job).
# Running the SAME suite with the SAME expected output against the updated/
# upgraded database proves it behaves identically to a fresh install.
#
# "update" (this) is extension-level (ALTER EXTENSION UPDATE); "upgrade" is
# cluster-level (pg_upgrade) - 'existing' is how that axis is exercised.
# Don't conflate the two in variable names or comments.
#
# The mode is signalled to test/deps.sql via the count_nulls.test_load_mode
# placeholder GUC. pg_regress does not forward make variables, but the psql
# processes it spawns inherit the environment, so PGOPTIONS reaches deps.sql.
# It's exported unconditionally so deps.sql can read it without missing_ok
# and fail loudly if it didn't propagate, instead of silently defaulting to
# fresh and running the wrong suite.
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
export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_load_mode=$(TEST_LOAD_SOURCE)

# TEST_SCHEMA selects which schema test/deps.sql installs count_nulls into,
# for the WHOLE test run (every test file uses the SAME schema in a given
# run - previously each test file hardcoded its own literal schema name as a
# stand-in for real schema-qualification coverage, which only ever tested
# two fixed, always-lowercase names).
#
# Empty (the default): don't target any schema at all. The extension
# installs wherever the session's own default search_path already resolves
# (ordinarily 'public'), exercising the plain, untouched default-install
# code path - the "without a schema" leg.
#
# Non-empty: explicitly CREATE SCHEMA/SET search_path to that name before
# installing - the "with a schema" leg. In particular, a schema whose name
# requires SQL identifier quoting (mixed case - unquoted would silently fold
# to lowercase) exercises the suite's %I schema-qualification, not just its
# literal test data. Locally: `make test TEST_SCHEMA=Quoted`.
#
# Both legs matter and both run in CI - "without" and "with" are genuinely
# different code paths (one never touches search_path, the other explicitly
# targets a schema), not one being a redundant special case of the other.
#
# Propagated the same way as TEST_LOAD_SOURCE: via the count_nulls.test_schema
# GUC, exported unconditionally through PGOPTIONS. Empty is a valid,
# deliberate value here (not an error) - deps.sql reads it without
# missing_ok, so a truly unset GUC (the pipeline failing to propagate at all)
# still fails loudly, while an explicitly empty one means "no schema".
TEST_SCHEMA ?=
export PGOPTIONS := $(PGOPTIONS) -c count_nulls.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
237 changes: 237 additions & 0 deletions bin/test_existing
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
#
# Exercise the count_nulls test suite against a REAL database whose extension
# was installed/updated/pg_upgraded OUTSIDE the suite ("existing" mode). Both
# the pg-upgrade-test and extension-update-test jobs in
# .github/workflows/ci.yml repeat the same sequence:
#
# install -> plant dependency guard -> update/upgrade -> assert version
# -> drop the guard -> run the suite in existing mode
#
# so it lives here once instead of being duplicated as inline YAML. It is NOT
# CI-only: a developer can run any subcommand locally against a scratch
# database. Modeled on Postgres-Extensions/cat_tools's bin/test_existing.
# Two differences from that script: count_nulls ships no
# SELECT-*-over-catalog views, so it has no known pg_upgrade-unsafe old
# version to bridge past before running pg_upgrade; and its own suite has a
# legitimate (though harmless - always rolled back) DROP EXTENSION test, so
# here the guard is dropped before run-suite instead of surviving through it.
#
# USAGE: bin/test_existing <subcommand> [args]
#
# plant-guard DB SCHEMA
# Plant + prove the dependency guard (extension must already exist).
#
# update DB [TO_VERSION]
# ALTER EXTENSION count_nulls UPDATE [TO 'TO_VERSION'] (empty => current).
#
# prepare-old DB SCHEMA INSTALL_VERSION
# Old-cluster prep for pg-upgrade-test: create DB + extension at
# INSTALL_VERSION in SCHEMA, then plant + prove the guard.
#
# run-suite DB SCHEMA
# Assert the current version, re-prove the guard, drop it, then run the
# suite in existing mode (extension must be at the current version).
#
# update-scenario DB SCHEMA FROM_VERSION
# Create DB + extension at FROM_VERSION in SCHEMA, plant the guard,
# update to current, then run-suite against that real updated database.
#
# Run `bin/test_existing` with no subcommand to print usage.
#
# Why the dependency guard: "existing" mode must run the suite against the
# ACTUAL upgraded/updated objects. If anything silently dropped + reinstalled
# the extension, the suite would test a FRESH install and hide a regression.
# We plant an object that HARD-references a count_nulls member so a
# non-CASCADE DROP EXTENSION fails, and actively PROVE that (see
# bin/test_existing.sql/assert_guard.sql): if the drop unexpectedly succeeds,
# this script fails CI rather than silently passing.
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")")/.."

# ---------------------------------------------------------------------------
# psql helpers
# ---------------------------------------------------------------------------

# Capture the single-value (-tAc) output of a query against a database.
psql_value() {
local db=$1 sql=$2
psql -d "$db" -tAc "$sql"
}

# Run SQL that must succeed, aborting the whole script on any error.
psql_do() {
local db=$1
shift
psql -d "$db" -v ON_ERROR_STOP=1 "$@"
}

# ---------------------------------------------------------------------------
# Version / guard helpers
# ---------------------------------------------------------------------------

current_version() {
make -s print-PGXNVERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p'
}

installed_version() {
psql_value "$1" \
"SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'"
}

guard_present() {
test "$(psql_value "$1" \
"SELECT count(*) FROM pg_views WHERE schemaname = 'count_nulls_drop_guard' AND viewname = 'guard'")" = 1
}

# Plant the guard and PROVE it blocks a non-CASCADE drop. Call right after
# CREATE EXTENSION (and before any update/upgrade) so it persists through them.
plant_guard() {
local db=$1 schema=$2
psql -d "$db" -v ON_ERROR_STOP=1 -v schema="$schema" -f bin/test_existing.sql/plant_guard.sql
assert_drop_blocked "$db"
}

# The core safeguard self-check: a non-CASCADE DROP EXTENSION MUST fail while
# the guard exists. assert_guard.sql fails loudly (nonzero exit) if the drop
# unexpectedly succeeds, or if the extension/guard are missing afterward.
assert_drop_blocked() {
local db=$1
psql -d "$db" -v ON_ERROR_STOP=1 -f bin/test_existing.sql/assert_guard.sql
echo "OK: non-CASCADE DROP EXTENSION is blocked in '$db' (dependency guard effective)"
}

drop_guard() {
local db=$1
psql -d "$db" -v ON_ERROR_STOP=1 -f bin/test_existing.sql/drop_guard.sql
}

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: count_nulls 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
# current). Use `if`, not `&&`: a false test under `set -e` would abort.
if [ -n "$to" ]; then to="TO '$to'"; fi
psql_do "$db" -c "ALTER EXTENSION count_nulls UPDATE $to"
}

# CREATE EXTENSION count_nulls at VERSION, targeting SCHEMA - unless SCHEMA
# is empty, in which case it's created untouched, wherever the session's
# own default search_path resolves (ordinarily 'public'). A quoted empty
# identifier ("") is a real Postgres syntax error, so this can't just always
# emit `CREATE SCHEMA IF NOT EXISTS "$schema"` - the empty case has to skip
# that entirely, mirroring test/deps.sql's :count_nulls_has_schema branch.
create_extension_in_schema() {
local db=$1 schema=$2 version=$3 sql=""
if [ -n "$schema" ]; then
sql="CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; "
fi
psql_do "$db" -c "${sql}CREATE EXTENSION count_nulls VERSION '$version'"
}

# ---------------------------------------------------------------------------
# Subcommand implementations
# ---------------------------------------------------------------------------

# prepare-old DB SCHEMA INSTALL_VERSION
# Old-cluster preparation for pg-upgrade-test: create the database and the
# extension at INSTALL_VERSION in SCHEMA, then plant + prove the guard. No
# bridge-update step first: count_nulls ships no SELECT-*-over-catalog
# views, so it has no known pg_upgrade-unsafe old version to bridge past.
prepare_old() {
local db=$1 schema=$2 install=$3
createdb "$db"
create_extension_in_schema "$db" "$schema" "$install"
plant_guard "$db" "$schema"
}

# update-scenario DB SCHEMA FROM_VERSION
# Full extension-update flow that runs the existing-mode suite: create the
# DB and extension at FROM_VERSION in SCHEMA, plant + prove the guard,
# update to the current version, then run the suite against that real
# updated database.
update_scenario() {
local db=$1 schema=$2 from=$3
createdb "$db"
create_extension_in_schema "$db" "$schema" "$from"
plant_guard "$db" "$schema"
update_ext "$db"
run_suite "$db" "$schema"
}

# 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/upgrade), drops the guard
# (see the file header for why - count_nulls's own suite legitimately drops
# the extension, harmlessly, inside a transaction that's always rolled
# back), then runs the suite via --use-existing so pg_regress does NOT
# drop/recreate the database.
run_suite() {
local db=$1 schema=$2
assert_version "$db" current
assert_drop_blocked "$db"
drop_guard "$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:
# 1. PGXNTOOL_ENABLE_TEST_BUILD=no: base.mk auto-enables the test-build
# sanity check (test/build/upgrade.sql), adding it 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. test-build is a fresh-install
# smoke check already run by the fresh `test` job on every PG version
# - it adds nothing here.
# 2. `make verify-results` (not just `make test`) is the real pass/fail
# gate: base.mk's `.IGNORE: installcheck` means `make test` prints
# regression.diffs but exits 0 regardless. verify-results depends on
# `test`, so it must carry the SAME existing-mode overrides or it
# would re-run FRESH (against a new throwaway DB) instead of
# verifying THIS existing database.
local existing_args="TEST_LOAD_SOURCE=existing TEST_SCHEMA=$schema CONTRIB_TESTDB=$db EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no"
make test $existing_args
make verify-results $existing_args
}

usage() {
echo "usage: bin/test_existing <subcommand> [args]" >&2
echo " plant-guard DB SCHEMA" >&2
echo " update DB [TO_VERSION]" >&2
echo " prepare-old DB SCHEMA INSTALL_VERSION" >&2
echo " run-suite DB SCHEMA" >&2
echo " update-scenario DB SCHEMA FROM_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 "$@" ;;
prepare-old) prepare_old "$@" ;;
run-suite) run_suite "$@" ;;
update-scenario) update_scenario "$@" ;;
*) usage ;;
esac
}

main "$@"
35 changes: 35 additions & 0 deletions bin/test_existing.sql/assert_guard.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Proves the guard planted by plant_guard.sql actually blocks a
* non-CASCADE DROP EXTENSION - prove it, don't assume it. Re-run after
* every step (install, pg_upgrade, post-upgrade ALTER EXTENSION UPDATE):
* the guard disappearing at any point means a CASCADE drop happened
* somewhere upstream, i.e. the "existing" run downstream would actually be
* a silent fresh install.
*
* Usage: psql -v ON_ERROR_STOP=1 -f assert_guard.sql
*/
\set ON_ERROR_STOP on

DO $$
BEGIN
DROP EXTENSION count_nulls;
-- Only reached if the drop above unexpectedly succeeded.
RAISE EXCEPTION 'GUARD FAILURE: non-CASCADE DROP EXTENSION count_nulls unexpectedly succeeded';
EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'guard held: DROP EXTENSION count_nulls correctly blocked';
END
$$;

DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'count_nulls') THEN
RAISE EXCEPTION 'GUARD FAILURE: count_nulls extension missing after guard check';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'guard' AND n.nspname = 'count_nulls_drop_guard'
) THEN
RAISE EXCEPTION 'GUARD FAILURE: count_nulls_drop_guard.guard view missing';
END IF;
END
$$;
11 changes: 11 additions & 0 deletions bin/test_existing.sql/drop_guard.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Removes the guard (plant_guard.sql) once its job is done - proving the
* install/pg_upgrade/update steps didn't corrupt the real extension - so it
* doesn't then block the pgTap suite's own DROP EXTENSION test
* (test__shutdown__drop_all, run in a transaction that's rolled back
* regardless, so re-dropping the real extension there is harmless).
*
* Usage: psql -v ON_ERROR_STOP=1 -f drop_guard.sql
*/
\set ON_ERROR_STOP on
DROP SCHEMA count_nulls_drop_guard CASCADE;
30 changes: 30 additions & 0 deletions bin/test_existing.sql/plant_guard.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Dependency guard: plants an object with a hard pg_depend dependency on a
* stable, never-dropped/redefined extension member (null_count(anyarray),
* unchanged since 0.9.0), so that a non-CASCADE DROP EXTENSION count_nulls
* is blocked. Used by the pg_upgrade CI job to prove a real pg_upgrade/
* update run didn't silently destroy the extension it's meant to be
* testing (a stray CASCADE drop, a logic bug, a bad CI step would
* otherwise fall through to a silent fresh reinstall and the job would
* still report green).
*
* Usage: psql -v ON_ERROR_STOP=1 -v schema=<schema-or-empty> -f plant_guard.sql
* (empty schema means "wherever null_count already resolves unqualified" -
* i.e. the extension was installed without targeting a schema).
*/
\set ON_ERROR_STOP on

/*
* schema_prefix: either empty, or the quoted schema name followed by a
* literal '.' - so the view definition below is a single statement with a
* plain (unquoted) substitution, rather than branching the whole CREATE
* VIEW on whether a schema was given. quote_ident(), not :"schema" -
* :schema_prefix is pasted as-is (unquoted substitution), so it must
* already be valid, properly-quoted SQL text by the time it lands there.
*/
SELECT CASE WHEN :'schema' <> '' THEN quote_ident(:'schema') || '.' ELSE '' END AS schema_prefix
\gset

CREATE SCHEMA IF NOT EXISTS count_nulls_drop_guard;
CREATE OR REPLACE VIEW count_nulls_drop_guard.guard AS
SELECT :schema_prefix null_count(NULL::int, NULL::int) AS guarded_member;
Loading
Loading