Skip to content
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

After pushing to a branch with an open PR, monitor CI using `gh pr checks --watch` in a background subagent until all jobs pass or a failure is confirmed. Investigate and fix failures immediately rather than leaving them for the user to notice.

## psql Script Conventions

Any `\if`/`\elsif`/`\else`/`\endif` block spanning more than ~a dozen lines needs a short comment naming the block on each of its control statements (including `\endif`), so a reader scrolling past `\else`/`\endif` on their own can tell at a glance which `\if` they belong to without scrolling back up. Put the comment on its own line immediately above the control statement, NOT trailing on the same line -- unlike SQL statements, psql's `\if`/`\else`/`\endif` don't treat a trailing `--` as a comment to strip: `\if` parses the entire rest of the line as its boolean expression (so a trailing comment breaks parsing outright), and `\else`/`\endif` parse it as an "extra argument" that gets ignored but still prints a warning into the actual output. Example:

```sql
-- existing-mode install
\if :is_existing
...
-- existing-mode install
\else
...
-- existing-mode install
\endif
```

Short `\if` blocks don't need this -- the `\if` is still visible on screen alongside its `\else`/`\endif`.

## Project Overview

This is **test_factory**, a PostgreSQL extension that provides a framework for managing unit test data in databases. It solves the common problem of creating and maintaining test data by providing a system to register test data definitions once and retrieve them efficiently with automatic dependency resolution.
Expand Down
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ include pgxntool/base.mk
# instead of silently disabling this check.
PGXNTOOL_ENABLE_TEST_BUILD = yes

PGXNTOOL_ENABLE_TEST_INSTALL = yes

# ------------------------------------------------------------------------------
# TEST_LOAD_SOURCE: how test/install/load.sql gets the extension to its
# target state (fresh/update/existing). See test/install/load.sql for what
# each mode actually does.
# ------------------------------------------------------------------------------
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

# Only meaningful in 'update' mode; empty TO means "update to current".
TEST_UPDATE_FROM ?= 0.5.0
TEST_UPDATE_TO ?=

# Export unconditionally -- load.sql must never treat "absent" as "fresh".
export PGOPTIONS := $(PGOPTIONS) -c test_factory.test_load_mode=$(TEST_LOAD_SOURCE) -c test_factory.test_update_from=$(TEST_UPDATE_FROM) -c test_factory.test_update_to=$(TEST_UPDATE_TO)

# make test-update: convenience wrapper. Must re-invoke $(MAKE) (not just
# depend on test) so the parse-time TEST_LOAD_SOURCE validation above
# re-evaluates for the child invocation.
.PHONY: test-update
test-update:
$(MAKE) test TEST_LOAD_SOURCE=update

# Hook for test to ensure dependencies in control file are set correctly
testdeps: check_control

