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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
# ===========================================================================
# Test strategy
#
# An object_reference install can be arrived at more than one way, each of
# which can break differently, so each is exercised by its own job below:
#
# - `lint`: the cheapest possible check (no database, no container beyond
# a plain checkout, seconds to run) -- gates everything else so a broken
# style baseline never ties up runner slots on the heavier jobs below.
#
# - `changes`: cheap docs-only gate, PLUS the single source of truth for
# the supported-PostgreSQL-major list every other job's matrix derives
# from (see its own "Derive ..." step).
#
# - `test`: FRESH install (CREATE EXTENSION at the current "stable"
# version) via `make test`, on every supported PostgreSQL major. The
# baseline a brand-new user gets.
#
# - `extension-update-test`: UPDATE TO CURRENT. Installs the one real
# historical PGXN release (0.1.0), ALTER EXTENSION UPDATEs it to
# "stable" via bin/test_existing's update-scenario, structurally
# compares the result against a fresh "stable" install
# (bin/structural_diff -- so a divergent function/view body produced
# only by the update path, and never by a fresh install, cannot slip
# through silently), then runs the full pgTAP suite against that real
# updated database in `existing` mode (TEST_LOAD_SOURCE=existing,
# --use-existing). A planted dependency guard (a view hard-referencing
# _object_reference.object's row type) blocks a stray non-CASCADE DROP
# EXTENSION throughout, and is re-proved present after every step -- see
# bin/test_existing's own header for the full rationale. Runs on a
# SINGLE PostgreSQL major (the newest supported), not the full matrix:
# 0.1.0's install script has no identified PostgreSQL-version floor (no
# SELECT * over a system catalog, no ALTER TYPE ... ADD VALUE), so
# crossing this axis against every major would just multiply job count
# for no added coverage.
#
# - No binary pg_upgrade job (the cat_tools reference this effort is
# modeled on has `pg-upgrade-test` / `pg-upgrade-stepwise`) exists yet.
# Deliberate, not an oversight: that job exists to catch a view/function
# that breaks across a PostgreSQL major specifically because it touches
# catalog internals (SELECT * over a system catalog whose columns get
# added/exposed/removed between majors). object_reference has no such
# construct in either its current or 0.1.0 install script (checked
# directly -- no view or function selects * from a system catalog; every
# object_reference table/view is an ordinary user object), so the risk
# that job protects against is correspondingly low here. Left as
# explicitly-noted future work rather than built preemptively; revisit
# if/when object_reference grows a catalog-touching view or function.
#
# - `all-checks-passed`: single stable required-status-check name; see its
# own comment below.
# ===========================================================================
name: CI
on:
push:
Expand Down Expand Up @@ -145,14 +197,50 @@ jobs:
- name: Test on PostgreSQL ${{ matrix.pg }}
run: make test

# Extension UPDATE path: install the one real historical PGXN release
# (0.1.0), ALTER EXTENSION UPDATE to the current version ("stable"), and
# run the full suite against the real updated database in `existing` mode
# -- see the "Test strategy" comment at the top of this file and
# bin/test_existing's own header for the full flow and dependency-guard
# rationale.
extension-update-test:
# Gated behind lint+test (not just changes): this job installs a second,
# older extension version and runs the update path, which is wasted
# effort against a baseline that's already broken by a style violation
# or a failing fresh-install test. success() must be written explicitly
# -- GitHub only assumes success() as a job's default when it has no
# if: at all.
needs: [changes, lint, test]
if: success() && needs.changes.outputs.docs_only != 'true'
name: ⬆️ Extension update test (0.1.0 → stable)
runs-on: ubuntu-latest
container: pgxn/pgxn-tools
steps:
# A single PostgreSQL major (the newest supported, from the `changes`
# job's single source of truth) -- see the Test strategy comment at
# the top of this file for why this isn't crossed against the full PG
# matrix.
- name: Start PostgreSQL ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }}
run: pg-start ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Install object_reference + its 0.1.0-only test dependency (count_nulls)
# TEST_LOAD_SOURCE=update activates the Makefile's conditional
# `install: count_nulls` prerequisite (see the Makefile's own
# comment on it): 0.1.0's install script needs count_nulls even
# though current object_reference.control no longer declares it.
run: make install TEST_LOAD_SOURCE=update
- name: Update 0.1.0 -> stable, structurally compare, run the suite (existing mode)
run: bin/test_existing update-scenario object_reference_update 0.1.0

