diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dbfdb7..0109382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,354 @@ +# =========================================================================== +# Test strategy +# +# count_nulls can be arrived at several ways, each of which can break +# differently, so each is exercised by its own job below: +# +# test -- FRESH install: CREATE EXTENSION at the current +# version, across every supported PostgreSQL +# major. Also proves the IN-PLACE extension +# update path (CREATE EXTENSION at 0.9.6, then +# ALTER EXTENSION UPDATE - same PostgreSQL, no +# pg_upgrade) in the same job/matrix, rather +# than a dedicated job: a load mode is just an +# input the same assertions run against, not a +# real environment difference, so giving it its +# own job would only duplicate this job's own +# per-PG-version container/checkout setup for +# no added confidence. +# pg-upgrade-test -- BINARY pg_upgrade: install 0.9.6 on an OLD +# PostgreSQL major, update the extension to +# current (still on the old major), THEN +# binary-upgrade the cluster to a NEWER major - +# proves pg_upgrade correctly migrates the +# objects the extension actually creates +# TODAY, not objects frozen at some past +# version (which would be untestable anyway - +# that old version already shipped). A smaller +# old_pg/new_pg matrix (not the full PG matrix +# - by far the most expensive job here, +# installing two full PostgreSQL majors and +# running the real pg_upgrade binary per leg). +# pg-tle-test -- pg_tle DEPLOYMENT: fresh install AND the +# 0.9.6 -> current update path, both registered +# through AWS pg_tle's database-backed catalog +# instead of a filesystem .control file. +# +# Every TEST_SCHEMA value (empty - no schema targeting at all - and +# 'Quoted', a name requiring SQL identifier quoting) is exercised in every +# job above too, but never as a CI matrix dimension - a schema name is just +# an input the same assertions run against, not a real environment +# difference, so crossing it into the matrix would only multiply job count +# for no added confidence (see the Makefile's TEST_SCHEMA_VALUES comment). +# `test` loops it (both its fresh and update legs) via `make +# test-schema-all` / `make test-update-schema-all`; `pg-upgrade-test` +# (shell, not `make test`, for the parts that matter here) prepares two +# databases - one per schema - +# ahead of a single pg_upgrade call that migrates both at once, which is +# strictly better than a doubled matrix would have been: it also halves the +# number of actual pg_upgrade binary invocations, not just container/ +# checkout overhead. Every leg passes against the SAME +# test/expected/extension_tests.out (see test/README.md for how the suite +# keeps its output schema-invariant). +# +# `changes` is a cheap gate that lets the heavy jobs above skip themselves on +# doc-only pushes, and also derives the shared PostgreSQL-major list those +# jobs consume from a single set of constants. `all-checks-passed` is the +# single stable required-status-check name. +# +# Draft PRs get a further reduction, independent of `changes`/docs_only, +# aimed at cutting shared-runner load while a PR is still being iterated on +# (this repo's org-wide Actions queue backs up easily): `lint` always runs +# in full; `test`'s matrix drops to just the newest supported PostgreSQL +# major (see its own comment) instead of running full or being skipped +# outright, since it's cheap per-leg and a draft author still wants signal +# on every push; every other heavy job (`pg-tle-test`, `pg-upgrade-test`) +# is skipped entirely via an added `&& github.event.pull_request.draft != +# true` on its existing `if:`. None of this applies to a `push` event (e.g. +# the post-merge run on master) or a non-draft PR, both of which always run +# the full suite exactly as before. `pull_request.types` below includes +# `ready_for_review` specifically so marking a draft PR ready retriggers +# immediately at full scope (draft is already false by the time that event +# fires) instead of leaving the reduced draft-time result on the PR's last +# commit as its current status until some later real push. +# +# CI PRIORITY: a `gh stack rebase` cascade pushes to every PR above the one +# actually being changed, purely to move it onto a new base - the PR's own +# diff (what it actually contributes) is unchanged. Running that at the +# same priority as real new commits means a burst of cascade pushes can +# crowd out the runner capacity a genuinely active push needs for fast +# feedback. `changes` tells the two apart via a base-independent content +# hash (bin/patch_id_hash, robust to rebase-induced context shifts - see +# its own comment - persisted per-PR across pushes via actions/cache since +# GitHub Actions has no other cross-run memory): a synchronize push whose +# hash matches the last one observed for this PR is routed to a +# LOW-priority lane (`ci--lowprio-<0..2>`, a small fixed set shared +# across ALL PRs, capping rebase noise to at most 3 concurrent runs +# repo-wide instead of letting it consume the account's whole concurrency +# budget) rather than the per-PR high-priority lane real pushes use. It +# still gets the FULL matrix either way, just possibly later - a rebase CAN +# break something the diff itself didn't touch, and this repo won't merge +# without a clean run regardless. `edited` is in `pull_request.types` so a +# base retarget (the signal gh stack sends when this PR is promoted to the +# bottom of its stack - the next one due to merge) can be caught by the +# `escalate` step and bumped to an immediate high-priority run bypassing +# whatever lane its last push landed in, UNLESS a full run already exists +# for that exact head SHA (checked via the Checks API), in which case +# there's nothing to gain by re-running it. `ready_for_review` gets the +# same escalation unconditionally (no existing-run check): its prior run, +# if any, was necessarily the reduced draft-time one above, which doesn't +# count as "already had one". +# =========================================================================== name: CI on: push: branches: - master pull_request: + # Explicit (not the GitHub default of [opened, synchronize, reopened]): + # `ready_for_review` retriggers immediately when a draft PR is marked + # ready (see the top-of-file comment); `edited` catches a base retarget + # for the priority-escalation check in the `changes` job below. + types: [opened, synchronize, reopened, edited, ready_for_review] 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 job consumes: every job that cares which majors are + # supported reads the SAME list, so they can't silently drift onto + # different sets, and adding a new major is a one-line change here + # instead of an edit in several jobs. `newest_pg` is the same NEWEST + # constant emitted again as a bare scalar (not wrapped in the JSON-array + # `supported_pg`), consumed only by the `test` job's draft-PR matrix + # reduction (see the top-of-file comment and that job's own comment) - so + # NEWEST still only needs to change in one place. + changes: + name: ๐Ÿ” Detect docs-only changes & derive PG matrix + runs-on: ubuntu-latest + permissions: + contents: read + checks: read # escalate step: has this SHA already got a full run? + actions: write # escalate step: cancel a superseded low-priority run + outputs: + docs_only: ${{ steps.diff.outputs.docs_only }} + supported_pg: ${{ steps.pg.outputs.supported_pg }} + newest_pg: ${{ steps.pg.outputs.newest_pg }} + priority: ${{ steps.diff.outputs.priority }} + lane: ${{ steps.lane.outputs.lane }} + 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: Base retargeted onto a new branch? Check whether this SHA already has a full run + # Fires only for `edited` WITH a base change - the gh-stack-promotion + # signal described at the top of this file. A plain title/body edit + # also fires `edited` but leaves `github.event.changes.base` absent, + # so it's excluded here and falls through to the diff step's + # edited-with-nothing-to-do branch below. + id: escalate + if: >- + github.event_name == 'pull_request' && + github.event.action == 'edited' && + github.event.changes.base != null + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + existing=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \ + --jq '[.check_runs[] | select(.name == "all-checks-passed")]' 2>/dev/null || echo '[]') + count=$(echo "$existing" | jq 'length') + if [ "$count" -gt 0 ]; then + echo "escalate=false" >> "$GITHUB_OUTPUT" + echo "SHA $SHA already has an all-checks-passed run - nothing to escalate" + else + echo "escalate=true" >> "$GITHUB_OUTPUT" + fi + + - name: Cancel a superseded low-priority run for this SHA + # An escalation (base retarget, or draft->ready below) is about to + # trigger a fresh high-priority run for this exact SHA; a + # low-priority run still queued/running for the SAME SHA would just + # duplicate that work, so kill it first. ready_for_review always + # qualifies (unconditionally, no escalate check) since its prior + # run, if any, was necessarily the reduced draft-time one - never + # worth keeping once the PR is no longer a draft. + if: >- + steps.escalate.outputs.escalate == 'true' || + github.event.action == 'ready_for_review' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + gh api "repos/$REPO/actions/workflows/ci.yml/runs" --paginate \ + --jq ".workflow_runs[] | select(.head_sha == \"$SHA\" and .status != \"completed\" and .id != ${{ github.run_id }}) | .id" \ + | while read -r run_id; do + echo "Cancelling superseded run $run_id for $SHA" + gh api -X POST "repos/$REPO/actions/runs/$run_id/cancel" || true + done + + - name: Restore this PR's previously observed content patch-id + id: patchid-restore + if: github.event_name == 'pull_request' && github.event.action != 'edited' + uses: actions/cache/restore@v4 + with: + path: /tmp/prev_patch_id + # A key guaranteed not to already exist, so this always falls + # through to restore-keys (prefix match, picks the MOST RECENT + # save) instead of ever hitting its own key directly. + key: unused-${{ github.run_id }} + restore-keys: | + ci-patchid-pr-${{ github.event.pull_request.number }}- + + - name: Compute per-push changed files, docs-only status, and CI priority + id: diff + run: | + # Fail safe to running the full matrix at high priority: default + # both immediately, before anything below has a chance to compute + # or fail. Writing the same GITHUB_OUTPUT key twice is fine (the + # last write wins), so the only way this step ends with + # docs_only=true or priority=low is by genuinely proving it + # further down - never by skipping past an edge case with a + # default. + echo "docs_only=false" >> "$GITHUB_OUTPUT" + echo "priority=high" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event.action }}" = "edited" ]; then + # Either a base retarget with nothing outstanding to run + # (escalate above said false - an all-checks-passed run already + # covers this SHA), or a plain title/description edit (no base + # change at all, so escalate didn't even run). Either way, + # there's no code to test right now. + if [ "${{ steps.escalate.outputs.escalate }}" != "true" ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + 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/ready_for_review/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" + + # 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. docs_only is already false from above; + # just stop here rather than risk skipping tests. + if [ -z "$HEAD" ] || [ -z "$BASE" ] || [[ "$BASE" =~ ^0+$ ]]; then + 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" + + # Priority: a synchronize push whose own patch-id (the PR's + # actual contribution, independent of what it's based on) matches + # the last one observed for this PR is a pure gh-stack rebase + # cascade, not new work - safe to route to the low-priority lane. + # Real new commits, and this PR's first appearance (nothing to + # compare against yet), keep the high priority set above. + if [ "${{ github.event_name }}" = "pull_request" ]; then + NEW_PATCH_ID=$(bin/patch_id_hash "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}") + echo "content patch-id: $NEW_PATCH_ID" + if [ "${{ github.event.action }}" = "synchronize" ] && \ + [ -f /tmp/prev_patch_id ] && \ + [ "$(cat /tmp/prev_patch_id)" = "$NEW_PATCH_ID" ]; then + echo "priority=low" >> "$GITHUB_OUTPUT" + fi + echo "$NEW_PATCH_ID" > /tmp/prev_patch_id + fi + + - name: Save this push's content patch-id for the next push to compare against + if: github.event_name == 'pull_request' && github.event.action != 'edited' + uses: actions/cache/save@v4 + with: + path: /tmp/prev_patch_id + key: ci-patchid-pr-${{ github.event.pull_request.number }}-${{ github.run_id }} + + - name: Assign a low-priority lane number + id: lane + # A fixed small number of shared lanes (not one lane per PR) bounds + # how much of the account's concurrent-runner budget rebase-cascade + # noise can ever occupy at once, so it can't crowd out real-work + # runs waiting for a runner - at the cost of not deduping a stale + # rebase run against a newer one for the SAME PR if they land in the + # same lane as an unrelated PR's job (acceptable: a stale completed + # check on an old SHA doesn't block merging the new SHA). + if: github.event_name == 'pull_request' + run: echo "lane=$(( ${{ github.event.pull_request.number }} % 3 ))" >> "$GITHUB_OUTPUT" + + - name: Derive the supported-PostgreSQL-major list + id: pg + run: | + # A dozen-odd lines to replace what looks like a handful of version + # references, but it buys CONSISTENCY: the fresh-install/update + # `test` matrix derives its PostgreSQL set from this ONE source, so + # it cannot silently drift onto a different list. Adding a new + # major is a one-line NEWEST bump here, not an edit in N places. + # + # Only one floor is needed here: 0.9.6 (the oldest version + # count_nulls still ships a full install script for) is pure SQL + # over anyarray/json/jsonb with no catalog-version sensitivity, so + # it installs on every PostgreSQL major count_nulls supports - + # there's no separate legacy-only floor to carve out. + NEWEST=18 + 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" + + # Also emitted as a bare scalar (not a JSON array) so the `test` + # job's draft-PR matrix reduction (see its own comment) can build a + # single-element list from it via fromJSON(format(...)) without a + # second hardcoded "18" anywhere in this file. + echo "newest_pg=$NEWEST" >> "$GITHUB_OUTPUT" + lint: name: ๐Ÿงน SQL lint runs-on: ubuntu-latest @@ -17,20 +361,49 @@ jobs: # is what actually proves that works from a plain clone. run: make lint - # Fresh install, across the PG matrix. Every TEST_SCHEMA value (empty - - # no schema targeting at all - and 'Quoted', a name requiring SQL - # identifier quoting) is exercised too, via `make test-schema-all`'s - # in-Makefile loop rather than a CI matrix dimension - a schema name is + # Fresh install, then the in-place extension update path, both across the + # PG matrix. The update leg CREATE EXTENSIONs at the oldest version we + # still ship a full install script for (0.9.6), then ALTER EXTENSION + # UPDATEs to current (no pg_upgrade, same PostgreSQL) and reruns the + # suite - a single job rather than a dedicated one, since a load mode is # just an input the same assertions run against, not a real environment + # difference (same reasoning as TEST_SCHEMA below), and the per-version + # container/checkout setup would otherwise be duplicated across two jobs + # with the same PG matrix. Every TEST_SCHEMA value (empty - no schema + # targeting at all - and 'Quoted', a name requiring SQL identifier + # quoting) is exercised too, via `make test-schema-all`'s in-Makefile + # loop rather than a CI matrix dimension - a schema name is just an + # input the same assertions run against, not a real environment # difference, so crossing it into the matrix would only multiply job # count for no added confidence (see the Makefile's TEST_SCHEMA_VALUES - # comment). Both legs pass against the SAME + # comment). Every leg passes against the SAME # test/expected/extension_tests.out (see test/README.md for how the # suite keeps its output schema-invariant). test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + # "ci-test-" prefix, distinct from pg-upgrade-test's/pg-tle-test's below: + # those run CONCURRENTLY with this job within the same PR's workflow run, + # so sharing a group name across job types would make them cancel/queue + # behind EACH OTHER instead of behind their own prior runs for this job. + # See the top-of-file CI PRIORITY comment for the low-priority lane + # scheme this implements. + concurrency: + group: >- + ${{ needs.changes.outputs.priority == 'low' + && format('ci-test-lowprio-{0}', needs.changes.outputs.lane) + || format('ci-test-{0}', github.event.pull_request.number || github.sha) }} + cancel-in-progress: ${{ needs.changes.outputs.priority != 'low' }} strategy: matrix: - pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] + # From the single source in the changes job. On a draft PR, reduced + # to just the newest supported major (never skipped outright, unlike + # the other heavy jobs below - this is the one signal a draft author + # still wants on every push): `github.event.pull_request.draft` is + # null/falsy for a push event (e.g. the post-merge run on master), so + # this expression falls through to the full list there with no extra + # guard needed. + pg: ${{ github.event.pull_request.draft && fromJSON(format('[{0}]', needs.changes.outputs.newest_pg)) || fromJSON(needs.changes.outputs.supported_pg) }} name: ๐Ÿ˜ PostgreSQL ${{ matrix.pg }} runs-on: ubuntu-latest container: pgxn/pgxn-tools @@ -41,8 +414,200 @@ jobs: uses: actions/checkout@v4 - name: Test on PostgreSQL ${{ matrix.pg }}, across every TEST_SCHEMA value run: make test-schema-all + - name: Install count_nulls + run: make install + - name: Update 0.9.6 -> current and run the suite, across every TEST_SCHEMA value + run: make test-update-schema-all + - name: Structurally compare the updated objects against a fresh install, across every TEST_SCHEMA value + # A fixed pgTAP suite only proves the specific behaviors it asserts + # still hold; it can't catch an update script that leaves some + # definition/comment/ACL subtly different from what a fresh install + # of the same version produces. bin/compare_fresh_vs_update installs + # both ways itself (in its own scratch databases) and diffs every + # object the extension owns - any nonempty diff fails the step. Not + # a make target (it's a standalone script, not `make test`), so + # looped directly here rather than via test-schema-all. + run: | + for schema in "" Quoted; do + echo "=== schema=$schema ===" + bin/compare_fresh_vs_update "$schema" 0.9.6 || exit 1 + done + # Proves count_nulls survives a BINARY pg_upgrade (in-place catalog + # migration to a newer PostgreSQL major). Installs 0.9.6 on an old + # cluster, plants a dependency guard, updates the extension to CURRENT + # (still on the old major), THEN binary-pg_upgrades to a newer cluster, + # then runs the suite against the REAL migrated objects in existing mode. + # Updating before the binary upgrade (not after) is deliberate: the whole + # point of this job is proving pg_upgrade correctly migrates the objects + # count_nulls' CURRENT code actually creates - migrating 0.9.6's objects + # and updating afterward would instead test whether pg_upgrade can + # migrate a legacy structure frozen in the past, which isn't actionable + # (that version already shipped; nothing to fix if it turned out + # fragile). No bridge-update step first: 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. + # + # Deliberately not doing a stepwise every-major-in-sequence climb (one + # cluster walking 10->11->12->...->newest, vs. the single big jumps here): + # that would catch a regression specific to one particular major-to-major + # boundary, which would matter if count_nulls had views/functions touching + # catalog internals, but it doesn't - pure SQL functions over anyarray/ + # json/jsonb, nothing version-sensitive to break at a specific boundary. + # Revisit if count_nulls ever grows something catalog-touching. + # + # Every TEST_SCHEMA value is exercised here too, but NOT via a matrix + # dimension (would double this job's already-expensive count) and not + # via a make-level loop either (bin/test_existing's steps below are + # shell, not `make test`) - instead, TWO databases (one per schema) are + # prepared before the SINGLE pg_upgrade call, which migrates the WHOLE + # cluster (every database in it) in one pass. This is strictly better + # than a doubled matrix would have been, not just cheaper: it also + # halves the number of actual pg_upgrade binary invocations (the single + # most expensive operation in this job) instead of just avoiding + # redundant container/checkout overhead. + pg-upgrade-test: + needs: [changes] + # Skipped outright (not just matrix-reduced like `test`) on a draft PR: + # this is a heavy job, and a draft author doesn't need a real binary + # pg_upgrade re-proven on every push while still iterating. + if: needs.changes.outputs.docs_only != 'true' && github.event.pull_request.draft != true + # "ci-pgupgrade-" prefix - see the `test` job's concurrency comment above. + concurrency: + group: >- + ${{ needs.changes.outputs.priority == 'low' + && format('ci-pgupgrade-lowprio-{0}', needs.changes.outputs.lane) + || format('ci-pgupgrade-{0}', github.event.pull_request.number || github.sha) }} + cancel-in-progress: ${{ needs.changes.outputs.priority != 'low' }} + strategy: + matrix: + old_pg: ["10", "12"] + new_pg: ["18"] + name: ๐Ÿ”„ Binary pg_upgrade ${{ matrix.old_pg }} โ†’ ${{ matrix.new_pg }} + 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), across every TEST_SCHEMA value + # prepare-old installs count_nulls at 0.9.6, 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. Two separate databases (distinct + # names, one per TEST_SCHEMA value) so both exist in the SAME + # cluster ahead of the single pg_upgrade call below - that one + # binary upgrade migrates both at once. + run: | + bin/test_existing prepare-old count_nulls_upgrade_none "" 0.9.6 + bin/test_existing prepare-old count_nulls_upgrade_quoted Quoted 0.9.6 + - name: Update the extension to the current version (still on the old cluster), across every TEST_SCHEMA value + # Exercises ALTER EXTENSION UPDATE on the OLD cluster, BEFORE the + # binary pg_upgrade below, running the 0.9.6->stable update script, + # once per database prepared above - deliberately in this order + # (not update-after-upgrade): this job exists to prove pg_upgrade + # correctly migrates the objects count_nulls' CURRENT code creates, + # so pg_upgrade must run against already-current objects, not 0.9.6 + # ones. `make install` above already installed the current + # version's update scripts/control file into this (old) cluster's + # sharedir, so they're in place for this ALTER EXTENSION UPDATE to + # use. + run: | + bin/test_existing update count_nulls_upgrade_none + bin/test_existing update count_nulls_upgrade_quoted + - 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: Run the suite against the pg_upgraded database (existing mode), across every TEST_SCHEMA value + # run-suite asserts the version, re-proves the dependency guard + # still blocks a non-CASCADE drop (i.e. it survived both the update + # and pg_upgrade), drops the guard, then runs the suite against the + # REAL pg_upgraded 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_none "" + bin/test_existing run-suite count_nulls_upgrade_quoted Quoted + - name: Structurally compare the pg_upgraded database against a fresh install, across every TEST_SCHEMA value + # Same rationale as the test job's own update leg's use of this tool + # (see above), but here the "other side" is the REAL database a binary + # pg_upgrade + ALTER EXTENSION UPDATE just produced, not a scratch + # database this tool created itself - passed as EXISTING_DB so the + # script queries it in place instead of re-deriving it. Catches a + # divergence class the fixed pgTAP suite above doesn't: an object + # left subtly different (body, comment, ACL) by surviving a real + # catalog migration, as opposed to only an in-place update. Each + # pg_upgraded database is compared against a fresh install in ITS + # OWN schema, matching prepare-old above. + run: | + bin/compare_fresh_vs_update "" 0.9.6 count_nulls_upgrade_none + bin/compare_fresh_vs_update Quoted 0.9.6 count_nulls_upgrade_quoted + + # Covers both a fresh install AND the 0.9.6 -> current update path, both + # purely via pg_tle. pgxntool 2.3.0's fix for installcheck's ordering bug + # (Postgres-Extensions/pgxntool#83) made `installcheck` (and so `make + # test`) unconditionally depend on `install`, which writes a real + # .control file to disk - defeating the entire point of proving a pg_tle + # deployment never touches the filesystem. There's still no upstream fix + # for that (Postgres-Extensions/pgxntool#90, open) that would let + # bin/test_existing's real pgTAP suite run without it, so the update-path + # steps below use TEST_EXISTING_DEPLOY=pgtle (see bin/test_existing), + # which instead sandboxes `make test`'s install step behind a scratch + # DESTDIR - harmless here since a pg_tle-deployed database never needs + # those files. pg-tle-test: + needs: [changes] + # Skipped outright (not just matrix-reduced like `test` above) on a + # draft PR: this is a heavy job, and a draft author doesn't need the + # pg_tle deployment path re-proven on every push while still iterating. + if: needs.changes.outputs.docs_only != 'true' && github.event.pull_request.draft != true + # "ci-pgtle-" prefix - see the `test` job's concurrency comment above. + concurrency: + group: >- + ${{ needs.changes.outputs.priority == 'low' + && format('ci-pgtle-lowprio-{0}', needs.changes.outputs.lane) + || format('ci-pgtle-{0}', github.event.pull_request.number || github.sha) }} + cancel-in-progress: ${{ needs.changes.outputs.priority != 'low' }} strategy: matrix: # Intersection of count_nulls' own supported range (10-18, see the @@ -161,3 +726,81 @@ jobs: fi - name: Verify no stray extension control files after the fresh-install smoke test run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg_tle.control + - name: Install count_nulls at 0.9.6, purely via pg_tle (update-path prep) + # A SECOND, separate scratch database, created after the template1 + # registration above so it inherits both registrations too (same + # reasoning as count_nulls_smoke). prepare-old creates the database, + # CREATE EXTENSIONs at 0.9.6 (pure SQL - resolves through pg_tle's + # catalog, no `make install` call, which would defeat the whole + # point), then plants + proves the dependency guard so a stray + # CASCADE drop anywhere below can't silently turn the eventual + # existing-mode run into a fresh install instead. + run: | + test ! -e /usr/share/postgresql/${{ matrix.pg }}/extension/count_nulls.control + bin/test_existing prepare-old count_nulls_pgtle_update "" 0.9.6 + - name: Verify no stray extension control files after installing 0.9.6 via pg_tle + run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg_tle.control + - name: Update 0.9.6 -> current, purely via pg_tle + # Pure SQL (ALTER EXTENSION ... UPDATE), no filesystem write either. + run: bin/test_existing update count_nulls_pgtle_update + - name: Verify no stray extension control files after the pg_tle update + run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg_tle.control + - name: Run the real pgTAP suite against the pg_tle-updated database (existing mode) + # run-suite re-proves the dependency guard, drops it, then runs the + # FULL suite via --use-existing against the real pg_tle-deployed + + # updated database - the same suite/expected-output as every other + # leg (see test/README.md). TEST_EXISTING_DEPLOY=pgtle makes + # run-suite sandbox `make test`'s otherwise-unavoidable `install` + # step behind a scratch DESTDIR instead of writing to the real + # extension directory (see bin/test_existing's TEST_EXISTING_DEPLOY + # comment), and makes test/install/load.sql's existing-mode + # assertion cross-check pgtle.available_extensions() instead of + # pg_available_extensions (which never sees pg_tle registrations - + # see the Makefile's TEST_EXISTING_DEPLOY comment). + run: TEST_EXISTING_DEPLOY=pgtle bin/test_existing run-suite count_nulls_pgtle_update "" + - name: Verify no stray extension control files after the pgTAP suite + # THE step that actually proves the DESTDIR sandboxing worked: the + # real extension directory must still be clean after `make test` + # ran (with its otherwise-unavoidable `install` step) sandboxed + # behind a scratch DESTDIR. + run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg_tle.control + + # 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 none)" 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, lint, test, pg-upgrade-test, pg-tle-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/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8efa338..a76dc99 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,12 +27,88 @@ concurrency: cancel-in-progress: true jobs: - claude-review: - # jnasbyupgrade's own PRs only, and skip drafts (don't spend API/CI on - # unfinished PRs). + # Cheap gate that skips the paid review when this push's PATCH is + # identical to the last one reviewed for this PR - a pure gh-stack rebase + # cascade changes only the base commit, not the PR's own diff, so there's + # nothing new to review. Restricted to `synchronize`: `opened`/`reopened`/ + # `ready_for_review` always run the review regardless of patch-id, since + # draft->ready is itself a meaningful trigger even if nothing about the + # diff needed to change, and comparing against a stale cached patch-id + # there would otherwise wrongly suppress it. Runs before the (slow) "wait + # for CI" gate in claude-review below, so a rebase-only push never enters + # that wait at all. + content-check: + runs-on: ubuntu-latest if: >- github.event.pull_request.draft == false && github.event.pull_request.user.login == 'jnasbyupgrade' + outputs: + content_changed: ${{ steps.diff.outputs.changed }} + steps: + # Default (non-overridden) checkout for a pull_request_target event + # is the BASE branch - deliberate here: bin/patch_id_hash must be on + # disk to run, and an older PR predating this script (checked out at + # its own head, as claude-review's later step does) wouldn't have it. + # The script itself diffs base...head via git plumbing on commit + # objects, not the working tree, so which ref is checked out doesn't + # affect the computed hash - only whether the SCRIPT is present. + - name: Check out the base branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch the PR head commit + # fetch-depth 0 above pulls this repo's full history but not a + # fork's (or an as-yet-unmerged) ref - fetch head.sha explicitly so + # it's reachable for the diff below. + run: git fetch origin ${{ github.event.pull_request.head.sha }} + + - name: Restore this PR's previously observed content patch-id + id: patchid-restore + if: github.event.action == 'synchronize' + uses: actions/cache/restore@v4 + with: + path: /tmp/prev_patch_id + # A key guaranteed not to already exist, so this always falls + # through to restore-keys (prefix match, picks the MOST RECENT + # save) instead of ever hitting its own key directly. Same cache + # namespace as ci.yml's `changes` job - both are answering the + # identical "did this PR's patch change" question, so whichever + # workflow runs first primes the cache for the other. + key: unused-${{ github.run_id }} + restore-keys: | + ci-patchid-pr-${{ github.event.pull_request.number }}- + + - name: Compare against the last observed patch-id + id: diff + run: | + NEW=$(bin/patch_id_hash "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}") + echo "content patch-id: $NEW" + if [ "${{ github.event.action }}" = "synchronize" ] && \ + [ -f /tmp/prev_patch_id ] && \ + [ "$(cat /tmp/prev_patch_id)" = "$NEW" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + echo "$NEW" > /tmp/prev_patch_id + + - name: Save this push's content patch-id for the next push to compare against + if: always() + uses: actions/cache/save@v4 + with: + path: /tmp/prev_patch_id + key: ci-patchid-pr-${{ github.event.pull_request.number }}-${{ github.run_id }} + + claude-review: + needs: [content-check] + # jnasbyupgrade's own PRs only, skip drafts (don't spend API/CI on + # unfinished PRs), and skip a pure rebase-cascade push (see + # content-check above - nothing new to review). + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.user.login == 'jnasbyupgrade' && + needs.content-check.outputs.content_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 60 permissions: diff --git a/Makefile b/Makefile index 7a0a979..5934bb2 100644 --- a/Makefile +++ b/Makefile @@ -42,10 +42,10 @@ TEST_SCHEMA ?= export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_schema=$(TEST_SCHEMA) # Every TEST_SCHEMA value the suite is tested against. A single source so -# test-schema-all and CI (once collapsed - see the "why not a CI matrix" -# note below) can't silently drift onto different sets. See the TEST_SCHEMA -# comment above for why exercising more than one value here is meaningful -# (search_path exclusion), not just "install into schema A vs schema B". +# test-schema-all/test-update-schema-all and CI can't silently drift onto +# different sets. See the TEST_SCHEMA comment above for why exercising more +# than one value here is meaningful (search_path exclusion), not just +# "install into schema A vs schema B". TEST_SCHEMA_VALUES = "" Quoted # TEST_SCHEMA is deliberately NOT a CI matrix dimension: unlike PostgreSQL @@ -69,3 +69,82 @@ test-schema-all: echo "=== TEST_SCHEMA=$$schema ==="; \ $(MAKE) test TEST_SCHEMA="$$schema" || exit 1; \ done + +# TEST_LOAD_SOURCE selects how test/install/load.sql installs count_nulls +# for the WHOLE test run: +# - 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 - +# committed, since test/install runs outside any per-test rolled-back +# transaction (see pgxntool/README.asc's Update & Upgrade (U&U) Testing +# section for why the commit matters). +# - existing: count_nulls is already installed (a real `pg_upgrade` run, +# external to this invocation) - test/install 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 against a +# real database, not via a make wrapper here. +# +# "update" (this) is extension-level (ALTER EXTENSION UPDATE); "upgrade" is +# cluster-level (pg_upgrade) - 'existing' is how that axis is exercised. +# +# Propagated the same way as TEST_SCHEMA: via the count_nulls.test_load_mode +# GUC, exported unconditionally through PGOPTIONS, read without missing_ok. +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_EXISTING_DEPLOY: in 'existing' mode (TEST_LOAD_SOURCE=existing), how +# was the extension actually deployed onto the cluster before this run +# started? Unlike TEST_LOAD_SOURCE/TEST_SCHEMA above, this does not select or +# change any install behavior - it only tells test/install/load.sql's +# existing-mode assertion where to cross-check "what does the cluster +# consider count_nulls's current version" against the actually-installed +# extversion: +# - filesystem (default): a real .control file is on disk (a real `make +# install`, or a pg_upgrade'd cluster carrying one over) - cross-check +# against pg_available_extensions.default_version, which reads .control +# files directly off disk. +# - pgtle: count_nulls was registered purely through pg_tle's +# database-backed catalog (see the pg-tle-test CI job), never touching +# the filesystem. pg_available_extensions does NOT see pg_tle +# registrations at all - it only ever reads .control files off disk - so +# its default_version comes back NULL for a pg_tle-only extension even +# though a version-less CREATE EXTENSION resolves correctly through +# pg_tle. pg_tle ships its own separate, non-integrated analog instead: +# pgtle.available_extensions(), a C function whose own doc comment in +# pg_tle's tleextension.c says "The system view pg_available_extensions +# provides a user interface to this SRF" - i.e. pg_tle's SRF is modeled +# on pg_available_extensions, but pg_tle never hooks or populates the +# real view itself. In this mode, cross-check against pg_tle's SRF +# instead. +# +# Deliberately the SAME env var name bin/test_existing already reads (to +# decide whether to sandbox `make test`'s install step via a scratch +# DESTDIR) - it's already exported to any `make` invocation bin/test_existing +# spawns as a child process (and explicitly passed through on run_suite's +# `make test` command line too), so no extra plumbing is needed to get it +# here. +TEST_EXISTING_DEPLOY ?= filesystem +ifeq ($(filter $(TEST_EXISTING_DEPLOY),filesystem pgtle),) +$(error TEST_EXISTING_DEPLOY must be 'filesystem' or 'pgtle', got '$(TEST_EXISTING_DEPLOY)') +endif +export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_existing_deploy=$(TEST_EXISTING_DEPLOY) + +# 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 + +# Same TEST_SCHEMA loop as test-schema-all, but in update mode - used by the +# test CI job's update leg instead of crossing TEST_SCHEMA into ITS matrix +# too, same reasoning as test-schema-all above. +.PHONY: test-update-schema-all +test-update-schema-all: + @for schema in $(TEST_SCHEMA_VALUES); do \ + echo "=== TEST_SCHEMA=$$schema (update) ==="; \ + $(MAKE) test TEST_LOAD_SOURCE=update TEST_SCHEMA="$$schema" || exit 1; \ + done diff --git a/bin/compare_fresh_vs_update b/bin/compare_fresh_vs_update new file mode 100755 index 0000000..30184e5 --- /dev/null +++ b/bin/compare_fresh_vs_update @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# compare_fresh_vs_update - structurally compare every object the count_nulls +# extension owns between a FRESH install (CREATE EXTENSION at current) and an +# UPDATED one (CREATE EXTENSION at FROM_VERSION, then ALTER EXTENSION +# UPDATE), in the same schema. A fixed pgTAP expected-output suite (see +# TEST_LOAD_SOURCE in the Makefile) only proves the specific behaviors it +# happens to assert still work - it does not prove an update script left +# object definitions/comments/ACLs BYTE-FOR-BYTE identical to a fresh +# install of the same version. This catches divergence classes a fixed +# suite doesn't already know to test for. +# +# Modeled on the manual technique used in Postgres-Extensions/cat_tools#46, +# which found a real bug this way (a pre-0.2.2 update path left +# `EXECUTE PROCEDURE` hardcoded in a trigger body that fresh installs had +# already updated to `EXECUTE FUNCTION`). Unlike that PR, this is committed, +# reusable tooling rather than a one-off manual diff. +# +# Postgres-Extensions/cat_tools#67 proposes generalizing this exact +# capability - "list every object an extension owns, with each object's +# actual definition" - into a real, user-facing cat_tools feature, not +# test-only plumbing tied to one extension's update path. If that lands, +# this script's object-discovery-and-rendering logic (extension_members() +# below, and the per-kind comparisons) is a natural candidate to +# reimplement on top of it rather than staying a bespoke, +# count_nulls-specific copy. +# +# What's compared, per object the extension owns (discovered live via +# pg_depend - see extension_members(), not a hardcoded object list, so a +# newly added function is automatically covered without editing this +# script): pg_get_functiondef() (full definition: schema, args, body, +# volatility, strictness - everything), its comment (obj_description), and +# its ACL (proacl). count_nulls currently ships only functions/triggers, no +# views - the doc this is modeled on also compares pg_get_viewdef/type +# labels/extension membership for extensions that have those; add a query +# for the relevant catalog (pg_class for views, pg_type for types, ...) the +# same way if count_nulls ever grows one. +# +# USAGE: bin/compare_fresh_vs_update [SCHEMA] [FROM_VERSION] [EXISTING_DB] +# SCHEMA - schema both installs target (default: unqualified, same +# as TEST_SCHEMA empty - see the Makefile). Both installs +# use the SAME schema, since the point is comparing object +# definitions, not exercising schema-qualification (that's +# TEST_SCHEMA's job in the regular suite). +# FROM_VERSION - the update origin (default: 0.9.6, the oldest version +# count_nulls still ships a full install script for). +# Ignored when EXISTING_DB is given - that database's +# history is whatever already produced it. +# EXISTING_DB - compare against this ALREADY-POPULATED database instead +# of creating+updating a scratch one (default: none, create +# our own scratch "updated" database as before). Lets +# callers that produced their update/upgrade result some +# other way - e.g. bin/test_existing's real binary +# pg_upgrade path - reuse this same comparison without this +# script re-deriving that database itself. The caller owns +# EXISTING_DB's lifecycle: it is never created, updated, or +# dropped here, only queried. +# +# Exits nonzero (and prints a real diff) on ANY difference. Scratch +# database(s) this script created itself are dropped on exit regardless of +# outcome; an EXISTING_DB passed in is left untouched. +set -euo pipefail + +cd "$(dirname "$(readlink -f "$0")")/.." + +schema=${1:-} +from_version=${2:-0.9.6} +existing_db=${3:-} + +fresh_db=compare_fresh_vs_update_fresh +update_db=${existing_db:-compare_fresh_vs_update_updated} +fresh_snapshot=$(mktemp) +update_snapshot=$(mktemp) + +cleanup() { + dropdb --if-exists "$fresh_db" + # Only drop update_db if we created it ourselves - an EXISTING_DB belongs + # to the caller (e.g. the real pg_upgraded database bin/test_existing is + # still using) and must survive this script running. + if [ -z "$existing_db" ]; then + dropdb --if-exists "$update_db" + fi + rm -f "$fresh_snapshot" "$update_snapshot" +} +trap cleanup EXIT + +# extension_members(): every object pg_depend records as owned by the +# count_nulls extension (deptype 'e'), restricted to pg_proc for now (see +# the header comment on extending this). Ordered by name/args so the two +# snapshots line up for a textual diff regardless of OID assignment order, +# which differs between a fresh install and an update. +query() { + cat <<'SQL' +SELECT + '-- ' || p.oid::regprocedure::text || E'\n' + || pg_get_functiondef(p.oid) || E'\n' + || '-- comment: ' || coalesce(obj_description(p.oid, 'pg_proc'), '(none)') || E'\n' + || '-- acl: ' || coalesce(p.proacl::text, '(default)') || E'\n' +FROM pg_depend d +JOIN pg_extension x ON d.refobjid = x.oid AND x.extname = 'count_nulls' +JOIN pg_proc p ON d.objid = p.oid AND d.classid = 'pg_proc'::regclass +WHERE d.deptype = 'e' +ORDER BY p.proname, p.oid::regprocedure::text; +SQL +} + +install_in_schema() { + local sql="" + if [ -n "$schema" ]; then + sql="CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; " + fi + echo "$sql" +} + +createdb "$fresh_db" +psql -d "$fresh_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls" + +if [ -z "$existing_db" ]; then + createdb "$update_db" + psql -d "$update_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls VERSION '$from_version'" + psql -d "$update_db" -v ON_ERROR_STOP=1 -c "SET client_min_messages = WARNING; ALTER EXTENSION count_nulls UPDATE" +fi + +psql -d "$fresh_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$fresh_snapshot" +psql -d "$update_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$update_snapshot" + +source_desc="$from_version->current update" +[ -n "$existing_db" ] && source_desc="'$existing_db'" + +if diff -u "$fresh_snapshot" "$update_snapshot"; then + echo "OK: fresh install and $source_desc produce IDENTICAL object definitions/comments/ACLs" +else + echo "FAIL: $source_desc diverges from a fresh install of the same version - see diff above" >&2 + exit 1 +fi diff --git a/bin/patch_id_hash b/bin/patch_id_hash new file mode 100755 index 0000000..d91493b --- /dev/null +++ b/bin/patch_id_hash @@ -0,0 +1,13 @@ +#!/bin/sh +# Emits, on stdout, a single hash representing the CONTENT of the diff +# between $1 (base) and $2 (head) - via `git patch-id --stable`, not a plain +# `git diff | sha256sum`. patch-id normalizes away the context-line shifts a +# rebase causes when the base commit itself changes, so it comes out +# IDENTICAL across a pure rebase that introduces no new content. That's the +# signal CI uses to tell "just a gh-stack rebase cascade" apart from "real +# new commits", and route the former to a low-priority runner lane instead +# of treating it like fresh work. +set -eu +base="$1" +head="$2" +git diff "$base...$head" | git patch-id --stable | awk '{print $1}' diff --git a/bin/test_existing b/bin/test_existing new file mode 100755 index 0000000..d47ca8f --- /dev/null +++ b/bin/test_existing @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# +# Exercise the count_nulls test suite against a REAL database whose extension +# was installed/upgraded OUTSIDE this pg_regress invocation ("existing" mode) +# - specifically, a real binary pg_upgrade run. Unlike an in-place ALTER +# EXTENSION UPDATE (which test/install/load.sql's own 'update' mode handles +# entirely by itself - see `make test-update`), a real pg_upgrade is an +# external binary process pg_regress can't invoke itself, so THAT leg needs +# an external script to drive it. This is a smaller script than it might look +# like it needs to be: only the pieces that genuinely can't live inside a +# single pg_regress invocation are here. +# +# The pg-upgrade-test CI job repeats the same sequence: +# +# prepare-old (install + plant guard) -> update (ALTER EXTENSION UPDATE, +# on the OLD cluster, before pg_upgrade) -> [real pg_upgrade binary, in +# CI] -> run-suite (assert + run existing-mode) +# +# so it lives here once instead of being duplicated as inline YAML. update +# runs BEFORE the binary pg_upgrade, not after: the point of this job is +# proving pg_upgrade correctly migrates the objects count_nulls' CURRENT +# code creates, so pg_upgrade needs to run against already-current objects, +# not ones still frozen at the old INSTALL_VERSION. +# +# The pg-tle-test CI job's update-path leg reuses the exact same three +# subcommands unmodified, in the same prepare-old -> update -> run-suite +# order (a pg_tle-registered database needs no real pg_upgrade or +# filesystem install between prepare-old and update - both are pure SQL, +# so there's no binary-upgrade step to reorder around there) - see +# TEST_EXISTING_DEPLOY below for the one piece of plumbing that job +# needed. +# +# 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 [args] +# +# 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 dependency guard. +# +# update DB [TO_VERSION] +# ALTER EXTENSION count_nulls UPDATE [TO 'TO_VERSION'] (empty => current). +# +# 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). +# +# Run `bin/test_existing` with no subcommand to print usage. +# +# Why the dependency guard: "existing" mode must run the suite against the +# ACTUAL upgraded objects. If anything silently dropped + reinstalled the +# extension (a stray CASCADE, a logic bug, a bad CI step), 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. +# +# TEST_EXISTING_DEPLOY (optional environment variable, read by run_suite): +# unset/filesystem (default): count_nulls was deployed the normal way (a +# real `make install`). run_suite calls `make test` exactly as before. +# pgtle: count_nulls was deployed purely through pg_tle's database-backed +# catalog (see the pg-tle-test CI job), never `make install`ed onto the +# filesystem. pgxntool's `test` target unconditionally depends on +# `install` (base.mk: `TEST_DEPS += install installcheck`, and +# `installcheck: install` - the pgxntool#83/2.3.0 ordering fix), so +# naively calling `make test` here would silently write a real +# count_nulls.control to disk - which would then shadow the pg_tle +# registration on any FUTURE version-less CREATE EXTENSION in the same +# cluster, defeating the entire point of proving a pg_tle-only +# deployment, without ever raising an error. +# +# In this mode run_suite instead passes DESTDIR= to `make +# test`: PGXS's own `install` target prefixes every path with +# $(DESTDIR), so it still "runs" (satisfying Make's dependency graph) +# but writes harmlessly to a throwaway directory instead of the real +# extension directory - fine here specifically because the pg_tle- +# deployed database under test never needs those files (it's already +# live via pg_tle's own catalog). One more prerequisite needs help: +# `installcheck: pgtap` has a DESTDIR-aware prerequisite check +# ($(DESTDIR)$(datadir)/extension/pgtap.control) but a DESTDIR-blind +# recipe (`pgxn install pgtap --sudo`) - against an empty scratch +# DESTDIR, Make would consider pgtap "missing" and re-run that recipe +# for real (network + sudo), even though pgtap is already genuinely +# installed from an earlier CI step. seed_pgtap_stub() pre-seeds a +# zero-byte stub at that exact scratch-prefixed path so Make considers +# the prerequisite already satisfied and never invokes the real recipe. +# See Postgres-Extensions/pgxntool#90 (open, no upstream fix yet). +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 +# --------------------------------------------------------------------------- + +psql_value() { + local db=$1 sql=$2 + psql -d "$db" -tAc "$sql" +} + +psql_do() { + local db=$1 + shift + psql -d "$db" -v ON_ERROR_STOP=1 "$@" +} + +# --------------------------------------------------------------------------- +# Version / guard helpers +# --------------------------------------------------------------------------- + +current_version() { + # EXTENSION_count_nulls_VERSION (the .control file's default_version), NOT + # PGXNVERSION (the PGXN distribution version, from META.in.json) - a + # version-less CREATE EXTENSION/ALTER EXTENSION UPDATE installs whatever + # the control file's default_version says, and count_nulls' is currently + # the 'stable' pseudo-version, not the last real release. Using PGXNVERSION + # here would compare an installed 'stable' against an expected real version + # number and always report a mismatch. See RELEASE.md's note on + # distribution vs. extension versions; the pg-tle-test CI job makes the + # same distinction for the same reason. + make -s print-EXTENSION_count_nulls_VERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p' +} + +installed_version() { + psql_value "$1" \ + "SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'" +} + +# 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/install/load.sql's own :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'" +} + +# --------------------------------------------------------------------------- +# pg_tle DESTDIR sandbox helpers (see TEST_EXISTING_DEPLOY in the file header) +# --------------------------------------------------------------------------- + +# Seed a stub pgtap.control at the exact DESTDIR-prefixed path pgxntool's +# `pgtap` target's prerequisite check looks for +# ($(DESTDIR)$(datadir)/extension/pgtap.control), so Make considers that +# prerequisite already satisfied and never invokes the real recipe (`pgxn +# install pgtap --sudo`) against a scratch DESTDIR that can never contain a +# real install. Existence is all Make checks here - pgtap has no other +# prerequisites to compare timestamps against - so a zero-byte file is +# enough. +seed_pgtap_stub() { + local destdir=$1 datadir control_path + datadir=$(make -s print-datadir 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p') + if [ -z "$datadir" ]; then + echo "FAIL: could not resolve datadir (make -s print-datadir)" >&2 + exit 1 + fi + control_path="${destdir}${datadir}/extension/pgtap.control" + mkdir -p "$(dirname "$control_path")" + : > "$control_path" +} + +# --------------------------------------------------------------------------- +# 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" +} + +# Run the pgTAP suite against an already-populated database in existing mode. +# Verifies count_nulls 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 deploy=${TEST_EXISTING_DEPLOY:-filesystem} + case "$deploy" in + filesystem|pgtle) ;; + *) + echo "FAIL: TEST_EXISTING_DEPLOY must be 'filesystem' or 'pgtle', got '$deploy'" >&2 + exit 1 + ;; + esac + + assert_version "$db" current + assert_drop_blocked "$db" + drop_guard "$db" + + # See TEST_EXISTING_DEPLOY in the file header. Only 'pgtle' changes + # anything here; a scratch DESTDIR is created and baked into a trap so it + # is cleaned up regardless of how this function/script exits (a RETURN + # trap would NOT fire under `set -e` if `make test` below reports a + # regression - EXIT does, for any exit reason). + local destdir_opt=() + if [ "$deploy" = pgtle ]; then + local scratch + scratch=$(mktemp -d) + trap "rm -rf '$scratch'" EXIT + seed_pgtap_stub "$scratch" + destdir_opt=(DESTDIR="$scratch") + fi + + # In existing mode pg_regress runs against $db via --use-existing and must + # NOT create/drop its own database. `make test` (not just `make + # verify-results`) is a real gate as of pgxntool 2.3.0 - it now exits + # non-zero on regression failures instead of always exiting 0 regardless + # of pg_regress's result (see this repo's pgxntool 2.3.0 bump). + # TEST_EXISTING_DEPLOY is passed through explicitly (not left to + # environment inheritance) so test/install/load.sql's existing-mode + # assertion (via the count_nulls.test_existing_deploy GUC - see the + # Makefile) picks the right source regardless of how this function is + # invoked. + make test TEST_LOAD_SOURCE=existing TEST_SCHEMA="$schema" CONTRIB_TESTDB="$db" EXTRA_REGRESS_OPTS=--use-existing TEST_EXISTING_DEPLOY="$deploy" "${destdir_opt[@]}" +} + +usage() { + echo "usage: bin/test_existing [args]" >&2 + echo " prepare-old DB SCHEMA INSTALL_VERSION" >&2 + echo " update DB [TO_VERSION]" >&2 + echo " run-suite DB SCHEMA" >&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 + prepare-old) prepare_old "$@" ;; + update) update_ext "$@" ;; + run-suite) run_suite "$@" ;; + *) usage ;; + esac +} + +main "$@" diff --git a/bin/test_existing.sql/assert_guard.sql b/bin/test_existing.sql/assert_guard.sql new file mode 100644 index 0000000..33946cf --- /dev/null +++ b/bin/test_existing.sql/assert_guard.sql @@ -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 +$$; diff --git a/bin/test_existing.sql/drop_guard.sql b/bin/test_existing.sql/drop_guard.sql new file mode 100644 index 0000000..744c3b5 --- /dev/null +++ b/bin/test_existing.sql/drop_guard.sql @@ -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; diff --git a/bin/test_existing.sql/plant_guard.sql b/bin/test_existing.sql/plant_guard.sql new file mode 100644 index 0000000..1c3e84d --- /dev/null +++ b/bin/test_existing.sql/plant_guard.sql @@ -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= -f plant_guard.sql + * (empty schema means "wherever null_count already resolves unqualified" - + * i.e. count_nulls 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; diff --git a/test/README.md b/test/README.md index 3ae3a96..72411f6 100644 --- a/test/README.md +++ b/test/README.md @@ -24,6 +24,14 @@ then invoke via `runtests()`. `test__*` functions covering function definitions, immutability/ strictness, and behavior across `anyarray`/`json`/`jsonb` and both trigger functions. +- `../bin/compare_fresh_vs_update` โ€” not part of the pgTAP suite itself: a + standalone script the `test` CI job's update leg runs after + `TEST_LOAD_SOURCE=update`, which installs fresh and 0.9.6-then-updated + copies of the extension in their own scratch databases and diffs + `pg_get_functiondef`/comments/ACLs for every object the extension owns. + Catches an update script leaving some definition subtly different from a + fresh install, even when the fixed pgTAP suite above still passes (it + only asserts the specific behaviors it happens to check). - `sql/extension_tests.sql` โ€” `\i`'s `core/functions.sql`, adds two more `test__*` functions of its own (`test__check_ncs`, asserting count_nulls landed where expected; `test__shutdown__drop_all`, asserting it can be diff --git a/test/install/load.sql b/test/install/load.sql index 37db853..0cb1899 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -59,4 +59,94 @@ SELECT CASE WHEN :'count_nulls_has_schema' CREATE SCHEMA IF NOT EXISTS :"schema"; \endif +/* + * 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, committed (this file runs outside any + * per-test rolled-back transaction, unlike the old test/deps.sql approach - + * see pgxntool/README.asc's U&U section for why the commit matters); + * 'existing' asserts count_nulls is already installed (a real `pg_upgrade` + * run, external to this invocation) and touches nothing. + * + * Read without missing_ok, same reasoning as count_nulls.test_schema above. + */ +SELECT current_setting('count_nulls.test_load_mode') AS count_nulls_test_load_mode + , current_setting('count_nulls.test_load_mode') = 'update' AS count_nulls_update_mode + , current_setting('count_nulls.test_load_mode') = 'existing' AS count_nulls_existing_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 +$$; + +\if :count_nulls_existing_mode +/* + * Already installed by something external to this pg_regress invocation + * (a real pg_upgrade run, or a pg_tle registration - see the + * pg-upgrade-test / pg-tle-test CI jobs). Only assert it's present and at + * the current version; do NOT drop/create/update it - the whole point of + * this mode is testing the REAL migrated/deployed objects. + * + * The "current version" half of that assertion (v_default below) has two + * sources depending on count_nulls.test_existing_deploy (see the + * TEST_EXISTING_DEPLOY comment in the Makefile): + * - filesystem (default): pg_available_extensions.default_version, read + * straight from a real .control file on disk. + * - pgtle: count_nulls was registered purely through pg_tle's + * database-backed catalog, never touching the filesystem. + * pg_available_extensions does NOT see pg_tle registrations at all - it + * only ever reads .control files off disk - so it comes back NULL here + * even though CREATE EXTENSION correctly resolves the default version + * through pg_tle. pg_tle ships its own separate, non-integrated analog + * for this: pgtle.available_extensions() (see pg_tle's tleextension.c, + * which documents pg_available_extensions as merely modeled on this + * SRF, not backed by it). Use that instead when running under pg_tle, + * rather than weakening the check for the filesystem case. + */ +DO $$ +DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'); + v_deploy text := current_setting('count_nulls.test_existing_deploy'); + v_default text; +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_deploy = 'pgtle' THEN + SELECT default_version INTO v_default + FROM pgtle.available_extensions() WHERE name = 'count_nulls'; + ELSIF v_deploy = 'filesystem' THEN + SELECT default_version INTO v_default + FROM pg_available_extensions WHERE name = 'count_nulls'; + ELSE + RAISE EXCEPTION + 'count_nulls.test_existing_deploy must be ''filesystem'' or ''pgtle'', got ''%''' + , v_deploy + ; + END IF; + + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'count_nulls installed at % but default_version (deploy=%) is %', v_installed, v_deploy, v_default; + END IF; +END +$$; +\elif :count_nulls_update_mode +CREATE EXTENSION count_nulls:with_schema_clause 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:with_schema_clause; +\endif