diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45cdcd9..58630cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,23 @@ on: - master pull_request: jobs: + # Fresh install, across the PG matrix AND a schema matrix (TEST_SCHEMA, + # picked up from the environment by test/install/load.sql via the + # count_nulls.test_schema GUC - see the Makefile). Empty ('') runs WITHOUT + # specifying a schema at all; 'Quoted' runs WITH one explicitly specified, + # using a name that requires SQL identifier quoting. Both legs matter and + # both pass against the SAME test/expected/extension_tests.out (see + # test/README.md for how the suite keeps its output schema-invariant). test: strategy: matrix: pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] - name: 🐘 PostgreSQL ${{ matrix.pg }} + schema: ["", Quoted] + name: 🐘 PostgreSQL ${{ matrix.pg }} (schema ${{ matrix.schema == '' && 'none' || matrix.schema }}) runs-on: ubuntu-latest container: pgxn/pgxn-tools + env: + TEST_SCHEMA: ${{ matrix.schema }} steps: - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} diff --git a/Makefile b/Makefile index 71b142d..d6ed08a 100644 --- a/Makefile +++ b/Makefile @@ -6,3 +6,22 @@ include pgxntool/base.mk # Temporary hack testdeps: $(wildcard test/*/*.sql) $(wildcard test/*.sql) # Be careful not to include directories in this + +# TEST_SCHEMA selects which schema test/install/load.sql installs count_nulls +# into, for the WHOLE test run (every test file sees the SAME schema in a +# given run). +# +# Empty (the default): don't target any schema at all - count_nulls installs +# wherever the session's own default search_path already resolves. Non-empty: +# explicitly CREATE SCHEMA/SET search_path to that name first - including a +# name that requires SQL identifier quoting (mixed case - unquoted would fold +# to lowercase), to exercise the suite's %I schema-qualification rather than +# just its literal test data. Locally: `make test TEST_SCHEMA=Quoted`. +# +# Propagated as a GUC (count_nulls.test_schema), exported unconditionally via +# PGOPTIONS - pg_regress doesn't forward make variables, but the psql +# processes it spawns inherit the environment. Empty is a valid, deliberate +# value (not an error) - read without missing_ok, so a truly unpropagated GUC +# still fails loudly instead of looking identical to a deliberately empty one. +TEST_SCHEMA ?= +export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_schema=$(TEST_SCHEMA) diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..4d87210 --- /dev/null +++ b/test/README.md @@ -0,0 +1,78 @@ +# count_nulls test suite + +This suite is structured differently from most pgTAP-based extension tests: +rather than each `test/sql/*.sql` file writing its own independent +assertions, `core/functions.sql` defines a shared library of `test__*` +functions (pgTAP's `runtests()` naming convention) that test files `\i` and +then invoke via `runtests()`. + +## Layout + +- `install/load.sql` — installs count_nulls once, committed, before the main + `test/sql/` schedule (see pgxntool/README.asc's `test/install` section). + Its own output isn't tracked (see `install/.gitignore`) - correctness + comes from this file failing loudly if something's wrong, not from a + textual comparison. +- `deps.sql` — loaded by every test file (via `load.sql` -> + `pgxntool/setup.sql` -> `deps.sql`). No longer installs count_nulls + itself (that's `install/load.sql`'s job); only for genuine per-test + dependency statements. +- `core/functions.sql` — a shared helper, `\i`'d by `sql/extension_tests.sql`. + Defines `ncs()` (discovers, live, which schema count_nulls is actually + installed in - never trusts a hardcoded/passed-in value) plus a battery of + `test__*` functions covering function definitions, immutability/ + strictness, and behavior across `anyarray`/`json`/`jsonb` and both + trigger functions. +- `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 + cleanly dropped), then runs everything via `runtests()`. + +## TEST_SCHEMA + +A make var/GUC (`count_nulls.test_schema`, propagated the same way as any +other placeholder GUC: `make var` -> `PGOPTIONS -c ...` -> `current_setting()` +- pg_regress doesn't forward make variables, but the psql processes it +spawns inherit the environment) selecting which schema `install/load.sql` +installs count_nulls into: + +- Empty (default): no schema targeting at all - count_nulls lands wherever + the session's own default search_path resolves. Since `install/load.sql` + runs in its own bare connection (not the in-suite session pgTAP's own + `tap_setup.sql` runs in), that's `public`. +- Non-empty: explicitly `CREATE SCHEMA`/`SET search_path` to that name + first. `TEST_SCHEMA=Quoted` locally exercises a name requiring SQL + identifier quoting (mixed case - unquoted would fold to lowercase). + +Both legs run in CI - genuinely different code paths, not one a redundant +special case of the other. + +**Assertion descriptions deliberately never embed the schema name.** +`core/functions.sql`'s assertions build the SQL they *execute* via `%I` +qualification (through `ncs()`, so they're always correct no matter which +real schema count_nulls landed in) but pass an *explicit*, schema-free +description to every pgTAP call - overriding pgTAP's own auto-generated +descriptions, which otherwise embed the schema. This is what keeps +`test/expected/extension_tests.out` a single file that both TEST_SCHEMA +legs pass against, instead of needing one file per schema value. + +**One unavoidable, genuine exception**: `test__shutdown__drop_all` drops +the schema TEST_SCHEMA created - a real, correct behavioral difference (not +an artifact) between "there's a schema to clean up" (non-empty) and "there +isn't" (empty, nothing to drop). `test/expected/extension_tests_1.out` is +`pg_regress`'s native numbered-alternate mechanism for exactly this: the +default file expects a `SKIP` there, `_1.out` (captured from a real +`TEST_SCHEMA=Quoted` run, never hand-authored) expects the schema actually +dropped. `pg_regress` tries the default first, then each numbered +alternate in turn, and passes if any one matches. + +## Regenerating expected output + +Never hand-edit files under `expected/`. Regenerate via `make results` +(guarded by `make verify-results`, which refuses to copy while +`regression.diffs` shows real failures - use +`PGXNTOOL_ENABLE_VERIFY_RESULTS=no` to bypass that guard for a run you've +already reviewed and know is a legitimate, intentional change, not a way to +skip reviewing the diff). `make results` only ever writes the unsuffixed +default; alternates (`_1.out`, ...) have to be copied by hand from a real +`test/results/.out` for that scenario. diff --git a/test/core/functions.sql b/test/core/functions.sql index e755178..40ea9d8 100644 --- a/test/core/functions.sql +++ b/test/core/functions.sql @@ -43,19 +43,32 @@ BEGIN ); END IF; + /* + * Explicit descriptions below (not pgTAP's auto-generated default, + * which schema-qualifies via ncs()): this suite may run against + * count_nulls installed in ANY schema (see TEST_SCHEMA in the + * Makefile), and the description text is exact-matched by pg_regress + * against a single committed expected-output file - it must stay + * IDENTICAL no matter which schema the extension actually landed in. + * ncs() itself is still used to locate and call the real function; + * only the visible description text drops it. + */ RETURN NEXT function_returns( ncs(), f_name, f_args , 'int'::regtype::text -- Sanitize type name + , format('Function %s(%s) should return int', f_name, array_to_string(f_args, ',')) ); -- TODO: isnt_definer RETURN NEXT isnt_strict( ncs(), f_name, f_args + , format('Function %s(%s) should not be strict', f_name, array_to_string(f_args, ',')) ); RETURN NEXT volatility_is( ncs(), f_name, f_args , 'immutable' + , format('Function %s(%s) should be IMMUTABLE', f_name, array_to_string(f_args, ',')) ); END LOOP; END LOOP; @@ -65,16 +78,19 @@ BEGIN RETURN NEXT function_returns( ncs(), f_name, f_args , 'trigger' + , format('Function %s() should return trigger', f_name) ); -- TODO: isnt_definer RETURN NEXT isnt_strict( ncs(), f_name, f_args + , format('Function %s() should not be strict', f_name) ); RETURN NEXT volatility_is( ncs(), f_name, f_args , 'immutable' + , format('Function %s() should be IMMUTABLE', f_name) ); END LOOP; END @@ -90,6 +106,13 @@ CREATE FUNCTION pg_temp.test_trigger_raw( , exec text , errmsg text , errdesc text + /* + * Schema-free stand-in for exec, used ONLY in the visible description + * below - exec itself (schema-qualified via ncs(), by callers) is what + * actually runs. Keeps this suite's output identical no matter which + * schema count_nulls is installed in (see TEST_SCHEMA). + */ + , exec_desc text ) RETURNS SETOF text LANGUAGE plpgsql AS $body$ DECLARE @@ -100,8 +123,15 @@ DECLARE , exec ) ; + c_command_desc CONSTANT text := + format( $$CREATE TRIGGER %s %s INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE %s$$ + , trigger_name + , ba + , exec_desc + ) + ; BEGIN - RETURN NEXT lives_ok( c_command, c_command ); + RETURN NEXT lives_ok( c_command, c_command_desc ); RETURN NEXT throws_ok( $$INSERT INTO test_data VALUES (1,1,NULL)$$ , 'P0001' @@ -136,6 +166,11 @@ BEGIN ) , errmsg := coalesce( err, format( 'test_data must contain 1 %s fields', upper( replace( nn, '_', ' ' ) ) ) ) , errdesc := 'Test ' || c_trigger_name + , exec_desc := format( + $$%s_count_trigger(1, %L)$$ + , nn + , err + ) ) ; END @@ -163,7 +198,7 @@ BEGIN RETURN NEXT bag_eq( format($$SELECT a, b, c, %1$I.null_count( a, b, c ), %1$I.not_null_count( a, b, c ) FROM test_data$$, ncs()) , $$SELECT *, 3-null_count AS not_null_count FROM test_data$$ - , format('Test %I.null_count(a, b, c)', ncs()) + , 'Test null_count(a, b, c)' ); -- Test JSON versions @@ -171,12 +206,12 @@ BEGIN RETURN NEXT bag_eq( format($$SELECT a, b, c, %1$I.null_count( row_to_json( row(a, b, c) ) ), %1$I.not_null_count( row_to_json( row(a, b, c) ) ) FROM test_data$$, ncs()) , $$SELECT *, 3-null_count AS not_null_count FROM test_data$$ - , format('Test %I.null_count(json)', ncs()) + , 'Test null_count(json)' ); RETURN NEXT bag_eq( format($$SELECT a, b, c, %1$I.null_count( row_to_json( row(a, b, c) )::jsonb ), %1$I.not_null_count( row_to_json( row(a, b, c) )::jsonb ) FROM test_data$$, ncs()) , $$SELECT *, 3-null_count AS not_null_count FROM test_data$$ - , format('Test %I.null_count(jsonb)', ncs()) + , 'Test null_count(jsonb)' ); -- Doesn't work for array types @@ -198,10 +233,13 @@ BEGIN WHEN args = '' AND not_ = '' THEN $$test trigger usage: number of NULL columns[, error message]$$ ELSE 'test trigger: first argument must not be null' END - , 'Test ' || trig + , 'Test ' || trig_desc + , trig_desc ) FROM ( - SELECT *, format( '%I.%snull_count_trigger( %s )', ncs(), not_, args ) AS trig + SELECT * + , format( '%I.%snull_count_trigger( %s )', ncs(), not_, args ) AS trig + , format( '%snull_count_trigger( %s )', not_, args ) AS trig_desc FROM unnest( array['not_', ''] ) not_ , unnest( array['NULL', ''] ) args diff --git a/test/expected/extension_tests.out b/test/expected/extension_tests.out index 7bf8f0e..83991f5 100644 --- a/test/expected/extension_tests.out +++ b/test/expected/extension_tests.out @@ -1,85 +1,87 @@ \set ECHO none # Subtest: _null_count_test.test__check_ncs() - ok 1 - ncs() resolves to the schema count_nulls actually installed in - 1..1 + ok 1 + ok 2 - count_nulls' schema should not be in search path + 1..2 ok 1 - _null_count_test.test__check_ncs # Subtest: _null_count_test.test__definition() ok 1 - ensure null_count({anyarray}) is not in search_path - ok 2 - Function public.null_count(anyarray) should return integer - ok 3 - Function public.null_count(anyarray) should not be strict - ok 4 - Function public.null_count(anyarray) should be IMMUTABLE + ok 2 - Function null_count(anyarray) should return int + ok 3 - Function null_count(anyarray) should not be strict + ok 4 - Function null_count(anyarray) should be IMMUTABLE ok 5 - ensure null_count({json}) is not in search_path - ok 6 - Function public.null_count(json) should return integer - ok 7 - Function public.null_count(json) should not be strict - ok 8 - Function public.null_count(json) should be IMMUTABLE + ok 6 - Function null_count(json) should return int + ok 7 - Function null_count(json) should not be strict + ok 8 - Function null_count(json) should be IMMUTABLE ok 9 - ensure null_count({jsonb}) is not in search_path - ok 10 - Function public.null_count(jsonb) should return integer - ok 11 - Function public.null_count(jsonb) should not be strict - ok 12 - Function public.null_count(jsonb) should be IMMUTABLE + ok 10 - Function null_count(jsonb) should return int + ok 11 - Function null_count(jsonb) should not be strict + ok 12 - Function null_count(jsonb) should be IMMUTABLE ok 13 - ensure not_null_count({anyarray}) is not in search_path - ok 14 - Function public.not_null_count(anyarray) should return integer - ok 15 - Function public.not_null_count(anyarray) should not be strict - ok 16 - Function public.not_null_count(anyarray) should be IMMUTABLE + ok 14 - Function not_null_count(anyarray) should return int + ok 15 - Function not_null_count(anyarray) should not be strict + ok 16 - Function not_null_count(anyarray) should be IMMUTABLE ok 17 - ensure not_null_count({json}) is not in search_path - ok 18 - Function public.not_null_count(json) should return integer - ok 19 - Function public.not_null_count(json) should not be strict - ok 20 - Function public.not_null_count(json) should be IMMUTABLE + ok 18 - Function not_null_count(json) should return int + ok 19 - Function not_null_count(json) should not be strict + ok 20 - Function not_null_count(json) should be IMMUTABLE ok 21 - ensure not_null_count({jsonb}) is not in search_path - ok 22 - Function public.not_null_count(jsonb) should return integer - ok 23 - Function public.not_null_count(jsonb) should not be strict - ok 24 - Function public.not_null_count(jsonb) should be IMMUTABLE - ok 25 - Function public.null_count_trigger() should return trigger - ok 26 - Function public.null_count_trigger() should not be strict - ok 27 - Function public.null_count_trigger() should be IMMUTABLE - ok 28 - Function public.not_null_count_trigger() should return trigger - ok 29 - Function public.not_null_count_trigger() should not be strict - ok 30 - Function public.not_null_count_trigger() should be IMMUTABLE + ok 22 - Function not_null_count(jsonb) should return int + ok 23 - Function not_null_count(jsonb) should not be strict + ok 24 - Function not_null_count(jsonb) should be IMMUTABLE + ok 25 - Function null_count_trigger() should return trigger + ok 26 - Function null_count_trigger() should not be strict + ok 27 - Function null_count_trigger() should be IMMUTABLE + ok 28 - Function not_null_count_trigger() should return trigger + ok 29 - Function not_null_count_trigger() should not be strict + ok 30 - Function not_null_count_trigger() should be IMMUTABLE 1..30 ok 2 - _null_count_test.test__definition # Subtest: _null_count_test.test__functionality() - ok 1 - Test public.null_count(a, b, c) - ok 2 - Test public.null_count(json) - ok 3 - Test public.null_count(jsonb) - ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger( NULL ) - ok 5 - Test public.not_null_count_trigger( NULL ) + ok 1 - Test null_count(a, b, c) + ok 2 - Test null_count(json) + ok 3 - Test null_count(jsonb) + ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger( NULL ) + ok 5 - Test not_null_count_trigger( NULL ) ok 6 - DROP TRIGGER "test trigger" - ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger( ) - ok 8 - Test public.not_null_count_trigger( ) + ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger( ) + ok 8 - Test not_null_count_trigger( ) ok 9 - DROP TRIGGER "test trigger" - ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger( NULL ) - ok 11 - Test public.null_count_trigger( NULL ) + ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger( NULL ) + ok 11 - Test null_count_trigger( NULL ) ok 12 - DROP TRIGGER "test trigger" - ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger( ) - ok 14 - Test public.null_count_trigger( ) + ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger( ) + ok 14 - Test null_count_trigger( ) ok 15 - DROP TRIGGER "test trigger" - ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, 'error_message') + ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, 'error_message') ok 17 - Test "null_BEFORE_error_message" ok 18 - DROP TRIGGER "null_BEFORE_error_message" - ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, 'error_message') + ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, 'error_message') ok 20 - Test "null_AFTER_error_message" ok 21 - DROP TRIGGER "null_AFTER_error_message" - ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, 'error_message') + ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, 'error_message') ok 23 - Test "not_null_BEFORE_error_message" ok 24 - DROP TRIGGER "not_null_BEFORE_error_message" - ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, 'error_message') + ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, 'error_message') ok 26 - Test "not_null_AFTER_error_message" ok 27 - DROP TRIGGER "not_null_AFTER_error_message" - ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, NULL) + ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, NULL) ok 29 - Test "null_BEFORE_" ok 30 - DROP TRIGGER "null_BEFORE_" - ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.null_count_trigger(1, NULL) + ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, NULL) ok 32 - Test "null_AFTER_" ok 33 - DROP TRIGGER "null_AFTER_" - ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, NULL) + ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, NULL) ok 35 - Test "not_null_BEFORE_" ok 36 - DROP TRIGGER "not_null_BEFORE_" - ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE public.not_null_count_trigger(1, NULL) + ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, NULL) ok 38 - Test "not_null_AFTER_" ok 39 - DROP TRIGGER "not_null_AFTER_" 1..39 ok 3 - _null_count_test.test__functionality # Subtest: _null_count_test.test__shutdown__drop_all() ok 1 - 1..1 + ok 2 # SKIP TEST_SCHEMA is empty - no dedicated schema to drop + 1..2 ok 4 - _null_count_test.test__shutdown__drop_all 1..4 diff --git a/test/expected/extension_tests_1.out b/test/expected/extension_tests_1.out new file mode 100644 index 0000000..62ae915 --- /dev/null +++ b/test/expected/extension_tests_1.out @@ -0,0 +1,87 @@ +\set ECHO none +# Subtest: _null_count_test.test__check_ncs() + ok 1 + ok 2 - count_nulls' schema should not be in search path + 1..2 +ok 1 - _null_count_test.test__check_ncs +# Subtest: _null_count_test.test__definition() + ok 1 - ensure null_count({anyarray}) is not in search_path + ok 2 - Function null_count(anyarray) should return int + ok 3 - Function null_count(anyarray) should not be strict + ok 4 - Function null_count(anyarray) should be IMMUTABLE + ok 5 - ensure null_count({json}) is not in search_path + ok 6 - Function null_count(json) should return int + ok 7 - Function null_count(json) should not be strict + ok 8 - Function null_count(json) should be IMMUTABLE + ok 9 - ensure null_count({jsonb}) is not in search_path + ok 10 - Function null_count(jsonb) should return int + ok 11 - Function null_count(jsonb) should not be strict + ok 12 - Function null_count(jsonb) should be IMMUTABLE + ok 13 - ensure not_null_count({anyarray}) is not in search_path + ok 14 - Function not_null_count(anyarray) should return int + ok 15 - Function not_null_count(anyarray) should not be strict + ok 16 - Function not_null_count(anyarray) should be IMMUTABLE + ok 17 - ensure not_null_count({json}) is not in search_path + ok 18 - Function not_null_count(json) should return int + ok 19 - Function not_null_count(json) should not be strict + ok 20 - Function not_null_count(json) should be IMMUTABLE + ok 21 - ensure not_null_count({jsonb}) is not in search_path + ok 22 - Function not_null_count(jsonb) should return int + ok 23 - Function not_null_count(jsonb) should not be strict + ok 24 - Function not_null_count(jsonb) should be IMMUTABLE + ok 25 - Function null_count_trigger() should return trigger + ok 26 - Function null_count_trigger() should not be strict + ok 27 - Function null_count_trigger() should be IMMUTABLE + ok 28 - Function not_null_count_trigger() should return trigger + ok 29 - Function not_null_count_trigger() should not be strict + ok 30 - Function not_null_count_trigger() should be IMMUTABLE + 1..30 +ok 2 - _null_count_test.test__definition +# Subtest: _null_count_test.test__functionality() + ok 1 - Test null_count(a, b, c) + ok 2 - Test null_count(json) + ok 3 - Test null_count(jsonb) + ok 4 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger( NULL ) + ok 5 - Test not_null_count_trigger( NULL ) + ok 6 - DROP TRIGGER "test trigger" + ok 7 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger( ) + ok 8 - Test not_null_count_trigger( ) + ok 9 - DROP TRIGGER "test trigger" + ok 10 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger( NULL ) + ok 11 - Test null_count_trigger( NULL ) + ok 12 - DROP TRIGGER "test trigger" + ok 13 - CREATE TRIGGER "test trigger" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger( ) + ok 14 - Test null_count_trigger( ) + ok 15 - DROP TRIGGER "test trigger" + ok 16 - CREATE TRIGGER "null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, 'error_message') + ok 17 - Test "null_BEFORE_error_message" + ok 18 - DROP TRIGGER "null_BEFORE_error_message" + ok 19 - CREATE TRIGGER "null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, 'error_message') + ok 20 - Test "null_AFTER_error_message" + ok 21 - DROP TRIGGER "null_AFTER_error_message" + ok 22 - CREATE TRIGGER "not_null_BEFORE_error_message" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, 'error_message') + ok 23 - Test "not_null_BEFORE_error_message" + ok 24 - DROP TRIGGER "not_null_BEFORE_error_message" + ok 25 - CREATE TRIGGER "not_null_AFTER_error_message" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, 'error_message') + ok 26 - Test "not_null_AFTER_error_message" + ok 27 - DROP TRIGGER "not_null_AFTER_error_message" + ok 28 - CREATE TRIGGER "null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, NULL) + ok 29 - Test "null_BEFORE_" + ok 30 - DROP TRIGGER "null_BEFORE_" + ok 31 - CREATE TRIGGER "null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE null_count_trigger(1, NULL) + ok 32 - Test "null_AFTER_" + ok 33 - DROP TRIGGER "null_AFTER_" + ok 34 - CREATE TRIGGER "not_null_BEFORE_" BEFORE INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, NULL) + ok 35 - Test "not_null_BEFORE_" + ok 36 - DROP TRIGGER "not_null_BEFORE_" + ok 37 - CREATE TRIGGER "not_null_AFTER_" AFTER INSERT ON test_data FOR EACH ROW EXECUTE PROCEDURE not_null_count_trigger(1, NULL) + ok 38 - Test "not_null_AFTER_" + ok 39 - DROP TRIGGER "not_null_AFTER_" + 1..39 +ok 3 - _null_count_test.test__functionality +# Subtest: _null_count_test.test__shutdown__drop_all() + ok 1 + ok 2 + 1..2 +ok 4 - _null_count_test.test__shutdown__drop_all +1..4 diff --git a/test/install/load.sql b/test/install/load.sql index d598560..1737ebc 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -9,4 +9,27 @@ -- self-identical regardless of content. Correctness here comes from this -- file failing loudly (aborting the session) if something's wrong, not -- from a textual comparison - matching cat_tools' test/install/load.sql. + +/* + * TEST_SCHEMA (the count_nulls.test_schema GUC, set via the Makefile): + * which schema to install count_nulls into. Empty (the default) means + * "don't target any schema at all" - lands wherever this session's own + * default search_path resolves (ordinarily 'public', since test/install + * runs in its own bare connection, not the in-suite session pgTAP's + * tap_setup.sql runs in - see phase 1's commit message for why that + * matters). Non-empty explicitly creates and targets that schema. + * + * Read without missing_ok: a genuinely unpropagated GUC must fail loudly, + * not be indistinguishable from a deliberately empty one. + */ +SELECT current_setting('count_nulls.test_schema') AS schema +\gset +SELECT :'schema' <> '' AS count_nulls_has_schema +\gset + +\if :count_nulls_has_schema +CREATE SCHEMA IF NOT EXISTS :"schema"; +SET search_path = :"schema"; +\endif + CREATE EXTENSION count_nulls; diff --git a/test/sql/extension_tests.sql b/test/sql/extension_tests.sql index 056b435..80e1cac 100644 --- a/test/sql/extension_tests.sql +++ b/test/sql/extension_tests.sql @@ -5,31 +5,72 @@ \i test/core/functions.sql /* - * count_nulls is installed by test/install/load.sql with no schema - * targeting - it lands wherever the session's own search_path resolves at - * CREATE EXTENSION time (in-suite, that's pgTap's own schema, put on - * search_path first by test/pgxntool/tap_setup.sql). This just proves - * ncs() actually resolves to something real; a future TEST_SCHEMA switch - * (see pgxntool/README.asc's U&U section) would let this assert an exact, - * known location instead. + * This file leaves search_path as functions.sql set it (_null_count_test, + * tap) - with an explicit TEST_SCHEMA, that keeps count_nulls' own schema + * off search_path, so every check below only passes if functions.sql's + * %I-qualified calls (via ncs()) are actually correct, never relying on + * count_nulls' own schema being reachable unqualified. When TEST_SCHEMA is + * empty, count_nulls lands in 'public' (test/install/load.sql runs in its + * own bare connection, with no schema targeting - see phase 1's commit + * message), which is NOT on search_path here either. + * + * schema_hint reads the count_nulls.test_schema GUC directly (the Makefile + * exports it via PGOPTIONS for the whole run - see test/install/load.sql, + * which installs into it) rather than via a psql variable relayed through + * test/deps.sql: nothing in this per-test session needs deps.sql to have + * set anything, since the GUC is readable from any session in the run. + * NULLIF turns the empty-TEST_SCHEMA case into NULL, and runtests() calls + * every test__* function with no arguments, so it always gets this default. */ -CREATE FUNCTION _null_count_test.test__check_ncs -() RETURNS SETOF text LANGUAGE plpgsql AS $body$ +CREATE FUNCTION _null_count_test.test__check_ncs( + schema_hint name DEFAULT NULLIF(current_setting('count_nulls.test_schema'), '')::name +) RETURNS SETOF text LANGUAGE plpgsql AS $body$ +DECLARE + /* + * When TEST_SCHEMA is non-empty we know exactly where count_nulls + * should be, so compare ncs() against that known value - a real + * assertion. When it's empty (schema_hint is NULL), there's no fixed + * expectation (it lands in 'public', an artifact of test/install's own + * bare connection - not something this test should hardcode), so fall + * back to ncs() itself: a no-op comparison that still exercises the + * call, without asserting a location this file has no business + * assuming. + */ + s CONSTANT name = COALESCE(schema_hint, ncs()); BEGIN - RETURN NEXT isnt( + RETURN NEXT is( ncs() - , NULL - , 'ncs() resolves to the schema count_nulls actually installed in' + , s + ); + RETURN NEXT is( + current_schemas(true) @> array[s] + , false + , 'count_nulls'' schema should not be in search path' ); END $body$; -CREATE FUNCTION _null_count_test.test__shutdown__drop_all -() RETURNS SETOF text LANGUAGE plpgsql AS $body$ +CREATE FUNCTION _null_count_test.test__shutdown__drop_all( + schema_hint name DEFAULT NULLIF(current_setting('count_nulls.test_schema'), '')::name +) RETURNS SETOF text LANGUAGE plpgsql AS $body$ BEGIN RETURN NEXT lives_ok( $$DROP EXTENSION count_nulls$$ ); + + /* + * Only try to drop a schema when TEST_SCHEMA actually created one - + * when it's empty (schema_hint is NULL), count_nulls lives in 'public' + * (see test/install/load.sql), which this file has no business + * dropping. + */ + IF schema_hint IS NOT NULL THEN + RETURN NEXT lives_ok( + format('DROP SCHEMA %I', schema_hint) + ); + ELSE + RETURN NEXT skip('TEST_SCHEMA is empty - no dedicated schema to drop'); + END IF; END $body$;