# A single stable check name for use as a required status check in branch
# protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14"
# which would all need to be listed individually and updated whenever the
# matrix changes. This job passes if all others passed or were skipped
# (e.g. test, on a docs-only push), and fails if any failed or were
# cancelled.
all-checks-passed:
needs: [changes, lint, test]
needs: [changes, lint, test, extension-update-test]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
85 changes: 85 additions & 0 deletions bin/structural_diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# Structurally compare, object-by-object, every member of the
# object_reference extension in two databases: function bodies
# (pg_get_functiondef), view definitions (pg_get_viewdef), table/composite
# column lists, comments, and ACLs. A nonempty diff is a bug -- the whole
# point of an extension UPDATE script is that it reaches the SAME objects a
# fresh install of the target version would.
#
# Modeled on cat_tools's bin/structural_diff (Postgres-Extensions/cat_tools),
# which generalized a manual comparison that found a real fresh-vs-update
# divergence in cat_tools#46. bin/structural_diff.sql here is a near-verbatim
# copy of that file -- it is already written generically (parameterized on
# :extname, driven entirely off pg_depend's deptype='e' membership edge, with
# no cat_tools-specific object names), so it applies to any extension as-is.
#
# USAGE: bin/structural_diff <subcommand> [args]
#
# dump DB [EXTNAME]
# Print the signature of every EXTNAME member object in DB (EXTNAME
# defaults to object_reference). Useful on its own for eyeballing one
# database's structure, and it's what `compare` diffs under the hood.
#
# compare DB1 DB2 [EXTNAME]
# Dump both databases and diff them. Prints a unified diff and exits
# non-zero if they differ; exits 0 (and prints an OK line) if
# identical.
#
# See bin/structural_diff.sql for the query that defines "signature" (and how
# it decides which object kinds get a real structural definition vs. falling
# back to just identity/comment/ACL).
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)

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

dump() {
local db=$1 extname=${2:-object_reference}
psql -d "$db" -v extname="'$extname'" -f "$SCRIPT_DIR/structural_diff.sql"
}

compare() {
local db1=$1 db2=$2 extname=${3:-object_reference}
# Run in a subshell so the EXIT trap (temp-file cleanup) is scoped to this
# comparison only. A trap set with plain `trap ... RETURN` is NOT scoped to
# the function that set it -- it re-fires on every later function return in
# the same shell, including main()'s, by which point f1/f2 no longer exist.
(
f1=$(mktemp)
f2=$(mktemp)
trap 'rm -f "$f1" "$f2"' EXIT
dump "$db1" "$extname" > "$f1"
dump "$db2" "$extname" > "$f2"
if diff -u --label "$db1" --label "$db2" "$f1" "$f2"; then
echo "OK: '$db1' and '$db2' are structurally identical for extension '$extname'"
else
echo "FAIL: structural diff between '$db1' and '$db2' for extension '$extname' (see diff above) -- an update path reached objects that differ from a fresh install" >&2
exit 1
fi
)
}

usage() {
echo "usage: bin/structural_diff <subcommand> [args]" >&2
echo " dump DB [EXTNAME]" >&2
echo " compare DB1 DB2 [EXTNAME]" >&2
exit 2
}

# Explicit subcommand dispatch on $1, matching bin/test_existing's
# convention.
main() {
local cmd=${1:-}
shift || true
case "$cmd" in
dump) dump "$@" ;;
compare) compare "$@" ;;
*) usage ;;
esac
}

