diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4aaec9..f04ee8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,129 @@ name: CI on: [push, pull_request] jobs: + # Cheap gate that lets the heavy jobs below skip themselves on commits that + # touch only docs. Must run on every push/pull_request (no paths-ignore on + # the workflow itself), otherwise the required all-checks-passed check + # would never report on doc-only pushes and get stuck Pending in branch + # protection. + # + # Also derives, from a SINGLE set of constants, the supported-PostgreSQL- + # major list the test / extension-update jobs consume (see the "Derive ..." + # step) - per Postgres-Extensions/cat_tools PR #48, taken near-verbatim: + # count_nulls has no legacy-floor complication (unlike cat_tools's PG10-only + # pre-0.2.2 scripts), so this needs only ONE list, not cat_tools's three. + changes: + name: 🔍 Detect docs-only changes & derive PG matrix + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.diff.outputs.docs_only }} + supported_pg: ${{ steps.pg.outputs.supported_pg }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + # Full history needed so BASE and HEAD below are both reachable + # for `git diff`. + fetch-depth: 0 + - name: Compute per-push changed files + id: diff + run: | + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.action }}" = "synchronize" ] && \ + [ -n "${{ github.event.before }}" ]; then + # A push to an already-open PR: before/after give the true + # per-push diff, same as for a branch push. + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + elif [ "${{ github.event_name }}" = "pull_request" ]; then + # First run for this PR (opened/reopened/etc, or synchronize + # without a usable before): fall back to the whole base...head + # diff. + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + fi + + echo "base=$BASE" + echo "head=$HEAD" + + # Fail-safe: a missing HEAD, or an all-zeros BASE (e.g. a new + # branch's first push, where GitHub reports no prior commit), + # means we can't compute a real diff. Run the full matrix rather + # than risk skipping tests. + if [ -z "$HEAD" ] || [ -z "$BASE" ] || [[ "$BASE" =~ ^0+$ ]]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff --name-only "$BASE" "$HEAD" || echo __DIFF_FAILED__) + + DOCS_ONLY=true + if [ "$CHANGED" = "__DIFF_FAILED__" ] || [ -z "$CHANGED" ]; then + DOCS_ONLY=false + else + while IFS= read -r f; do + if ! [[ "$f" =~ \.(md|asc)$ ]]; then + DOCS_ONLY=false + break + fi + done <<< "$CHANGED" + fi + + echo "changed files:" + echo "$CHANGED" + echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" + + - name: Derive the supported-PostgreSQL-major list + id: pg + run: | + # Spending a dozen-odd lines to replace what looks like a handful of + # version references is worth doing anyway: the point is + # CONSISTENCY - both the fresh-install `test` matrix and the + # `extension-update-test` matrix derive their PostgreSQL set from + # this ONE source, so they cannot silently drift onto different + # lists. Adding a new major is a one-line NEWEST bump here, not an + # edit in N places. + # + # count_nulls has only ONE floor (unlike cat_tools's separate + # current-version floor and legacy pre-0.2.2 floor): 0.9.6 is a + # pure-SQL-function install script with no catalog-version + # sensitivity, so it installs on every PostgreSQL major count_nulls + # supports - there is no PG10-only legacy leg to carve out. + NEWEST=17 + FLOOR=10 + + supported=$(seq "$NEWEST" -1 "$FLOOR") + + # Emit a JSON array from a list of ints, for the job matrices to + # consume with fromJSON (GitHub evaluates a literal dollar-brace + # expression even inside a run block, so none is written here). + json() { printf '%s\n' "$@" | paste -sd, - | sed 's/^/[/; s/$/]/'; } + + echo "supported_pg=$(json $supported)" >> "$GITHUB_OUTPUT" + + # Baseline: fresh CREATE EXTENSION, across the PG matrix AND a schema + # matrix (TEST_SCHEMA, picked up from the environment by test/deps.sql via + # the count_nulls.test_schema GUC - see the Makefile). 'public' is the + # common case; 'Quoted' requires SQL identifier quoting (mixed case - + # unquoted would fold to lowercase and silently test 'public' again), + # exercising the suite's %I schema-qualification rather than just its + # literal test data. test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: - pg: [17, 16, 15, 14, 13, 12, 11, 10] - name: 🐘 PostgreSQL ${{ matrix.pg }} + # From the single source in the changes job. + pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }} + schema: [public, Quoted] + name: 🐘 PostgreSQL ${{ matrix.pg }} (schema ${{ matrix.schema }}) runs-on: ubuntu-latest container: pgxn/pgxn-tools + env: + TEST_SCHEMA: ${{ matrix.schema }} steps: - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} @@ -15,3 +131,185 @@ jobs: uses: actions/checkout@v4 - name: Test on PostgreSQL ${{ matrix.pg }} run: pg-build-test + + # Proves the in-place extension update path: CREATE EXTENSION at the + # oldest version we still ship a full install script for (0.9.6), then + # ALTER EXTENSION UPDATE (no pg_upgrade, same PostgreSQL), then run the + # FULL suite against the updated database (same expected output as a + # fresh install of the same schema, so an updated DB must behave + # identically). Complements pg-upgrade-test, which covers the + # cross-major-version binary upgrade instead. + extension-update-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + strategy: + matrix: + # From the single source in the changes job. + pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }} + schema: [public, Quoted] + name: ⬆️ Extension update test on PostgreSQL ${{ matrix.pg }} (schema ${{ matrix.schema }}) + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + steps: + - name: Start PostgreSQL ${{ matrix.pg }} + run: pg-start ${{ matrix.pg }} + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install count_nulls + run: make install + - name: Update 0.9.6 -> current and run the suite (existing mode) + # update-scenario creates a real database at 0.9.6, plants + proves + # the dependency guard, ALTER EXTENSION UPDATEs to the current + # version, and runs the suite against that updated database in + # existing mode (asserting the version and that the guard still + # blocked a drop, before dropping it itself - see bin/test_existing). + run: bin/test_existing update-scenario count_nulls_update "${{ matrix.schema }}" 0.9.6 + + # Proves count_nulls survives a BINARY pg_upgrade (in-place catalog + # migration to a newer PostgreSQL major), not just an in-place extension + # update. Installs 0.9.6 on an old cluster, plants a dependency guard, + # binary-pg_upgrades to a newer cluster, updates the extension to current, + # then runs the suite against the REAL migrated objects in existing mode. + # No bridge-update step (unlike cat_tools's pg-upgrade-test, which has to + # dig itself out of pg_upgrade-unsafe versions it shipped before this + # testing pattern existed): count_nulls has always been pure SQL functions + # with no SELECT-*-over-catalog views, so it has no known + # pg_upgrade-unsafe old version to bridge past + # (advanced-extension-testing.md #7/#9). + # + # Deliberately NOT adding cat_tools's companion `pg-upgrade-stepwise` job + # (one cluster climbing every major in sequence, vs. the single big jumps + # here): that job exists to catch a regression specific to one particular + # major-to-major boundary, which matters for cat_tools's views/functions + # that touch catalog internals (advanced-extension-testing.md #7) but not + # for count_nulls - pure SQL functions over anyarray/json/jsonb, nothing + # version-sensitive to break at a specific boundary. The two big-jump legs + # below (10->17, 12->17) already cover the axis that matters here. Revisit + # if count_nulls ever grows something catalog-touching. + pg-upgrade-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + strategy: + matrix: + include: + - old_pg: "10" + new_pg: "17" + schema: public + - old_pg: "10" + new_pg: "17" + schema: Quoted + - old_pg: "12" + new_pg: "17" + schema: public + - old_pg: "12" + new_pg: "17" + schema: Quoted + name: 🔄 Binary pg_upgrade ${{ matrix.old_pg }} → ${{ matrix.new_pg }} (schema ${{ matrix.schema }}) + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + env: + # Both clusters must use the same initdb options or pg_upgrade + # refuses to run. + INITDB_OPTS: --data-checksums --auth trust + steps: + - name: Start PostgreSQL ${{ matrix.old_pg }} + run: pg-start ${{ matrix.old_pg }} + - name: Recreate old cluster with data checksums enabled + run: | + pg_ctlcluster ${{ matrix.old_pg }} test stop + pg_dropcluster ${{ matrix.old_pg }} test + # -p 5432: pg_createcluster assigns the next available port, which + # may not be 5432 after pg-start has claimed and released it. + # Force 5432 so subsequent psql/createdb calls connect without -p. + pg_createcluster -p 5432 ${{ matrix.old_pg }} test -- $INITDB_OPTS + pg_ctlcluster ${{ matrix.old_pg }} test start + pg_isready -t 30 + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install count_nulls into old cluster + run: make install + - name: Prepare the old cluster (install + dependency guard) + # prepare-old installs count_nulls at 0.9.6 in the leg's schema, + # then plants + proves the dependency guard, so a later accidental + # CASCADE drop anywhere in this job cannot silently make the + # eventual existing-mode run test a fresh install instead. + run: bin/test_existing prepare-old count_nulls_upgrade "${{ matrix.schema }}" 0.9.6 + - name: Install PostgreSQL ${{ matrix.new_pg }} + run: apt-get install -y postgresql-${{ matrix.new_pg }} postgresql-server-dev-${{ matrix.new_pg }} + - name: Install count_nulls into new cluster + # PG_CONFIG must be specified explicitly: at this point both old + # and new PostgreSQL are installed, and the default pg_config on + # PATH may not be the new version's. + run: make install PG_CONFIG=/usr/lib/postgresql/${{ matrix.new_pg }}/bin/pg_config + - name: Stop old cluster, binary pg_upgrade to PostgreSQL ${{ matrix.new_pg }}, start new cluster + run: | + pg_ctlcluster ${{ matrix.old_pg }} test stop + pg_createcluster -p 5432 ${{ matrix.new_pg }} test -- $INITDB_OPTS + # PG17+ writes logs to $new_datadir/pg_upgrade_output.d/; older + # versions write to CWD. Search both on failure. + mkdir -p /tmp/pg_upgrade_logs + chown postgres:postgres /tmp/pg_upgrade_logs + su -c "cd /tmp/pg_upgrade_logs && /usr/lib/postgresql/${{ matrix.new_pg }}/bin/pg_upgrade \ + -b /usr/lib/postgresql/${{ matrix.old_pg }}/bin \ + -B /usr/lib/postgresql/${{ matrix.new_pg }}/bin \ + -d /var/lib/postgresql/${{ matrix.old_pg }}/test \ + -D /var/lib/postgresql/${{ matrix.new_pg }}/test \ + -o '-c config_file=/etc/postgresql/${{ matrix.old_pg }}/test/postgresql.conf' \ + -O '-c config_file=/etc/postgresql/${{ matrix.new_pg }}/test/postgresql.conf'" postgres \ + || { find /tmp/pg_upgrade_logs \ + /var/lib/postgresql/${{ matrix.new_pg }}/test/pg_upgrade_output.d \ + -name '*.log' 2>/dev/null | sort | xargs -r tail -n +1; exit 1; } + pg_ctlcluster ${{ matrix.new_pg }} test start + - name: Update the pg_upgraded extension to the current version + # Exercises ALTER EXTENSION UPDATE on genuinely pg_upgraded objects + # (the extension binary pg_upgrade just migrated), running the + # 0.9.6->1.0.0 update script. + run: bin/test_existing update count_nulls_upgrade + - name: Run the suite against the pg_upgraded database (existing mode) + # run-suite asserts the version, re-proves the dependency guard + # still blocks a non-CASCADE drop (i.e. it survived pg_upgrade), + # drops the guard, then runs the suite against the REAL pg_upgraded + # + updated database via --use-existing (so pg_regress does not + # drop/recreate it) - a plain fresh `make test` would silently test + # a fresh install instead of the migrated objects. + run: bin/test_existing run-suite count_nulls_upgrade "${{ matrix.schema }}" + + # A single stable check name for use as a required status check in branch + # protection rules. Matrix jobs produce check names like + # "🐘 PostgreSQL 14 (schema public)" which would all need to be listed + # individually and updated whenever the matrix changes. This job passes if + # all others passed or were skipped (e.g. the heavy jobs gated off by the + # `changes` job on a docs-only push), and fails if any failed or were + # cancelled. + all-checks-passed: + needs: [changes, test, extension-update-test, pg-upgrade-test] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify all jobs are listed in needs + # Ensures this job won't silently ignore a newly-added job that was + # omitted from the needs list above. + run: | + DEFINED=$(python3 -c " + import yaml + with open('.github/workflows/ci.yml') as f: + w = yaml.safe_load(f) + print('\n'.join(sorted(j for j in w['jobs'] if j != 'all-checks-passed'))) + ") + NEEDED=$(echo '${{ toJson(needs) }}' | python3 -c " + import json, sys + print('\n'.join(sorted(json.load(sys.stdin)))) + ") + if [ "$DEFINED" != "$NEEDED" ]; then + echo "Some jobs are missing from all-checks-passed needs:" + diff <(echo "$DEFINED") <(echo "$NEEDED") + exit 1 + fi + - name: Check all jobs passed or were skipped + run: | + if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi +# vi: expandtab ts=2 sw=2 diff --git a/Makefile b/Makefile index e57011a..269de69 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,65 @@ 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. +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= 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). Combined with TEST_LOAD_SOURCE this +# drives a schema x mode CI matrix; in particular, a schema whose name +# requires SQL identifier quoting (mixed case - unquoted would silently fold +# to lowercase and test the wrong, matching schema instead) needs its own +# leg, not just 'public'. Locally: `make test TEST_SCHEMA=Quoted`. +# +# Propagated the same way as TEST_LOAD_SOURCE: via the count_nulls.test_schema +# GUC, exported unconditionally through PGOPTIONS. +TEST_SCHEMA ?= public +ifeq ($(strip $(TEST_SCHEMA)),) +$(error TEST_SCHEMA must not be empty) +endif +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 diff --git a/bin/test_existing b/bin/test_existing new file mode 100755 index 0000000..f729760 --- /dev/null +++ b/bin/test_existing @@ -0,0 +1,225 @@ +#!/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, with +# two differences: count_nulls has no pg_upgrade-unsafe old versions to bridge +# past (advanced-extension-testing.md #7/#9 don't apply - it ships no +# SELECT-*-over-catalog views), and its suite has its OWN 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 [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 +# test/pg_upgrade/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 test/pg_upgrade/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 test/pg_upgrade/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 test/pg_upgrade/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" +} + +# --------------------------------------------------------------------------- +# 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 step (unlike cat_tools): count_nulls ships no +# SELECT-*-over-catalog views, so it has no known pg_upgrade-unsafe old +# version to bridge past (advanced-extension-testing.md #7/#9). +prepare_old() { + local db=$1 schema=$2 install=$3 + createdb "$db" + psql_do "$db" -c "CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; CREATE EXTENSION count_nulls VERSION '$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" + psql_do "$db" -c "CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; CREATE EXTENSION count_nulls VERSION '$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 + # (advanced-extension-testing.md #8). 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 [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 "$@" diff --git a/test/build/expected/upgrade.out b/test/build/expected/upgrade.out new file mode 100644 index 0000000..25fdbb1 --- /dev/null +++ b/test/build/expected/upgrade.out @@ -0,0 +1 @@ +\set ECHO none diff --git a/test/build/upgrade.sql b/test/build/upgrade.sql new file mode 100644 index 0000000..c5a5946 --- /dev/null +++ b/test/build/upgrade.sql @@ -0,0 +1,11 @@ +\set ECHO none +\i test/pgxntool/psql.sql +\t + +-- Sanity check: install the oldest available version and upgrade to current. +-- sql/count_nulls--0.9.6.sql is the oldest full install script we still ship +-- (0.9.0/0.9.2/0.9.5 only exist as upgrade diffs, not standalone installs). +BEGIN; +CREATE EXTENSION count_nulls VERSION '0.9.6'; +ALTER EXTENSION count_nulls UPDATE; +ROLLBACK; diff --git a/test/core/functions.sql b/test/core/functions.sql index 482526c..e5120b0 100644 --- a/test/core/functions.sql +++ b/test/core/functions.sql @@ -219,6 +219,10 @@ BEGIN END $body$; -SET SEARCH_PATH = _null_count_test, tap, :schema; +-- Deliberately does NOT restore :"schema" (the schema count_nulls is +-- installed into) onto search_path here - whether the caller wants it there +-- (testing unqualified/on-search-path use) or not (testing fully-qualified/ +-- off-search-path use) differs per test file, so each caller sets its own +-- search_path explicitly after \i'ing this file. -- vi: expandtab sw=2 ts=2 diff --git a/test/deps.sql b/test/deps.sql index 9705f9f..458674d 100644 --- a/test/deps.sql +++ b/test/deps.sql @@ -1,8 +1,98 @@ -- Add any test dependency statements here --- IF NOT EXISTS will emit NOTICEs, which is annoying + +/* + * Mode selection: 'fresh' installs the current version directly; 'update' + * installs the oldest version we still ship a full script for (0.9.6) and + * runs ALTER EXTENSION UPDATE; 'existing' asserts the extension is already + * installed (a real `pg_upgrade` leg, or an out-of-band update) and touches + * nothing. Since every test file loads this via test/load.sql, running the + * suite in each mode (make test / make test-update / make test + * TEST_LOAD_SOURCE=existing) exercises the SAME tests against a fresh vs. + * updated vs. really-upgraded install, with the same expected output either + * way. + * + * "update" here is extension-level (ALTER EXTENSION UPDATE); it is not + * "upgrade" (cluster-level pg_upgrade) - 'existing' is how that axis is + * exercised, via a real pg_upgrade run outside this test suite (see the + * pg_upgrade CI job). + * + * The Makefile always exports this GUC, so we read it without missing_ok: + * if it failed to propagate we want a loud error here, not a silent + * fall-through to 'fresh'. + */ +SELECT current_setting('count_nulls.test_load_mode') AS count_nulls_test_load_mode +\gset + +DO $$ +BEGIN + IF current_setting('count_nulls.test_load_mode') NOT IN ('fresh', 'update', 'existing') THEN + RAISE EXCEPTION + 'count_nulls.test_load_mode must be ''fresh'', ''update'' or ''existing'', got ''%''' + , current_setting('count_nulls.test_load_mode') + ; + END IF; +END +$$; + +SELECT :'count_nulls_test_load_mode' = 'update' AS count_nulls_update_mode +\gset +SELECT :'count_nulls_test_load_mode' = 'existing' AS count_nulls_existing_mode +\gset + +/* + * TEST_SCHEMA (the count_nulls.test_schema GUC, set via the Makefile): + * which schema the extension is installed into, for the WHOLE test run - + * the SAME schema for every test file, not a different literal hardcoded + * per file. This is what drives the schema x mode CI matrix: at minimum + * 'public' (the default) and a schema whose name requires SQL identifier + * quoting (mixed case - unquoted would fold to lowercase and silently test + * a different, matching schema instead), to exercise test files' %I + * schema-qualification rather than just their literal test data. + * + * Read with :"schema" (quoted-identifier form) wherever used as a bare + * identifier below and in the test files, since the quoting-required case + * is exactly what this exists to exercise. + */ +SELECT current_setting('count_nulls.test_schema') AS schema +\gset + +-- :"schema" is created/searched in every mode, including 'existing', even +-- though 'existing' mode never installs the extension into it: test files +-- (e.g. test__shutdown__drop_all's `DROP SCHEMA :"schema"`) expect it to +-- exist regardless of mode, and an empty schema is harmless. IF NOT EXISTS +-- emits NOTICEs, which is annoying. SET client_min_messages = WARNING; -CREATE SCHEMA IF NOT EXISTS :schema; -SET search_path = :schema; +CREATE SCHEMA IF NOT EXISTS :"schema"; +SET search_path = :"schema"; SET client_min_messages = NOTICE; +\if :count_nulls_existing_mode +-- Extension is already installed (real pg_upgrade, or an out-of-band +-- update) - assert it's present and current, but do NOT drop/create/update +-- it. The CI job driving this mode is responsible for having installed the +-- real extension into :"schema" (matching this run's TEST_SCHEMA) before +-- getting here, so the rest of the suite sees the same schema regardless of +-- mode. +DO $$ +DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'); + v_default text := (SELECT default_version FROM pg_available_extensions WHERE name = 'count_nulls'); +BEGIN + IF v_installed IS NULL THEN + RAISE EXCEPTION 'count_nulls.test_load_mode=existing but count_nulls is not installed'; + END IF; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'count_nulls installed at % but default_version is %', v_installed, v_default; + END IF; +END +$$; +\elif :count_nulls_update_mode +CREATE EXTENSION count_nulls VERSION '0.9.6'; +-- Suppress the "already installed, no update" NOTICE class of messages any +-- update script might emit. +SET client_min_messages = WARNING; +ALTER EXTENSION count_nulls UPDATE; +SET client_min_messages = NOTICE; +\else CREATE EXTENSION count_nulls; +\endif diff --git a/test/expected/extension_tests.out b/test/expected/extension_tests.out index 3a22c24..3d213b5 100644 --- a/test/expected/extension_tests.out +++ b/test/expected/extension_tests.out @@ -1,80 +1,80 @@ \set ECHO none # Subtest: _null_count_test.test__check_ncs() ok 1 - ok 2 - schema schema_to_load_count_nulls should not be in search path + ok 2 - schema public should not be in search path 1..2 ok 1 - _null_count_test.test__check_ncs # Subtest: _null_count_test.test__definition() ok 1 - ensure null_count({anyarray}) is not in search_path - ok 2 - Function schema_to_load_count_nulls.null_count(anyarray) should return integer - ok 3 - Function schema_to_load_count_nulls.null_count(anyarray) should not be strict - ok 4 - Function schema_to_load_count_nulls.null_count(anyarray) should be IMMUTABLE + ok 2 - Function public.null_count(anyarray) should return integer + ok 3 - Function public.null_count(anyarray) should not be strict + ok 4 - Function public.null_count(anyarray) should be IMMUTABLE ok 5 - ensure null_count({json}) is not in search_path - ok 6 - Function schema_to_load_count_nulls.null_count(json) should return integer - ok 7 - Function schema_to_load_count_nulls.null_count(json) should not be strict - ok 8 - Function schema_to_load_count_nulls.null_count(json) should be IMMUTABLE + ok 6 - Function public.null_count(json) should return integer + ok 7 - Function public.null_count(json) should not be strict + ok 8 - Function public.null_count(json) should be IMMUTABLE ok 9 - ensure null_count({jsonb}) is not in search_path - ok 10 - Function schema_to_load_count_nulls.null_count(jsonb) should return integer - ok 11 - Function schema_to_load_count_nulls.null_count(jsonb) should not be strict - ok 12 - Function schema_to_load_count_nulls.null_count(jsonb) should be IMMUTABLE + ok 10 - Function public.null_count(jsonb) should return integer + ok 11 - Function public.null_count(jsonb) should not be strict + ok 12 - Function public.null_count(jsonb) should be IMMUTABLE ok 13 - ensure not_null_count({anyarray}) is not in search_path - ok 14 - Function schema_to_load_count_nulls.not_null_count(anyarray) should return integer - ok 15 - Function schema_to_load_count_nulls.not_null_count(anyarray) should not be strict - ok 16 - Function schema_to_load_count_nulls.not_null_count(anyarray) should be IMMUTABLE + ok 14 - Function public.not_null_count(anyarray) should return integer + ok 15 - Function public.not_null_count(anyarray) should not be strict + ok 16 - Function public.not_null_count(anyarray) should be IMMUTABLE ok 17 - ensure not_null_count({json}) is not in search_path - ok 18 - Function schema_to_load_count_nulls.not_null_count(json) should return integer - ok 19 - Function schema_to_load_count_nulls.not_null_count(json) should not be strict - ok 20 - Function schema_to_load_count_nulls.not_null_count(json) should be IMMUTABLE + ok 18 - Function public.not_null_count(json) should return integer + ok 19 - Function public.not_null_count(json) should not be strict + ok 20 - Function public.not_null_count(json) should be IMMUTABLE ok 21 - ensure not_null_count({jsonb}) is not in search_path - ok 22 - Function schema_to_load_count_nulls.not_null_count(jsonb) should return integer - ok 23 - Function schema_to_load_count_nulls.not_null_count(jsonb) should not be strict - ok 24 - Function schema_to_load_count_nulls.not_null_count(jsonb) should be IMMUTABLE - ok 25 - Function schema_to_load_count_nulls.null_count_trigger() should return trigger - ok 26 - Function schema_to_load_count_nulls.null_count_trigger() should not be strict - ok 27 - Function schema_to_load_count_nulls.null_count_trigger() should be IMMUTABLE - ok 28 - Function schema_to_load_count_nulls.not_null_count_trigger() should return trigger - ok 29 - Function schema_to_load_count_nulls.not_null_count_trigger() should not be strict - ok 30 - Function schema_to_load_count_nulls.not_null_count_trigger() should be IMMUTABLE + ok 22 - Function public.not_null_count(jsonb) should return integer + ok 23 - Function public.not_null_count(jsonb) should not be strict + ok 24 - Function public.not_null_count(jsonb) should be IMMUTABLE + ok 25 - Function public.null_count_trigger() should return trigger + ok 26 - Function public.null_count_trigger() should not be strict + ok 27 - Function public.null_count_trigger() should be IMMUTABLE + ok 28 - Function public.not_null_count_trigger() should return trigger + ok 29 - Function public.not_null_count_trigger() should not be strict + ok 30 - Function public.not_null_count_trigger() should be IMMUTABLE 1..30 ok 2 - _null_count_test.test__definition # Subtest: _null_count_test.test__functionality() - ok 1 - Test schema_to_load_count_nulls.null_count(a, b, c) - ok 2 - Test schema_to_load_count_nulls.null_count(json) - ok 3 - Test schema_to_load_count_nulls.null_count(jsonb) - ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger( NULL ) - ok 5 - Test schema_to_load_count_nulls.not_null_count_trigger( NULL ) + ok 1 - Test public.null_count(a, b, c) + ok 2 - Test public.null_count(json) + ok 3 - Test public.null_count(jsonb) + ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger( NULL ) + ok 5 - Test public.not_null_count_trigger( NULL ) ok 6 - DROP TRIGGER "test trigger" - ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger( ) - ok 8 - Test schema_to_load_count_nulls.not_null_count_trigger( ) + ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger( ) + ok 8 - Test public.not_null_count_trigger( ) ok 9 - DROP TRIGGER "test trigger" - ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger( NULL ) - ok 11 - Test schema_to_load_count_nulls.null_count_trigger( NULL ) + ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger( NULL ) + ok 11 - Test public.null_count_trigger( NULL ) ok 12 - DROP TRIGGER "test trigger" - ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger( ) - ok 14 - Test schema_to_load_count_nulls.null_count_trigger( ) + ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger( ) + ok 14 - Test public.null_count_trigger( ) ok 15 - DROP TRIGGER "test trigger" - ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger(1, 'error_message') + ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, 'error_message') ok 17 - Test "null_BEFORE_error_message" ok 18 - DROP TRIGGER "null_BEFORE_error_message" - ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger(1, 'error_message') + ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, 'error_message') ok 20 - Test "null_AFTER_error_message" ok 21 - DROP TRIGGER "null_AFTER_error_message" - ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger(1, 'error_message') + ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, 'error_message') ok 23 - Test "not_null_BEFORE_error_message" ok 24 - DROP TRIGGER "not_null_BEFORE_error_message" - ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger(1, 'error_message') + ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, 'error_message') ok 26 - Test "not_null_AFTER_error_message" ok 27 - DROP TRIGGER "not_null_AFTER_error_message" - ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger(1, NULL) + ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, NULL) ok 29 - Test "null_BEFORE_" ok 30 - DROP TRIGGER "null_BEFORE_" - ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.null_count_trigger(1, NULL) + ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, NULL) ok 32 - Test "null_AFTER_" ok 33 - DROP TRIGGER "null_AFTER_" - ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger(1, NULL) + ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, NULL) ok 35 - Test "not_null_BEFORE_" ok 36 - DROP TRIGGER "not_null_BEFORE_" - ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE schema_to_load_count_nulls.not_null_count_trigger(1, NULL) + ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, NULL) ok 38 - Test "not_null_AFTER_" ok 39 - DROP TRIGGER "not_null_AFTER_" 1..39 diff --git a/test/expected/extension_tests_1.out b/test/expected/extension_tests_1.out new file mode 100644 index 0000000..7466a35 --- /dev/null +++ b/test/expected/extension_tests_1.out @@ -0,0 +1,87 @@ +\set ECHO none +# Subtest: _null_count_test.test__check_ncs() + ok 1 + ok 2 - schema "Quoted" should not be in search path + 1..2 +ok 1 - _null_count_test.test__check_ncs +# Subtest: _null_count_test.test__definition() + ok 1 - ensure null_count({anyarray}) is not in search_path + ok 2 - Function "Quoted".null_count(anyarray) should return integer + ok 3 - Function "Quoted".null_count(anyarray) should not be strict + ok 4 - Function "Quoted".null_count(anyarray) should be IMMUTABLE + ok 5 - ensure null_count({json}) is not in search_path + ok 6 - Function "Quoted".null_count(json) should return integer + ok 7 - Function "Quoted".null_count(json) should not be strict + ok 8 - Function "Quoted".null_count(json) should be IMMUTABLE + ok 9 - ensure null_count({jsonb}) is not in search_path + ok 10 - Function "Quoted".null_count(jsonb) should return integer + ok 11 - Function "Quoted".null_count(jsonb) should not be strict + ok 12 - Function "Quoted".null_count(jsonb) should be IMMUTABLE + ok 13 - ensure not_null_count({anyarray}) is not in search_path + ok 14 - Function "Quoted".not_null_count(anyarray) should return integer + ok 15 - Function "Quoted".not_null_count(anyarray) should not be strict + ok 16 - Function "Quoted".not_null_count(anyarray) should be IMMUTABLE + ok 17 - ensure not_null_count({json}) is not in search_path + ok 18 - Function "Quoted".not_null_count(json) should return integer + ok 19 - Function "Quoted".not_null_count(json) should not be strict + ok 20 - Function "Quoted".not_null_count(json) should be IMMUTABLE + ok 21 - ensure not_null_count({jsonb}) is not in search_path + ok 22 - Function "Quoted".not_null_count(jsonb) should return integer + ok 23 - Function "Quoted".not_null_count(jsonb) should not be strict + ok 24 - Function "Quoted".not_null_count(jsonb) should be IMMUTABLE + ok 25 - Function "Quoted".null_count_trigger() should return trigger + ok 26 - Function "Quoted".null_count_trigger() should not be strict + ok 27 - Function "Quoted".null_count_trigger() should be IMMUTABLE + ok 28 - Function "Quoted".not_null_count_trigger() should return trigger + ok 29 - Function "Quoted".not_null_count_trigger() should not be strict + ok 30 - Function "Quoted".not_null_count_trigger() should be IMMUTABLE + 1..30 +ok 2 - _null_count_test.test__definition +# Subtest: _null_count_test.test__functionality() + ok 1 - Test "Quoted".null_count(a, b, c) + ok 2 - Test "Quoted".null_count(json) + ok 3 - Test "Quoted".null_count(jsonb) + ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger( NULL ) + ok 5 - Test "Quoted".not_null_count_trigger( NULL ) + ok 6 - DROP TRIGGER "test trigger" + ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger( ) + ok 8 - Test "Quoted".not_null_count_trigger( ) + ok 9 - DROP TRIGGER "test trigger" + ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger( NULL ) + ok 11 - Test "Quoted".null_count_trigger( NULL ) + ok 12 - DROP TRIGGER "test trigger" + ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger( ) + ok 14 - Test "Quoted".null_count_trigger( ) + ok 15 - DROP TRIGGER "test trigger" + ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, 'error_message') + ok 17 - Test "null_BEFORE_error_message" + ok 18 - DROP TRIGGER "null_BEFORE_error_message" + ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, 'error_message') + ok 20 - Test "null_AFTER_error_message" + ok 21 - DROP TRIGGER "null_AFTER_error_message" + ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, 'error_message') + ok 23 - Test "not_null_BEFORE_error_message" + ok 24 - DROP TRIGGER "not_null_BEFORE_error_message" + ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, 'error_message') + ok 26 - Test "not_null_AFTER_error_message" + ok 27 - DROP TRIGGER "not_null_AFTER_error_message" + ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, NULL) + ok 29 - Test "null_BEFORE_" + ok 30 - DROP TRIGGER "null_BEFORE_" + ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, NULL) + ok 32 - Test "null_AFTER_" + ok 33 - DROP TRIGGER "null_AFTER_" + ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, NULL) + ok 35 - Test "not_null_BEFORE_" + ok 36 - DROP TRIGGER "not_null_BEFORE_" + ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, NULL) + ok 38 - Test "not_null_AFTER_" + ok 39 - DROP TRIGGER "not_null_AFTER_" + 1..39 +ok 3 - _null_count_test.test__functionality +# Subtest: _null_count_test.test__shutdown__drop_all() + ok 1 + ok 2 + 1..2 +ok 4 - _null_count_test.test__shutdown__drop_all +1..4 diff --git a/test/expected/simple_1.out b/test/expected/simple_1.out new file mode 100644 index 0000000..39b3388 --- /dev/null +++ b/test/expected/simple_1.out @@ -0,0 +1,71 @@ +\set ECHO none +# Subtest: _null_count_test.test__definition() + ok 1 - Function "Quoted".null_count(anyarray) should return integer + ok 2 - Function "Quoted".null_count(anyarray) should not be strict + ok 3 - Function "Quoted".null_count(anyarray) should be IMMUTABLE + ok 4 - Function "Quoted".null_count(json) should return integer + ok 5 - Function "Quoted".null_count(json) should not be strict + ok 6 - Function "Quoted".null_count(json) should be IMMUTABLE + ok 7 - Function "Quoted".null_count(jsonb) should return integer + ok 8 - Function "Quoted".null_count(jsonb) should not be strict + ok 9 - Function "Quoted".null_count(jsonb) should be IMMUTABLE + ok 10 - Function "Quoted".not_null_count(anyarray) should return integer + ok 11 - Function "Quoted".not_null_count(anyarray) should not be strict + ok 12 - Function "Quoted".not_null_count(anyarray) should be IMMUTABLE + ok 13 - Function "Quoted".not_null_count(json) should return integer + ok 14 - Function "Quoted".not_null_count(json) should not be strict + ok 15 - Function "Quoted".not_null_count(json) should be IMMUTABLE + ok 16 - Function "Quoted".not_null_count(jsonb) should return integer + ok 17 - Function "Quoted".not_null_count(jsonb) should not be strict + ok 18 - Function "Quoted".not_null_count(jsonb) should be IMMUTABLE + ok 19 - Function "Quoted".null_count_trigger() should return trigger + ok 20 - Function "Quoted".null_count_trigger() should not be strict + ok 21 - Function "Quoted".null_count_trigger() should be IMMUTABLE + ok 22 - Function "Quoted".not_null_count_trigger() should return trigger + ok 23 - Function "Quoted".not_null_count_trigger() should not be strict + ok 24 - Function "Quoted".not_null_count_trigger() should be IMMUTABLE + 1..24 +ok 1 - _null_count_test.test__definition +# Subtest: _null_count_test.test__functionality() + ok 1 - Test "Quoted".null_count(a, b, c) + ok 2 - Test "Quoted".null_count(json) + ok 3 - Test "Quoted".null_count(jsonb) + ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger( NULL ) + ok 5 - Test "Quoted".not_null_count_trigger( NULL ) + ok 6 - DROP TRIGGER "test trigger" + ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger( ) + ok 8 - Test "Quoted".not_null_count_trigger( ) + ok 9 - DROP TRIGGER "test trigger" + ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger( NULL ) + ok 11 - Test "Quoted".null_count_trigger( NULL ) + ok 12 - DROP TRIGGER "test trigger" + ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger( ) + ok 14 - Test "Quoted".null_count_trigger( ) + ok 15 - DROP TRIGGER "test trigger" + ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, 'error_message') + ok 17 - Test "null_BEFORE_error_message" + ok 18 - DROP TRIGGER "null_BEFORE_error_message" + ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, 'error_message') + ok 20 - Test "null_AFTER_error_message" + ok 21 - DROP TRIGGER "null_AFTER_error_message" + ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, 'error_message') + ok 23 - Test "not_null_BEFORE_error_message" + ok 24 - DROP TRIGGER "not_null_BEFORE_error_message" + ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, 'error_message') + ok 26 - Test "not_null_AFTER_error_message" + ok 27 - DROP TRIGGER "not_null_AFTER_error_message" + ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, NULL) + ok 29 - Test "null_BEFORE_" + ok 30 - DROP TRIGGER "null_BEFORE_" + ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".null_count_trigger(1, NULL) + ok 32 - Test "null_AFTER_" + ok 33 - DROP TRIGGER "null_AFTER_" + ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, NULL) + ok 35 - Test "not_null_BEFORE_" + ok 36 - DROP TRIGGER "not_null_BEFORE_" + ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE "Quoted".not_null_count_trigger(1, NULL) + ok 38 - Test "not_null_AFTER_" + ok 39 - DROP TRIGGER "not_null_AFTER_" + 1..39 +ok 2 - _null_count_test.test__functionality +1..2 diff --git a/test/pg_upgrade/assert_guard.sql b/test/pg_upgrade/assert_guard.sql new file mode 100644 index 0000000..a427515 --- /dev/null +++ b/test/pg_upgrade/assert_guard.sql @@ -0,0 +1,33 @@ +-- Proves the guard planted by plant_guard.sql actually blocks a +-- non-CASCADE DROP EXTENSION - "prove it, don't assume it" +-- (advanced-extension-testing.md #4). 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 +$$; diff --git a/test/pg_upgrade/drop_guard.sql b/test/pg_upgrade/drop_guard.sql new file mode 100644 index 0000000..ae8dd04 --- /dev/null +++ b/test/pg_upgrade/drop_guard.sql @@ -0,0 +1,9 @@ +-- 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; diff --git a/test/pg_upgrade/plant_guard.sql b/test/pg_upgrade/plant_guard.sql new file mode 100644 index 0000000..a4d7b06 --- /dev/null +++ b/test/pg_upgrade/plant_guard.sql @@ -0,0 +1,14 @@ +-- Dependency guard (advanced-extension-testing.md #4): 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= -f plant_guard.sql +\set ON_ERROR_STOP on +CREATE SCHEMA IF NOT EXISTS count_nulls_drop_guard; +CREATE OR REPLACE VIEW count_nulls_drop_guard.guard AS + SELECT :"schema".null_count(NULL::int, NULL::int) AS guarded_member; diff --git a/test/sql/extension_tests.sql b/test/sql/extension_tests.sql index ba88447..9786fc5 100644 --- a/test/sql/extension_tests.sql +++ b/test/sql/extension_tests.sql @@ -1,17 +1,22 @@ \set ECHO none -\set schema schema_to_load_count_nulls \i test/load.sql -\set schema public \i test/core/functions.sql ---CREATE OR REPLACE FUNCTION ncs() RETURNS name LANGUAGE sql IMMUTABLE AS $$SELECT 'schema_to_load_count_nulls'::name$$; +-- Unlike simple.sql, this file deliberately leaves search_path as +-- functions.sql set it (_null_count_test, tap) - the schema count_nulls is +-- installed into (TEST_SCHEMA / test/deps.sql's :"schema") stays off +-- search_path, so every check below only passes if functions.sql's +-- %I-qualified calls (via ncs()) are actually correct, regardless of which +-- schema TEST_SCHEMA names. CREATE FUNCTION _null_count_test.test__check_ncs () RETURNS SETOF text LANGUAGE plpgsql AS $body$ DECLARE - s CONSTANT name = 'schema_to_load_count_nulls'; + -- current_setting(), not a psql :"schema" substitution: psql does not + -- interpolate variables inside dollar-quoted function bodies. + s CONSTANT name = current_setting('count_nulls.test_schema')::name; BEGIN RETURN NEXT is( ncs() @@ -20,8 +25,7 @@ BEGIN RETURN NEXT is( current_schemas(true) @> array[s] , false - --, format('schema %I should not be in search path (%s)', s, current_schemas(true)) - , format('schema %I should not be in search path', s) --, current_schemas(true)) + , format('schema %I should not be in search path', s) ); END $body$; @@ -34,7 +38,7 @@ BEGIN ); RETURN NEXT lives_ok( - $$DROP SCHEMA schema_to_load_count_nulls$$ + format('DROP SCHEMA %I', current_setting('count_nulls.test_schema')) ); END $body$; diff --git a/test/sql/sanity.sql b/test/sql/sanity.sql index 6c9ea2f..53d2ad0 100644 --- a/test/sql/sanity.sql +++ b/test/sql/sanity.sql @@ -2,7 +2,29 @@ \set VERBOSITY verbose BEGIN; + +-- Unlike the other test files, this one doesn't route through +-- test/deps.sql - it's meant to be a minimal, standalone smoke test. It +-- still has to respect TEST_SCHEMA/TEST_LOAD_SOURCE though: under 'existing' +-- mode the extension is already installed (CREATE EXTENSION would error), +-- and either way the calls below are unqualified, so search_path needs to +-- include wherever count_nulls actually lives. +SELECT current_setting('count_nulls.test_load_mode') = 'existing' AS count_nulls_existing_mode +\gset +SELECT current_setting('count_nulls.test_schema') AS schema +\gset + +\if :count_nulls_existing_mode +SET search_path = :"schema"; +\else +-- IF NOT EXISTS emits a NOTICE for the (very common) case of :"schema" +-- being 'public', which always already exists. +SET client_min_messages = WARNING; +CREATE SCHEMA IF NOT EXISTS :"schema"; +SET search_path = :"schema"; +SET client_min_messages = NOTICE; CREATE EXTENSION count_nulls; +\endif -- Remember that JSON only accepts 'null' SELECT null_count('{"a": null}'::jsonb); diff --git a/test/sql/simple.sql b/test/sql/simple.sql index 9406729..a429fdd 100644 --- a/test/sql/simple.sql +++ b/test/sql/simple.sql @@ -1,11 +1,14 @@ \set ECHO none -\set schema public - \i test/load.sql \i test/core/functions.sql +-- Unlike extension_tests.sql, this file tests count_nulls used unqualified +-- (i.e. installed schema IS on search_path), regardless of which schema +-- TEST_SCHEMA (test/deps.sql's :"schema") actually names. +SET SEARCH_PATH = _null_count_test, tap, :"schema"; + --SET client_min_messages = debug; SELECT * FROM runtests( '_null_count_test'::name );