Expand Down
85 changes: 75 additions & 10 deletions test/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,82 @@ The test_factory extension uses **pgTAP** (PostgreSQL's unit testing framework)
### Test Helpers
- `test/helpers/setup.sql` - Test environment initialization and pgTAP setup
- `test/helpers/create.sql` - Test data registration and security validation
- `test/helpers/create_extension.sql` - Extension creation wrapper
- `test/helpers/deps.sql` - Test dependency management
- `test/helpers/deps.sql` - Test dependency management (`\i`'s `test/roles.sql`)
- `test/roles.sql` - Single source of truth for test-only role names
- Other helper files for role management and pgTAP integration

## Load Modes (`TEST_LOAD_SOURCE`)

`test/install/load.sql` runs once, committed, before the regular test files
(pgxntool's `PGXNTOOL_ENABLE_TEST_INSTALL` feature), so its state survives
into every test file. It is the *only* place that knows how the extension
gets onto the system -- `test/sql/base.sql` and `test/sql/pgtap.sql` always
assume both extensions are already installed, in every mode, and are
otherwise identical regardless of which mode ran. `TEST_LOAD_SOURCE`
(default `fresh`) picks how the extension gets to its target state:

- **fresh** (default) - drops both extensions first (so a re-run against a
non-fresh dev DB starts clean), lands `pgtap` in a dedicated `tap` schema,
then `CREATE EXTENSION test_factory_pgtap CASCADE` (proving
`test_factory_pgtap.control`'s `requires` line actually pulls
`test_factory` in).
- **update** - `CREATE EXTENSION test_factory VERSION :from` then
`ALTER EXTENSION UPDATE` (`TEST_UPDATE_FROM`/`TEST_UPDATE_TO` make vars),
then installs `test_factory_pgtap` at current (it has only ever shipped
one version, so there's no update path of its own to exercise yet -- the
mechanism exists for when a second version ships, but no CI job drives it
yet). `make test-update` is a shorthand for `make test
TEST_LOAD_SOURCE=update`.
- **existing** - the extension is already installed (a real `pg_upgrade`
target, or an out-of-band update) -- `load.sql` only asserts it's present
at the current version, plants a dependency guard (see below), and
proves it; never drops/creates/updates anything.

Run against a real pre-existing install with:
```
make test TEST_LOAD_SOURCE=existing CONTRIB_TESTDB=<db> \
EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no
```

All three modes leave the system in the same observable end state (both
extensions installed, `pgtap` in schema `tap`), so `base.sql`/`pgtap.sql`
and their expected output are shared across all of them -- no
per-mode alternate expected files.

### Dependency Guard

Planted only in `existing` mode (see `load.sql`): a view in schema
`test_factory_drop_guard` depending on `tf.tap(text,text)` blocks a
non-CASCADE `DROP EXTENSION test_factory_pgtap`. `test_factory` itself
doesn't need an artificial guard -- `test_factory_pgtap`'s own control file
(`requires = 'pgtap, test_factory'`) already blocks a non-CASCADE
`DROP EXTENSION test_factory` as long as `test_factory_pgtap` is installed;
`load.sql` proves that natural protection too. The point of the guard: in
`existing` mode, nothing else stops a stray drop (or a logic bug that falls
through to the fresh/update branch) from silently destroying the real
upgraded/updated objects this mode exists to test.

## Test Coverage Analysis

### Core Functionality Tests (`base.sql`)
1. **Extension Setup** - Creates extension and test tables
2. **Data Registration** - Tests `tf.register()` with multiple test sets
3. **Basic Retrieval** - Tests `tf.get()` returns correct data
4. **Dependency Resolution** - Tests automatic creation of dependent data (customer → invoice)
5. **Caching Behavior** - Verifies data consistency across multiple `tf.get()` calls
6. **Table Independence** - Tests that cached data persists after source table changes
7. **Function-based Test Data** - Tests using functions as test data sources
Assumes both extensions are already installed (by `test/install/load.sql`,
in every mode) and test tables not yet created:
1. **Data Registration** - Tests `tf.register()` with multiple test sets
2. **Basic Retrieval** - Tests `tf.get()` returns correct data
3. **Dependency Resolution** - Tests automatic creation of dependent data (customer → invoice)
4. **Caching Behavior** - Verifies data consistency across multiple `tf.get()` calls
5. **Table Independence** - Tests that cached data persists after source table changes
6. **Function-based Test Data** - Tests using functions as test data sources

### Security Tests (`create.sql`)
- **Role Management** - Validates proper role restoration after installation
- **Security Definer Functions** - Ensures all privileged functions use `search_path=pg_catalog`
- **Permission Isolation** - Tests with unprivileged `test_role`
- **Temp Table Cleanup** - Verifies temporary installation objects are removed

Role-restore verification (does `CREATE EXTENSION` correctly restore the
calling role?) lives in `test/install/load.sql` instead, where `CREATE
EXTENSION` actually runs.

### Raw SQL Syntax Tests (`test/build/syntax.sql`)
- Runs `sql/test_factory.sql` and `sql/test_factory_pgtap.sql` (the actual
source files, not the generated `sql/*--VERSION.sql` copies) directly via
Expand All @@ -57,8 +112,18 @@ The test_factory extension uses **pgTAP** (PostgreSQL's unit testing framework)
- See the comments in that file for the known/expected errors baked into its
expected output (`pg_extension_config_dump()` and `SET ROLE ""`), which are
artifacts of running the file outside of CREATE EXTENSION, not bugs.
- This is the *only* thing `test/build` is for: running extension scripts
"bare" for better error context. Its results are always thrown away
(unlike `test/install`, which is intended to commit and persist).
Packaging checks (dependency declarations, clean install) belong in
`test/install/load.sql` instead.

### pgTAP Integration Tests (`pgtap.sql`)
- **Dependency Enforcement** - Confirms `test_factory_pgtap.control`'s
`requires = 'pgtap, test_factory'` line is real and enforced by Postgres,
via a `pg_depend` extension-requires-extension edge (`deptype = 'n'`),
not just documentation. Checking final catalog state this way works
uniformly in every `TEST_LOAD_SOURCE` mode.
- **tf.tap() Function** - Tests pgTAP wrapper functionality
- **Error Handling** - Tests proper error reporting for invalid inputs

Expand Down
36 changes: 17 additions & 19 deletions test/expected/base.out
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
\set ECHO none
Creating extension test_factory
ok 1 - Register test customers
ok 2 - Create function customer__add
ok 3 - Register test invoices
ok 4 - Ensure original_role temp table was dropped
ok 5 - Ensure role is put back after install
ok 6 - Security definer function _tf.schema__getsert has search_path=pg_catalog
ok 7 - Security definer function _tf.test_factory__get has search_path=pg_catalog
ok 8 - Security definer function _tf.test_factory__set has search_path=pg_catalog
ok 9 - Security definer function _tf.table_create has search_path=pg_catalog
ok 10 - Security definer function _tf.get has search_path=pg_catalog
ok 11 - customer table is empty
ok 12 - invoice table is empty
ok 13 - invoice factory output
ok 14 - invoice table content
ok 15 - customer table content
ok 16 - invoice factory second call
ok 17 - invoice table content stayed constant
ok 18 - customer table content stayed constant
ok 19 - Test function factory
ok 20 - customer table has new row
ok 21 - truncate invoice
ok 22 - invoice factory get remains the same after truncate
ok 5 - Security definer function _tf.schema__getsert has search_path=pg_catalog
ok 6 - Security definer function _tf.test_factory__get has search_path=pg_catalog
ok 7 - Security definer function _tf.test_factory__set has search_path=pg_catalog
ok 8 - Security definer function _tf.table_create has search_path=pg_catalog
ok 9 - Security definer function _tf.get has search_path=pg_catalog
ok 10 - customer table is empty
ok 11 - invoice table is empty
ok 12 - invoice factory output
ok 13 - invoice table content
ok 14 - customer table content
ok 15 - invoice factory second call
ok 16 - invoice table content stayed constant
ok 17 - customer table content stayed constant
ok 18 - Test function factory
ok 19 - customer table has new row
ok 20 - truncate invoice
ok 21 - invoice factory get remains the same after truncate
12 changes: 5 additions & 7 deletions test/expected/pgtap.out
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
\set ECHO none
Creating extension test_factory
Creating extension test_factory_pgtap
ok 1 - Register test customers
ok 2 - Create function customer__add
ok 3 - Register test invoices
ok 4 - Ensure original_role temp table was dropped
ok 5 - Ensure role is put back after install
ok 1 - test_factory_pgtap depends on test_factory (control file requires is real and enforced)
ok 2 - Register test customers
ok 3 - Create function customer__add
ok 4 - Register test invoices
ok 5 - Ensure original_role temp table was dropped
ok 6 - Security definer function _tf.schema__getsert has search_path=pg_catalog
ok 7 - Security definer function _tf.test_factory__get has search_path=pg_catalog
ok 8 - Security definer function _tf.test_factory__set has search_path=pg_catalog
Expand Down
23 changes: 14 additions & 9 deletions test/helpers/create.sql
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
SET ROLE = DEFAULT;
CREATE ROLE test_role;
GRANT USAGE ON SCHEMA tap TO test_role;
/*
* test_role itself is created once, idempotently, by test/install/load.sql
* (test/roles.sql is the single source of truth for the name; \i'd via
* test/helpers/deps.sql).
*/
GRANT USAGE ON SCHEMA tap TO :test_role;
/*
* DO NOT GRANT test_role TO test_factory__owner; the whole point test_role is
* to check for security problems.
*/

CREATE SCHEMA test AUTHORIZATION test_role;
SET ROLE = test_role;
CREATE SCHEMA test AUTHORIZATION :test_role;
SET ROLE = :test_role;
SET search_path = test, tap;

CREATE TABLE customer(
Expand Down Expand Up @@ -79,11 +83,12 @@ SELECT hasnt_table(
, 'Ensure original_role temp table was dropped'
);

SELECT is(
(SELECT * FROM post_install_role)
, (SELECT * FROM pre_install_role)
, 'Ensure role is put back after install'
);
/*
* Role-restore verification (does CREATE EXTENSION correctly restore the
* calling role?) now lives in test/install/load.sql, where CREATE
* EXTENSION actually runs in every mode -- there's nothing to check here
* anymore now that this file no longer runs it itself.
*/

SELECT cmp_ok(
proconfig
Expand Down
7 changes: 0 additions & 7 deletions test/helpers/create_extension.sql

This file was deleted.

1 change: 1 addition & 0 deletions test/helpers/deps.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
\i test/roles.sql
1 change: 1 addition & 0 deletions test/install/load.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
\set ECHO none
Loading
Loading