main "$@"
179 changes: 179 additions & 0 deletions bin/structural_diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Structural signature dump for every object that belongs to an extension
* (pg_depend deptype = 'e'), used by bin/structural_diff to compare a
* database reached via an extension UPDATE against a FRESH install of the
* same target version. Any nonempty diff between two runs of this query is a
* bug: the two paths are supposed to produce byte-identical objects.
*
* Run via: psql -d DBNAME -v extname="'object_reference'" -f bin/structural_diff.sql
*
* Modeled on cat_tools's bin/structural_diff.sql (Postgres-Extensions/
* cat_tools), which generalized a manual comparison technique (diffing
* pg_get_functiondef / pg_get_viewdef / type labels / comments / ACLs /
* extension membership between a fresh install and an updated database) used
* to find a real fresh-vs-update divergence in that extension. This file is
* copied near-verbatim -- it is written generically off pg_depend's deptype
* = 'e' membership edge, with no cat_tools-specific object names, so it
* applies to object_reference (and any other extension) as-is; only the
* default :extname in bin/structural_diff's wrapper differs.
*
* Emits one text block per member object, ordered by its pg_describe_object()
* identity so the SAME object sorts to the SAME position regardless of the
* OIDs assigned along each installation path. Each block covers:
* - a structural definition, using pg_get_functiondef/pg_get_viewdef for
* routines/views, an ordered column dump for a plain table or standalone
* composite type, an ordered label list for enums, or a cast/domain
* summary -- whichever a member's catalog/kind actually calls for.
* object_reference currently has no enums, domains or casts of its own;
* those branches are kept anyway (harmless no-ops today) so a future
* member of one of those kinds is compared structurally too, without
* needing to remember to add it. Row types implicitly created BY a
* member relation, and the array type shadowing any other member type,
* are skipped: their structure is fully captured by the relation/base-
* type entry already, so listing them again would just duplicate that
* comparison under a second identity.
* - its comment (pg_description), generically via obj_description().
* - its ACL, generically via whichever ACL column its catalog has (proacl /
* typacl / relacl / nspacl); sorted, since grant order is not meaningful.
*
* This is deliberately NOT specific to object_reference's current object
* list: any object kind this extension does not (yet) use falls through to
* the ELSE branch below, which still includes it (via its
* pg_describe_object identity, comment and ACL) so a future new member is
* compared at least at that level rather than silently skipped, even though
* this file does not (yet) know how to render a structural definition for
* it.
*/
\set ON_ERROR_STOP on
\pset format unaligned
\pset tuples_only on
\pset fieldsep ''

WITH ext AS (
SELECT oid FROM pg_extension WHERE extname = :extname
), members AS (
SELECT d.classid, d.objid
FROM pg_depend d, ext
WHERE d.refclassid = 'pg_extension'::regclass
AND d.refobjid = ext.oid
AND d.deptype = 'e'
), skip_shadow AS (
/* Implicit row type of a member relation: same structure as the relation
* itself, so comparing it too would just duplicate that check. */
SELECT t.oid
FROM pg_type t
JOIN members rel ON rel.classid = 'pg_class'::regclass AND rel.objid = t.typrelid
WHERE t.typtype = 'c'
UNION
/* Array type shadowing another member type: same element type, no
* independent structure of its own. */
SELECT t.oid
FROM pg_type t
JOIN members base ON base.classid = 'pg_type'::regclass AND base.objid = t.typelem
WHERE t.typelem <> 0
), acl AS (
SELECT m.classid, m.objid,
(
SELECT array_to_string(array_agg(a::text ORDER BY a::text), ',')
FROM unnest(
CASE m.classid
WHEN 'pg_proc'::regclass THEN (SELECT proacl FROM pg_proc WHERE oid = m.objid)
WHEN 'pg_type'::regclass THEN (SELECT typacl FROM pg_type WHERE oid = m.objid)
WHEN 'pg_class'::regclass THEN (SELECT relacl FROM pg_class WHERE oid = m.objid)
WHEN 'pg_namespace'::regclass THEN (SELECT nspacl FROM pg_namespace WHERE oid = m.objid)
ELSE NULL
END
) a
) AS acl_text
FROM members m
), relation_cols AS (
/* Ordered column dump, shared by the plain-table case (pg_class relkind
* 'r') and the standalone-composite-type case (pg_type typtype 'c' whose
* typrelid is NOT a member relation, i.e. survived skip_shadow) -- both
* describe a set of (name, type, not-null, default) columns identically. */
SELECT m.classid, m.objid,
(
SELECT string_agg(
format(
'%s %s%s%s'
, a.attname
, format_type(a.atttypid, a.atttypmod)
, CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END
, COALESCE(' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid), '')
)
, E'\n' ORDER BY a.attnum
)
FROM pg_attribute a
LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
WHERE a.attrelid = CASE m.classid
WHEN 'pg_class'::regclass THEN m.objid
WHEN 'pg_type'::regclass THEN (SELECT typrelid FROM pg_type WHERE oid = m.objid)
END
AND a.attnum > 0
AND NOT a.attisdropped
)
/* Table constraints (PK/UNIQUE/CHECK/FK) have no equivalent on a
* standalone composite type, so this is NULL there and simply appends
* nothing. */
|| COALESCE(
E'\n' || (
SELECT string_agg(pg_get_constraintdef(c.oid), E'\n' ORDER BY c.conname)
FROM pg_constraint c
WHERE m.classid = 'pg_class'::regclass AND c.conrelid = m.objid
)
, ''
) AS cols
FROM members m
WHERE (m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r')
OR (m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c')
)
SELECT
'=== ' || pg_describe_object(m.classid, m.objid, 0) || E' ===\n'
|| 'DEFINITION:' || E'\n' || COALESCE(
CASE
WHEN m.classid = 'pg_proc'::regclass
THEN pg_get_functiondef(m.objid)
WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) IN ('v', 'm')
THEN pg_get_viewdef(m.objid, true)
WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r'
THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid)
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'e'
THEN (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid = m.objid)
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c'
THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid)
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'd'
THEN (
SELECT format(
'base=%s notnull=%s default=%s check=%s'
, t.typbasetype::regtype, t.typnotnull, t.typdefault
, (SELECT string_agg(pg_get_constraintdef(c.oid), ' AND ' ORDER BY c.oid)
FROM pg_constraint c WHERE c.contypid = m.objid)
)
FROM pg_type t WHERE t.oid = m.objid
)
WHEN m.classid = 'pg_cast'::regclass
THEN (
SELECT format(
'CAST (%s AS %s) METHOD %s CONTEXT %s'
, ct.castsource::regtype, ct.casttarget::regtype
, CASE ct.castmethod
WHEN 'f' THEN 'FUNCTION ' || ct.castfunc::regprocedure::text
WHEN 'i' THEN 'INOUT'
WHEN 'b' THEN 'BINARY COERCION'
END
, ct.castcontext
)
FROM pg_cast ct WHERE ct.oid = m.objid
)
ELSE NULL
END
, '(no structural definition rendered for this object kind -- see identity/comment/ACL below)'
)
|| E'\n' || 'COMMENT: ' || COALESCE(obj_description(m.objid, m.classid::regclass::text), '(none)')
|| E'\n' || 'ACL: ' || COALESCE((SELECT acl_text FROM acl WHERE acl.classid = m.classid AND acl.objid = m.objid), '(none)')
|| E'\n'
AS block
FROM members m
LEFT JOIN skip_shadow s ON m.classid = 'pg_type'::regclass AND s.oid = m.objid
WHERE s.oid IS NULL
ORDER BY pg_describe_object(m.classid, m.objid, 0);
Loading
Loading