From dd2d6d72ef1f5e50a97e62427cc904cc8c026f3c Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Wed, 4 Feb 2026 17:42:16 -0600 Subject: [PATCH 01/11] Switch to standard pgxntool testing I don't remember why we originally loaded pgTap from raw files. Standardize on what pgxntool recommends. --- test/expected/extension_tests.out | 8 +- test/expected/simple.out | 4 +- test/helpers/pgtap-core.sql | 3794 ------------------- test/helpers/pgtap-schema.sql | 5631 ----------------------------- test/helpers/setup.sql | 13 - test/helpers/tap_setup.sql | 18 - test/load.sql | 15 + test/sql/extension_tests.sql | 2 +- test/sql/simple.sql | 2 +- 9 files changed, 23 insertions(+), 9464 deletions(-) delete mode 100644 test/helpers/pgtap-core.sql delete mode 100644 test/helpers/pgtap-schema.sql delete mode 100644 test/helpers/setup.sql delete mode 100644 test/helpers/tap_setup.sql create mode 100644 test/load.sql diff --git a/test/expected/extension_tests.out b/test/expected/extension_tests.out index f651c83..3a22c24 100644 --- a/test/expected/extension_tests.out +++ b/test/expected/extension_tests.out @@ -1,10 +1,10 @@ \set ECHO none - # Subtest: _null_count_test.test__check_ncs() +# Subtest: _null_count_test.test__check_ncs() ok 1 ok 2 - schema schema_to_load_count_nulls should not be in search path 1..2 ok 1 - _null_count_test.test__check_ncs - # Subtest: _null_count_test.test__definition() +# Subtest: _null_count_test.test__definition() ok 1 - ensure null_count({anyarray}) is not in search_path ok 2 - Function schema_to_load_count_nulls.null_count(anyarray) should return integer ok 3 - Function schema_to_load_count_nulls.null_count(anyarray) should not be strict @@ -37,7 +37,7 @@ ok 1 - _null_count_test.test__check_ncs ok 30 - Function schema_to_load_count_nulls.not_null_count_trigger() should be IMMUTABLE 1..30 ok 2 - _null_count_test.test__definition - # Subtest: _null_count_test.test__functionality() +# Subtest: _null_count_test.test__functionality() ok 1 - Test schema_to_load_count_nulls.null_count(a, b, c) ok 2 - Test schema_to_load_count_nulls.null_count(json) ok 3 - Test schema_to_load_count_nulls.null_count(jsonb) @@ -79,7 +79,7 @@ ok 2 - _null_count_test.test__definition ok 39 - DROP TRIGGER "not_null_AFTER_" 1..39 ok 3 - _null_count_test.test__functionality - # Subtest: _null_count_test.test__shutdown__drop_all() +# Subtest: _null_count_test.test__shutdown__drop_all() ok 1 ok 2 1..2 diff --git a/test/expected/simple.out b/test/expected/simple.out index c2d030f..ea8bad2 100644 --- a/test/expected/simple.out +++ b/test/expected/simple.out @@ -1,5 +1,5 @@ \set ECHO none - # Subtest: _null_count_test.test__definition() +# Subtest: _null_count_test.test__definition() ok 1 - Function public.null_count(anyarray) should return integer ok 2 - Function public.null_count(anyarray) should not be strict ok 3 - Function public.null_count(anyarray) should be IMMUTABLE @@ -26,7 +26,7 @@ ok 24 - Function public.not_null_count_trigger() should be IMMUTABLE 1..24 ok 1 - _null_count_test.test__definition - # Subtest: _null_count_test.test__functionality() +# 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) diff --git a/test/helpers/pgtap-core.sql b/test/helpers/pgtap-core.sql deleted file mode 100644 index ebe56a7..0000000 --- a/test/helpers/pgtap-core.sql +++ /dev/null @@ -1,3794 +0,0 @@ - --- This file defines pgTAP Core, a portable collection of assertion --- functions for TAP-based unit testing on PostgreSQL 8.3 or higher. It is --- distributed under the revised FreeBSD license. The home page for the pgTAP --- project is: - --- --- http://pgtap.org/ --- - -CREATE OR REPLACE FUNCTION pg_version() -RETURNS text AS 'SELECT current_setting(''server_version'')' -LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION pg_version_num() -RETURNS integer AS $$ - SELECT s.a[1]::int * 10000 - + COALESCE(substring(s.a[2] FROM '[[:digit:]]+')::int, 0) * 100 - + COALESCE(substring(s.a[3] FROM '[[:digit:]]+')::int, 0) - FROM ( - SELECT string_to_array(current_setting('server_version'), '.') AS a - ) AS s; -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION pgtap_version() -RETURNS NUMERIC AS 'SELECT 0.95;' -LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION plan( integer ) -RETURNS TEXT AS $$ -DECLARE - rcount INTEGER; -BEGIN - BEGIN - EXECUTE ' - CREATE TEMP SEQUENCE __tcache___id_seq; - CREATE TEMP TABLE __tcache__ ( - id INTEGER NOT NULL DEFAULT nextval(''__tcache___id_seq''), - label TEXT NOT NULL, - value INTEGER NOT NULL, - note TEXT NOT NULL DEFAULT '''' - ); - CREATE UNIQUE INDEX __tcache___key ON __tcache__(id); - GRANT ALL ON TABLE __tcache__ TO PUBLIC; - GRANT ALL ON TABLE __tcache___id_seq TO PUBLIC; - - CREATE TEMP SEQUENCE __tresults___numb_seq; - GRANT ALL ON TABLE __tresults___numb_seq TO PUBLIC; - '; - - EXCEPTION WHEN duplicate_table THEN - -- Raise an exception if there's already a plan. - EXECUTE 'SELECT TRUE FROM __tcache__ WHERE label = ''plan'''; - GET DIAGNOSTICS rcount = ROW_COUNT; - IF rcount > 0 THEN - RAISE EXCEPTION 'You tried to plan twice!'; - END IF; - END; - - -- Save the plan and return. - PERFORM _set('plan', $1 ); - PERFORM _set('failed', 0 ); - RETURN '1..' || $1; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION no_plan() -RETURNS SETOF boolean AS $$ -BEGIN - PERFORM plan(0); - RETURN; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _get ( text ) -RETURNS integer AS $$ -DECLARE - ret integer; -BEGIN - EXECUTE 'SELECT value FROM __tcache__ WHERE label = ' || quote_literal($1) || ' LIMIT 1' INTO ret; - RETURN ret; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _get_latest ( text ) -RETURNS integer[] AS $$ -DECLARE - ret integer[]; -BEGIN - EXECUTE 'SELECT ARRAY[id, value] FROM __tcache__ WHERE label = ' || - quote_literal($1) || ' AND id = (SELECT MAX(id) FROM __tcache__ WHERE label = ' || - quote_literal($1) || ') LIMIT 1' INTO ret; - RETURN ret; -EXCEPTION WHEN undefined_table THEN - RAISE EXCEPTION 'You tried to run a test without a plan! Gotta have a plan'; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _get_latest ( text, integer ) -RETURNS integer AS $$ -DECLARE - ret integer; -BEGIN - EXECUTE 'SELECT MAX(id) FROM __tcache__ WHERE label = ' || - quote_literal($1) || ' AND value = ' || $2 INTO ret; - RETURN ret; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _get_note ( text ) -RETURNS text AS $$ -DECLARE - ret text; -BEGIN - EXECUTE 'SELECT note FROM __tcache__ WHERE label = ' || quote_literal($1) || ' LIMIT 1' INTO ret; - RETURN ret; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _get_note ( integer ) -RETURNS text AS $$ -DECLARE - ret text; -BEGIN - EXECUTE 'SELECT note FROM __tcache__ WHERE id = ' || $1 || ' LIMIT 1' INTO ret; - RETURN ret; -END; -$$ LANGUAGE plpgsql strict; - -CREATE OR REPLACE FUNCTION _set ( text, integer, text ) -RETURNS integer AS $$ -DECLARE - rcount integer; -BEGIN - EXECUTE 'UPDATE __tcache__ SET value = ' || $2 - || CASE WHEN $3 IS NULL THEN '' ELSE ', note = ' || quote_literal($3) END - || ' WHERE label = ' || quote_literal($1); - GET DIAGNOSTICS rcount = ROW_COUNT; - IF rcount = 0 THEN - RETURN _add( $1, $2, $3 ); - END IF; - RETURN $2; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _set ( text, integer ) -RETURNS integer AS $$ - SELECT _set($1, $2, '') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _set ( integer, integer ) -RETURNS integer AS $$ -BEGIN - EXECUTE 'UPDATE __tcache__ SET value = ' || $2 - || ' WHERE id = ' || $1; - RETURN $2; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _add ( text, integer, text ) -RETURNS integer AS $$ -BEGIN - EXECUTE 'INSERT INTO __tcache__ (label, value, note) values (' || - quote_literal($1) || ', ' || $2 || ', ' || quote_literal(COALESCE($3, '')) || ')'; - RETURN $2; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _add ( text, integer ) -RETURNS integer AS $$ - SELECT _add($1, $2, '') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION add_result ( bool, bool, text, text, text ) -RETURNS integer AS $$ -BEGIN - IF NOT $1 THEN PERFORM _set('failed', _get('failed') + 1); END IF; - RETURN nextval('__tresults___numb_seq'); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION num_failed () -RETURNS INTEGER AS $$ - SELECT _get('failed'); -$$ LANGUAGE SQL strict; - -CREATE OR REPLACE FUNCTION _finish (INTEGER, INTEGER, INTEGER) -RETURNS SETOF TEXT AS $$ -DECLARE - curr_test ALIAS FOR $1; - exp_tests INTEGER := $2; - num_faild ALIAS FOR $3; - plural CHAR; -BEGIN - plural := CASE exp_tests WHEN 1 THEN '' ELSE 's' END; - - IF curr_test IS NULL THEN - RAISE EXCEPTION '# No tests run!'; - END IF; - - IF exp_tests = 0 OR exp_tests IS NULL THEN - -- No plan. Output one now. - exp_tests = curr_test; - RETURN NEXT '1..' || exp_tests; - END IF; - - IF curr_test <> exp_tests THEN - RETURN NEXT diag( - 'Looks like you planned ' || exp_tests || ' test' || - plural || ' but ran ' || curr_test - ); - ELSIF num_faild > 0 THEN - RETURN NEXT diag( - 'Looks like you failed ' || num_faild || ' test' || - CASE num_faild WHEN 1 THEN '' ELSE 's' END - || ' of ' || exp_tests - ); - ELSE - - END IF; - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION finish () -RETURNS SETOF TEXT AS $$ - SELECT * FROM _finish( - _get('curr_test'), - _get('plan'), - num_failed() - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION diag ( msg text ) -RETURNS TEXT AS $$ - SELECT '# ' || replace( - replace( - replace( $1, E'\r\n', E'\n# ' ), - E'\n', - E'\n# ' - ), - E'\r', - E'\n# ' - ); -$$ LANGUAGE sql strict; - -CREATE OR REPLACE FUNCTION diag ( msg anyelement ) -RETURNS TEXT AS $$ - SELECT diag($1::text); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION diag( VARIADIC text[] ) -RETURNS TEXT AS $$ - SELECT diag(array_to_string($1, '')); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION diag( VARIADIC anyarray ) -RETURNS TEXT AS $$ - SELECT diag(array_to_string($1, '')); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION ok ( boolean, text ) -RETURNS TEXT AS $$ -DECLARE - aok ALIAS FOR $1; - descr text := $2; - test_num INTEGER; - todo_why TEXT; - ok BOOL; -BEGIN - todo_why := _todo(); - ok := CASE - WHEN aok = TRUE THEN aok - WHEN todo_why IS NULL THEN COALESCE(aok, false) - ELSE TRUE - END; - IF _get('plan') IS NULL THEN - RAISE EXCEPTION 'You tried to run a test without a plan! Gotta have a plan'; - END IF; - - test_num := add_result( - ok, - COALESCE(aok, false), - descr, - CASE WHEN todo_why IS NULL THEN '' ELSE 'todo' END, - COALESCE(todo_why, '') - ); - - RETURN (CASE aok WHEN TRUE THEN '' ELSE 'not ' END) - || 'ok ' || _set( 'curr_test', test_num ) - || CASE descr WHEN '' THEN '' ELSE COALESCE( ' - ' || substr(diag( descr ), 3), '' ) END - || COALESCE( ' ' || diag( 'TODO ' || todo_why ), '') - || CASE aok WHEN TRUE THEN '' ELSE E'\n' || - diag('Failed ' || - CASE WHEN todo_why IS NULL THEN '' ELSE '(TODO) ' END || - 'test ' || test_num || - CASE descr WHEN '' THEN '' ELSE COALESCE(': "' || descr || '"', '') END ) || - CASE WHEN aok IS NULL THEN E'\n' || diag(' (test result was NULL)') ELSE '' END - END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION ok ( boolean ) -RETURNS TEXT AS $$ - SELECT ok( $1, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION is (anyelement, anyelement, text) -RETURNS TEXT AS $$ -DECLARE - result BOOLEAN; - output TEXT; -BEGIN - -- Would prefer $1 IS NOT DISTINCT FROM, but that's not supported by 8.1. - result := NOT $1 IS DISTINCT FROM $2; - output := ok( result, $3 ); - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag( - ' have: ' || CASE WHEN $1 IS NULL THEN 'NULL' ELSE $1::text END || - E'\n want: ' || CASE WHEN $2 IS NULL THEN 'NULL' ELSE $2::text END - ) END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION is (anyelement, anyelement) -RETURNS TEXT AS $$ - SELECT is( $1, $2, NULL); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION isnt (anyelement, anyelement, text) -RETURNS TEXT AS $$ -DECLARE - result BOOLEAN; - output TEXT; -BEGIN - result := $1 IS DISTINCT FROM $2; - output := ok( result, $3 ); - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag( - ' have: ' || COALESCE( $1::text, 'NULL' ) || - E'\n want: anything else' - ) END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION isnt (anyelement, anyelement) -RETURNS TEXT AS $$ - SELECT isnt( $1, $2, NULL); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _alike ( BOOLEAN, ANYELEMENT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - result ALIAS FOR $1; - got ALIAS FOR $2; - rx ALIAS FOR $3; - descr ALIAS FOR $4; - output TEXT; -BEGIN - output := ok( result, descr ); - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag( - ' ' || COALESCE( quote_literal(got), 'NULL' ) || - E'\n doesn''t match: ' || COALESCE( quote_literal(rx), 'NULL' ) - ) END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION matches ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~ $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION matches ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~ $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION imatches ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~* $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION imatches ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~* $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION alike ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~~ $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION alike ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~~ $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION ialike ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~~* $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION ialike ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _alike( $1 ~~* $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _unalike ( BOOLEAN, ANYELEMENT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - result ALIAS FOR $1; - got ALIAS FOR $2; - rx ALIAS FOR $3; - descr ALIAS FOR $4; - output TEXT; -BEGIN - output := ok( result, descr ); - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag( - ' ' || COALESCE( quote_literal(got), 'NULL' ) || - E'\n matches: ' || COALESCE( quote_literal(rx), 'NULL' ) - ) END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION doesnt_match ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~ $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION doesnt_match ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~ $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION doesnt_imatch ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~* $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION doesnt_imatch ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~* $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION unalike ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~~ $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION unalike ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~~ $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION unialike ( anyelement, text, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~~* $2, $1, $2, $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION unialike ( anyelement, text ) -RETURNS TEXT AS $$ - SELECT _unalike( $1 !~~* $2, $1, $2, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION cmp_ok (anyelement, text, anyelement, text) -RETURNS TEXT AS $$ -DECLARE - have ALIAS FOR $1; - op ALIAS FOR $2; - want ALIAS FOR $3; - descr ALIAS FOR $4; - result BOOLEAN; - output TEXT; -BEGIN - EXECUTE 'SELECT ' || - COALESCE(quote_literal( have ), 'NULL') || '::' || pg_typeof(have) || ' ' - || op || ' ' || - COALESCE(quote_literal( want ), 'NULL') || '::' || pg_typeof(want) - INTO result; - output := ok( COALESCE(result, FALSE), descr ); - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag( - ' ' || COALESCE( quote_literal(have), 'NULL' ) || - E'\n ' || op || - E'\n ' || COALESCE( quote_literal(want), 'NULL' ) - ) END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION cmp_ok (anyelement, text, anyelement) -RETURNS TEXT AS $$ - SELECT cmp_ok( $1, $2, $3, NULL ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION pass ( text ) -RETURNS TEXT AS $$ - SELECT ok( TRUE, $1 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION pass () -RETURNS TEXT AS $$ - SELECT ok( TRUE, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION fail ( text ) -RETURNS TEXT AS $$ - SELECT ok( FALSE, $1 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION fail () -RETURNS TEXT AS $$ - SELECT ok( FALSE, NULL ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION todo ( why text, how_many int ) -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', COALESCE(how_many, 1), COALESCE(why, '')); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo ( how_many int, why text ) -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', COALESCE(how_many, 1), COALESCE(why, '')); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo ( why text ) -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', 1, COALESCE(why, '')); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo ( how_many int ) -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', COALESCE(how_many, 1), ''); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo_start (text) -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', -1, COALESCE($1, '')); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo_start () -RETURNS SETOF BOOLEAN AS $$ -BEGIN - PERFORM _add('todo', -1, ''); - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION in_todo () -RETURNS BOOLEAN AS $$ -DECLARE - todos integer; -BEGIN - todos := _get('todo'); - RETURN CASE WHEN todos IS NULL THEN FALSE ELSE TRUE END; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION todo_end () -RETURNS SETOF BOOLEAN AS $$ -DECLARE - id integer; -BEGIN - id := _get_latest( 'todo', -1 ); - IF id IS NULL THEN - RAISE EXCEPTION 'todo_end() called without todo_start()'; - END IF; - EXECUTE 'DELETE FROM __tcache__ WHERE id = ' || id; - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _todo() -RETURNS TEXT AS $$ -DECLARE - todos INT[]; - note text; -BEGIN - -- Get the latest id and value, because todo() might have been called - -- again before the todos ran out for the first call to todo(). This - -- allows them to nest. - todos := _get_latest('todo'); - IF todos IS NULL THEN - -- No todos. - RETURN NULL; - END IF; - IF todos[2] = 0 THEN - -- Todos depleted. Clean up. - EXECUTE 'DELETE FROM __tcache__ WHERE id = ' || todos[1]; - RETURN NULL; - END IF; - -- Decrement the count of counted todos and return the reason. - IF todos[2] <> -1 THEN - PERFORM _set(todos[1], todos[2] - 1); - END IF; - note := _get_note(todos[1]); - - IF todos[2] = 1 THEN - -- This was the last todo, so delete the record. - EXECUTE 'DELETE FROM __tcache__ WHERE id = ' || todos[1]; - END IF; - - RETURN note; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION skip ( why text, how_many int ) -RETURNS TEXT AS $$ -DECLARE - output TEXT[]; -BEGIN - output := '{}'; - FOR i IN 1..how_many LOOP - output = array_append( - output, - ok( TRUE ) || ' ' || diag( 'SKIP' || COALESCE( ' ' || why, '') ) - ); - END LOOP; - RETURN array_to_string(output, E'\n'); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION skip ( text ) -RETURNS TEXT AS $$ - SELECT ok( TRUE ) || ' ' || diag( 'SKIP' || COALESCE(' ' || $1, '') ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION skip( int, text ) -RETURNS TEXT AS 'SELECT skip($2, $1)' -LANGUAGE sql; - -CREATE OR REPLACE FUNCTION skip( int ) -RETURNS TEXT AS 'SELECT skip(NULL, $1)' -LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _query( TEXT ) -RETURNS TEXT AS $$ - SELECT CASE - WHEN $1 LIKE '"%' OR $1 !~ '[[:space:]]' THEN 'EXECUTE ' || $1 - ELSE $1 - END; -$$ LANGUAGE SQL; - --- throws_ok ( sql, errcode, errmsg, description ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, CHAR(5), TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - query TEXT := _query($1); - errcode ALIAS FOR $2; - errmsg ALIAS FOR $3; - desctext ALIAS FOR $4; - descr TEXT; -BEGIN - descr := COALESCE( - desctext, - 'threw ' || errcode || ': ' || errmsg, - 'threw ' || errcode, - 'threw ' || errmsg, - 'threw an exception' - ); - EXECUTE query; - RETURN ok( FALSE, descr ) || E'\n' || diag( - ' caught: no exception' || - E'\n wanted: ' || COALESCE( errcode, 'an exception' ) - ); -EXCEPTION WHEN OTHERS THEN - IF (errcode IS NULL OR SQLSTATE = errcode) - AND ( errmsg IS NULL OR SQLERRM = errmsg) - THEN - -- The expected errcode and/or message was thrown. - RETURN ok( TRUE, descr ); - ELSE - -- This was not the expected errcode or errmsg. - RETURN ok( FALSE, descr ) || E'\n' || diag( - ' caught: ' || SQLSTATE || ': ' || SQLERRM || - E'\n wanted: ' || COALESCE( errcode, 'an exception' ) || - COALESCE( ': ' || errmsg, '') - ); - END IF; -END; -$$ LANGUAGE plpgsql; - --- throws_ok ( sql, errcode, errmsg ) --- throws_ok ( sql, errmsg, description ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF octet_length($2) = 5 THEN - RETURN throws_ok( $1, $2::char(5), $3, NULL ); - ELSE - RETURN throws_ok( $1, NULL, $2, $3 ); - END IF; -END; -$$ LANGUAGE plpgsql; - --- throws_ok ( query, errcode ) --- throws_ok ( query, errmsg ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF octet_length($2) = 5 THEN - RETURN throws_ok( $1, $2::char(5), NULL, NULL ); - ELSE - RETURN throws_ok( $1, NULL, $2, NULL ); - END IF; -END; -$$ LANGUAGE plpgsql; - --- throws_ok ( sql ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT ) -RETURNS TEXT AS $$ - SELECT throws_ok( $1, NULL, NULL, NULL ); -$$ LANGUAGE SQL; - --- Magically cast integer error codes. --- throws_ok ( sql, errcode, errmsg, description ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, int4, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_ok( $1, $2::char(5), $3, $4 ); -$$ LANGUAGE SQL; - --- throws_ok ( sql, errcode, errmsg ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, int4, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_ok( $1, $2::char(5), $3, NULL ); -$$ LANGUAGE SQL; - --- throws_ok ( sql, errcode ) -CREATE OR REPLACE FUNCTION throws_ok ( TEXT, int4 ) -RETURNS TEXT AS $$ - SELECT throws_ok( $1, $2::char(5), NULL, NULL ); -$$ LANGUAGE SQL; - --- lives_ok( sql, description ) -CREATE OR REPLACE FUNCTION lives_ok ( TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - code TEXT := _query($1); - descr ALIAS FOR $2; -BEGIN - EXECUTE code; - RETURN ok( TRUE, descr ); -EXCEPTION WHEN OTHERS THEN - -- There should have been no exception. - RETURN ok( FALSE, descr ) || E'\n' || diag( - ' died: ' || SQLSTATE || ': ' || SQLERRM - ); -END; -$$ LANGUAGE plpgsql; - --- lives_ok( sql ) -CREATE OR REPLACE FUNCTION lives_ok ( TEXT ) -RETURNS TEXT AS $$ - SELECT lives_ok( $1, NULL ); -$$ LANGUAGE SQL; - --- performs_ok ( sql, milliseconds, description ) -CREATE OR REPLACE FUNCTION performs_ok ( TEXT, NUMERIC, TEXT ) -RETURNS TEXT AS $$ -DECLARE - query TEXT := _query($1); - max_time ALIAS FOR $2; - descr ALIAS FOR $3; - starts_at TEXT; - act_time NUMERIC; -BEGIN - starts_at := timeofday(); - EXECUTE query; - act_time := extract( millisecond from timeofday()::timestamptz - starts_at::timestamptz); - IF act_time < max_time THEN RETURN ok(TRUE, descr); END IF; - RETURN ok( FALSE, descr ) || E'\n' || diag( - ' runtime: ' || act_time || ' ms' || - E'\n exceeds: ' || max_time || ' ms' - ); -END; -$$ LANGUAGE plpgsql; - --- performs_ok ( sql, milliseconds ) -CREATE OR REPLACE FUNCTION performs_ok ( TEXT, NUMERIC ) -RETURNS TEXT AS $$ - SELECT performs_ok( - $1, $2, 'Should run in less than ' || $2 || ' ms' - ); -$$ LANGUAGE sql; - --- Convenience function to run a query many times and returns --- the middle set of those times as defined by the last argument --- e.g. _time_trials('SELECT 1', 100, 0.8) will execute 'SELECT 1' --- 100 times, and return the execution times for the middle 80 runs --- --- I could have left this logic in performs_within, but I have --- plans to hook into this function for other purposes outside --- of pgTAP -CREATE TYPE _time_trial_type AS (a_time NUMERIC); -CREATE OR REPLACE FUNCTION _time_trials(TEXT, INT, NUMERIC) -RETURNS SETOF _time_trial_type AS $$ -DECLARE - query TEXT := _query($1); - iterations ALIAS FOR $2; - return_percent ALIAS FOR $3; - start_time TEXT; - act_time NUMERIC; - times NUMERIC[]; - offset_it INT; - limit_it INT; - offset_percent NUMERIC; - a_time _time_trial_type; -BEGIN - -- Execute the query over and over - FOR i IN 1..iterations LOOP - start_time := timeofday(); - EXECUTE query; - -- Store the execution time for the run in an array of times - times[i] := extract(millisecond from timeofday()::timestamptz - start_time::timestamptz); - END LOOP; - offset_percent := (1.0 - return_percent) / 2.0; - -- Ensure that offset skips the bottom X% of runs, or set it to 0 - SELECT GREATEST((offset_percent * iterations)::int, 0) INTO offset_it; - -- Ensure that with limit the query to returning only the middle X% of runs - SELECT GREATEST((return_percent * iterations)::int, 1) INTO limit_it; - - FOR a_time IN SELECT times[i] - FROM generate_series(array_lower(times, 1), array_upper(times, 1)) i - ORDER BY 1 - OFFSET offset_it - LIMIT limit_it LOOP - RETURN NEXT a_time; - END LOOP; -END; -$$ LANGUAGE plpgsql; - --- performs_within( sql, average_milliseconds, within, iterations, description ) -CREATE OR REPLACE FUNCTION performs_within(TEXT, NUMERIC, NUMERIC, INT, TEXT) RETURNS TEXT AS $$ -DECLARE - query TEXT := _query($1); - expected_avg ALIAS FOR $2; - within ALIAS FOR $3; - iterations ALIAS FOR $4; - descr ALIAS FOR $5; - avg_time NUMERIC; -BEGIN - SELECT avg(a_time) FROM _time_trials(query, iterations, 0.8) t1 INTO avg_time; - IF abs(avg_time - expected_avg) < within THEN RETURN ok(TRUE, descr); END IF; - RETURN ok(FALSE, descr) || E'\n' || diag(' average runtime: ' || avg_time || ' ms' - || E'\n desired average: ' || expected_avg || ' +/- ' || within || ' ms' - ); -END; -$$ LANGUAGE plpgsql; - --- performs_within( sql, average_milliseconds, within, iterations ) -CREATE OR REPLACE FUNCTION performs_within(TEXT, NUMERIC, NUMERIC, INT) RETURNS TEXT AS $$ -SELECT performs_within( - $1, $2, $3, $4, - 'Should run within ' || $2 || ' +/- ' || $3 || ' ms'); -$$ LANGUAGE sql; --- performs_within( sql, average_milliseconds, within, description ) -CREATE OR REPLACE FUNCTION performs_within(TEXT, NUMERIC, NUMERIC, TEXT) RETURNS TEXT AS $$ -SELECT performs_within( - $1, $2, $3, 10, $4 - ); -$$ LANGUAGE sql; - --- performs_within( sql, average_milliseconds, within ) -CREATE OR REPLACE FUNCTION performs_within(TEXT, NUMERIC, NUMERIC) RETURNS TEXT AS $$ -SELECT performs_within( - $1, $2, $3, 10, - 'Should run within ' || $2 || ' +/- ' || $3 || ' ms'); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _ident_array_to_string( name[], text ) -RETURNS text AS $$ - SELECT array_to_string(ARRAY( - SELECT quote_ident($1[i]) - FROM generate_series(1, array_upper($1, 1)) s(i) - ORDER BY i - ), $2); -$$ LANGUAGE SQL immutable; - --- Borrowed from newsysviews: http://pgfoundry.org/projects/newsysviews/ -CREATE OR REPLACE VIEW tap_funky - AS SELECT p.oid AS oid, - n.nspname AS schema, - p.proname AS name, - pg_catalog.pg_get_userbyid(p.proowner) AS owner, - array_to_string(p.proargtypes::regtype[], ',') AS args, - CASE p.proretset WHEN TRUE THEN 'setof ' ELSE '' END - || p.prorettype::regtype AS returns, - p.prolang AS langoid, - p.proisstrict AS is_strict, - p.proisagg AS is_agg, - p.prosecdef AS is_definer, - p.proretset AS returns_set, - p.provolatile::char AS volatility, - pg_catalog.pg_function_is_visible(p.oid) AS is_visible - FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid -; - -CREATE OR REPLACE FUNCTION _got_func ( NAME, NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT TRUE - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _got_func ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( SELECT TRUE FROM tap_funky WHERE schema = $1 AND name = $2 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _got_func ( NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT TRUE - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _got_func ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( SELECT TRUE FROM tap_funky WHERE name = $1 AND is_visible); -$$ LANGUAGE SQL; - --- has_function( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION has_function ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _got_func($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- has_function( schema, function, args[] ) -CREATE OR REPLACE FUNCTION has_function( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _got_func($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should exist' - ); -$$ LANGUAGE sql; - --- has_function( schema, function, description ) -CREATE OR REPLACE FUNCTION has_function ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _got_func($1, $2), $3 ); -$$ LANGUAGE SQL; - --- has_function( schema, function ) -CREATE OR REPLACE FUNCTION has_function( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _got_func($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should exist' - ); -$$ LANGUAGE sql; - --- has_function( function, args[], description ) -CREATE OR REPLACE FUNCTION has_function ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _got_func($1, $2), $3 ); -$$ LANGUAGE SQL; - --- has_function( function, args[] ) -CREATE OR REPLACE FUNCTION has_function( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _got_func($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should exist' - ); -$$ LANGUAGE sql; - --- has_function( function, description ) -CREATE OR REPLACE FUNCTION has_function( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _got_func($1), $2 ); -$$ LANGUAGE sql; - --- has_function( function ) -CREATE OR REPLACE FUNCTION has_function( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _got_func($1), 'Function ' || quote_ident($1) || '() should exist' ); -$$ LANGUAGE sql; - --- hasnt_function( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION hasnt_function ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _got_func($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- hasnt_function( schema, function, args[] ) -CREATE OR REPLACE FUNCTION hasnt_function( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _got_func($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should not exist' - ); -$$ LANGUAGE sql; - --- hasnt_function( schema, function, description ) -CREATE OR REPLACE FUNCTION hasnt_function ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _got_func($1, $2), $3 ); -$$ LANGUAGE SQL; - --- hasnt_function( schema, function ) -CREATE OR REPLACE FUNCTION hasnt_function( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _got_func($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should not exist' - ); -$$ LANGUAGE sql; - --- hasnt_function( function, args[], description ) -CREATE OR REPLACE FUNCTION hasnt_function ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _got_func($1, $2), $3 ); -$$ LANGUAGE SQL; - --- hasnt_function( function, args[] ) -CREATE OR REPLACE FUNCTION hasnt_function( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _got_func($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should not exist' - ); -$$ LANGUAGE sql; - --- hasnt_function( function, description ) -CREATE OR REPLACE FUNCTION hasnt_function( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _got_func($1), $2 ); -$$ LANGUAGE sql; - --- hasnt_function( function ) -CREATE OR REPLACE FUNCTION hasnt_function( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _got_func($1), 'Function ' || quote_ident($1) || '() should not exist' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _pg_sv_type_array( OID[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT t.typname - FROM pg_catalog.pg_type t - JOIN generate_series(1, array_upper($1, 1)) s(i) ON t.oid = $1[i] - ORDER BY i - ) -$$ LANGUAGE SQL stable; - --- can( schema, functions[], description ) -CREATE OR REPLACE FUNCTION can ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - missing text[]; -BEGIN - SELECT ARRAY( - SELECT quote_ident($2[i]) - FROM generate_series(1, array_upper($2, 1)) s(i) - LEFT JOIN tap_funky ON name = $2[i] AND schema = $1 - WHERE oid IS NULL - GROUP BY $2[i], s.i - ORDER BY MIN(s.i) - ) INTO missing; - IF missing[1] IS NULL THEN - RETURN ok( true, $3 ); - END IF; - RETURN ok( false, $3 ) || E'\n' || diag( - ' ' || quote_ident($1) || '.' || - array_to_string( missing, E'() missing\n ' || quote_ident($1) || '.') || - '() missing' - ); -END; -$$ LANGUAGE plpgsql; - --- can( schema, functions[] ) -CREATE OR REPLACE FUNCTION can ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT can( $1, $2, 'Schema ' || quote_ident($1) || ' can' ); -$$ LANGUAGE sql; - --- can( functions[], description ) -CREATE OR REPLACE FUNCTION can ( NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - missing text[]; -BEGIN - SELECT ARRAY( - SELECT quote_ident($1[i]) - FROM generate_series(1, array_upper($1, 1)) s(i) - LEFT JOIN pg_catalog.pg_proc p - ON $1[i] = p.proname - AND pg_catalog.pg_function_is_visible(p.oid) - WHERE p.oid IS NULL - ORDER BY s.i - ) INTO missing; - IF missing[1] IS NULL THEN - RETURN ok( true, $2 ); - END IF; - RETURN ok( false, $2 ) || E'\n' || diag( - ' ' || - array_to_string( missing, E'() missing\n ') || - '() missing' - ); -END; -$$ LANGUAGE plpgsql; - --- can( functions[] ) -CREATE OR REPLACE FUNCTION can ( NAME[] ) -RETURNS TEXT AS $$ - SELECT can( $1, 'Schema ' || _ident_array_to_string(current_schemas(true), ' or ') || ' can' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_type( NAME, NAME, CHAR[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid - WHERE t.typisdefined - AND n.nspname = $1 - AND t.typname = $2 - AND t.typtype = ANY( COALESCE($3, ARRAY['b', 'c', 'd', 'p', 'e']) ) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_type( NAME, CHAR[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_type t - WHERE t.typisdefined - AND pg_catalog.pg_type_is_visible(t.oid) - AND t.typname = $1 - AND t.typtype = ANY( COALESCE($2, ARRAY['b', 'c', 'd', 'p', 'e']) ) - ); -$$ LANGUAGE sql; - --- has_type( schema, type, description ) -CREATE OR REPLACE FUNCTION has_type( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, $2, NULL ), $3 ); -$$ LANGUAGE sql; - --- has_type( schema, type ) -CREATE OR REPLACE FUNCTION has_type( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_type( $1, $2, 'Type ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE sql; - --- has_type( type, description ) -CREATE OR REPLACE FUNCTION has_type( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, NULL ), $2 ); -$$ LANGUAGE sql; - --- has_type( type ) -CREATE OR REPLACE FUNCTION has_type( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, NULL ), ('Type ' || quote_ident($1) || ' should exist')::text ); -$$ LANGUAGE sql; - --- hasnt_type( schema, type, description ) -CREATE OR REPLACE FUNCTION hasnt_type( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, $2, NULL ), $3 ); -$$ LANGUAGE sql; - --- hasnt_type( schema, type ) -CREATE OR REPLACE FUNCTION hasnt_type( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_type( $1, $2, 'Type ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' ); -$$ LANGUAGE sql; - --- hasnt_type( type, description ) -CREATE OR REPLACE FUNCTION hasnt_type( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, NULL ), $2 ); -$$ LANGUAGE sql; - --- hasnt_type( type ) -CREATE OR REPLACE FUNCTION hasnt_type( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, NULL ), ('Type ' || quote_ident($1) || ' should not exist')::text ); -$$ LANGUAGE sql; - --- has_domain( schema, domain, description ) -CREATE OR REPLACE FUNCTION has_domain( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, $2, ARRAY['d'] ), $3 ); -$$ LANGUAGE sql; - --- has_domain( schema, domain ) -CREATE OR REPLACE FUNCTION has_domain( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_domain( $1, $2, 'Domain ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE sql; - --- has_domain( domain, description ) -CREATE OR REPLACE FUNCTION has_domain( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, ARRAY['d'] ), $2 ); -$$ LANGUAGE sql; - --- has_domain( domain ) -CREATE OR REPLACE FUNCTION has_domain( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, ARRAY['d'] ), ('Domain ' || quote_ident($1) || ' should exist')::text ); -$$ LANGUAGE sql; - --- hasnt_domain( schema, domain, description ) -CREATE OR REPLACE FUNCTION hasnt_domain( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, $2, ARRAY['d'] ), $3 ); -$$ LANGUAGE sql; - --- hasnt_domain( schema, domain ) -CREATE OR REPLACE FUNCTION hasnt_domain( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_domain( $1, $2, 'Domain ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' ); -$$ LANGUAGE sql; - --- hasnt_domain( domain, description ) -CREATE OR REPLACE FUNCTION hasnt_domain( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, ARRAY['d'] ), $2 ); -$$ LANGUAGE sql; - --- hasnt_domain( domain ) -CREATE OR REPLACE FUNCTION hasnt_domain( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, ARRAY['d'] ), ('Domain ' || quote_ident($1) || ' should not exist')::text ); -$$ LANGUAGE sql; - --- has_enum( schema, enum, description ) -CREATE OR REPLACE FUNCTION has_enum( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, $2, ARRAY['e'] ), $3 ); -$$ LANGUAGE sql; - --- has_enum( schema, enum ) -CREATE OR REPLACE FUNCTION has_enum( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_enum( $1, $2, 'Enum ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE sql; - --- has_enum( enum, description ) -CREATE OR REPLACE FUNCTION has_enum( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, ARRAY['e'] ), $2 ); -$$ LANGUAGE sql; - --- has_enum( enum ) -CREATE OR REPLACE FUNCTION has_enum( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_type( $1, ARRAY['e'] ), ('Enum ' || quote_ident($1) || ' should exist')::text ); -$$ LANGUAGE sql; - --- hasnt_enum( schema, enum, description ) -CREATE OR REPLACE FUNCTION hasnt_enum( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, $2, ARRAY['e'] ), $3 ); -$$ LANGUAGE sql; - --- hasnt_enum( schema, enum ) -CREATE OR REPLACE FUNCTION hasnt_enum( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_enum( $1, $2, 'Enum ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' ); -$$ LANGUAGE sql; - --- hasnt_enum( enum, description ) -CREATE OR REPLACE FUNCTION hasnt_enum( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, ARRAY['e'] ), $2 ); -$$ LANGUAGE sql; - --- hasnt_enum( enum ) -CREATE OR REPLACE FUNCTION hasnt_enum( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_type( $1, ARRAY['e'] ), ('Enum ' || quote_ident($1) || ' should not exist')::text ); -$$ LANGUAGE sql; - --- enum_has_labels( schema, enum, labels, description ) -CREATE OR REPLACE FUNCTION enum_has_labels( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT is( - ARRAY( - SELECT e.enumlabel - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_enum e ON t.oid = e.enumtypid - JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid - WHERE t.typisdefined - AND n.nspname = $1 - AND t.typname = $2 - AND t.typtype = 'e' - ORDER BY e.enumsortorder - ), - $3, - $4 - ); -$$ LANGUAGE sql; - --- enum_has_labels( schema, enum, labels ) -CREATE OR REPLACE FUNCTION enum_has_labels( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT enum_has_labels( - $1, $2, $3, - 'Enum ' || quote_ident($1) || '.' || quote_ident($2) || ' should have labels (' || array_to_string( $3, ', ' ) || ')' - ); -$$ LANGUAGE sql; - --- enum_has_labels( enum, labels, description ) -CREATE OR REPLACE FUNCTION enum_has_labels( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT is( - ARRAY( - SELECT e.enumlabel - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_enum e ON t.oid = e.enumtypid - WHERE t.typisdefined - AND pg_catalog.pg_type_is_visible(t.oid) - AND t.typname = $1 - AND t.typtype = 'e' - ORDER BY e.enumsortorder - ), - $2, - $3 - ); -$$ LANGUAGE sql; - --- enum_has_labels( enum, labels ) -CREATE OR REPLACE FUNCTION enum_has_labels( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT enum_has_labels( - $1, $2, - 'Enum ' || quote_ident($1) || ' should have labels (' || array_to_string( $2, ', ' ) || ')' - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _cmp_types(oid, name) -RETURNS BOOLEAN AS $$ -DECLARE - dtype TEXT := pg_catalog.format_type($1, NULL); -BEGIN - RETURN dtype = _quote_ident_like($2, dtype); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _cast_exists ( NAME, NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_cast c - JOIN pg_catalog.pg_proc p ON c.castfunc = p.oid - JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid - WHERE _cmp_types(castsource, $1) - AND _cmp_types(casttarget, $2) - AND n.nspname = $3 - AND p.proname = $4 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _cast_exists ( NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_cast c - JOIN pg_catalog.pg_proc p ON c.castfunc = p.oid - WHERE _cmp_types(castsource, $1) - AND _cmp_types(casttarget, $2) - AND p.proname = $3 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _cast_exists ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_cast c - WHERE _cmp_types(castsource, $1) - AND _cmp_types(casttarget, $2) - ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type, schema, function, description ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _cast_exists( $1, $2, $3, $4 ), $5 ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type, schema, function ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _cast_exists( $1, $2, $3, $4 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') WITH FUNCTION ' || quote_ident($3) - || '.' || quote_ident($4) || '() should exist' - ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type, function, description ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _cast_exists( $1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type, function ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _cast_exists( $1, $2, $3 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') WITH FUNCTION ' || quote_ident($3) || '() should exist' - ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type, description ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _cast_exists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_cast( source_type, target_type ) -CREATE OR REPLACE FUNCTION has_cast ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _cast_exists( $1, $2 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') should exist' - ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type, schema, function, description ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _cast_exists( $1, $2, $3, $4 ), $5 ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type, schema, function ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _cast_exists( $1, $2, $3, $4 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') WITH FUNCTION ' || quote_ident($3) - || '.' || quote_ident($4) || '() should not exist' - ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type, function, description ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _cast_exists( $1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type, function ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _cast_exists( $1, $2, $3 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') WITH FUNCTION ' || quote_ident($3) || '() should not exist' - ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type, description ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _cast_exists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_cast( source_type, target_type ) -CREATE OR REPLACE FUNCTION hasnt_cast ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _cast_exists( $1, $2 ), - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') should not exist' - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _expand_context( char ) -RETURNS text AS $$ - SELECT CASE $1 - WHEN 'i' THEN 'implicit' - WHEN 'a' THEN 'assignment' - WHEN 'e' THEN 'explicit' - ELSE 'unknown' END -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _get_context( NAME, NAME ) -RETURNS "char" AS $$ - SELECT c.castcontext - FROM pg_catalog.pg_cast c - WHERE _cmp_types(castsource, $1) - AND _cmp_types(casttarget, $2) -$$ LANGUAGE SQL; - --- cast_context_is( source_type, target_type, context, description ) -CREATE OR REPLACE FUNCTION cast_context_is( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want char = substring(LOWER($3) FROM 1 FOR 1); - have char := _get_context($1, $2); -BEGIN - IF have IS NOT NULL THEN - RETURN is( _expand_context(have), _expand_context(want), $4 ); - END IF; - - RETURN ok( false, $4 ) || E'\n' || diag( - ' Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') does not exist' - ); -END; -$$ LANGUAGE plpgsql; - --- cast_context_is( source_type, target_type, context ) -CREATE OR REPLACE FUNCTION cast_context_is( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT cast_context_is( - $1, $2, $3, - 'Cast (' || quote_ident($1) || ' AS ' || quote_ident($2) - || ') context should be ' || _expand_context(substring(LOWER($3) FROM 1 FOR 1)) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _op_exists ( NAME, NAME, NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_operator o - JOIN pg_catalog.pg_namespace n ON o.oprnamespace = n.oid - WHERE n.nspname = $2 - AND o.oprname = $3 - AND CASE o.oprkind WHEN 'l' THEN $1 IS NULL - ELSE _cmp_types(o.oprleft, $1) END - AND CASE o.oprkind WHEN 'r' THEN $4 IS NULL - ELSE _cmp_types(o.oprright, $4) END - AND _cmp_types(o.oprresult, $5) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _op_exists ( NAME, NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_operator o - WHERE pg_catalog.pg_operator_is_visible(o.oid) - AND o.oprname = $2 - AND CASE o.oprkind WHEN 'l' THEN $1 IS NULL - ELSE _cmp_types(o.oprleft, $1) END - AND CASE o.oprkind WHEN 'r' THEN $3 IS NULL - ELSE _cmp_types(o.oprright, $3) END - AND _cmp_types(o.oprresult, $4) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _op_exists ( NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_operator o - WHERE pg_catalog.pg_operator_is_visible(o.oid) - AND o.oprname = $2 - AND CASE o.oprkind WHEN 'l' THEN $1 IS NULL - ELSE _cmp_types(o.oprleft, $1) END - AND CASE o.oprkind WHEN 'r' THEN $3 IS NULL - ELSE _cmp_types(o.oprright, $3) END - ); -$$ LANGUAGE SQL; - --- has_operator( left_type, schema, name, right_type, return_type, description ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists($1, $2, $3, $4, $5 ), $6 ); -$$ LANGUAGE SQL; - --- has_operator( left_type, schema, name, right_type, return_type ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, $3, $4, $5 ), - 'Operator ' || quote_ident($2) || '.' || $3 || '(' || $1 || ',' || $4 - || ') RETURNS ' || $5 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_operator( left_type, name, right_type, return_type, description ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists($1, $2, $3, $4 ), $5 ); -$$ LANGUAGE SQL; - --- has_operator( left_type, name, right_type, return_type ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, $3, $4 ), - 'Operator ' || $2 || '(' || $1 || ',' || $3 - || ') RETURNS ' || $4 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_operator( left_type, name, right_type, description ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists($1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- has_operator( left_type, name, right_type ) -CREATE OR REPLACE FUNCTION has_operator ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, $3 ), - 'Operator ' || $2 || '(' || $1 || ',' || $3 - || ') should exist' - ); -$$ LANGUAGE SQL; - --- has_leftop( schema, name, right_type, return_type, description ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists(NULL, $1, $2, $3, $4), $5 ); -$$ LANGUAGE SQL; - --- has_leftop( schema, name, right_type, return_type ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists(NULL, $1, $2, $3, $4 ), - 'Left operator ' || quote_ident($1) || '.' || $2 || '(NONE,' - || $3 || ') RETURNS ' || $4 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_leftop( name, right_type, return_type, description ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists(NULL, $1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- has_leftop( name, right_type, return_type ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists(NULL, $1, $2, $3 ), - 'Left operator ' || $1 || '(NONE,' || $2 || ') RETURNS ' || $3 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_leftop( name, right_type, description ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists(NULL, $1, $2), $3 ); -$$ LANGUAGE SQL; - --- has_leftop( name, right_type ) -CREATE OR REPLACE FUNCTION has_leftop ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists(NULL, $1, $2 ), - 'Left operator ' || $1 || '(NONE,' || $2 || ') should exist' - ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, schema, name, return_type, description ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists( $1, $2, $3, NULL, $4), $5 ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, schema, name, return_type ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, $3, NULL, $4 ), - 'Right operator ' || quote_ident($2) || '.' || $3 || '(' - || $1 || ',NONE) RETURNS ' || $4 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, name, return_type, description ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists( $1, $2, NULL, $3), $4 ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, name, return_type ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, NULL, $3 ), - 'Right operator ' || $2 || '(' - || $1 || ',NONE) RETURNS ' || $3 || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, name, description ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _op_exists( $1, $2, NULL), $3 ); -$$ LANGUAGE SQL; - --- has_rightop( left_type, name ) -CREATE OR REPLACE FUNCTION has_rightop ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _op_exists($1, $2, NULL ), - 'Right operator ' || $2 || '(' || $1 || ',NONE) should exist' - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _is_trusted( NAME ) -RETURNS BOOLEAN AS $$ - SELECT lanpltrusted FROM pg_catalog.pg_language WHERE lanname = $1; -$$ LANGUAGE SQL; - --- has_language( language, description) -CREATE OR REPLACE FUNCTION has_language( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_trusted($1) IS NOT NULL, $2 ); -$$ LANGUAGE SQL; - --- has_language( language ) -CREATE OR REPLACE FUNCTION has_language( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_trusted($1) IS NOT NULL, 'Procedural language ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_language( language, description) -CREATE OR REPLACE FUNCTION hasnt_language( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_trusted($1) IS NULL, $2 ); -$$ LANGUAGE SQL; - --- hasnt_language( language ) -CREATE OR REPLACE FUNCTION hasnt_language( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_trusted($1) IS NULL, 'Procedural language ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - --- language_is_trusted( language, description ) -CREATE OR REPLACE FUNCTION language_is_trusted( NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - is_trusted boolean := _is_trusted($1); -BEGIN - IF is_trusted IS NULL THEN - RETURN fail( $2 ) || E'\n' || diag( ' Procedural language ' || quote_ident($1) || ' does not exist') ; - END IF; - RETURN ok( is_trusted, $2 ); -END; -$$ LANGUAGE plpgsql; - --- language_is_trusted( language ) -CREATE OR REPLACE FUNCTION language_is_trusted( NAME ) -RETURNS TEXT AS $$ - SELECT language_is_trusted($1, 'Procedural language ' || quote_ident($1) || ' should be trusted' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _opc_exists( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - WHERE n.nspname = $1 - AND oc.opcname = $2 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _opc_exists( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_opclass oc - WHERE oc.opcname = $1 - AND pg_opclass_is_visible(oid) - ); -$$ LANGUAGE SQL; - --- has_opclass( schema, name, description ) -CREATE OR REPLACE FUNCTION has_opclass( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _opc_exists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_opclass( schema, name ) -CREATE OR REPLACE FUNCTION has_opclass( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _opc_exists( $1, $2 ), 'Operator class ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE SQL; - --- has_opclass( name, description ) -CREATE OR REPLACE FUNCTION has_opclass( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _opc_exists( $1 ), $2) -$$ LANGUAGE SQL; - --- has_opclass( name ) -CREATE OR REPLACE FUNCTION has_opclass( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _opc_exists( $1 ), 'Operator class ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_opclass( schema, name, description ) -CREATE OR REPLACE FUNCTION hasnt_opclass( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _opc_exists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_opclass( schema, name ) -CREATE OR REPLACE FUNCTION hasnt_opclass( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _opc_exists( $1, $2 ), 'Operator class ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_opclass( name, description ) -CREATE OR REPLACE FUNCTION hasnt_opclass( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _opc_exists( $1 ), $2) -$$ LANGUAGE SQL; - --- hasnt_opclass( name ) -CREATE OR REPLACE FUNCTION hasnt_opclass( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _opc_exists( $1 ), 'Operator class ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- opclasses_are( schema, opclasses[], description ) -CREATE OR REPLACE FUNCTION _nosuch( NAME, NAME, NAME[]) -RETURNS TEXT AS $$ - SELECT E'\n' || diag( - ' Function ' - || CASE WHEN $1 IS NOT NULL THEN quote_ident($1) || '.' ELSE '' END - || quote_ident($2) || '(' - || array_to_string($3, ', ') || ') does not exist' - ); -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _func_compare( NAME, NAME, NAME[], anyelement, anyelement, TEXT) -RETURNS TEXT AS $$ - SELECT CASE WHEN $4 IS NULL - THEN ok( FALSE, $6 ) || _nosuch($1, $2, $3) - ELSE is( $4, $5, $6 ) - END; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _func_compare( NAME, NAME, NAME[], boolean, TEXT) -RETURNS TEXT AS $$ - SELECT CASE WHEN $4 IS NULL - THEN ok( FALSE, $5 ) || _nosuch($1, $2, $3) - ELSE ok( $4, $5 ) - END; -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _func_compare( NAME, NAME, anyelement, anyelement, TEXT) -RETURNS TEXT AS $$ - SELECT CASE WHEN $3 IS NULL - THEN ok( FALSE, $5 ) || _nosuch($1, $2, '{}') - ELSE is( $3, $4, $5 ) - END; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _func_compare( NAME, NAME, boolean, TEXT) -RETURNS TEXT AS $$ - SELECT CASE WHEN $3 IS NULL - THEN ok( FALSE, $4 ) || _nosuch($1, $2, '{}') - ELSE ok( $3, $4 ) - END; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _lang ( NAME, NAME, NAME[] ) -RETURNS NAME AS $$ - SELECT l.lanname - FROM tap_funky f - JOIN pg_catalog.pg_language l ON f.langoid = l.oid - WHERE f.schema = $1 - and f.name = $2 - AND f.args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _lang ( NAME, NAME ) -RETURNS NAME AS $$ - SELECT l.lanname - FROM tap_funky f - JOIN pg_catalog.pg_language l ON f.langoid = l.oid - WHERE f.schema = $1 - and f.name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _lang ( NAME, NAME[] ) -RETURNS NAME AS $$ - SELECT l.lanname - FROM tap_funky f - JOIN pg_catalog.pg_language l ON f.langoid = l.oid - WHERE f.name = $1 - AND f.args = array_to_string($2, ',') - AND f.is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _lang ( NAME ) -RETURNS NAME AS $$ - SELECT l.lanname - FROM tap_funky f - JOIN pg_catalog.pg_language l ON f.langoid = l.oid - WHERE f.name = $1 - AND f.is_visible; -$$ LANGUAGE SQL; - --- function_lang_is( schema, function, args[], language, description ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME, NAME[], NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _lang($1, $2, $3), $4, $5 ); -$$ LANGUAGE SQL; - --- function_lang_is( schema, function, args[], language ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME, NAME[], NAME ) -RETURNS TEXT AS $$ - SELECT function_lang_is( - $1, $2, $3, $4, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be written in ' || quote_ident($4) - ); -$$ LANGUAGE SQL; - --- function_lang_is( schema, function, language, description ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _lang($1, $2), $3, $4 ); -$$ LANGUAGE SQL; - --- function_lang_is( schema, function, language ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT function_lang_is( - $1, $2, $3, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) - || '() should be written in ' || quote_ident($3) - ); -$$ LANGUAGE SQL; - --- function_lang_is( function, args[], language, description ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME[], NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _lang($1, $2), $3, $4 ); -$$ LANGUAGE SQL; - --- function_lang_is( function, args[], language ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME[], NAME ) -RETURNS TEXT AS $$ - SELECT function_lang_is( - $1, $2, $3, - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be written in ' || quote_ident($3) - ); -$$ LANGUAGE SQL; - --- function_lang_is( function, language, description ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _lang($1), $2, $3 ); -$$ LANGUAGE SQL; - --- function_lang_is( function, language ) -CREATE OR REPLACE FUNCTION function_lang_is( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT function_lang_is( - $1, $2, - 'Function ' || quote_ident($1) - || '() should be written in ' || quote_ident($2) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _returns ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT returns - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _returns ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT returns FROM tap_funky WHERE schema = $1 AND name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _returns ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT returns - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _returns ( NAME ) -RETURNS TEXT AS $$ - SELECT returns FROM tap_funky WHERE name = $1 AND is_visible; -$$ LANGUAGE SQL; - --- function_returns( schema, function, args[], type, description ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _returns($1, $2, $3), $4, $5 ); -$$ LANGUAGE SQL; - --- function_returns( schema, function, args[], type ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT function_returns( - $1, $2, $3, $4, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should return ' || $4 - ); -$$ LANGUAGE SQL; - --- function_returns( schema, function, type, description ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _returns($1, $2), $3, $4 ); -$$ LANGUAGE SQL; - --- function_returns( schema, function, type ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT function_returns( - $1, $2, $3, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) - || '() should return ' || $3 - ); -$$ LANGUAGE SQL; - --- function_returns( function, args[], type, description ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _returns($1, $2), $3, $4 ); -$$ LANGUAGE SQL; - --- function_returns( function, args[], type ) -CREATE OR REPLACE FUNCTION function_returns( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT function_returns( - $1, $2, $3, - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should return ' || $3 - ); -$$ LANGUAGE SQL; - --- function_returns( function, type, description ) -CREATE OR REPLACE FUNCTION function_returns( NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _returns($1), $2, $3 ); -$$ LANGUAGE SQL; - --- function_returns( function, type ) -CREATE OR REPLACE FUNCTION function_returns( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT function_returns( - $1, $2, - 'Function ' || quote_ident($1) || '() should return ' || $2 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _definer ( NAME, NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_definer - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _definer ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_definer FROM tap_funky WHERE schema = $1 AND name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _definer ( NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_definer - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _definer ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_definer FROM tap_funky WHERE name = $1 AND is_visible; -$$ LANGUAGE SQL; - --- is_definer( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION is_definer ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _definer($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- is_definer( schema, function, args[] ) -CREATE OR REPLACE FUNCTION is_definer( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _definer($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be security definer' - ); -$$ LANGUAGE sql; - --- is_definer( schema, function, description ) -CREATE OR REPLACE FUNCTION is_definer ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _definer($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_definer( schema, function ) -CREATE OR REPLACE FUNCTION is_definer( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _definer($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should be security definer' - ); -$$ LANGUAGE sql; - --- is_definer( function, args[], description ) -CREATE OR REPLACE FUNCTION is_definer ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _definer($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_definer( function, args[] ) -CREATE OR REPLACE FUNCTION is_definer( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _definer($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be security definer' - ); -$$ LANGUAGE sql; - --- is_definer( function, description ) -CREATE OR REPLACE FUNCTION is_definer( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _definer($1), $2 ); -$$ LANGUAGE sql; - --- is_definer( function ) -CREATE OR REPLACE FUNCTION is_definer( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _definer($1), 'Function ' || quote_ident($1) || '() should be security definer' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _agg ( NAME, NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_agg - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _agg ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_agg FROM tap_funky WHERE schema = $1 AND name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _agg ( NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_agg - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _agg ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_agg FROM tap_funky WHERE name = $1 AND is_visible; -$$ LANGUAGE SQL; - --- is_aggregate( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION is_aggregate ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _agg($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- is_aggregate( schema, function, args[] ) -CREATE OR REPLACE FUNCTION is_aggregate( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _agg($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be an aggregate function' - ); -$$ LANGUAGE sql; - --- is_aggregate( schema, function, description ) -CREATE OR REPLACE FUNCTION is_aggregate ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _agg($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_aggregate( schema, function ) -CREATE OR REPLACE FUNCTION is_aggregate( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _agg($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should be an aggregate function' - ); -$$ LANGUAGE sql; - --- is_aggregate( function, args[], description ) -CREATE OR REPLACE FUNCTION is_aggregate ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _agg($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_aggregate( function, args[] ) -CREATE OR REPLACE FUNCTION is_aggregate( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _agg($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be an aggregate function' - ); -$$ LANGUAGE sql; - --- is_aggregate( function, description ) -CREATE OR REPLACE FUNCTION is_aggregate( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _agg($1), $2 ); -$$ LANGUAGE sql; - --- is_aggregate( function ) -CREATE OR REPLACE FUNCTION is_aggregate( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _agg($1), 'Function ' || quote_ident($1) || '() should be an aggregate function' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _strict ( NAME, NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_strict - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _strict ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_strict FROM tap_funky WHERE schema = $1 AND name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _strict ( NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT is_strict - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _strict ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT is_strict FROM tap_funky WHERE name = $1 AND is_visible; -$$ LANGUAGE SQL; - --- is_strict( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION is_strict ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _strict($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- is_strict( schema, function, args[] ) -CREATE OR REPLACE FUNCTION is_strict( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _strict($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be strict' - ); -$$ LANGUAGE sql; - --- is_strict( schema, function, description ) -CREATE OR REPLACE FUNCTION is_strict ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _strict($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_strict( schema, function ) -CREATE OR REPLACE FUNCTION is_strict( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _strict($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should be strict' - ); -$$ LANGUAGE sql; - --- is_strict( function, args[], description ) -CREATE OR REPLACE FUNCTION is_strict ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _strict($1, $2), $3 ); -$$ LANGUAGE SQL; - --- is_strict( function, args[] ) -CREATE OR REPLACE FUNCTION is_strict( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - _strict($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be strict' - ); -$$ LANGUAGE sql; - --- is_strict( function, description ) -CREATE OR REPLACE FUNCTION is_strict( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _strict($1), $2 ); -$$ LANGUAGE sql; - --- is_strict( function ) -CREATE OR REPLACE FUNCTION is_strict( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _strict($1), 'Function ' || quote_ident($1) || '() should be strict' ); -$$ LANGUAGE sql; - --- isnt_strict( schema, function, args[], description ) -CREATE OR REPLACE FUNCTION isnt_strict ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, NOT _strict($1, $2, $3), $4 ); -$$ LANGUAGE SQL; - --- isnt_strict( schema, function, args[] ) -CREATE OR REPLACE FUNCTION isnt_strict( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _strict($1, $2, $3), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should not be strict' - ); -$$ LANGUAGE sql; - --- isnt_strict( schema, function, description ) -CREATE OR REPLACE FUNCTION isnt_strict ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, NOT _strict($1, $2), $3 ); -$$ LANGUAGE SQL; - --- isnt_strict( schema, function ) -CREATE OR REPLACE FUNCTION isnt_strict( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _strict($1, $2), - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '() should not be strict' - ); -$$ LANGUAGE sql; - --- isnt_strict( function, args[], description ) -CREATE OR REPLACE FUNCTION isnt_strict ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, NOT _strict($1, $2), $3 ); -$$ LANGUAGE SQL; - --- isnt_strict( function, args[] ) -CREATE OR REPLACE FUNCTION isnt_strict( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _strict($1, $2), - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should not be strict' - ); -$$ LANGUAGE sql; - --- isnt_strict( function, description ) -CREATE OR REPLACE FUNCTION isnt_strict( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, NOT _strict($1), $2 ); -$$ LANGUAGE sql; - --- isnt_strict( function ) -CREATE OR REPLACE FUNCTION isnt_strict( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _strict($1), 'Function ' || quote_ident($1) || '() should not be strict' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _expand_vol( char ) -RETURNS TEXT AS $$ - SELECT CASE $1 - WHEN 'i' THEN 'IMMUTABLE' - WHEN 's' THEN 'STABLE' - WHEN 'v' THEN 'VOLATILE' - ELSE 'UNKNOWN' END -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _refine_vol( text ) -RETURNS text AS $$ - SELECT _expand_vol(substring(LOWER($1) FROM 1 FOR 1)::char); -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _vol ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _expand_vol(volatility) - FROM tap_funky f - WHERE f.schema = $1 - and f.name = $2 - AND f.args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _vol ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT _expand_vol(volatility) FROM tap_funky f - WHERE f.schema = $1 and f.name = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _vol ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _expand_vol(volatility) - FROM tap_funky f - WHERE f.name = $1 - AND f.args = array_to_string($2, ',') - AND f.is_visible; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _vol ( NAME ) -RETURNS TEXT AS $$ - SELECT _expand_vol(volatility) FROM tap_funky f - WHERE f.name = $1 AND f.is_visible; -$$ LANGUAGE SQL; - --- volatility_is( schema, function, args[], volatility, description ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, $3, _vol($1, $2, $3), _refine_vol($4), $5 ); -$$ LANGUAGE SQL; - --- volatility_is( schema, function, args[], volatility ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT volatility_is( - $1, $2, $3, $4, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be ' || _refine_vol($4) - ); -$$ LANGUAGE SQL; - --- volatility_is( schema, function, volatility, description ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare($1, $2, _vol($1, $2), _refine_vol($3), $4 ); -$$ LANGUAGE SQL; - --- volatility_is( schema, function, volatility ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT volatility_is( - $1, $2, $3, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) - || '() should be ' || _refine_vol($3) - ); -$$ LANGUAGE SQL; - --- volatility_is( function, args[], volatility, description ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, $2, _vol($1, $2), _refine_vol($3), $4 ); -$$ LANGUAGE SQL; - --- volatility_is( function, args[], volatility ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT volatility_is( - $1, $2, $3, - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be ' || _refine_vol($3) - ); -$$ LANGUAGE SQL; - --- volatility_is( function, volatility, description ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _func_compare(NULL, $1, _vol($1), _refine_vol($2), $3 ); -$$ LANGUAGE SQL; - --- volatility_is( function, volatility ) -CREATE OR REPLACE FUNCTION volatility_is( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT volatility_is( - $1, $2, - 'Function ' || quote_ident($1) || '() should be ' || _refine_vol($2) - ); -$$ LANGUAGE SQL; - --- check_test( test_output, pass, name, description, diag, match_diag ) -CREATE OR REPLACE FUNCTION findfuncs( NAME, TEXT ) -RETURNS TEXT[] AS $$ - SELECT ARRAY( - SELECT DISTINCT quote_ident(n.nspname) || '.' || quote_ident(p.proname) AS pname - FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = $1 - AND p.proname ~ $2 - ORDER BY pname - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION findfuncs( TEXT ) -RETURNS TEXT[] AS $$ - SELECT ARRAY( - SELECT DISTINCT quote_ident(n.nspname) || '.' || quote_ident(p.proname) AS pname - FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid - WHERE pg_catalog.pg_function_is_visible(p.oid) - AND p.proname ~ $1 - ORDER BY pname - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _runem( text[], boolean ) -RETURNS SETOF TEXT AS $$ -DECLARE - tap text; - lbound int := array_lower($1, 1); -BEGIN - IF lbound IS NULL THEN RETURN; END IF; - FOR i IN lbound..array_upper($1, 1) LOOP - -- Send the name of the function to diag if warranted. - IF $2 THEN RETURN NEXT diag( $1[i] || '()' ); END IF; - -- Execute the tap function and return its results. - FOR tap IN EXECUTE 'SELECT * FROM ' || $1[i] || '()' LOOP - RETURN NEXT tap; - END LOOP; - END LOOP; - RETURN; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _is_verbose() -RETURNS BOOLEAN AS $$ - SELECT current_setting('client_min_messages') NOT IN ( - 'warning', 'error', 'fatal', 'panic' - ); -$$ LANGUAGE sql STABLE; - --- do_tap( schema, pattern ) -CREATE OR REPLACE FUNCTION do_tap( name, text ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runem( findfuncs($1, $2), _is_verbose() ); -$$ LANGUAGE sql; - --- do_tap( schema ) -CREATE OR REPLACE FUNCTION do_tap( name ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runem( findfuncs($1, '^test'), _is_verbose() ); -$$ LANGUAGE sql; - --- do_tap( pattern ) -CREATE OR REPLACE FUNCTION do_tap( text ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runem( findfuncs($1), _is_verbose() ); -$$ LANGUAGE sql; - --- do_tap() -CREATE OR REPLACE FUNCTION do_tap( ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runem( findfuncs('^test'), _is_verbose()); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _currtest() -RETURNS INTEGER AS $$ -BEGIN - RETURN currval('__tresults___numb_seq'); -EXCEPTION - WHEN object_not_in_prerequisite_state THEN RETURN 0; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _cleanup() -RETURNS boolean AS $$ - DROP SEQUENCE __tresults___numb_seq; - DROP TABLE __tcache__; - DROP SEQUENCE __tcache___id_seq; - SELECT TRUE; -$$ LANGUAGE sql; - --- diag_test_name ( test_name ) -CREATE OR REPLACE FUNCTION diag_test_name(TEXT) -RETURNS TEXT AS $$ - SELECT diag($1 || '()'); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _runner( text[], text[], text[], text[], text[] ) -RETURNS SETOF TEXT AS $$ -DECLARE - startup ALIAS FOR $1; - shutdown ALIAS FOR $2; - setup ALIAS FOR $3; - teardown ALIAS FOR $4; - tests ALIAS FOR $5; - tap TEXT; - tfaild INTEGER := 0; - ffaild INTEGER := 0; - tnumb INTEGER := 0; - fnumb INTEGER := 0; - tok BOOLEAN := TRUE; - errmsg TEXT; -BEGIN - BEGIN - -- No plan support. - PERFORM * FROM no_plan(); - FOR tap IN SELECT * FROM _runem(startup, false) LOOP RETURN NEXT tap; END LOOP; - EXCEPTION - -- Catch all exceptions and simply rethrow custom exceptions. This - -- will roll back everything in the above block. - WHEN raise_exception THEN RAISE EXCEPTION '%', SQLERRM; - END; - - -- Record how startup tests have failed. - tfaild := num_failed(); - - FOR i IN 1..COALESCE(array_upper(tests, 1), 0) LOOP - -- What subtest are we running? - RETURN NEXT ' ' || diag_test_name('Subtest: ' || tests[i]); - - -- Reset the results. - tok := TRUE; - tnumb := COALESCE(_get('curr_test'), 0); - - IF tnumb > 0 THEN - EXECUTE 'ALTER SEQUENCE __tresults___numb_seq RESTART WITH 1'; - PERFORM _set('curr_test', 0); - PERFORM _set('failed', 0); - END IF; - - BEGIN - BEGIN - -- Run the setup functions. - FOR tap IN SELECT * FROM _runem(setup, false) LOOP - RETURN NEXT regexp_replace(tap, '^', ' ', 'gn'); - END LOOP; - - -- Run the actual test function. - FOR tap IN EXECUTE 'SELECT * FROM ' || tests[i] || '()' LOOP - RETURN NEXT regexp_replace(tap, '^', ' ', 'gn'); - END LOOP; - - -- Run the teardown functions. - FOR tap IN SELECT * FROM _runem(teardown, false) LOOP - RETURN NEXT regexp_replace(tap, '^', ' ', 'gn'); - END LOOP; - - -- Emit the plan. - fnumb := COALESCE(_get('curr_test'), 0); - RETURN NEXT ' 1..' || fnumb; - - -- Emit any error messages. - IF fnumb = 0 THEN - RETURN NEXT ' # No tests run!'; - tok = false; - ELSE - -- Report failures. - ffaild := num_failed(); - IF ffaild > 0 THEN - tok := FALSE; - RETURN NEXT ' ' || diag( - 'Looks like you failed ' || ffaild || ' test' || - CASE tfaild WHEN 1 THEN '' ELSE 's' END - || ' of ' || fnumb - ); - END IF; - END IF; - - EXCEPTION WHEN raise_exception THEN - -- Something went wrong. Record that fact. - errmsg := SQLERRM; - END; - - -- Always raise an exception to rollback any changes. - RAISE EXCEPTION '__TAP_ROLLBACK__'; - - EXCEPTION WHEN raise_exception THEN - IF errmsg IS NOT NULL THEN - -- Something went wrong. Emit the error message. - tok := FALSE; - RETURN NEXT ' ' || diag('Test died: ' || errmsg); - errmsg := NULL; - END IF; - END; - - -- Restore the sequence. - EXECUTE 'ALTER SEQUENCE __tresults___numb_seq RESTART WITH ' || tnumb + 1; - PERFORM _set('curr_test', tnumb); - PERFORM _set('failed', tfaild); - - -- Record this test. - RETURN NEXT ok(tok, tests[i]); - IF NOT tok THEN tfaild := tfaild + 1; END IF; - - END LOOP; - - -- Run the shutdown functions. - FOR tap IN SELECT * FROM _runem(shutdown, false) LOOP RETURN NEXT tap; END LOOP; - - -- Finish up. - FOR tap IN SELECT * FROM _finish( COALESCE(_get('curr_test'), 0), 0, tfaild ) LOOP - RETURN NEXT tap; - END LOOP; - - -- Clean up and return. - PERFORM _cleanup(); - RETURN; -END; -$$ LANGUAGE plpgsql; - --- runtests( schema, match ) -CREATE OR REPLACE FUNCTION runtests( NAME, TEXT ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runner( - findfuncs( $1, '^startup' ), - findfuncs( $1, '^shutdown' ), - findfuncs( $1, '^setup' ), - findfuncs( $1, '^teardown' ), - findfuncs( $1, $2 ) - ); -$$ LANGUAGE sql; - --- runtests( schema ) -CREATE OR REPLACE FUNCTION runtests( NAME ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM runtests( $1, '^test' ); -$$ LANGUAGE sql; - --- runtests( match ) -CREATE OR REPLACE FUNCTION runtests( TEXT ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM _runner( - findfuncs( '^startup' ), - findfuncs( '^shutdown' ), - findfuncs( '^setup' ), - findfuncs( '^teardown' ), - findfuncs( $1 ) - ); -$$ LANGUAGE sql; - --- runtests( ) -CREATE OR REPLACE FUNCTION runtests( ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM runtests( '^test' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _temptable ( TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - EXECUTE 'CREATE TEMP TABLE ' || $2 || ' AS ' || _query($1); - return $2; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _temptable ( anyarray, TEXT ) -RETURNS TEXT AS $$ -BEGIN - CREATE TEMP TABLE _____coltmp___ AS - SELECT $1[i] - FROM generate_series(array_lower($1, 1), array_upper($1, 1)) s(i); - EXECUTE 'ALTER TABLE _____coltmp___ RENAME TO ' || $2; - return $2; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _temptypes( TEXT ) -RETURNS TEXT AS $$ - SELECT array_to_string(ARRAY( - SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - WHERE c.oid = ('pg_temp.' || $1)::pg_catalog.regclass - AND attnum > 0 - AND NOT attisdropped - ORDER BY attnum - ), ','); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _docomp( TEXT, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have ALIAS FOR $1; - want ALIAS FOR $2; - extras TEXT[] := '{}'; - missing TEXT[] := '{}'; - res BOOLEAN := TRUE; - msg TEXT := ''; - rec RECORD; -BEGIN - BEGIN - -- Find extra records. - FOR rec in EXECUTE 'SELECT * FROM ' || have || ' EXCEPT ' || $4 - || 'SELECT * FROM ' || want LOOP - extras := extras || rec::text; - END LOOP; - - -- Find missing records. - FOR rec in EXECUTE 'SELECT * FROM ' || want || ' EXCEPT ' || $4 - || 'SELECT * FROM ' || have LOOP - missing := missing || rec::text; - END LOOP; - - -- Drop the temporary tables. - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - EXCEPTION WHEN syntax_error OR datatype_mismatch THEN - msg := E'\n' || diag( - E' Columns differ between queries:\n' - || ' have: (' || _temptypes(have) || E')\n' - || ' want: (' || _temptypes(want) || ')' - ); - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - RETURN ok(FALSE, $3) || msg; - END; - - -- What extra records do we have? - IF extras[1] IS NOT NULL THEN - res := FALSE; - msg := E'\n' || diag( - E' Extra records:\n ' - || array_to_string( extras, E'\n ' ) - ); - END IF; - - -- What missing records do we have? - IF missing[1] IS NOT NULL THEN - res := FALSE; - msg := msg || E'\n' || diag( - E' Missing records:\n ' - || array_to_string( missing, E'\n ' ) - ); - END IF; - - RETURN ok(res, $3) || msg; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _relcomp( TEXT, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _docomp( - _temptable( $1, '__taphave__' ), - _temptable( $2, '__tapwant__' ), - $3, $4 - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _relcomp( TEXT, anyarray, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _docomp( - _temptable( $1, '__taphave__' ), - _temptable( $2, '__tapwant__' ), - $3, $4 - ); -$$ LANGUAGE sql; - --- set_eq( sql, sql, description ) -CREATE OR REPLACE FUNCTION set_eq( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, '' ); -$$ LANGUAGE sql; - --- set_eq( sql, sql ) -CREATE OR REPLACE FUNCTION set_eq( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::text, '' ); -$$ LANGUAGE sql; - --- set_eq( sql, array, description ) -CREATE OR REPLACE FUNCTION set_eq( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, '' ); -$$ LANGUAGE sql; - --- set_eq( sql, array ) -CREATE OR REPLACE FUNCTION set_eq( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::text, '' ); -$$ LANGUAGE sql; - --- bag_eq( sql, sql, description ) -CREATE OR REPLACE FUNCTION bag_eq( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_eq( sql, sql ) -CREATE OR REPLACE FUNCTION bag_eq( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::text, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_eq( sql, array, description ) -CREATE OR REPLACE FUNCTION bag_eq( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_eq( sql, array ) -CREATE OR REPLACE FUNCTION bag_eq( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::text, 'ALL ' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _do_ne( TEXT, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have ALIAS FOR $1; - want ALIAS FOR $2; - extras TEXT[] := '{}'; - missing TEXT[] := '{}'; - res BOOLEAN := TRUE; - msg TEXT := ''; -BEGIN - BEGIN - -- Find extra records. - EXECUTE 'SELECT EXISTS ( ' - || '( SELECT * FROM ' || have || ' EXCEPT ' || $4 - || ' SELECT * FROM ' || want - || ' ) UNION ( ' - || ' SELECT * FROM ' || want || ' EXCEPT ' || $4 - || ' SELECT * FROM ' || have - || ' ) LIMIT 1 )' INTO res; - - -- Drop the temporary tables. - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - EXCEPTION WHEN syntax_error OR datatype_mismatch THEN - msg := E'\n' || diag( - E' Columns differ between queries:\n' - || ' have: (' || _temptypes(have) || E')\n' - || ' want: (' || _temptypes(want) || ')' - ); - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - RETURN ok(FALSE, $3) || msg; - END; - - -- Return the value from the query. - RETURN ok(res, $3); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _relne( TEXT, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _do_ne( - _temptable( $1, '__taphave__' ), - _temptable( $2, '__tapwant__' ), - $3, $4 - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _relne( TEXT, anyarray, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _do_ne( - _temptable( $1, '__taphave__' ), - _temptable( $2, '__tapwant__' ), - $3, $4 - ); -$$ LANGUAGE sql; - --- set_ne( sql, sql, description ) -CREATE OR REPLACE FUNCTION set_ne( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, $3, '' ); -$$ LANGUAGE sql; - --- set_ne( sql, sql ) -CREATE OR REPLACE FUNCTION set_ne( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, NULL::text, '' ); -$$ LANGUAGE sql; - --- set_ne( sql, array, description ) -CREATE OR REPLACE FUNCTION set_ne( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, $3, '' ); -$$ LANGUAGE sql; - --- set_ne( sql, array ) -CREATE OR REPLACE FUNCTION set_ne( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, NULL::text, '' ); -$$ LANGUAGE sql; - --- bag_ne( sql, sql, description ) -CREATE OR REPLACE FUNCTION bag_ne( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, $3, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_ne( sql, sql ) -CREATE OR REPLACE FUNCTION bag_ne( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, NULL::text, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_ne( sql, array, description ) -CREATE OR REPLACE FUNCTION bag_ne( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, $3, 'ALL ' ); -$$ LANGUAGE sql; - --- bag_ne( sql, array ) -CREATE OR REPLACE FUNCTION bag_ne( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT _relne( $1, $2, NULL::text, 'ALL ' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _relcomp( TEXT, TEXT, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have TEXT := _temptable( $1, '__taphave__' ); - want TEXT := _temptable( $2, '__tapwant__' ); - results TEXT[] := '{}'; - res BOOLEAN := TRUE; - msg TEXT := ''; - rec RECORD; -BEGIN - BEGIN - -- Find relevant records. - FOR rec in EXECUTE 'SELECT * FROM ' || want || ' ' || $4 - || ' SELECT * FROM ' || have LOOP - results := results || rec::text; - END LOOP; - - -- Drop the temporary tables. - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - EXCEPTION WHEN syntax_error OR datatype_mismatch THEN - msg := E'\n' || diag( - E' Columns differ between queries:\n' - || ' have: (' || _temptypes(have) || E')\n' - || ' want: (' || _temptypes(want) || ')' - ); - EXECUTE 'DROP TABLE ' || have; - EXECUTE 'DROP TABLE ' || want; - RETURN ok(FALSE, $3) || msg; - END; - - -- What records do we have? - IF results[1] IS NOT NULL THEN - res := FALSE; - msg := msg || E'\n' || diag( - ' ' || $5 || E' records:\n ' - || array_to_string( results, E'\n ' ) - ); - END IF; - - RETURN ok(res, $3) || msg; -END; -$$ LANGUAGE plpgsql; - --- set_has( sql, sql, description ) -CREATE OR REPLACE FUNCTION set_has( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'EXCEPT', 'Missing' ); -$$ LANGUAGE sql; - --- set_has( sql, sql ) -CREATE OR REPLACE FUNCTION set_has( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::TEXT, 'EXCEPT', 'Missing' ); -$$ LANGUAGE sql; - --- bag_has( sql, sql, description ) -CREATE OR REPLACE FUNCTION bag_has( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'EXCEPT ALL', 'Missing' ); -$$ LANGUAGE sql; - --- bag_has( sql, sql ) -CREATE OR REPLACE FUNCTION bag_has( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::TEXT, 'EXCEPT ALL', 'Missing' ); -$$ LANGUAGE sql; - --- set_hasnt( sql, sql, description ) -CREATE OR REPLACE FUNCTION set_hasnt( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'INTERSECT', 'Extra' ); -$$ LANGUAGE sql; - --- set_hasnt( sql, sql ) -CREATE OR REPLACE FUNCTION set_hasnt( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::TEXT, 'INTERSECT', 'Extra' ); -$$ LANGUAGE sql; - --- bag_hasnt( sql, sql, description ) -CREATE OR REPLACE FUNCTION bag_hasnt( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, $3, 'INTERSECT ALL', 'Extra' ); -$$ LANGUAGE sql; - --- bag_hasnt( sql, sql ) -CREATE OR REPLACE FUNCTION bag_hasnt( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _relcomp( $1, $2, NULL::TEXT, 'INTERSECT ALL', 'Extra' ); -$$ LANGUAGE sql; - --- results_eq( cursor, cursor, description ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, refcursor, text ) -RETURNS TEXT AS $$ -DECLARE - have ALIAS FOR $1; - want ALIAS FOR $2; - have_rec RECORD; - want_rec RECORD; - have_found BOOLEAN; - want_found BOOLEAN; - rownum INTEGER := 1; -BEGIN - FETCH have INTO have_rec; - have_found := FOUND; - FETCH want INTO want_rec; - want_found := FOUND; - WHILE have_found OR want_found LOOP - IF have_rec::text IS DISTINCT FROM want_rec::text OR have_found <> want_found THEN - RETURN ok( false, $3 ) || E'\n' || diag( - ' Results differ beginning at row ' || rownum || E':\n' || - ' have: ' || CASE WHEN have_found THEN have_rec::text ELSE 'NULL' END || E'\n' || - ' want: ' || CASE WHEN want_found THEN want_rec::text ELSE 'NULL' END - ); - END IF; - rownum = rownum + 1; - FETCH have INTO have_rec; - have_found := FOUND; - FETCH want INTO want_rec; - want_found := FOUND; - END LOOP; - - RETURN ok( true, $3 ); -EXCEPTION - WHEN datatype_mismatch THEN - RETURN ok( false, $3 ) || E'\n' || diag( - E' Number of columns or their types differ between the queries' || - CASE WHEN have_rec::TEXT = want_rec::text THEN '' ELSE E':\n' || - ' have: ' || CASE WHEN have_found THEN have_rec::text ELSE 'NULL' END || E'\n' || - ' want: ' || CASE WHEN want_found THEN want_rec::text ELSE 'NULL' END - END - ); -END; -$$ LANGUAGE plpgsql; - --- results_eq( cursor, cursor ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, refcursor ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_eq( sql, sql, description ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - want REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - OPEN want FOR EXECUTE _query($2); - res := results_eq(have, want, $3); - CLOSE have; - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_eq( sql, sql ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_eq( sql, array, description ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - want REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - OPEN want FOR SELECT $2[i] - FROM generate_series(array_lower($2, 1), array_upper($2, 1)) s(i); - res := results_eq(have, want, $3); - CLOSE have; - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_eq( sql, array ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_eq( sql, cursor, description ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, refcursor, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - res := results_eq(have, $2, $3); - CLOSE have; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_eq( sql, cursor ) -CREATE OR REPLACE FUNCTION results_eq( TEXT, refcursor ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_eq( cursor, sql, description ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want REFCURSOR; - res TEXT; -BEGIN - OPEN want FOR EXECUTE _query($2); - res := results_eq($1, want, $3); - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_eq( cursor, sql ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, TEXT ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_eq( cursor, array, description ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, anyarray, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want REFCURSOR; - res TEXT; -BEGIN - OPEN want FOR SELECT $2[i] - FROM generate_series(array_lower($2, 1), array_upper($2, 1)) s(i); - res := results_eq($1, want, $3); - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_eq( cursor, array ) -CREATE OR REPLACE FUNCTION results_eq( refcursor, anyarray ) -RETURNS TEXT AS $$ - SELECT results_eq( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( cursor, cursor, description ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, refcursor, text ) -RETURNS TEXT AS $$ -DECLARE - have ALIAS FOR $1; - want ALIAS FOR $2; - have_rec RECORD; - want_rec RECORD; - have_found BOOLEAN; - want_found BOOLEAN; -BEGIN - FETCH have INTO have_rec; - have_found := FOUND; - FETCH want INTO want_rec; - want_found := FOUND; - WHILE have_found OR want_found LOOP - IF have_rec::text IS DISTINCT FROM want_rec::text OR have_found <> want_found THEN - RETURN ok( true, $3 ); - ELSE - FETCH have INTO have_rec; - have_found := FOUND; - FETCH want INTO want_rec; - want_found := FOUND; - END IF; - END LOOP; - RETURN ok( false, $3 ); -EXCEPTION - WHEN datatype_mismatch THEN - RETURN ok( false, $3 ) || E'\n' || diag( - E' Columns differ between queries:\n' || - ' have: ' || CASE WHEN have_found THEN have_rec::text ELSE 'NULL' END || E'\n' || - ' want: ' || CASE WHEN want_found THEN want_rec::text ELSE 'NULL' END - ); -END; -$$ LANGUAGE plpgsql; - --- results_ne( cursor, cursor ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, refcursor ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( sql, sql, description ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - want REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - OPEN want FOR EXECUTE _query($2); - res := results_ne(have, want, $3); - CLOSE have; - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_ne( sql, sql ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( sql, array, description ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, anyarray, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - want REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - OPEN want FOR SELECT $2[i] - FROM generate_series(array_lower($2, 1), array_upper($2, 1)) s(i); - res := results_ne(have, want, $3); - CLOSE have; - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_ne( sql, array ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, anyarray ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( sql, cursor, description ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, refcursor, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have REFCURSOR; - res TEXT; -BEGIN - OPEN have FOR EXECUTE _query($1); - res := results_ne(have, $2, $3); - CLOSE have; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_ne( sql, cursor ) -CREATE OR REPLACE FUNCTION results_ne( TEXT, refcursor ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( cursor, sql, description ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want REFCURSOR; - res TEXT; -BEGIN - OPEN want FOR EXECUTE _query($2); - res := results_ne($1, want, $3); - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_ne( cursor, sql ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, TEXT ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- results_ne( cursor, array, description ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, anyarray, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want REFCURSOR; - res TEXT; -BEGIN - OPEN want FOR SELECT $2[i] - FROM generate_series(array_lower($2, 1), array_upper($2, 1)) s(i); - res := results_ne($1, want, $3); - CLOSE want; - RETURN res; -END; -$$ LANGUAGE plpgsql; - --- results_ne( cursor, array ) -CREATE OR REPLACE FUNCTION results_ne( refcursor, anyarray ) -RETURNS TEXT AS $$ - SELECT results_ne( $1, $2, NULL::text ); -$$ LANGUAGE sql; - --- isa_ok( value, regtype, description ) -CREATE OR REPLACE FUNCTION isa_ok( anyelement, regtype, TEXT ) -RETURNS TEXT AS $$ -DECLARE - typeof regtype := pg_typeof($1); -BEGIN - IF typeof = $2 THEN RETURN ok(true, $3 || ' isa ' || $2 ); END IF; - RETURN ok(false, $3 || ' isa ' || $2 ) || E'\n' || - diag(' ' || $3 || ' isn''t a "' || $2 || '" it''s a "' || typeof || '"'); -END; -$$ LANGUAGE plpgsql; - --- isa_ok( value, regtype ) -CREATE OR REPLACE FUNCTION isa_ok( anyelement, regtype ) -RETURNS TEXT AS $$ - SELECT isa_ok($1, $2, 'the value'); -$$ LANGUAGE sql; - --- is_empty( sql, description ) -CREATE OR REPLACE FUNCTION is_empty( TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - extras TEXT[] := '{}'; - res BOOLEAN := TRUE; - msg TEXT := ''; - rec RECORD; -BEGIN - -- Find extra records. - FOR rec in EXECUTE _query($1) LOOP - extras := extras || rec::text; - END LOOP; - - -- What extra records do we have? - IF extras[1] IS NOT NULL THEN - res := FALSE; - msg := E'\n' || diag( - E' Unexpected records:\n ' - || array_to_string( extras, E'\n ' ) - ); - END IF; - - RETURN ok(res, $2) || msg; -END; -$$ LANGUAGE plpgsql; - --- is_empty( sql ) -CREATE OR REPLACE FUNCTION is_empty( TEXT ) -RETURNS TEXT AS $$ - SELECT is_empty( $1, NULL ); -$$ LANGUAGE sql; - --- isnt_empty( sql, description ) -CREATE OR REPLACE FUNCTION collect_tap( text[] ) -RETURNS TEXT AS $$ - SELECT array_to_string($1, E'\n'); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _tlike ( BOOLEAN, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( $1, $4 ) || CASE WHEN $1 THEN '' ELSE E'\n' || diag( - ' error message: ' || COALESCE( quote_literal($2), 'NULL' ) || - E'\n doesn''t match: ' || COALESCE( quote_literal($3), 'NULL' ) - ) END; -$$ LANGUAGE sql; - --- throws_like ( sql, pattern, description ) -CREATE OR REPLACE FUNCTION throws_like ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - EXECUTE _query($1); - RETURN ok( FALSE, $3 ) || E'\n' || diag( ' no exception thrown' ); -EXCEPTION WHEN OTHERS THEN - return _tlike( SQLERRM ~~ $2, SQLERRM, $2, $3 ); -END; -$$ LANGUAGE plpgsql; - --- throws_like ( sql, pattern ) -CREATE OR REPLACE FUNCTION throws_like ( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_like($1, $2, 'Should throw exception like ' || quote_literal($2) ); -$$ LANGUAGE sql; - --- throws_ilike ( sql, pattern, description ) -CREATE OR REPLACE FUNCTION throws_ilike ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - EXECUTE _query($1); - RETURN ok( FALSE, $3 ) || E'\n' || diag( ' no exception thrown' ); -EXCEPTION WHEN OTHERS THEN - return _tlike( SQLERRM ~~* $2, SQLERRM, $2, $3 ); -END; -$$ LANGUAGE plpgsql; - --- throws_ilike ( sql, pattern ) -CREATE OR REPLACE FUNCTION throws_ilike ( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_ilike($1, $2, 'Should throw exception like ' || quote_literal($2) ); -$$ LANGUAGE sql; - --- throws_matching ( sql, pattern, description ) -CREATE OR REPLACE FUNCTION throws_matching ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - EXECUTE _query($1); - RETURN ok( FALSE, $3 ) || E'\n' || diag( ' no exception thrown' ); -EXCEPTION WHEN OTHERS THEN - return _tlike( SQLERRM ~ $2, SQLERRM, $2, $3 ); -END; -$$ LANGUAGE plpgsql; - --- throws_matching ( sql, pattern ) -CREATE OR REPLACE FUNCTION throws_matching ( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_matching($1, $2, 'Should throw exception matching ' || quote_literal($2) ); -$$ LANGUAGE sql; - --- throws_imatching ( sql, pattern, description ) -CREATE OR REPLACE FUNCTION throws_imatching ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - EXECUTE _query($1); - RETURN ok( FALSE, $3 ) || E'\n' || diag( ' no exception thrown' ); -EXCEPTION WHEN OTHERS THEN - return _tlike( SQLERRM ~* $2, SQLERRM, $2, $3 ); -END; -$$ LANGUAGE plpgsql; - --- throws_imatching ( sql, pattern ) -CREATE OR REPLACE FUNCTION throws_imatching ( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT throws_imatching($1, $2, 'Should throw exception matching ' || quote_literal($2) ); -$$ LANGUAGE sql; - --- roles_are( roles[], description ) -CREATE OR REPLACE FUNCTION _dexists ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_type t on n.oid = t.typnamespace - WHERE n.nspname = $1 - AND t.typname = $2 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _dexists ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_type t - WHERE t.typname = $1 - AND pg_catalog.pg_type_is_visible(t.oid) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_dtype( NAME, TEXT, BOOLEAN ) -RETURNS TEXT AS $$ - SELECT CASE WHEN $3 AND pg_catalog.pg_type_is_visible(t.oid) - THEN quote_ident(tn.nspname) || '.' - ELSE '' - END || pg_catalog.format_type(t.oid, t.typtypmod) - FROM pg_catalog.pg_type d - JOIN pg_catalog.pg_namespace dn ON d.typnamespace = dn.oid - JOIN pg_catalog.pg_type t ON d.typbasetype = t.oid - JOIN pg_catalog.pg_namespace tn ON t.typnamespace = tn.oid - WHERE d.typisdefined - AND dn.nspname = $1 - AND d.typname = LOWER($2) - AND d.typtype = 'd' -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_dtype( NAME ) -RETURNS TEXT AS $$ - SELECT pg_catalog.format_type(t.oid, t.typtypmod) - FROM pg_catalog.pg_type d - JOIN pg_catalog.pg_type t ON d.typbasetype = t.oid - WHERE d.typisdefined - AND pg_catalog.pg_type_is_visible(d.oid) - AND d.typname = LOWER($1) - AND d.typtype = 'd' -$$ LANGUAGE sql; - --- domain_type_is( schema, domain, schema, type, description ) -CREATE OR REPLACE FUNCTION domain_type_is( NAME, TEXT, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1, $2, true); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $5 ) || E'\n' || diag ( - ' Domain ' || quote_ident($1) || '.' || $2 - || ' does not exist' - ); - END IF; - - RETURN is( actual_type, quote_ident($3) || '.' || _quote_ident_like($4, actual_type), $5 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_is( schema, domain, schema, type ) -CREATE OR REPLACE FUNCTION domain_type_is( NAME, TEXT, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_is( - $1, $2, $3, $4, - 'Domain ' || quote_ident($1) || '.' || $2 - || ' should extend type ' || quote_ident($3) || '.' || $4 - ); -$$ LANGUAGE SQL; - --- domain_type_is( schema, domain, type, description ) -CREATE OR REPLACE FUNCTION domain_type_is( NAME, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1, $2, false); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $4 ) || E'\n' || diag ( - ' Domain ' || quote_ident($1) || '.' || $2 - || ' does not exist' - ); - END IF; - - RETURN is( actual_type, _quote_ident_like($3, actual_type), $4 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_is( schema, domain, type ) -CREATE OR REPLACE FUNCTION domain_type_is( NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_is( - $1, $2, $3, - 'Domain ' || quote_ident($1) || '.' || $2 - || ' should extend type ' || $3 - ); -$$ LANGUAGE SQL; - --- domain_type_is( domain, type, description ) -CREATE OR REPLACE FUNCTION domain_type_is( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $3 ) || E'\n' || diag ( - ' Domain ' || $1 || ' does not exist' - ); - END IF; - - RETURN is( actual_type, _quote_ident_like($2, actual_type), $3 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_is( domain, type ) -CREATE OR REPLACE FUNCTION domain_type_is( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_is( - $1, $2, - 'Domain ' || $1 || ' should extend type ' || $2 - ); -$$ LANGUAGE SQL; - --- domain_type_isnt( schema, domain, schema, type, description ) -CREATE OR REPLACE FUNCTION domain_type_isnt( NAME, TEXT, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1, $2, true); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $5 ) || E'\n' || diag ( - ' Domain ' || quote_ident($1) || '.' || $2 - || ' does not exist' - ); - END IF; - - RETURN isnt( actual_type, quote_ident($3) || '.' || _quote_ident_like($4, actual_type), $5 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_isnt( schema, domain, schema, type ) -CREATE OR REPLACE FUNCTION domain_type_isnt( NAME, TEXT, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_isnt( - $1, $2, $3, $4, - 'Domain ' || quote_ident($1) || '.' || $2 - || ' should not extend type ' || quote_ident($3) || '.' || $4 - ); -$$ LANGUAGE SQL; - --- domain_type_isnt( schema, domain, type, description ) -CREATE OR REPLACE FUNCTION domain_type_isnt( NAME, TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1, $2, false); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $4 ) || E'\n' || diag ( - ' Domain ' || quote_ident($1) || '.' || $2 - || ' does not exist' - ); - END IF; - - RETURN isnt( actual_type, _quote_ident_like($3, actual_type), $4 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_isnt( schema, domain, type ) -CREATE OR REPLACE FUNCTION domain_type_isnt( NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_isnt( - $1, $2, $3, - 'Domain ' || quote_ident($1) || '.' || $2 - || ' should not extend type ' || $3 - ); -$$ LANGUAGE SQL; - --- domain_type_isnt( domain, type, description ) -CREATE OR REPLACE FUNCTION domain_type_isnt( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - actual_type TEXT := _get_dtype($1); -BEGIN - IF actual_type IS NULL THEN - RETURN fail( $3 ) || E'\n' || diag ( - ' Domain ' || $1 || ' does not exist' - ); - END IF; - - RETURN isnt( actual_type, _quote_ident_like($2, actual_type), $3 ); -END; -$$ LANGUAGE plpgsql; - --- domain_type_isnt( domain, type ) -CREATE OR REPLACE FUNCTION domain_type_isnt( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT domain_type_isnt( - $1, $2, - 'Domain ' || $1 || ' should not extend type ' || $2 - ); -$$ LANGUAGE SQL; - --- row_eq( sql, record, description ) -CREATE OR REPLACE FUNCTION row_eq( TEXT, anyelement, TEXT ) -RETURNS TEXT AS $$ -DECLARE - rec RECORD; -BEGIN - EXECUTE _query($1) INTO rec; - IF NOT rec::text IS DISTINCT FROM $2::text THEN RETURN ok(true, $3); END IF; - RETURN ok(false, $3 ) || E'\n' || diag( - ' have: ' || CASE WHEN rec IS NULL THEN 'NULL' ELSE rec::text END || - E'\n want: ' || CASE WHEN $2 IS NULL THEN 'NULL' ELSE $2::text END - ); -END; -$$ LANGUAGE plpgsql; - --- row_eq( sql, record ) -CREATE OR REPLACE FUNCTION row_eq( TEXT, anyelement ) -RETURNS TEXT AS $$ - SELECT row_eq($1, $2, NULL ); -$$ LANGUAGE sql; - --- triggers_are( schema, table, triggers[], description ) diff --git a/test/helpers/pgtap-schema.sql b/test/helpers/pgtap-schema.sql deleted file mode 100644 index 9bf5bbd..0000000 --- a/test/helpers/pgtap-schema.sql +++ /dev/null @@ -1,5631 +0,0 @@ - --- This file defines pgTAP Schema, a portable collection of schema-testing --- functions for TAP-based unit testing on PostgreSQL 8.3 or higher. It is --- distributed under the revised FreeBSD license. The home page for the pgTAP --- project is: - --- --- http://pgtap.org/ --- - --- Requires pgtap-core.sql --- -CREATE OR REPLACE FUNCTION _relexists ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _relexists ( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_class c - WHERE pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $1 - ); -$$ LANGUAGE SQL; - --- has_relation( schema, relation, description ) -CREATE OR REPLACE FUNCTION has_relation ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _relexists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_relation( relation, description ) -CREATE OR REPLACE FUNCTION has_relation ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _relexists( $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_relation( relation ) -CREATE OR REPLACE FUNCTION has_relation ( NAME ) -RETURNS TEXT AS $$ - SELECT has_relation( $1, 'Relation ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_relation( schema, relation, description ) -CREATE OR REPLACE FUNCTION hasnt_relation ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _relexists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_relation( relation, description ) -CREATE OR REPLACE FUNCTION hasnt_relation ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _relexists( $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_relation( relation ) -CREATE OR REPLACE FUNCTION hasnt_relation ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_relation( $1, 'Relation ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _rexists ( CHAR, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE c.relkind = $1 - AND n.nspname = $2 - AND c.relname = $3 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _rexists ( CHAR, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_class c - WHERE c.relkind = $1 - AND pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $2 - ); -$$ LANGUAGE SQL; - --- has_table( schema, table, description ) -CREATE OR REPLACE FUNCTION has_table ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'r', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_table( schema, table ) -CREATE OR REPLACE FUNCTION has_table ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _rexists( 'r', $1, $2 ), - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_table( table, description ) -CREATE OR REPLACE FUNCTION has_table ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'r', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_table( table ) -CREATE OR REPLACE FUNCTION has_table ( NAME ) -RETURNS TEXT AS $$ - SELECT has_table( $1, 'Table ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_table( schema, table, description ) -CREATE OR REPLACE FUNCTION hasnt_table ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'r', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_table( schema, table ) -CREATE OR REPLACE FUNCTION hasnt_table ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _rexists( 'r', $1, $2 ), - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' - ); -$$ LANGUAGE SQL; - --- hasnt_table( table, description ) -CREATE OR REPLACE FUNCTION hasnt_table ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'r', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_table( table ) -CREATE OR REPLACE FUNCTION hasnt_table ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_table( $1, 'Table ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - --- has_view( schema, view, description ) -CREATE OR REPLACE FUNCTION has_view ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'v', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_view( view, description ) -CREATE OR REPLACE FUNCTION has_view ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'v', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_view( view ) -CREATE OR REPLACE FUNCTION has_view ( NAME ) -RETURNS TEXT AS $$ - SELECT has_view( $1, 'View ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_view( schema, view, description ) -CREATE OR REPLACE FUNCTION hasnt_view ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'v', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_view( view, description ) -CREATE OR REPLACE FUNCTION hasnt_view ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'v', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_view( view ) -CREATE OR REPLACE FUNCTION hasnt_view ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_view( $1, 'View ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - --- has_sequence( schema, sequence, description ) -CREATE OR REPLACE FUNCTION has_sequence ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'S', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_sequence( schema, sequence ) -CREATE OR REPLACE FUNCTION has_sequence ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _rexists( 'S', $1, $2 ), - 'Sequence ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_sequence( sequence, description ) -CREATE OR REPLACE FUNCTION has_sequence ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'S', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_sequence( sequence ) -CREATE OR REPLACE FUNCTION has_sequence ( NAME ) -RETURNS TEXT AS $$ - SELECT has_sequence( $1, 'Sequence ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_sequence( schema, sequence, description ) -CREATE OR REPLACE FUNCTION hasnt_sequence ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'S', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_sequence( sequence, description ) -CREATE OR REPLACE FUNCTION hasnt_sequence ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'S', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_sequence( sequence ) -CREATE OR REPLACE FUNCTION hasnt_sequence ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_sequence( $1, 'Sequence ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - --- has_foreign_table( schema, table, description ) -CREATE OR REPLACE FUNCTION has_foreign_table ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'f', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_foreign_table( schema, table ) -CREATE OR REPLACE FUNCTION has_foreign_table ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - _rexists( 'f', $1, $2 ), - 'Foreign table ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' - ); -$$ LANGUAGE SQL; - --- has_foreign_table( table, description ) -CREATE OR REPLACE FUNCTION has_foreign_table ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'f', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_foreign_table( table ) -CREATE OR REPLACE FUNCTION has_foreign_table ( NAME ) -RETURNS TEXT AS $$ - SELECT has_foreign_table( $1, 'Foreign table ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_foreign_table( schema, table, description ) -CREATE OR REPLACE FUNCTION hasnt_foreign_table ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'f', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_foreign_table( schema, table ) -CREATE OR REPLACE FUNCTION hasnt_foreign_table ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _rexists( 'f', $1, $2 ), - 'Foreign table ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' - ); -$$ LANGUAGE SQL; - --- hasnt_foreign_table( table, description ) -CREATE OR REPLACE FUNCTION hasnt_foreign_table ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'f', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_foreign_table( table ) -CREATE OR REPLACE FUNCTION hasnt_foreign_table ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_foreign_table( $1, 'Foreign table ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - --- has_composite( schema, type, description ) -CREATE OR REPLACE FUNCTION has_composite ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'c', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_composite( type, description ) -CREATE OR REPLACE FUNCTION has_composite ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'c', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_composite( type ) -CREATE OR REPLACE FUNCTION has_composite ( NAME ) -RETURNS TEXT AS $$ - SELECT has_composite( $1, 'Composite type ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_composite( schema, type, description ) -CREATE OR REPLACE FUNCTION hasnt_composite ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'c', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_composite( type, description ) -CREATE OR REPLACE FUNCTION hasnt_composite ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'c', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_composite( type ) -CREATE OR REPLACE FUNCTION hasnt_composite ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_composite( $1, 'Composite type ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _cexists ( NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $3 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _cexists ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE c.relname = $1 - AND pg_catalog.pg_table_is_visible(c.oid) - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $2 - ); -$$ LANGUAGE SQL; - --- has_column( schema, table, column, description ) -CREATE OR REPLACE FUNCTION has_column ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _cexists( $1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- has_column( table, column, description ) -CREATE OR REPLACE FUNCTION has_column ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _cexists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_column( table, column ) -CREATE OR REPLACE FUNCTION has_column ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_column( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_column( schema, table, column, description ) -CREATE OR REPLACE FUNCTION hasnt_column ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _cexists( $1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- hasnt_column( table, column, description ) -CREATE OR REPLACE FUNCTION hasnt_column ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _cexists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_column( table, column ) -CREATE OR REPLACE FUNCTION hasnt_column ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_column( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should not exist' ); -$$ LANGUAGE SQL; - --- _col_is_null( schema, table, column, desc, null ) -CREATE OR REPLACE FUNCTION _col_is_null ( NAME, NAME, NAME, TEXT, bool ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2, $3 ) THEN - RETURN fail( $4 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' ); - END IF; - RETURN ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $3 - AND a.attnotnull = $5 - ), $4 - ); -END; -$$ LANGUAGE plpgsql; - --- _col_is_null( table, column, desc, null ) -CREATE OR REPLACE FUNCTION _col_is_null ( NAME, NAME, TEXT, bool ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2 ) THEN - RETURN fail( $3 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' ); - END IF; - RETURN ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $1 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $2 - AND a.attnotnull = $4 - ), $3 - ); -END; -$$ LANGUAGE plpgsql; - --- col_not_null( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_not_null ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, $3, $4, true ); -$$ LANGUAGE SQL; - --- col_not_null( table, column, description ) -CREATE OR REPLACE FUNCTION col_not_null ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, $3, true ); -$$ LANGUAGE SQL; - --- col_not_null( table, column ) -CREATE OR REPLACE FUNCTION col_not_null ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should be NOT NULL', true ); -$$ LANGUAGE SQL; - --- col_is_null( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_null ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, $3, $4, false ); -$$ LANGUAGE SQL; - --- col_is_null( schema, table, column ) -CREATE OR REPLACE FUNCTION col_is_null ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, $3, false ); -$$ LANGUAGE SQL; - --- col_is_null( table, column ) -CREATE OR REPLACE FUNCTION col_is_null ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT _col_is_null( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should allow NULL', false ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_col_type ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attname = $3 - AND attnum > 0 - AND NOT a.attisdropped -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_col_type ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - WHERE pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $1 - AND a.attname = $2 - AND attnum > 0 - AND NOT a.attisdropped -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_col_ns_type ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - -- Always include the namespace. - SELECT CASE WHEN pg_catalog.pg_type_is_visible(t.oid) - THEN quote_ident(tn.nspname) || '.' - ELSE '' - END || pg_catalog.format_type(a.atttypid, a.atttypmod) - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - JOIN pg_catalog.pg_type t ON a.atttypid = t.oid - JOIN pg_catalog.pg_namespace tn ON t.typnamespace = tn.oid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attname = $3 - AND attnum > 0 - AND NOT a.attisdropped -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _quote_ident_like(TEXT, TEXT) -RETURNS TEXT AS $$ -DECLARE - have TEXT; - pcision TEXT; -BEGIN - -- Just return it if rhs isn't quoted. - IF $2 !~ '"' THEN RETURN $1; END IF; - - -- If it's quoted ident without precision, return it quoted. - IF $2 ~ '"$' THEN RETURN quote_ident($1); END IF; - - pcision := substring($1 FROM '[(][^")]+[)]$'); - - -- Just quote it if thre is no precision. - if pcision IS NULL THEN RETURN quote_ident($1); END IF; - - -- Quote the non-precision part and concatenate with precision. - RETURN quote_ident(substring($1 FOR char_length($1) - char_length(pcision))) - || pcision; -END; -$$ LANGUAGE plpgsql; - --- col_type_is( schema, table, column, schema, type, description ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have_type TEXT := _get_col_ns_type($1, $2, $3); - want_type TEXT; -BEGIN - IF have_type IS NULL THEN - RETURN fail( $6 ) || E'\n' || diag ( - ' Column ' || COALESCE(quote_ident($1) || '.', '') - || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' - ); - END IF; - - want_type := quote_ident($4) || '.' || _quote_ident_like($5, have_type); - IF have_type = want_type THEN - -- We're good to go. - RETURN ok( true, $6 ); - END IF; - - -- Wrong data type. tell 'em what we really got. - RETURN ok( false, $6 ) || E'\n' || diag( - ' have: ' || have_type || - E'\n want: ' || want_type - ); -END; -$$ LANGUAGE plpgsql; - --- col_type_is( schema, table, column, schema, type ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_type_is( $1, $2, $3, $4, $5, 'Column ' || quote_ident($1) || '.' || quote_ident($2) - || '.' || quote_ident($3) || ' should be type ' || quote_ident($4) || '.' || $5); -$$ LANGUAGE SQL; - --- col_type_is( schema, table, column, type, description ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - have_type TEXT; - want_type TEXT; -BEGIN - -- Get the data type. - IF $1 IS NULL THEN - have_type := _get_col_type($2, $3); - ELSE - have_type := _get_col_type($1, $2, $3); - END IF; - - IF have_type IS NULL THEN - RETURN fail( $5 ) || E'\n' || diag ( - ' Column ' || COALESCE(quote_ident($1) || '.', '') - || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' - ); - END IF; - - want_type := _quote_ident_like($4, have_type); - IF have_type = want_type THEN - -- We're good to go. - RETURN ok( true, $5 ); - END IF; - - -- Wrong data type. tell 'em what we really got. - RETURN ok( false, $5 ) || E'\n' || diag( - ' have: ' || have_type || - E'\n want: ' || want_type - ); -END; -$$ LANGUAGE plpgsql; - --- col_type_is( schema, table, column, type ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_type_is( $1, $2, $3, $4, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' should be type ' || $4 ); -$$ LANGUAGE SQL; - --- col_type_is( table, column, type, description ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT col_type_is( NULL, $1, $2, $3, $4 ); -$$ LANGUAGE SQL; - --- col_type_is( table, column, type ) -CREATE OR REPLACE FUNCTION col_type_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_type_is( $1, $2, $3, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should be type ' || $3 ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _has_def ( NAME, NAME, NAME ) -RETURNS boolean AS $$ - SELECT a.atthasdef - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $3 -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_def ( NAME, NAME ) -RETURNS boolean AS $$ - SELECT a.atthasdef - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE c.relname = $1 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $2 - AND pg_catalog.pg_table_is_visible(c.oid) -$$ LANGUAGE sql; - --- col_has_default( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_has_default ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2, $3 ) THEN - RETURN fail( $4 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' ); - END IF; - RETURN ok( _has_def( $1, $2, $3 ), $4 ); -END -$$ LANGUAGE plpgsql; - --- col_has_default( table, column, description ) -CREATE OR REPLACE FUNCTION col_has_default ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2 ) THEN - RETURN fail( $3 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' ); - END IF; - RETURN ok( _has_def( $1, $2 ), $3 ); -END; -$$ LANGUAGE plpgsql; - --- col_has_default( table, column ) -CREATE OR REPLACE FUNCTION col_has_default ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_has_default( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should have a default' ); -$$ LANGUAGE SQL; - --- col_hasnt_default( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_hasnt_default ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2, $3 ) THEN - RETURN fail( $4 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' ); - END IF; - RETURN ok( NOT _has_def( $1, $2, $3 ), $4 ); -END; -$$ LANGUAGE plpgsql; - --- col_hasnt_default( table, column, description ) -CREATE OR REPLACE FUNCTION col_hasnt_default ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2 ) THEN - RETURN fail( $3 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' ); - END IF; - RETURN ok( NOT _has_def( $1, $2 ), $3 ); -END; -$$ LANGUAGE plpgsql; - --- col_hasnt_default( table, column ) -CREATE OR REPLACE FUNCTION col_hasnt_default ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_hasnt_default( $1, $2, 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should not have a default' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _def_is( TEXT, TEXT, anyelement, TEXT ) -RETURNS TEXT AS $$ -DECLARE - thing text; -BEGIN - IF $1 ~ '^[^'']+[(]' THEN - -- It's a functional default. - RETURN is( $1, $3, $4 ); - END IF; - - EXECUTE 'SELECT is(' - || COALESCE($1, 'NULL' || '::' || $2) || '::' || $2 || ', ' - || COALESCE(quote_literal($3), 'NULL') || '::' || $2 || ', ' - || COALESCE(quote_literal($4), 'NULL') - || ')' INTO thing; - RETURN thing; -END; -$$ LANGUAGE plpgsql; - --- _cdi( schema, table, column, default, description ) -CREATE OR REPLACE FUNCTION _cdi ( NAME, NAME, NAME, anyelement, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2, $3 ) THEN - RETURN fail( $5 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' does not exist' ); - END IF; - - IF NOT _has_def( $1, $2, $3 ) THEN - RETURN fail( $5 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) || ' has no default' ); - END IF; - - RETURN _def_is( - pg_catalog.pg_get_expr(d.adbin, d.adrelid), - pg_catalog.format_type(a.atttypid, a.atttypmod), - $4, $5 - ) - FROM pg_catalog.pg_namespace n, pg_catalog.pg_class c, pg_catalog.pg_attribute a, - pg_catalog.pg_attrdef d - WHERE n.oid = c.relnamespace - AND c.oid = a.attrelid - AND a.atthasdef - AND a.attrelid = d.adrelid - AND a.attnum = d.adnum - AND n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $3; -END; -$$ LANGUAGE plpgsql; - --- _cdi( table, column, default, description ) -CREATE OR REPLACE FUNCTION _cdi ( NAME, NAME, anyelement, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF NOT _cexists( $1, $2 ) THEN - RETURN fail( $4 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' ); - END IF; - - IF NOT _has_def( $1, $2 ) THEN - RETURN fail( $4 ) || E'\n' - || diag (' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' has no default' ); - END IF; - - RETURN _def_is( - pg_catalog.pg_get_expr(d.adbin, d.adrelid), - pg_catalog.format_type(a.atttypid, a.atttypmod), - $3, $4 - ) - FROM pg_catalog.pg_class c, pg_catalog.pg_attribute a, pg_catalog.pg_attrdef d - WHERE c.oid = a.attrelid - AND pg_table_is_visible(c.oid) - AND a.atthasdef - AND a.attrelid = d.adrelid - AND a.attnum = d.adnum - AND c.relname = $1 - AND a.attnum > 0 - AND NOT a.attisdropped - AND a.attname = $2; -END; -$$ LANGUAGE plpgsql; - --- _cdi( table, column, default ) -CREATE OR REPLACE FUNCTION _cdi ( NAME, NAME, anyelement ) -RETURNS TEXT AS $$ - SELECT col_default_is( - $1, $2, $3, - 'Column ' || quote_ident($1) || '.' || quote_ident($2) || ' should default to ' - || COALESCE( quote_literal($3), 'NULL') - ); -$$ LANGUAGE sql; - --- col_default_is( schema, table, column, default, description ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, NAME, anyelement, TEXT ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3, $4, $5 ); -$$ LANGUAGE sql; - --- col_default_is( schema, table, column, default, description ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3, $4, $5 ); -$$ LANGUAGE sql; - --- col_default_is( table, column, default, description ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, anyelement, TEXT ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3, $4 ); -$$ LANGUAGE sql; - --- col_default_is( table, column, default, description ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3, $4 ); -$$ LANGUAGE sql; - --- col_default_is( table, column, default ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, anyelement ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3 ); -$$ LANGUAGE sql; - --- col_default_is( table, column, default::text ) -CREATE OR REPLACE FUNCTION col_default_is ( NAME, NAME, text ) -RETURNS TEXT AS $$ - SELECT _cdi( $1, $2, $3 ); -$$ LANGUAGE sql; - --- _hasc( schema, table, constraint_type ) -CREATE OR REPLACE FUNCTION _hasc ( NAME, NAME, CHAR ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid - JOIN pg_catalog.pg_constraint x ON c.oid = x.conrelid - WHERE c.relhaspkey = true - AND n.nspname = $1 - AND c.relname = $2 - AND x.contype = $3 - ); -$$ LANGUAGE sql; - --- _hasc( table, constraint_type ) -CREATE OR REPLACE FUNCTION _hasc ( NAME, CHAR ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_constraint x ON c.oid = x.conrelid - WHERE c.relhaspkey = true - AND pg_table_is_visible(c.oid) - AND c.relname = $1 - AND x.contype = $2 - ); -$$ LANGUAGE sql; - --- has_pk( schema, table, description ) -CREATE OR REPLACE FUNCTION has_pk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, $2, 'p' ), $3 ); -$$ LANGUAGE sql; - --- has_pk( table, description ) -CREATE OR REPLACE FUNCTION has_pk ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, 'p' ), $2 ); -$$ LANGUAGE sql; - --- has_pk( table ) -CREATE OR REPLACE FUNCTION has_pk ( NAME ) -RETURNS TEXT AS $$ - SELECT has_pk( $1, 'Table ' || quote_ident($1) || ' should have a primary key' ); -$$ LANGUAGE sql; - --- hasnt_pk( schema, table, description ) -CREATE OR REPLACE FUNCTION hasnt_pk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _hasc( $1, $2, 'p' ), $3 ); -$$ LANGUAGE sql; - --- hasnt_pk( table, description ) -CREATE OR REPLACE FUNCTION hasnt_pk ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _hasc( $1, 'p' ), $2 ); -$$ LANGUAGE sql; - --- hasnt_pk( table ) -CREATE OR REPLACE FUNCTION hasnt_pk ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_pk( $1, 'Table ' || quote_ident($1) || ' should not have a primary key' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _pg_sv_column_array( OID, SMALLINT[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT a.attname - FROM pg_catalog.pg_attribute a - JOIN generate_series(1, array_upper($2, 1)) s(i) ON a.attnum = $2[i] - WHERE attrelid = $1 - ORDER BY i - ) -$$ LANGUAGE SQL stable; - --- Borrowed from newsysviews: http://pgfoundry.org/projects/newsysviews/ -CREATE OR REPLACE FUNCTION _pg_sv_table_accessible( OID, OID ) -RETURNS BOOLEAN AS $$ - SELECT CASE WHEN has_schema_privilege($1, 'USAGE') THEN ( - has_table_privilege($2, 'SELECT') - OR has_table_privilege($2, 'INSERT') - or has_table_privilege($2, 'UPDATE') - OR has_table_privilege($2, 'DELETE') - OR has_table_privilege($2, 'RULE') - OR has_table_privilege($2, 'REFERENCES') - OR has_table_privilege($2, 'TRIGGER') - ) ELSE FALSE - END; -$$ LANGUAGE SQL immutable strict; - --- Borrowed from newsysviews: http://pgfoundry.org/projects/newsysviews/ -CREATE OR REPLACE VIEW pg_all_foreign_keys -AS - SELECT n1.nspname AS fk_schema_name, - c1.relname AS fk_table_name, - k1.conname AS fk_constraint_name, - c1.oid AS fk_table_oid, - _pg_sv_column_array(k1.conrelid,k1.conkey) AS fk_columns, - n2.nspname AS pk_schema_name, - c2.relname AS pk_table_name, - k2.conname AS pk_constraint_name, - c2.oid AS pk_table_oid, - ci.relname AS pk_index_name, - _pg_sv_column_array(k1.confrelid,k1.confkey) AS pk_columns, - CASE k1.confmatchtype WHEN 'f' THEN 'FULL' - WHEN 'p' THEN 'PARTIAL' - WHEN 'u' THEN 'NONE' - else null - END AS match_type, - CASE k1.confdeltype WHEN 'a' THEN 'NO ACTION' - WHEN 'c' THEN 'CASCADE' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'n' THEN 'SET NULL' - WHEN 'r' THEN 'RESTRICT' - else null - END AS on_delete, - CASE k1.confupdtype WHEN 'a' THEN 'NO ACTION' - WHEN 'c' THEN 'CASCADE' - WHEN 'd' THEN 'SET DEFAULT' - WHEN 'n' THEN 'SET NULL' - WHEN 'r' THEN 'RESTRICT' - ELSE NULL - END AS on_update, - k1.condeferrable AS is_deferrable, - k1.condeferred AS is_deferred - FROM pg_catalog.pg_constraint k1 - JOIN pg_catalog.pg_namespace n1 ON (n1.oid = k1.connamespace) - JOIN pg_catalog.pg_class c1 ON (c1.oid = k1.conrelid) - JOIN pg_catalog.pg_class c2 ON (c2.oid = k1.confrelid) - JOIN pg_catalog.pg_namespace n2 ON (n2.oid = c2.relnamespace) - JOIN pg_catalog.pg_depend d ON ( - d.classid = 'pg_constraint'::regclass - AND d.objid = k1.oid - AND d.objsubid = 0 - AND d.deptype = 'n' - AND d.refclassid = 'pg_class'::regclass - AND d.refobjsubid=0 - ) - JOIN pg_catalog.pg_class ci ON (ci.oid = d.refobjid AND ci.relkind = 'i') - LEFT JOIN pg_depend d2 ON ( - d2.classid = 'pg_class'::regclass - AND d2.objid = ci.oid - AND d2.objsubid = 0 - AND d2.deptype = 'i' - AND d2.refclassid = 'pg_constraint'::regclass - AND d2.refobjsubid = 0 - ) - LEFT JOIN pg_catalog.pg_constraint k2 ON ( - k2.oid = d2.refobjid - AND k2.contype IN ('p', 'u') - ) - WHERE k1.conrelid != 0 - AND k1.confrelid != 0 - AND k1.contype = 'f' - AND _pg_sv_table_accessible(n1.oid, c1.oid); - --- _keys( schema, table, constraint_type ) -CREATE OR REPLACE FUNCTION _keys ( NAME, NAME, CHAR ) -RETURNS SETOF NAME[] AS $$ - SELECT _pg_sv_column_array(x.conrelid,x.conkey) - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_constraint x ON c.oid = x.conrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND x.contype = $3 -$$ LANGUAGE sql; - --- _keys( table, constraint_type ) -CREATE OR REPLACE FUNCTION _keys ( NAME, CHAR ) -RETURNS SETOF NAME[] AS $$ - SELECT _pg_sv_column_array(x.conrelid,x.conkey) - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_constraint x ON c.oid = x.conrelid - AND c.relname = $1 - AND x.contype = $2 - WHERE pg_catalog.pg_table_is_visible(c.oid) -$$ LANGUAGE sql; - --- _ckeys( schema, table, constraint_type ) -CREATE OR REPLACE FUNCTION _ckeys ( NAME, NAME, CHAR ) -RETURNS NAME[] AS $$ - SELECT * FROM _keys($1, $2, $3) LIMIT 1; -$$ LANGUAGE sql; - --- _ckeys( table, constraint_type ) -CREATE OR REPLACE FUNCTION _ckeys ( NAME, CHAR ) -RETURNS NAME[] AS $$ - SELECT * FROM _keys($1, $2) LIMIT 1; -$$ LANGUAGE sql; - --- col_is_pk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT is( _ckeys( $1, $2, 'p' ), $3, $4 ); -$$ LANGUAGE sql; - --- col_is_pk( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT is( _ckeys( $1, 'p' ), $2, $3 ); -$$ LANGUAGE sql; - --- col_is_pk( table, column[] ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_is_pk( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should be a primary key' ); -$$ LANGUAGE sql; - --- col_is_pk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_pk( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_is_pk( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_pk( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_is_pk( table, column ) -CREATE OR REPLACE FUNCTION col_is_pk ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_is_pk( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should be a primary key' ); -$$ LANGUAGE sql; - --- col_isnt_pk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT isnt( _ckeys( $1, $2, 'p' ), $3, $4 ); -$$ LANGUAGE sql; - --- col_isnt_pk( table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT isnt( _ckeys( $1, 'p' ), $2, $3 ); -$$ LANGUAGE sql; - --- col_isnt_pk( table, column[] ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_isnt_pk( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should not be a primary key' ); -$$ LANGUAGE sql; - --- col_isnt_pk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_isnt_pk( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_isnt_pk( table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_isnt_pk( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_isnt_pk( table, column ) -CREATE OR REPLACE FUNCTION col_isnt_pk ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_isnt_pk( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should not be a primary key' ); -$$ LANGUAGE sql; - --- has_fk( schema, table, description ) -CREATE OR REPLACE FUNCTION has_fk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, $2, 'f' ), $3 ); -$$ LANGUAGE sql; - --- has_fk( table, description ) -CREATE OR REPLACE FUNCTION has_fk ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, 'f' ), $2 ); -$$ LANGUAGE sql; - --- has_fk( table ) -CREATE OR REPLACE FUNCTION has_fk ( NAME ) -RETURNS TEXT AS $$ - SELECT has_fk( $1, 'Table ' || quote_ident($1) || ' should have a foreign key constraint' ); -$$ LANGUAGE sql; - --- hasnt_fk( schema, table, description ) -CREATE OR REPLACE FUNCTION hasnt_fk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _hasc( $1, $2, 'f' ), $3 ); -$$ LANGUAGE sql; - --- hasnt_fk( table, description ) -CREATE OR REPLACE FUNCTION hasnt_fk ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _hasc( $1, 'f' ), $2 ); -$$ LANGUAGE sql; - --- hasnt_fk( table ) -CREATE OR REPLACE FUNCTION hasnt_fk ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_fk( $1, 'Table ' || quote_ident($1) || ' should not have a foreign key constraint' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _fkexists ( NAME, NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT TRUE - FROM pg_all_foreign_keys - WHERE fk_schema_name = $1 - AND quote_ident(fk_table_name) = quote_ident($2) - AND fk_columns = $3 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _fkexists ( NAME, NAME[] ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT TRUE - FROM pg_all_foreign_keys - WHERE quote_ident(fk_table_name) = quote_ident($1) - AND pg_catalog.pg_table_is_visible(fk_table_oid) - AND fk_columns = $2 - ); -$$ LANGUAGE SQL; - --- col_is_fk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - names text[]; -BEGIN - IF _fkexists($1, $2, $3) THEN - RETURN pass( $4 ); - END IF; - - -- Try to show the columns. - SELECT ARRAY( - SELECT _ident_array_to_string(fk_columns, ', ') - FROM pg_all_foreign_keys - WHERE fk_schema_name = $1 - AND fk_table_name = $2 - ORDER BY fk_columns - ) INTO names; - - IF names[1] IS NOT NULL THEN - RETURN fail($4) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || E' has foreign key constraints on these columns:\n ' - || array_to_string( names, E'\n ' ) - ); - END IF; - - -- No FKs in this table. - RETURN fail($4) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' has no foreign key columns' - ); -END; -$$ LANGUAGE plpgsql; - --- col_is_fk( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - names text[]; -BEGIN - IF _fkexists($1, $2) THEN - RETURN pass( $3 ); - END IF; - - -- Try to show the columns. - SELECT ARRAY( - SELECT _ident_array_to_string(fk_columns, ', ') - FROM pg_all_foreign_keys - WHERE fk_table_name = $1 - ORDER BY fk_columns - ) INTO names; - - IF NAMES[1] IS NOT NULL THEN - RETURN fail($3) || E'\n' || diag( - ' Table ' || quote_ident($1) || E' has foreign key constraints on these columns:\n ' - || array_to_string( names, E'\n ' ) - ); - END IF; - - -- No FKs in this table. - RETURN fail($3) || E'\n' || diag( - ' Table ' || quote_ident($1) || ' has no foreign key columns' - ); -END; -$$ LANGUAGE plpgsql; - --- col_is_fk( table, column[] ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_is_fk( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should be a foreign key' ); -$$ LANGUAGE sql; - --- col_is_fk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_fk( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_is_fk( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_fk( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_is_fk( table, column ) -CREATE OR REPLACE FUNCTION col_is_fk ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_is_fk( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should be a foreign key' ); -$$ LANGUAGE sql; - --- col_isnt_fk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _fkexists( $1, $2, $3 ), $4 ); -$$ LANGUAGE SQL; - --- col_isnt_fk( table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _fkexists( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- col_isnt_fk( table, column[] ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_isnt_fk( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should not be a foreign key' ); -$$ LANGUAGE sql; - --- col_isnt_fk( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_isnt_fk( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_isnt_fk( table, column, description ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_isnt_fk( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_isnt_fk( table, column ) -CREATE OR REPLACE FUNCTION col_isnt_fk ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_isnt_fk( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should not be a foreign key' ); -$$ LANGUAGE sql; - --- has_unique( schema, table, description ) -CREATE OR REPLACE FUNCTION has_unique ( TEXT, TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, $2, 'u' ), $3 ); -$$ LANGUAGE sql; - --- has_unique( table, description ) -CREATE OR REPLACE FUNCTION has_unique ( TEXT, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, 'u' ), $2 ); -$$ LANGUAGE sql; - --- has_unique( table ) -CREATE OR REPLACE FUNCTION has_unique ( TEXT ) -RETURNS TEXT AS $$ - SELECT has_unique( $1, 'Table ' || quote_ident($1) || ' should have a unique constraint' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _constraint ( NAME, NAME, CHAR, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - akey NAME[]; - keys TEXT[] := '{}'; - have TEXT; -BEGIN - FOR akey IN SELECT * FROM _keys($1, $2, $3) LOOP - IF akey = $4 THEN RETURN pass($5); END IF; - keys = keys || akey::text; - END LOOP; - IF array_upper(keys, 0) = 1 THEN - have := 'No ' || $6 || ' constraints'; - ELSE - have := array_to_string(keys, E'\n '); - END IF; - - RETURN fail($5) || E'\n' || diag( - ' have: ' || have - || E'\n want: ' || CASE WHEN $4 IS NULL THEN 'NULL' ELSE $4::text END - ); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _constraint ( NAME, CHAR, NAME[], TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - akey NAME[]; - keys TEXT[] := '{}'; - have TEXT; -BEGIN - FOR akey IN SELECT * FROM _keys($1, $2) LOOP - IF akey = $3 THEN RETURN pass($4); END IF; - keys = keys || akey::text; - END LOOP; - IF array_upper(keys, 0) = 1 THEN - have := 'No ' || $5 || ' constraints'; - ELSE - have := array_to_string(keys, E'\n '); - END IF; - - RETURN fail($4) || E'\n' || diag( - ' have: ' || have - || E'\n want: ' || CASE WHEN $3 IS NULL THEN 'NULL' ELSE $3::text END - ); -END; -$$ LANGUAGE plpgsql; - --- col_is_unique( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _constraint( $1, $2, 'u', $3, $4, 'unique' ); -$$ LANGUAGE sql; - --- col_is_unique( schema, table, column[] ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, $2, $3, 'Columns ' || quote_ident($2) || '(' || _ident_array_to_string($3, ', ') || ') should have a unique constraint' ); -$$ LANGUAGE sql; - --- col_is_unique( scheam, table, column ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, $2, ARRAY[$3], 'Column ' || quote_ident($2) || '(' || quote_ident($3) || ') should have a unique constraint' ); -$$ LANGUAGE sql; - --- col_is_unique( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _constraint( $1, 'u', $2, $3, 'unique' ); -$$ LANGUAGE sql; - --- col_is_unique( table, column[] ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should have a unique constraint' ); -$$ LANGUAGE sql; - --- col_is_unique( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_is_unique( table, column, description ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_is_unique( table, column ) -CREATE OR REPLACE FUNCTION col_is_unique ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_is_unique( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should have a unique constraint' ); -$$ LANGUAGE sql; - --- has_check( schema, table, description ) -CREATE OR REPLACE FUNCTION has_check ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, $2, 'c' ), $3 ); -$$ LANGUAGE sql; - --- has_check( table, description ) -CREATE OR REPLACE FUNCTION has_check ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _hasc( $1, 'c' ), $2 ); -$$ LANGUAGE sql; - --- has_check( table ) -CREATE OR REPLACE FUNCTION has_check ( NAME ) -RETURNS TEXT AS $$ - SELECT has_check( $1, 'Table ' || quote_ident($1) || ' should have a check constraint' ); -$$ LANGUAGE sql; - --- col_has_check( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _constraint( $1, $2, 'c', $3, $4, 'check' ); -$$ LANGUAGE sql; - --- col_has_check( table, column, description ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _constraint( $1, 'c', $2, $3, 'check' ); -$$ LANGUAGE sql; - --- col_has_check( table, column[] ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT col_has_check( $1, $2, 'Columns ' || quote_ident($1) || '(' || _ident_array_to_string($2, ', ') || ') should have a check constraint' ); -$$ LANGUAGE sql; - --- col_has_check( schema, table, column, description ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_has_check( $1, $2, ARRAY[$3], $4 ); -$$ LANGUAGE sql; - --- col_has_check( table, column, description ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT col_has_check( $1, ARRAY[$2], $3 ); -$$ LANGUAGE sql; - --- col_has_check( table, column ) -CREATE OR REPLACE FUNCTION col_has_check ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT col_has_check( $1, $2, 'Column ' || quote_ident($1) || '(' || quote_ident($2) || ') should have a check constraint' ); -$$ LANGUAGE sql; - --- fk_ok( fk_schema, fk_table, fk_column[], pk_schema, pk_table, pk_column[], description ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME[], NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - sch name; - tab name; - cols name[]; -BEGIN - SELECT pk_schema_name, pk_table_name, pk_columns - FROM pg_all_foreign_keys - WHERE fk_schema_name = $1 - AND fk_table_name = $2 - AND fk_columns = $3 - INTO sch, tab, cols; - - RETURN is( - -- have - quote_ident($1) || '.' || quote_ident($2) || '(' || _ident_array_to_string( $3, ', ' ) - || ') REFERENCES ' || COALESCE ( sch || '.' || tab || '(' || _ident_array_to_string( cols, ', ' ) || ')', 'NOTHING' ), - -- want - quote_ident($1) || '.' || quote_ident($2) || '(' || _ident_array_to_string( $3, ', ' ) - || ') REFERENCES ' || - $4 || '.' || $5 || '(' || _ident_array_to_string( $6, ', ' ) || ')', - $7 - ); -END; -$$ LANGUAGE plpgsql; - --- fk_ok( fk_table, fk_column[], pk_table, pk_column[], description ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME[], NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - tab name; - cols name[]; -BEGIN - SELECT pk_table_name, pk_columns - FROM pg_all_foreign_keys - WHERE fk_table_name = $1 - AND fk_columns = $2 - AND pg_catalog.pg_table_is_visible(fk_table_oid) - INTO tab, cols; - - RETURN is( - -- have - $1 || '(' || _ident_array_to_string( $2, ', ' ) - || ') REFERENCES ' || COALESCE( tab || '(' || _ident_array_to_string( cols, ', ' ) || ')', 'NOTHING'), - -- want - $1 || '(' || _ident_array_to_string( $2, ', ' ) - || ') REFERENCES ' || - $3 || '(' || _ident_array_to_string( $4, ', ' ) || ')', - $5 - ); -END; -$$ LANGUAGE plpgsql; - --- fk_ok( fk_schema, fk_table, fk_column[], fk_schema, pk_table, pk_column[] ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME[], NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, $2, $3, $4, $5, $6, - quote_ident($1) || '.' || quote_ident($2) || '(' || _ident_array_to_string( $3, ', ' ) - || ') should reference ' || - $4 || '.' || $5 || '(' || _ident_array_to_string( $6, ', ' ) || ')' - ); -$$ LANGUAGE sql; - --- fk_ok( fk_table, fk_column[], pk_table, pk_column[] ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME[], NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, $2, $3, $4, - $1 || '(' || _ident_array_to_string( $2, ', ' ) - || ') should reference ' || - $3 || '(' || _ident_array_to_string( $4, ', ' ) || ')' - ); -$$ LANGUAGE sql; - --- fk_ok( fk_schema, fk_table, fk_column, pk_schema, pk_table, pk_column, description ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, $2, ARRAY[$3], $4, $5, ARRAY[$6], $7 ); -$$ LANGUAGE sql; - --- fk_ok( fk_schema, fk_table, fk_column, pk_schema, pk_table, pk_column ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, $2, ARRAY[$3], $4, $5, ARRAY[$6] ); -$$ LANGUAGE sql; - --- fk_ok( fk_table, fk_column, pk_table, pk_column, description ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, ARRAY[$2], $3, ARRAY[$4], $5 ); -$$ LANGUAGE sql; - --- fk_ok( fk_table, fk_column, pk_table, pk_column ) -CREATE OR REPLACE FUNCTION fk_ok ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT fk_ok( $1, ARRAY[$2], $3, ARRAY[$4] ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _ikeys( NAME, NAME, NAME) -RETURNS TEXT[] AS $$ - SELECT ARRAY( - SELECT pg_catalog.pg_get_indexdef( ci.oid, s.i + 1, false) - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - JOIN generate_series(0, current_setting('max_index_keys')::int - 1) s(i) - ON x.indkey[s.i] IS NOT NULL - WHERE ct.relname = $2 - AND ci.relname = $3 - AND n.nspname = $1 - ORDER BY s.i - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _ikeys( NAME, NAME) -RETURNS TEXT[] AS $$ - SELECT ARRAY( - SELECT pg_catalog.pg_get_indexdef( ci.oid, s.i + 1, false) - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN generate_series(0, current_setting('max_index_keys')::int - 1) s(i) - ON x.indkey[s.i] IS NOT NULL - WHERE ct.relname = $1 - AND ci.relname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid) - ORDER BY s.i - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _have_index( NAME, NAME, NAME) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE n.nspname = $1 - AND ct.relname = $2 - AND ci.relname = $3 - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _have_index( NAME, NAME) -RETURNS BOOLEAN AS $$ - SELECT EXISTS ( - SELECT TRUE - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ct.relname = $1 - AND ci.relname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid) - ); -$$ LANGUAGE sql; - --- has_index( schema, table, index, columns[], description ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME, NAME[], text ) -RETURNS TEXT AS $$ -DECLARE - index_cols name[]; -BEGIN - index_cols := _ikeys($1, $2, $3 ); - - IF index_cols IS NULL OR index_cols = '{}'::name[] THEN - RETURN ok( false, $5 ) || E'\n' - || diag( 'Index ' || quote_ident($3) || ' ON ' || quote_ident($1) || '.' || quote_ident($2) || ' not found'); - END IF; - - RETURN is( - quote_ident($3) || ' ON ' || quote_ident($1) || '.' || quote_ident($2) || '(' || array_to_string( index_cols, ', ' ) || ')', - quote_ident($3) || ' ON ' || quote_ident($1) || '.' || quote_ident($2) || '(' || array_to_string( $4, ', ' ) || ')', - $5 - ); -END; -$$ LANGUAGE plpgsql; - --- has_index( schema, table, index, columns[] ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT has_index( $1, $2, $3, $4, 'Index ' || quote_ident($3) || ' should exist' ); -$$ LANGUAGE sql; - --- has_index( schema, table, index, column/expression, description ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ - SELECT has_index( $1, $2, $3, ARRAY[$4], $5 ); -$$ LANGUAGE sql; - --- has_index( schema, table, index, columns/expression ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_index( $1, $2, $3, $4, 'Index ' || quote_ident($3) || ' should exist' ); -$$ LANGUAGE sql; - --- has_index( table, index, columns[], description ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME[], text ) -RETURNS TEXT AS $$ -DECLARE - index_cols name[]; -BEGIN - index_cols := _ikeys($1, $2 ); - - IF index_cols IS NULL OR index_cols = '{}'::name[] THEN - RETURN ok( false, $4 ) || E'\n' - || diag( 'Index ' || quote_ident($2) || ' ON ' || quote_ident($1) || ' not found'); - END IF; - - RETURN is( - quote_ident($2) || ' ON ' || quote_ident($1) || '(' || array_to_string( index_cols, ', ' ) || ')', - quote_ident($2) || ' ON ' || quote_ident($1) || '(' || array_to_string( $3, ', ' ) || ')', - $4 - ); -END; -$$ LANGUAGE plpgsql; - --- has_index( table, index, columns[], description ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT has_index( $1, $2, $3, 'Index ' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE sql; - --- _is_schema( schema ) -CREATE OR REPLACE FUNCTION _is_schema( NAME ) -returns boolean AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace - WHERE nspname = $1 - ); -$$ LANGUAGE sql; - --- has_index( table, index, column/expression, description ) --- has_index( schema, table, index, column/expression ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ - SELECT CASE WHEN _is_schema( $1 ) THEN - -- Looking for schema.table index. - ok ( _have_index( $1, $2, $3 ), $4) - ELSE - -- Looking for particular columns. - has_index( $1, $2, ARRAY[$3], $4 ) - END; -$$ LANGUAGE sql; - --- has_index( table, index, column/expression ) --- has_index( schema, table, index ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ -BEGIN - IF _is_schema($1) THEN - -- ( schema, table, index ) - RETURN ok( _have_index( $1, $2, $3 ), 'Index ' || quote_ident($3) || ' should exist' ); - ELSE - -- ( table, index, column/expression ) - RETURN has_index( $1, $2, $3, 'Index ' || quote_ident($2) || ' should exist' ); - END IF; -END; -$$ LANGUAGE plpgsql; - --- has_index( table, index, description ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME, text ) -RETURNS TEXT AS $$ - SELECT CASE WHEN $3 LIKE '%(%' - THEN has_index( $1, $2, $3::name ) - ELSE ok( _have_index( $1, $2 ), $3 ) - END; -$$ LANGUAGE sql; - --- has_index( table, index ) -CREATE OR REPLACE FUNCTION has_index ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _have_index( $1, $2 ), 'Index ' || quote_ident($2) || ' should exist' ); -$$ LANGUAGE sql; - --- hasnt_index( schema, table, index, description ) -CREATE OR REPLACE FUNCTION hasnt_index ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -BEGIN - RETURN ok( NOT _have_index( $1, $2, $3 ), $4 ); -END; -$$ LANGUAGE plpgSQL; - --- hasnt_index( schema, table, index ) -CREATE OR REPLACE FUNCTION hasnt_index ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _have_index( $1, $2, $3 ), - 'Index ' || quote_ident($3) || ' should not exist' - ); -$$ LANGUAGE SQL; - --- hasnt_index( table, index, description ) -CREATE OR REPLACE FUNCTION hasnt_index ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _have_index( $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_index( table, index ) -CREATE OR REPLACE FUNCTION hasnt_index ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _have_index( $1, $2 ), - 'Index ' || quote_ident($2) || ' should not exist' - ); -$$ LANGUAGE SQL; - --- index_is_unique( schema, table, index, description ) -CREATE OR REPLACE FUNCTION index_is_unique ( NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisunique - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $2 - AND ci.relname = $3 - AND n.nspname = $1 - INTO res; - - RETURN ok( COALESCE(res, false), $4 ); -END; -$$ LANGUAGE plpgsql; - --- index_is_unique( schema, table, index ) -CREATE OR REPLACE FUNCTION index_is_unique ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT index_is_unique( - $1, $2, $3, - 'Index ' || quote_ident($3) || ' should be unique' - ); -$$ LANGUAGE sql; - --- index_is_unique( table, index ) -CREATE OR REPLACE FUNCTION index_is_unique ( NAME, NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisunique - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ct.relname = $1 - AND ci.relname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid) - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Index ' || quote_ident($2) || ' should be unique' - ); -END; -$$ LANGUAGE plpgsql; - --- index_is_unique( index ) -CREATE OR REPLACE FUNCTION index_is_unique ( NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisunique - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - WHERE ci.relname = $1 - AND pg_catalog.pg_table_is_visible(ct.oid) - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Index ' || quote_ident($1) || ' should be unique' - ); -END; -$$ LANGUAGE plpgsql; - --- index_is_primary( schema, table, index, description ) -CREATE OR REPLACE FUNCTION index_is_primary ( NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisprimary - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $2 - AND ci.relname = $3 - AND n.nspname = $1 - INTO res; - - RETURN ok( COALESCE(res, false), $4 ); -END; -$$ LANGUAGE plpgsql; - --- index_is_primary( schema, table, index ) -CREATE OR REPLACE FUNCTION index_is_primary ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT index_is_primary( - $1, $2, $3, - 'Index ' || quote_ident($3) || ' should be on a primary key' - ); -$$ LANGUAGE sql; - --- index_is_primary( table, index ) -CREATE OR REPLACE FUNCTION index_is_primary ( NAME, NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisprimary - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ct.relname = $1 - AND ci.relname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid) - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Index ' || quote_ident($2) || ' should be on a primary key' - ); -END; -$$ LANGUAGE plpgsql; - --- index_is_primary( index ) -CREATE OR REPLACE FUNCTION index_is_primary ( NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisprimary - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - WHERE ci.relname = $1 - AND pg_catalog.pg_table_is_visible(ct.oid) - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Index ' || quote_ident($1) || ' should be on a primary key' - ); -END; -$$ LANGUAGE plpgsql; - --- is_clustered( schema, table, index, description ) -CREATE OR REPLACE FUNCTION is_clustered ( NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisclustered - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $2 - AND ci.relname = $3 - AND n.nspname = $1 - INTO res; - - RETURN ok( COALESCE(res, false), $4 ); -END; -$$ LANGUAGE plpgsql; - --- is_clustered( schema, table, index ) -CREATE OR REPLACE FUNCTION is_clustered ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT is_clustered( - $1, $2, $3, - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || - ' should be clustered on index ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- is_clustered( table, index ) -CREATE OR REPLACE FUNCTION is_clustered ( NAME, NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisclustered - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ct.relname = $1 - AND ci.relname = $2 - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Table ' || quote_ident($1) || ' should be clustered on index ' || quote_ident($2) - ); -END; -$$ LANGUAGE plpgsql; - --- is_clustered( index ) -CREATE OR REPLACE FUNCTION is_clustered ( NAME ) -RETURNS TEXT AS $$ -DECLARE - res boolean; -BEGIN - SELECT x.indisclustered - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ci.relname = $1 - INTO res; - - RETURN ok( - COALESCE(res, false), - 'Table should be clustered on index ' || quote_ident($1) - ); -END; -$$ LANGUAGE plpgsql; - --- index_is_type( schema, table, index, type, description ) -CREATE OR REPLACE FUNCTION index_is_type ( NAME, NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - aname name; -BEGIN - SELECT am.amname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - JOIN pg_catalog.pg_am am ON ci.relam = am.oid - WHERE ct.relname = $2 - AND ci.relname = $3 - AND n.nspname = $1 - INTO aname; - - return is( aname, $4, $5 ); -END; -$$ LANGUAGE plpgsql; - --- index_is_type( schema, table, index, type ) -CREATE OR REPLACE FUNCTION index_is_type ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT index_is_type( - $1, $2, $3, $4, - 'Index ' || quote_ident($3) || ' should be a ' || quote_ident($4) || ' index' - ); -$$ LANGUAGE SQL; - --- index_is_type( table, index, type ) -CREATE OR REPLACE FUNCTION index_is_type ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ -DECLARE - aname name; -BEGIN - SELECT am.amname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_am am ON ci.relam = am.oid - WHERE ct.relname = $1 - AND ci.relname = $2 - INTO aname; - - return is( - aname, $3, - 'Index ' || quote_ident($2) || ' should be a ' || quote_ident($3) || ' index' - ); -END; -$$ LANGUAGE plpgsql; - --- index_is_type( index, type ) -CREATE OR REPLACE FUNCTION index_is_type ( NAME, NAME ) -RETURNS TEXT AS $$ -DECLARE - aname name; -BEGIN - SELECT am.amname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_am am ON ci.relam = am.oid - WHERE ci.relname = $1 - INTO aname; - - return is( - aname, $2, - 'Index ' || quote_ident($1) || ' should be a ' || quote_ident($2) || ' index' - ); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _trig ( NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - AND t.tgname = $3 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _trig ( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - WHERE c.relname = $1 - AND t.tgname = $2 - AND pg_catalog.pg_table_is_visible(c.oid) - ); -$$ LANGUAGE SQL; - --- has_trigger( schema, table, trigger, description ) -CREATE OR REPLACE FUNCTION has_trigger ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _trig($1, $2, $3), $4); -$$ LANGUAGE SQL; - --- has_trigger( schema, table, trigger ) -CREATE OR REPLACE FUNCTION has_trigger ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT has_trigger( - $1, $2, $3, - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should have trigger ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- has_trigger( table, trigger, description ) -CREATE OR REPLACE FUNCTION has_trigger ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _trig($1, $2), $3); -$$ LANGUAGE sql; - --- has_trigger( table, trigger ) -CREATE OR REPLACE FUNCTION has_trigger ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _trig($1, $2), 'Table ' || quote_ident($1) || ' should have trigger ' || quote_ident($2)); -$$ LANGUAGE SQL; - --- hasnt_trigger( schema, table, trigger, description ) -CREATE OR REPLACE FUNCTION hasnt_trigger ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _trig($1, $2, $3), $4); -$$ LANGUAGE SQL; - --- hasnt_trigger( schema, table, trigger ) -CREATE OR REPLACE FUNCTION hasnt_trigger ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( - NOT _trig($1, $2, $3), - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should not have trigger ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- hasnt_trigger( table, trigger, description ) -CREATE OR REPLACE FUNCTION hasnt_trigger ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _trig($1, $2), $3); -$$ LANGUAGE sql; - --- hasnt_trigger( table, trigger ) -CREATE OR REPLACE FUNCTION hasnt_trigger ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _trig($1, $2), 'Table ' || quote_ident($1) || ' should not have trigger ' || quote_ident($2)); -$$ LANGUAGE SQL; - --- trigger_is( schema, table, trigger, schema, function, description ) -CREATE OR REPLACE FUNCTION trigger_is ( NAME, NAME, NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - pname text; -BEGIN - SELECT quote_ident(ni.nspname) || '.' || quote_ident(p.proname) - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class ct ON ct.oid = t.tgrelid - JOIN pg_catalog.pg_namespace nt ON nt.oid = ct.relnamespace - JOIN pg_catalog.pg_proc p ON p.oid = t.tgfoid - JOIN pg_catalog.pg_namespace ni ON ni.oid = p.pronamespace - WHERE nt.nspname = $1 - AND ct.relname = $2 - AND t.tgname = $3 - INTO pname; - - RETURN is( pname, quote_ident($4) || '.' || quote_ident($5), $6 ); -END; -$$ LANGUAGE plpgsql; - --- trigger_is( schema, table, trigger, schema, function ) -CREATE OR REPLACE FUNCTION trigger_is ( NAME, NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT trigger_is( - $1, $2, $3, $4, $5, - 'Trigger ' || quote_ident($3) || ' should call ' || quote_ident($4) || '.' || quote_ident($5) || '()' - ); -$$ LANGUAGE sql; - --- trigger_is( table, trigger, function, description ) -CREATE OR REPLACE FUNCTION trigger_is ( NAME, NAME, NAME, text ) -RETURNS TEXT AS $$ -DECLARE - pname text; -BEGIN - SELECT p.proname - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class ct ON ct.oid = t.tgrelid - JOIN pg_catalog.pg_proc p ON p.oid = t.tgfoid - WHERE ct.relname = $1 - AND t.tgname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid) - INTO pname; - - RETURN is( pname, $3::text, $4 ); -END; -$$ LANGUAGE plpgsql; - --- trigger_is( table, trigger, function ) -CREATE OR REPLACE FUNCTION trigger_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT trigger_is( - $1, $2, $3, - 'Trigger ' || quote_ident($2) || ' should call ' || quote_ident($3) || '()' - ); -$$ LANGUAGE sql; - --- has_schema( schema, description ) -CREATE OR REPLACE FUNCTION has_schema( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_namespace - WHERE nspname = $1 - ), $2 - ); -$$ LANGUAGE sql; - --- has_schema( schema ) -CREATE OR REPLACE FUNCTION has_schema( NAME ) -RETURNS TEXT AS $$ - SELECT has_schema( $1, 'Schema ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE sql; - --- hasnt_schema( schema, description ) -CREATE OR REPLACE FUNCTION hasnt_schema( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( - NOT EXISTS( - SELECT true - FROM pg_catalog.pg_namespace - WHERE nspname = $1 - ), $2 - ); -$$ LANGUAGE sql; - --- hasnt_schema( schema ) -CREATE OR REPLACE FUNCTION hasnt_schema( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_schema( $1, 'Schema ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE sql; - --- has_tablespace( tablespace, location, description ) -CREATE OR REPLACE FUNCTION has_tablespace( NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -BEGIN - IF pg_version_num() >= 90200 THEN - RETURN ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_tablespace - WHERE spcname = $1 - AND pg_tablespace_location(oid) = $2 - ), $3 - ); - ELSE - RETURN ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_tablespace - WHERE spcname = $1 - AND spclocation = $2 - ), $3 - ); - END IF; -END; -$$ LANGUAGE plpgsql; - --- has_tablespace( tablespace, description ) -CREATE OR REPLACE FUNCTION has_tablespace( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( - EXISTS( - SELECT true - FROM pg_catalog.pg_tablespace - WHERE spcname = $1 - ), $2 - ); -$$ LANGUAGE sql; - --- has_tablespace( tablespace ) -CREATE OR REPLACE FUNCTION has_tablespace( NAME ) -RETURNS TEXT AS $$ - SELECT has_tablespace( $1, 'Tablespace ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE sql; - --- hasnt_tablespace( tablespace, description ) -CREATE OR REPLACE FUNCTION hasnt_tablespace( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( - NOT EXISTS( - SELECT true - FROM pg_catalog.pg_tablespace - WHERE spcname = $1 - ), $2 - ); -$$ LANGUAGE sql; - --- hasnt_tablespace( tablespace ) -CREATE OR REPLACE FUNCTION hasnt_tablespace( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_tablespace( $1, 'Tablespace ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_role( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_roles - WHERE rolname = $1 - ); -$$ LANGUAGE sql STRICT; - --- has_role( role, description ) -CREATE OR REPLACE FUNCTION has_role( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_role($1), $2 ); -$$ LANGUAGE sql; - --- has_role( role ) -CREATE OR REPLACE FUNCTION has_role( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_role($1), 'Role ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE sql; - --- hasnt_role( role, description ) -CREATE OR REPLACE FUNCTION hasnt_role( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_role($1), $2 ); -$$ LANGUAGE sql; - --- hasnt_role( role ) -CREATE OR REPLACE FUNCTION hasnt_role( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_role($1), 'Role ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_user( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( SELECT true FROM pg_catalog.pg_user WHERE usename = $1); -$$ LANGUAGE sql STRICT; - --- has_user( user, description ) -CREATE OR REPLACE FUNCTION has_user( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_user($1), $2 ); -$$ LANGUAGE sql; - --- has_user( user ) -CREATE OR REPLACE FUNCTION has_user( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_user( $1 ), 'User ' || quote_ident($1) || ' should exist'); -$$ LANGUAGE sql; - --- hasnt_user( user, description ) -CREATE OR REPLACE FUNCTION hasnt_user( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_user($1), $2 ); -$$ LANGUAGE sql; - --- hasnt_user( user ) -CREATE OR REPLACE FUNCTION hasnt_user( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_user( $1 ), 'User ' || quote_ident($1) || ' should not exist'); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _is_super( NAME ) -RETURNS BOOLEAN AS $$ - SELECT rolsuper - FROM pg_catalog.pg_roles - WHERE rolname = $1 -$$ LANGUAGE sql STRICT; - --- is_superuser( user, description ) -CREATE OR REPLACE FUNCTION is_superuser( NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - is_super boolean := _is_super($1); -BEGIN - IF is_super IS NULL THEN - RETURN fail( $2 ) || E'\n' || diag( ' User ' || quote_ident($1) || ' does not exist') ; - END IF; - RETURN ok( is_super, $2 ); -END; -$$ LANGUAGE plpgsql; - --- is_superuser( user ) -CREATE OR REPLACE FUNCTION is_superuser( NAME ) -RETURNS TEXT AS $$ - SELECT is_superuser( $1, 'User ' || quote_ident($1) || ' should be a super user' ); -$$ LANGUAGE sql; - --- isnt_superuser( user, description ) -CREATE OR REPLACE FUNCTION isnt_superuser( NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - is_super boolean := _is_super($1); -BEGIN - IF is_super IS NULL THEN - RETURN fail( $2 ) || E'\n' || diag( ' User ' || quote_ident($1) || ' does not exist') ; - END IF; - RETURN ok( NOT is_super, $2 ); -END; -$$ LANGUAGE plpgsql; - --- isnt_superuser( user ) -CREATE OR REPLACE FUNCTION isnt_superuser( NAME ) -RETURNS TEXT AS $$ - SELECT isnt_superuser( $1, 'User ' || quote_ident($1) || ' should not be a super user' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _has_group( NAME ) -RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT true - FROM pg_catalog.pg_group - WHERE groname = $1 - ); -$$ LANGUAGE sql STRICT; - --- has_group( group, description ) -CREATE OR REPLACE FUNCTION has_group( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _has_group($1), $2 ); -$$ LANGUAGE sql; - --- has_group( group ) -CREATE OR REPLACE FUNCTION has_group( NAME ) -RETURNS TEXT AS $$ - SELECT ok( _has_group($1), 'Group ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE sql; - --- hasnt_group( group, description ) -CREATE OR REPLACE FUNCTION hasnt_group( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_group($1), $2 ); -$$ LANGUAGE sql; - --- hasnt_group( group ) -CREATE OR REPLACE FUNCTION hasnt_group( NAME ) -RETURNS TEXT AS $$ - SELECT ok( NOT _has_group($1), 'Group ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _grolist ( NAME ) -RETURNS oid[] AS $$ - SELECT ARRAY( - SELECT member - FROM pg_catalog.pg_auth_members m - JOIN pg_catalog.pg_roles r ON m.roleid = r.oid - WHERE r.rolname = $1 - ); -$$ LANGUAGE sql; - --- is_member_of( role, members[], description ) -CREATE OR REPLACE FUNCTION is_member_of( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - missing text[]; -BEGIN - IF NOT _has_role($1) THEN - RETURN fail( $3 ) || E'\n' || diag ( - ' Role ' || quote_ident($1) || ' does not exist' - ); - END IF; - - SELECT ARRAY( - SELECT quote_ident($2[i]) - FROM generate_series(1, array_upper($2, 1)) s(i) - LEFT JOIN pg_catalog.pg_roles r ON rolname = $2[i] - WHERE r.oid IS NULL - OR NOT r.oid = ANY ( _grolist($1) ) - ORDER BY s.i - ) INTO missing; - IF missing[1] IS NULL THEN - RETURN ok( true, $3 ); - END IF; - RETURN ok( false, $3 ) || E'\n' || diag( - ' Members missing from the ' || quote_ident($1) || E' role:\n ' || - array_to_string( missing, E'\n ') - ); -END; -$$ LANGUAGE plpgsql; - --- is_member_of( role, member, description ) -CREATE OR REPLACE FUNCTION is_member_of( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT is_member_of( $1, ARRAY[$2], $3 ); -$$ LANGUAGE SQL; - --- is_member_of( role, members[] ) -CREATE OR REPLACE FUNCTION is_member_of( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT is_member_of( $1, $2, 'Should have members of role ' || quote_ident($1) ); -$$ LANGUAGE SQL; - --- is_member_of( role, member ) -CREATE OR REPLACE FUNCTION is_member_of( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT is_member_of( $1, ARRAY[$2] ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _are ( text, name[], name[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - what ALIAS FOR $1; - extras ALIAS FOR $2; - missing ALIAS FOR $3; - descr ALIAS FOR $4; - msg TEXT := ''; - res BOOLEAN := TRUE; -BEGIN - IF extras[1] IS NOT NULL THEN - res = FALSE; - msg := E'\n' || diag( - ' Extra ' || what || E':\n ' - || _ident_array_to_string( extras, E'\n ' ) - ); - END IF; - IF missing[1] IS NOT NULL THEN - res = FALSE; - msg := msg || E'\n' || diag( - ' Missing ' || what || E':\n ' - || _ident_array_to_string( missing, E'\n ' ) - ); - END IF; - - RETURN ok(res, descr) || msg; -END; -$$ LANGUAGE plpgsql; - --- tablespaces_are( tablespaces, description ) -CREATE OR REPLACE FUNCTION tablespaces_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'tablespaces', - ARRAY( - SELECT spcname - FROM pg_catalog.pg_tablespace - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT spcname - FROM pg_catalog.pg_tablespace - ), - $2 - ); -$$ LANGUAGE SQL; - --- tablespaces_are( tablespaces ) -CREATE OR REPLACE FUNCTION tablespaces_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT tablespaces_are( $1, 'There should be the correct tablespaces' ); -$$ LANGUAGE SQL; - --- schemas_are( schemas, description ) -CREATE OR REPLACE FUNCTION schemas_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'schemas', - ARRAY( - SELECT nspname - FROM pg_catalog.pg_namespace - WHERE nspname NOT LIKE 'pg_%' - AND nspname <> 'information_schema' - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT nspname - FROM pg_catalog.pg_namespace - WHERE nspname NOT LIKE 'pg_%' - AND nspname <> 'information_schema' - ), - $2 - ); -$$ LANGUAGE SQL; - --- schemas_are( schemas ) -CREATE OR REPLACE FUNCTION schemas_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT schemas_are( $1, 'There should be the correct schemas' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _extras ( CHAR, NAME, NAME[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT c.relname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE c.relkind = $1 - AND n.nspname = $2 - AND c.relname NOT IN('pg_all_foreign_keys', 'tap_funky', '__tresults___numb_seq', '__tcache___id_seq') - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _extras ( CHAR, NAME[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT c.relname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE pg_catalog.pg_table_is_visible(c.oid) - AND n.nspname <> 'pg_catalog' - AND c.relkind = $1 - AND c.relname NOT IN ('__tcache__', 'pg_all_foreign_keys', 'tap_funky', '__tresults___numb_seq', '__tcache___id_seq') - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _missing ( CHAR, NAME, NAME[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT c.relname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE c.relkind = $1 - AND n.nspname = $2 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _missing ( CHAR, NAME[] ) -RETURNS NAME[] AS $$ - SELECT ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT c.relname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - WHERE pg_catalog.pg_table_is_visible(c.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND c.relkind = $1 - ); -$$ LANGUAGE SQL; - --- tables_are( schema, tables, description ) -CREATE OR REPLACE FUNCTION tables_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'tables', _extras('r', $1, $2), _missing('r', $1, $2), $3); -$$ LANGUAGE SQL; - --- tables_are( tables, description ) -CREATE OR REPLACE FUNCTION tables_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'tables', _extras('r', $1), _missing('r', $1), $2); -$$ LANGUAGE SQL; - --- tables_are( schema, tables ) -CREATE OR REPLACE FUNCTION tables_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'tables', _extras('r', $1, $2), _missing('r', $1, $2), - 'Schema ' || quote_ident($1) || ' should have the correct tables' - ); -$$ LANGUAGE SQL; - --- tables_are( tables ) -CREATE OR REPLACE FUNCTION tables_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'tables', _extras('r', $1), _missing('r', $1), - 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct tables' - ); -$$ LANGUAGE SQL; - --- views_are( schema, views, description ) -CREATE OR REPLACE FUNCTION views_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'views', _extras('v', $1, $2), _missing('v', $1, $2), $3); -$$ LANGUAGE SQL; - --- views_are( views, description ) -CREATE OR REPLACE FUNCTION views_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'views', _extras('v', $1), _missing('v', $1), $2); -$$ LANGUAGE SQL; - --- views_are( schema, views ) -CREATE OR REPLACE FUNCTION views_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'views', _extras('v', $1, $2), _missing('v', $1, $2), - 'Schema ' || quote_ident($1) || ' should have the correct views' - ); -$$ LANGUAGE SQL; - --- views_are( views ) -CREATE OR REPLACE FUNCTION views_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'views', _extras('v', $1), _missing('v', $1), - 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct views' - ); -$$ LANGUAGE SQL; - --- sequences_are( schema, sequences, description ) -CREATE OR REPLACE FUNCTION sequences_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'sequences', _extras('S', $1, $2), _missing('S', $1, $2), $3); -$$ LANGUAGE SQL; - --- sequences_are( sequences, description ) -CREATE OR REPLACE FUNCTION sequences_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'sequences', _extras('S', $1), _missing('S', $1), $2); -$$ LANGUAGE SQL; - --- sequences_are( schema, sequences ) -CREATE OR REPLACE FUNCTION sequences_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'sequences', _extras('S', $1, $2), _missing('S', $1, $2), - 'Schema ' || quote_ident($1) || ' should have the correct sequences' - ); -$$ LANGUAGE SQL; - --- sequences_are( sequences ) -CREATE OR REPLACE FUNCTION sequences_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'sequences', _extras('S', $1), _missing('S', $1), - 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct sequences' - ); -$$ LANGUAGE SQL; - --- functions_are( schema, functions[], description ) -CREATE OR REPLACE FUNCTION functions_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'functions', - ARRAY( - SELECT name FROM tap_funky WHERE schema = $1 - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT name FROM tap_funky WHERE schema = $1 - ), - $3 - ); -$$ LANGUAGE SQL; - --- functions_are( schema, functions[] ) -CREATE OR REPLACE FUNCTION functions_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT functions_are( $1, $2, 'Schema ' || quote_ident($1) || ' should have the correct functions' ); -$$ LANGUAGE SQL; - --- functions_are( functions[], description ) -CREATE OR REPLACE FUNCTION functions_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'functions', - ARRAY( - SELECT name FROM tap_funky WHERE is_visible - AND schema NOT IN ('pg_catalog', 'information_schema') - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT name FROM tap_funky WHERE is_visible - AND schema NOT IN ('pg_catalog', 'information_schema') - ), - $2 - ); -$$ LANGUAGE SQL; - --- functions_are( functions[] ) -CREATE OR REPLACE FUNCTION functions_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT functions_are( $1, 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct functions' ); -$$ LANGUAGE SQL; - --- indexes_are( schema, table, indexes[], description ) -CREATE OR REPLACE FUNCTION indexes_are( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'indexes', - ARRAY( - SELECT ci.relname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $2 - AND n.nspname = $1 - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ), - ARRAY( - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT ci.relname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $2 - AND n.nspname = $1 - ), - $4 - ); -$$ LANGUAGE SQL; - --- indexes_are( schema, table, indexes[] ) -CREATE OR REPLACE FUNCTION indexes_are( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT indexes_are( $1, $2, $3, 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should have the correct indexes' ); -$$ LANGUAGE SQL; - --- indexes_are( table, indexes[], description ) -CREATE OR REPLACE FUNCTION indexes_are( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'indexes', - ARRAY( - SELECT ci.relname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $1 - AND pg_catalog.pg_table_is_visible(ct.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT ci.relname - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE ct.relname = $1 - AND pg_catalog.pg_table_is_visible(ct.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ), - $3 - ); -$$ LANGUAGE SQL; - --- indexes_are( table, indexes[] ) -CREATE OR REPLACE FUNCTION indexes_are( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT indexes_are( $1, $2, 'Table ' || quote_ident($1) || ' should have the correct indexes' ); -$$ LANGUAGE SQL; - --- users_are( users[], description ) -CREATE OR REPLACE FUNCTION users_are( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'users', - ARRAY( - SELECT usename - FROM pg_catalog.pg_user - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT usename - FROM pg_catalog.pg_user - ), - $2 - ); -$$ LANGUAGE SQL; - --- users_are( users[] ) -CREATE OR REPLACE FUNCTION users_are( NAME[] ) -RETURNS TEXT AS $$ - SELECT users_are( $1, 'There should be the correct users' ); -$$ LANGUAGE SQL; - --- groups_are( groups[], description ) -CREATE OR REPLACE FUNCTION groups_are( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'groups', - ARRAY( - SELECT groname - FROM pg_catalog.pg_group - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT groname - FROM pg_catalog.pg_group - ), - $2 - ); -$$ LANGUAGE SQL; - --- groups_are( groups[] ) -CREATE OR REPLACE FUNCTION groups_are( NAME[] ) -RETURNS TEXT AS $$ - SELECT groups_are( $1, 'There should be the correct groups' ); -$$ LANGUAGE SQL; - --- languages_are( languages[], description ) -CREATE OR REPLACE FUNCTION languages_are( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'languages', - ARRAY( - SELECT lanname - FROM pg_catalog.pg_language - WHERE lanispl - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT lanname - FROM pg_catalog.pg_language - WHERE lanispl - ), - $2 - ); -$$ LANGUAGE SQL; - --- languages_are( languages[] ) -CREATE OR REPLACE FUNCTION languages_are( NAME[] ) -RETURNS TEXT AS $$ - SELECT languages_are( $1, 'There should be the correct procedural languages' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION opclasses_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'operator classes', - ARRAY( - SELECT oc.opcname - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - WHERE n.nspname = $1 - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT oc.opcname - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - WHERE n.nspname = $1 - ), - $3 - ); -$$ LANGUAGE SQL; - --- opclasses_are( schema, opclasses[] ) -CREATE OR REPLACE FUNCTION opclasses_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT opclasses_are( $1, $2, 'Schema ' || quote_ident($1) || ' should have the correct operator classes' ); -$$ LANGUAGE SQL; - --- opclasses_are( opclasses[], description ) -CREATE OR REPLACE FUNCTION opclasses_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'operator classes', - ARRAY( - SELECT oc.opcname - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_opclass_is_visible(oc.oid) - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT oc.opcname - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_opclass_is_visible(oc.oid) - ), - $2 - ); -$$ LANGUAGE SQL; - --- opclasses_are( opclasses[] ) -CREATE OR REPLACE FUNCTION opclasses_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT opclasses_are( $1, 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct operator classes' ); -$$ LANGUAGE SQL; - --- rules_are( schema, table, rules[], description ) -CREATE OR REPLACE FUNCTION rules_are( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'rules', - ARRAY( - SELECT r.rulename - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - WHERE c.relname = $2 - AND n.nspname = $1 - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ), - ARRAY( - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT r.rulename - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - WHERE c.relname = $2 - AND n.nspname = $1 - ), - $4 - ); -$$ LANGUAGE SQL; - --- rules_are( schema, table, rules[] ) -CREATE OR REPLACE FUNCTION rules_are( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT rules_are( $1, $2, $3, 'Relation ' || quote_ident($1) || '.' || quote_ident($2) || ' should have the correct rules' ); -$$ LANGUAGE SQL; - --- rules_are( table, rules[], description ) -CREATE OR REPLACE FUNCTION rules_are( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'rules', - ARRAY( - SELECT r.rulename - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - WHERE c.relname = $1 - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_table_is_visible(c.oid) - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT r.rulename - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - AND c.relname = $1 - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_table_is_visible(c.oid) - ), - $3 - ); -$$ LANGUAGE SQL; - --- rules_are( table, rules[] ) -CREATE OR REPLACE FUNCTION rules_are( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT rules_are( $1, $2, 'Relation ' || quote_ident($1) || ' should have the correct rules' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _is_instead( NAME, NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT r.is_instead - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - WHERE r.rulename = $3 - AND c.relname = $2 - AND n.nspname = $1 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _is_instead( NAME, NAME ) -RETURNS BOOLEAN AS $$ - SELECT r.is_instead - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - WHERE r.rulename = $2 - AND c.relname = $1 - AND pg_catalog.pg_table_is_visible(c.oid) -$$ LANGUAGE SQL; - --- has_rule( schema, table, rule, description ) -CREATE OR REPLACE FUNCTION has_rule( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2, $3) IS NOT NULL, $4 ); -$$ LANGUAGE SQL; - --- has_rule( schema, table, rule ) -CREATE OR REPLACE FUNCTION has_rule( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2, $3) IS NOT NULL, 'Relation ' || quote_ident($1) || '.' || quote_ident($2) || ' should have rule ' || quote_ident($3) ); -$$ LANGUAGE SQL; - --- has_rule( table, rule, description ) -CREATE OR REPLACE FUNCTION has_rule( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2) IS NOT NULL, $3 ); -$$ LANGUAGE SQL; - --- has_rule( table, rule ) -CREATE OR REPLACE FUNCTION has_rule( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2) IS NOT NULL, 'Relation ' || quote_ident($1) || ' should have rule ' || quote_ident($2) ); -$$ LANGUAGE SQL; - --- hasnt_rule( schema, table, rule, description ) -CREATE OR REPLACE FUNCTION hasnt_rule( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2, $3) IS NULL, $4 ); -$$ LANGUAGE SQL; - --- hasnt_rule( schema, table, rule ) -CREATE OR REPLACE FUNCTION hasnt_rule( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2, $3) IS NULL, 'Relation ' || quote_ident($1) || '.' || quote_ident($2) || ' should not have rule ' || quote_ident($3) ); -$$ LANGUAGE SQL; - --- hasnt_rule( table, rule, description ) -CREATE OR REPLACE FUNCTION hasnt_rule( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2) IS NULL, $3 ); -$$ LANGUAGE SQL; - --- hasnt_rule( table, rule ) -CREATE OR REPLACE FUNCTION hasnt_rule( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT ok( _is_instead($1, $2) IS NULL, 'Relation ' || quote_ident($1) || ' should not have rule ' || quote_ident($2) ); -$$ LANGUAGE SQL; - --- rule_is_instead( schema, table, rule, description ) -CREATE OR REPLACE FUNCTION rule_is_instead( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - is_it boolean := _is_instead($1, $2, $3); -BEGIN - IF is_it IS NOT NULL THEN RETURN ok( is_it, $4 ); END IF; - RETURN ok( FALSE, $4 ) || E'\n' || diag( - ' Rule ' || quote_ident($3) || ' does not exist' - ); -END; -$$ LANGUAGE plpgsql; - --- rule_is_instead( schema, table, rule ) -CREATE OR REPLACE FUNCTION rule_is_instead( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT rule_is_instead( $1, $2, $3, 'Rule ' || quote_ident($3) || ' on relation ' || quote_ident($1) || '.' || quote_ident($2) || ' should be an INSTEAD rule' ); -$$ LANGUAGE SQL; - --- rule_is_instead( table, rule, description ) -CREATE OR REPLACE FUNCTION rule_is_instead( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - is_it boolean := _is_instead($1, $2); -BEGIN - IF is_it IS NOT NULL THEN RETURN ok( is_it, $3 ); END IF; - RETURN ok( FALSE, $3 ) || E'\n' || diag( - ' Rule ' || quote_ident($2) || ' does not exist' - ); -END; -$$ LANGUAGE plpgsql; - --- rule_is_instead( table, rule ) -CREATE OR REPLACE FUNCTION rule_is_instead( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT rule_is_instead($1, $2, 'Rule ' || quote_ident($2) || ' on relation ' || quote_ident($1) || ' should be an INSTEAD rule' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _expand_on( char ) -RETURNS text AS $$ - SELECT CASE $1 - WHEN '1' THEN 'SELECT' - WHEN '2' THEN 'UPDATE' - WHEN '3' THEN 'INSERT' - WHEN '4' THEN 'DELETE' - ELSE 'UNKNOWN' END -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _contract_on( TEXT ) -RETURNS "char" AS $$ - SELECT CASE substring(LOWER($1) FROM 1 FOR 1) - WHEN 's' THEN '1'::"char" - WHEN 'u' THEN '2'::"char" - WHEN 'i' THEN '3'::"char" - WHEN 'd' THEN '4'::"char" - ELSE '0'::"char" END -$$ LANGUAGE SQL IMMUTABLE; - -CREATE OR REPLACE FUNCTION _rule_on( NAME, NAME, NAME ) -RETURNS "char" AS $$ - SELECT r.ev_type - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid - WHERE r.rulename = $3 - AND c.relname = $2 - AND n.nspname = $1 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _rule_on( NAME, NAME ) -RETURNS "char" AS $$ - SELECT r.ev_type - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class - WHERE r.rulename = $2 - AND c.relname = $1 -$$ LANGUAGE SQL; - --- rule_is_on( schema, table, rule, event, description ) -CREATE OR REPLACE FUNCTION rule_is_on( NAME, NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want char := _contract_on($4); - have char := _rule_on($1, $2, $3); -BEGIN - IF have IS NOT NULL THEN - RETURN is( _expand_on(have), _expand_on(want), $5 ); - END IF; - - RETURN ok( false, $5 ) || E'\n' || diag( - ' Rule ' || quote_ident($3) || ' does not exist on ' - || quote_ident($1) || '.' || quote_ident($2) - ); -END; -$$ LANGUAGE plpgsql; - --- rule_is_on( schema, table, rule, event ) -CREATE OR REPLACE FUNCTION rule_is_on( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT rule_is_on( - $1, $2, $3, $4, - 'Rule ' || quote_ident($3) || ' should be on ' || _expand_on(_contract_on($4)::char) - || ' to ' || quote_ident($1) || '.' || quote_ident($2) - ); -$$ LANGUAGE SQL; - --- rule_is_on( table, rule, event, description ) -CREATE OR REPLACE FUNCTION rule_is_on( NAME, NAME, TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - want char := _contract_on($3); - have char := _rule_on($1, $2); -BEGIN - IF have IS NOT NULL THEN - RETURN is( _expand_on(have), _expand_on(want), $4 ); - END IF; - - RETURN ok( false, $4 ) || E'\n' || diag( - ' Rule ' || quote_ident($2) || ' does not exist on ' - || quote_ident($1) - ); -END; -$$ LANGUAGE plpgsql; - --- rule_is_on( table, rule, event ) -CREATE OR REPLACE FUNCTION rule_is_on( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT rule_is_on( - $1, $2, $3, - 'Rule ' || quote_ident($2) || ' should be on ' - || _expand_on(_contract_on($3)::char) || ' to ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION check_test( TEXT, BOOLEAN, TEXT, TEXT, TEXT, BOOLEAN ) -RETURNS SETOF TEXT AS $$ -DECLARE - tnumb INTEGER; - aok BOOLEAN; - adescr TEXT; - res BOOLEAN; - descr TEXT; - adiag TEXT; - have ALIAS FOR $1; - eok ALIAS FOR $2; - name ALIAS FOR $3; - edescr ALIAS FOR $4; - ediag ALIAS FOR $5; - matchit ALIAS FOR $6; -BEGIN - -- What test was it that just ran? - tnumb := currval('__tresults___numb_seq'); - - -- Fetch the results. - aok := substring(have, 1, 2) = 'ok'; - adescr := COALESCE(substring(have FROM E'(?:not )?ok [[:digit:]]+ - ([^\n]+)'), ''); - - -- Now delete those results. - EXECUTE 'ALTER SEQUENCE __tresults___numb_seq RESTART WITH ' || tnumb; - IF NOT aok THEN PERFORM _set('failed', _get('failed') - 1); END IF; - - -- Set up the description. - descr := coalesce( name || ' ', 'Test ' ) || 'should '; - - -- So, did the test pass? - RETURN NEXT is( - aok, - eok, - descr || CASE eok WHEN true then 'pass' ELSE 'fail' END - ); - - -- Was the description as expected? - IF edescr IS NOT NULL THEN - RETURN NEXT is( - adescr, - edescr, - descr || 'have the proper description' - ); - END IF; - - -- Were the diagnostics as expected? - IF ediag IS NOT NULL THEN - -- Remove ok and the test number. - adiag := substring( - have - FROM CASE WHEN aok THEN 4 ELSE 9 END + char_length(tnumb::text) - ); - - -- Remove the description, if there is one. - IF adescr <> '' THEN - adiag := substring( - adiag FROM 1 + char_length( ' - ' || substr(diag( adescr ), 3) ) - ); - END IF; - - IF NOT aok THEN - -- Remove failure message from ok(). - adiag := substring(adiag FROM 1 + char_length(diag( - 'Failed test ' || tnumb || - CASE adescr WHEN '' THEN '' ELSE COALESCE(': "' || adescr || '"', '') END - ))); - END IF; - - IF ediag <> '' THEN - -- Remove the space before the diagnostics. - adiag := substring(adiag FROM 2); - END IF; - - -- Remove the #s. - adiag := replace( substring(adiag from 3), E'\n# ', E'\n' ); - - -- Now compare the diagnostics. - IF matchit THEN - RETURN NEXT matches( - adiag, - ediag, - descr || 'have the proper diagnostics' - ); - ELSE - RETURN NEXT is( - adiag, - ediag, - descr || 'have the proper diagnostics' - ); - END IF; - END IF; - - -- And we're done - RETURN; -END; -$$ LANGUAGE plpgsql; - --- check_test( test_output, pass, name, description, diag ) -CREATE OR REPLACE FUNCTION check_test( TEXT, BOOLEAN, TEXT, TEXT, TEXT ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM check_test( $1, $2, $3, $4, $5, FALSE ); -$$ LANGUAGE sql; - --- check_test( test_output, pass, name, description ) -CREATE OR REPLACE FUNCTION check_test( TEXT, BOOLEAN, TEXT, TEXT ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM check_test( $1, $2, $3, $4, NULL, FALSE ); -$$ LANGUAGE sql; - --- check_test( test_output, pass, name ) -CREATE OR REPLACE FUNCTION check_test( TEXT, BOOLEAN, TEXT ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM check_test( $1, $2, $3, NULL, NULL, FALSE ); -$$ LANGUAGE sql; - --- check_test( test_output, pass ) -CREATE OR REPLACE FUNCTION check_test( TEXT, BOOLEAN ) -RETURNS SETOF TEXT AS $$ - SELECT * FROM check_test( $1, $2, NULL, NULL, NULL, FALSE ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION isnt_empty( TEXT, TEXT ) -RETURNS TEXT AS $$ -DECLARE - res BOOLEAN := FALSE; - rec RECORD; -BEGIN - -- Find extra records. - FOR rec in EXECUTE _query($1) LOOP - res := TRUE; - EXIT; - END LOOP; - - RETURN ok(res, $2); -END; -$$ LANGUAGE plpgsql; - --- isnt_empty( sql ) -CREATE OR REPLACE FUNCTION isnt_empty( TEXT ) -RETURNS TEXT AS $$ - SELECT isnt_empty( $1, NULL ); -$$ LANGUAGE sql; - --- collect_tap( tap, tap, tap ) -CREATE OR REPLACE FUNCTION roles_are( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'roles', - ARRAY( - SELECT rolname - FROM pg_catalog.pg_roles - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT rolname - FROM pg_catalog.pg_roles - ), - $2 - ); -$$ LANGUAGE SQL; - --- roles_are( roles[] ) -CREATE OR REPLACE FUNCTION roles_are( NAME[] ) -RETURNS TEXT AS $$ - SELECT roles_are( $1, 'There should be the correct roles' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _types_are ( NAME, NAME[], TEXT, CHAR[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'types', - ARRAY( - SELECT t.typname - FROM pg_catalog.pg_type t - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE ( - t.typrelid = 0 - OR (SELECT c.relkind = 'c' FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid) - ) - AND NOT EXISTS(SELECT 1 FROM pg_catalog.pg_type el WHERE el.oid = t.typelem AND el.typarray = t.oid) - AND n.nspname = $1 - AND t.typtype = ANY( COALESCE($4, ARRAY['b', 'c', 'd', 'p', 'e']) ) - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT t.typname - FROM pg_catalog.pg_type t - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE ( - t.typrelid = 0 - OR (SELECT c.relkind = 'c' FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid) - ) - AND NOT EXISTS(SELECT 1 FROM pg_catalog.pg_type el WHERE el.oid = t.typelem AND el.typarray = t.oid) - AND n.nspname = $1 - AND t.typtype = ANY( COALESCE($4, ARRAY['b', 'c', 'd', 'p', 'e']) ) - ), - $3 - ); -$$ LANGUAGE SQL; - --- types_are( schema, types[], description ) -CREATE OR REPLACE FUNCTION types_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, $3, NULL ); -$$ LANGUAGE SQL; - --- types_are( schema, types[] ) -CREATE OR REPLACE FUNCTION types_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, 'Schema ' || quote_ident($1) || ' should have the correct types', NULL ); -$$ LANGUAGE SQL; - --- types_are( types[], description ) -CREATE OR REPLACE FUNCTION _types_are ( NAME[], TEXT, CHAR[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'types', - ARRAY( - SELECT t.typname - FROM pg_catalog.pg_type t - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE ( - t.typrelid = 0 - OR (SELECT c.relkind = 'c' FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid) - ) - AND NOT EXISTS(SELECT 1 FROM pg_catalog.pg_type el WHERE el.oid = t.typelem AND el.typarray = t.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_type_is_visible(t.oid) - AND t.typtype = ANY( COALESCE($3, ARRAY['b', 'c', 'd', 'p', 'e']) ) - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT t.typname - FROM pg_catalog.pg_type t - LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE ( - t.typrelid = 0 - OR (SELECT c.relkind = 'c' FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid) - ) - AND NOT EXISTS(SELECT 1 FROM pg_catalog.pg_type el WHERE el.oid = t.typelem AND el.typarray = t.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_type_is_visible(t.oid) - AND t.typtype = ANY( COALESCE($3, ARRAY['b', 'c', 'd', 'p', 'e']) ) - ), - $2 - ); -$$ LANGUAGE SQL; - - --- types_are( types[], description ) -CREATE OR REPLACE FUNCTION types_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, NULL ); -$$ LANGUAGE SQL; - --- types_are( types[] ) -CREATE OR REPLACE FUNCTION types_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct types', NULL ); -$$ LANGUAGE SQL; - --- domains_are( schema, domains[], description ) -CREATE OR REPLACE FUNCTION domains_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, $3, ARRAY['d'] ); -$$ LANGUAGE SQL; - --- domains_are( schema, domains[] ) -CREATE OR REPLACE FUNCTION domains_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, 'Schema ' || quote_ident($1) || ' should have the correct domains', ARRAY['d'] ); -$$ LANGUAGE SQL; - --- domains_are( domains[], description ) -CREATE OR REPLACE FUNCTION domains_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, ARRAY['d'] ); -$$ LANGUAGE SQL; - --- domains_are( domains[] ) -CREATE OR REPLACE FUNCTION domains_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct domains', ARRAY['d'] ); -$$ LANGUAGE SQL; - --- enums_are( schema, enums[], description ) -CREATE OR REPLACE FUNCTION enums_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, $3, ARRAY['e'] ); -$$ LANGUAGE SQL; - --- enums_are( schema, enums[] ) -CREATE OR REPLACE FUNCTION enums_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, 'Schema ' || quote_ident($1) || ' should have the correct enums', ARRAY['e'] ); -$$ LANGUAGE SQL; - --- enums_are( enums[], description ) -CREATE OR REPLACE FUNCTION enums_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, $2, ARRAY['e'] ); -$$ LANGUAGE SQL; - --- enums_are( enums[] ) -CREATE OR REPLACE FUNCTION enums_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _types_are( $1, 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct enums', ARRAY['e'] ); -$$ LANGUAGE SQL; - --- _dexists( schema, domain ) -CREATE OR REPLACE FUNCTION triggers_are( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'triggers', - ARRAY( - SELECT t.tgname - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ), - ARRAY( - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT t.tgname - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - ), - $4 - ); -$$ LANGUAGE SQL; - --- triggers_are( schema, table, triggers[] ) -CREATE OR REPLACE FUNCTION triggers_are( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT triggers_are( $1, $2, $3, 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should have the correct triggers' ); -$$ LANGUAGE SQL; - --- triggers_are( table, triggers[], description ) -CREATE OR REPLACE FUNCTION triggers_are( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'triggers', - ARRAY( - SELECT t.tgname - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = $1 - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT t.tgname - FROM pg_catalog.pg_trigger t - JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ), - $3 - ); -$$ LANGUAGE SQL; - --- triggers_are( table, triggers[] ) -CREATE OR REPLACE FUNCTION triggers_are( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT triggers_are( $1, $2, 'Table ' || quote_ident($1) || ' should have the correct triggers' ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _areni ( text, text[], text[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - what ALIAS FOR $1; - extras ALIAS FOR $2; - missing ALIAS FOR $3; - descr ALIAS FOR $4; - msg TEXT := ''; - res BOOLEAN := TRUE; -BEGIN - IF extras[1] IS NOT NULL THEN - res = FALSE; - msg := E'\n' || diag( - ' Extra ' || what || E':\n ' - || array_to_string( extras, E'\n ' ) - ); - END IF; - IF missing[1] IS NOT NULL THEN - res = FALSE; - msg := msg || E'\n' || diag( - ' Missing ' || what || E':\n ' - || array_to_string( missing, E'\n ' ) - ); - END IF; - - RETURN ok(res, descr) || msg; -END; -$$ LANGUAGE plpgsql; - - --- casts_are( casts[], description ) -CREATE OR REPLACE FUNCTION casts_are ( TEXT[], TEXT ) -RETURNS TEXT AS $$ - SELECT _areni( - 'casts', - ARRAY( - SELECT pg_catalog.format_type(castsource, NULL) - || ' AS ' || pg_catalog.format_type(casttarget, NULL) - FROM pg_catalog.pg_cast c - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT pg_catalog.format_type(castsource, NULL) - || ' AS ' || pg_catalog.format_type(casttarget, NULL) - FROM pg_catalog.pg_cast c - ), - $2 - ); -$$ LANGUAGE sql; - --- casts_are( casts[] ) -CREATE OR REPLACE FUNCTION casts_are ( TEXT[] ) -RETURNS TEXT AS $$ - SELECT casts_are( $1, 'There should be the correct casts'); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION display_oper ( NAME, OID ) -RETURNS TEXT AS $$ - SELECT $1 || substring($2::regoperator::text, '[(][^)]+[)]$') -$$ LANGUAGE SQL; - --- operators_are( schema, operators[], description ) -CREATE OR REPLACE FUNCTION operators_are( NAME, TEXT[], TEXT ) -RETURNS TEXT AS $$ - SELECT _areni( - 'operators', - ARRAY( - SELECT display_oper(o.oprname, o.oid) || ' RETURNS ' || o.oprresult::regtype - FROM pg_catalog.pg_operator o - JOIN pg_catalog.pg_namespace n ON o.oprnamespace = n.oid - WHERE n.nspname = $1 - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT display_oper(o.oprname, o.oid) || ' RETURNS ' || o.oprresult::regtype - FROM pg_catalog.pg_operator o - JOIN pg_catalog.pg_namespace n ON o.oprnamespace = n.oid - WHERE n.nspname = $1 - ), - $3 - ); -$$ LANGUAGE SQL; - --- operators_are( schema, operators[] ) -CREATE OR REPLACE FUNCTION operators_are ( NAME, TEXT[] ) -RETURNS TEXT AS $$ - SELECT operators_are($1, $2, 'Schema ' || quote_ident($1) || ' should have the correct operators' ); -$$ LANGUAGE SQL; - --- operators_are( operators[], description ) -CREATE OR REPLACE FUNCTION operators_are( TEXT[], TEXT ) -RETURNS TEXT AS $$ - SELECT _areni( - 'operators', - ARRAY( - SELECT display_oper(o.oprname, o.oid) || ' RETURNS ' || o.oprresult::regtype - FROM pg_catalog.pg_operator o - JOIN pg_catalog.pg_namespace n ON o.oprnamespace = n.oid - WHERE pg_catalog.pg_operator_is_visible(o.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - EXCEPT - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - ), - ARRAY( - SELECT $1[i] - FROM generate_series(1, array_upper($1, 1)) s(i) - EXCEPT - SELECT display_oper(o.oprname, o.oid) || ' RETURNS ' || o.oprresult::regtype - FROM pg_catalog.pg_operator o - JOIN pg_catalog.pg_namespace n ON o.oprnamespace = n.oid - WHERE pg_catalog.pg_operator_is_visible(o.oid) - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ), - $2 - ); -$$ LANGUAGE SQL; - --- operators_are( operators[] ) -CREATE OR REPLACE FUNCTION operators_are ( TEXT[] ) -RETURNS TEXT AS $$ - SELECT operators_are($1, 'There should be the correct operators') -$$ LANGUAGE SQL; - --- columns_are( schema, table, columns[], description ) -CREATE OR REPLACE FUNCTION columns_are( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'columns', - ARRAY( - SELECT a.attname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ), - ARRAY( - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT a.attname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - ), - $4 - ); -$$ LANGUAGE SQL; - --- columns_are( schema, table, columns[] ) -CREATE OR REPLACE FUNCTION columns_are( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT columns_are( $1, $2, $3, 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should have the correct columns' ); -$$ LANGUAGE SQL; - --- columns_are( table, columns[], description ) -CREATE OR REPLACE FUNCTION columns_are( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( - 'columns', - ARRAY( - SELECT a.attname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $1 - AND a.attnum > 0 - AND NOT a.attisdropped - EXCEPT - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - ), - ARRAY( - SELECT $2[i] - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT a.attname - FROM pg_catalog.pg_namespace n - JOIN pg_catalog.pg_class c ON n.oid = c.relnamespace - JOIN pg_catalog.pg_attribute a ON c.oid = a.attrelid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND pg_catalog.pg_table_is_visible(c.oid) - AND c.relname = $1 - AND a.attnum > 0 - AND NOT a.attisdropped - ), - $3 - ); -$$ LANGUAGE SQL; - --- columns_are( table, columns[] ) -CREATE OR REPLACE FUNCTION columns_are( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT columns_are( $1, $2, 'Table ' || quote_ident($1) || ' should have the correct columns' ); -$$ LANGUAGE SQL; - --- _get_db_owner( dbname ) -CREATE OR REPLACE FUNCTION _get_db_owner( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(datdba) - FROM pg_catalog.pg_database - WHERE datname = $1; -$$ LANGUAGE SQL; - --- db_owner_is ( dbname, user, description ) -CREATE OR REPLACE FUNCTION db_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - dbowner NAME := _get_db_owner($1); -BEGIN - -- Make sure the database exists. - IF dbowner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Database ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(dbowner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- db_owner_is ( dbname, user ) -CREATE OR REPLACE FUNCTION db_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT db_owner_is( - $1, $2, - 'Database ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- _get_schema_owner( schema ) -CREATE OR REPLACE FUNCTION _get_schema_owner( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(nspowner) - FROM pg_catalog.pg_namespace - WHERE nspname = $1; -$$ LANGUAGE SQL; - --- schema_owner_is ( schema, user, description ) -CREATE OR REPLACE FUNCTION schema_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_schema_owner($1); -BEGIN - -- Make sure the schema exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Schema ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- schema_owner_is ( schema, user ) -CREATE OR REPLACE FUNCTION schema_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT schema_owner_is( - $1, $2, - 'Schema ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_rel_owner ( NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(c.relowner) - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_rel_owner ( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(c.relowner) - FROM pg_catalog.pg_class c - WHERE c.relname = $1 - AND pg_catalog.pg_table_is_visible(c.oid) -$$ LANGUAGE SQL; - --- relation_owner_is ( schema, relation, user, description ) -CREATE OR REPLACE FUNCTION relation_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner($1, $2); -BEGIN - -- Make sure the relation exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Relation ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- relation_owner_is ( schema, relation, user ) -CREATE OR REPLACE FUNCTION relation_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT relation_owner_is( - $1, $2, $3, - 'Relation ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- relation_owner_is ( relation, user, description ) -CREATE OR REPLACE FUNCTION relation_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner($1); -BEGIN - -- Make sure the relation exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Relation ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- relation_owner_is ( relation, user ) -CREATE OR REPLACE FUNCTION relation_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT relation_owner_is( - $1, $2, - 'Relation ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_rel_owner ( CHAR, NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(c.relowner) - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind = $1 - AND n.nspname = $2 - AND c.relname = $3 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_rel_owner ( CHAR, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(c.relowner) - FROM pg_catalog.pg_class c - WHERE c.relkind = $1 - AND c.relname = $2 - AND pg_catalog.pg_table_is_visible(c.oid) -$$ LANGUAGE SQL; - --- table_owner_is ( schema, table, user, description ) -CREATE OR REPLACE FUNCTION table_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('r'::char, $1, $2); -BEGIN - -- Make sure the table exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- table_owner_is ( schema, table, user ) -CREATE OR REPLACE FUNCTION table_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT table_owner_is( - $1, $2, $3, - 'Table ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- table_owner_is ( table, user, description ) -CREATE OR REPLACE FUNCTION table_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('r'::char, $1); -BEGIN - -- Make sure the table exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Table ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- table_owner_is ( table, user ) -CREATE OR REPLACE FUNCTION table_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT table_owner_is( - $1, $2, - 'Table ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- view_owner_is ( schema, view, user, description ) -CREATE OR REPLACE FUNCTION view_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('v'::char, $1, $2); -BEGIN - -- Make sure the view exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' View ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- view_owner_is ( schema, view, user ) -CREATE OR REPLACE FUNCTION view_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT view_owner_is( - $1, $2, $3, - 'View ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- view_owner_is ( view, user, description ) -CREATE OR REPLACE FUNCTION view_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('v'::char, $1); -BEGIN - -- Make sure the view exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' View ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- view_owner_is ( view, user ) -CREATE OR REPLACE FUNCTION view_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT view_owner_is( - $1, $2, - 'View ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- sequence_owner_is ( schema, sequence, user, description ) -CREATE OR REPLACE FUNCTION sequence_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('S'::char, $1, $2); -BEGIN - -- Make sure the sequence exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Sequence ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- sequence_owner_is ( schema, sequence, user ) -CREATE OR REPLACE FUNCTION sequence_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT sequence_owner_is( - $1, $2, $3, - 'Sequence ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- sequence_owner_is ( sequence, user, description ) -CREATE OR REPLACE FUNCTION sequence_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('S'::char, $1); -BEGIN - -- Make sure the sequence exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Sequence ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- sequence_owner_is ( sequence, user ) -CREATE OR REPLACE FUNCTION sequence_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT sequence_owner_is( - $1, $2, - 'Sequence ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- composite_owner_is ( schema, composite, user, description ) -CREATE OR REPLACE FUNCTION composite_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('c'::char, $1, $2); -BEGIN - -- Make sure the composite exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Composite type ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- composite_owner_is ( schema, composite, user ) -CREATE OR REPLACE FUNCTION composite_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT composite_owner_is( - $1, $2, $3, - 'Composite type ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- composite_owner_is ( composite, user, description ) -CREATE OR REPLACE FUNCTION composite_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('c'::char, $1); -BEGIN - -- Make sure the composite exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Composite type ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- composite_owner_is ( composite, user ) -CREATE OR REPLACE FUNCTION composite_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT composite_owner_is( - $1, $2, - 'Composite type ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- foreign_table_owner_is ( schema, table, user, description ) -CREATE OR REPLACE FUNCTION foreign_table_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('f'::char, $1, $2); -BEGIN - -- Make sure the table exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Foreign table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- foreign_table_owner_is ( schema, table, user ) -CREATE OR REPLACE FUNCTION foreign_table_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT foreign_table_owner_is( - $1, $2, $3, - 'Foreign table ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- foreign_table_owner_is ( table, user, description ) -CREATE OR REPLACE FUNCTION foreign_table_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('f'::char, $1); -BEGIN - -- Make sure the table exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Foreign table ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- foreign_table_owner_is ( table, user ) -CREATE OR REPLACE FUNCTION foreign_table_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT foreign_table_owner_is( - $1, $2, - 'Foreign table ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_func_owner ( NAME, NAME, NAME[] ) -RETURNS NAME AS $$ - SELECT owner - FROM tap_funky - WHERE schema = $1 - AND name = $2 - AND args = array_to_string($3, ',') -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_func_owner ( NAME, NAME[] ) -RETURNS NAME AS $$ - SELECT owner - FROM tap_funky - WHERE name = $1 - AND args = array_to_string($2, ',') - AND is_visible -$$ LANGUAGE SQL; - --- function_owner_is( schema, function, args[], user, description ) -CREATE OR REPLACE FUNCTION function_owner_is ( NAME, NAME, NAME[], NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_func_owner($1, $2, $3); -BEGIN - -- Make sure the function exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - E' Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') does not exist' - ); - END IF; - - RETURN is(owner, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- function_owner_is( schema, function, args[], user ) -CREATE OR REPLACE FUNCTION function_owner_is( NAME, NAME, NAME[], NAME ) -RETURNS TEXT AS $$ - SELECT function_owner_is( - $1, $2, $3, $4, - 'Function ' || quote_ident($1) || '.' || quote_ident($2) || '(' || - array_to_string($3, ', ') || ') should be owned by ' || quote_ident($4) - ); -$$ LANGUAGE sql; - --- function_owner_is( function, args[], user, description ) -CREATE OR REPLACE FUNCTION function_owner_is ( NAME, NAME[], NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_func_owner($1, $2); -BEGIN - -- Make sure the function exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- function_owner_is( function, args[], user ) -CREATE OR REPLACE FUNCTION function_owner_is( NAME, NAME[], NAME ) -RETURNS TEXT AS $$ - SELECT function_owner_is( - $1, $2, $3, - 'Function ' || quote_ident($1) || '(' || - array_to_string($2, ', ') || ') should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- _get_tablespace_owner( tablespace ) -CREATE OR REPLACE FUNCTION _get_tablespace_owner( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(spcowner) - FROM pg_catalog.pg_tablespace - WHERE spcname = $1; -$$ LANGUAGE SQL; - --- tablespace_owner_is ( tablespace, user, description ) -CREATE OR REPLACE FUNCTION tablespace_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_tablespace_owner($1); -BEGIN - -- Make sure the tablespace exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Tablespace ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- tablespace_owner_is ( tablespace, user ) -CREATE OR REPLACE FUNCTION tablespace_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT tablespace_owner_is( - $1, $2, - 'Tablespace ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_index_owner( NAME, NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(ci.relowner) - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace - WHERE n.nspname = $1 - AND ct.relname = $2 - AND ci.relname = $3; -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_index_owner( NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(ci.relowner) - FROM pg_catalog.pg_index x - JOIN pg_catalog.pg_class ct ON ct.oid = x.indrelid - JOIN pg_catalog.pg_class ci ON ci.oid = x.indexrelid - WHERE ct.relname = $1 - AND ci.relname = $2 - AND pg_catalog.pg_table_is_visible(ct.oid); -$$ LANGUAGE sql; - --- index_owner_is ( schema, table, index, user, description ) -CREATE OR REPLACE FUNCTION index_owner_is ( NAME, NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_index_owner($1, $2, $3); -BEGIN - -- Make sure the index exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - E' Index ' || quote_ident($3) || ' ON ' - || quote_ident($1) || '.' || quote_ident($2) || ' not found' - ); - END IF; - - RETURN is(owner, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- index_owner_is ( schema, table, index, user ) -CREATE OR REPLACE FUNCTION index_owner_is ( NAME, NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT index_owner_is( - $1, $2, $3, $4, - 'Index ' || quote_ident($3) || ' ON ' - || quote_ident($1) || '.' || quote_ident($2) - || ' should be owned by ' || quote_ident($4) - ); -$$ LANGUAGE sql; - --- index_owner_is ( table, index, user, description ) -CREATE OR REPLACE FUNCTION index_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_index_owner($1, $2); -BEGIN - -- Make sure the index exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Index ' || quote_ident($2) || ' ON ' || quote_ident($1) || ' not found' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- index_owner_is ( table, index, user ) -CREATE OR REPLACE FUNCTION index_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT index_owner_is( - $1, $2, $3, - 'Index ' || quote_ident($2) || ' ON ' - || quote_ident($1) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- _get_language_owner( language ) -CREATE OR REPLACE FUNCTION _get_language_owner( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(lanowner) - FROM pg_catalog.pg_language - WHERE lanname = $1; -$$ LANGUAGE SQL; - --- language_owner_is ( language, user, description ) -CREATE OR REPLACE FUNCTION language_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_language_owner($1); -BEGIN - -- Make sure the language exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Language ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- language_owner_is ( language, user ) -CREATE OR REPLACE FUNCTION language_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT language_owner_is( - $1, $2, - 'Language ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_opclass_owner ( NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(opcowner) - FROM pg_catalog.pg_opclass oc - JOIN pg_catalog.pg_namespace n ON oc.opcnamespace = n.oid - WHERE n.nspname = $1 - AND opcname = $2; -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_opclass_owner ( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(opcowner) - FROM pg_catalog.pg_opclass - WHERE opcname = $1 - AND pg_catalog.pg_opclass_is_visible(oid); -$$ LANGUAGE SQL; - --- opclass_owner_is( schema, opclass, user, description ) -CREATE OR REPLACE FUNCTION opclass_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_opclass_owner($1, $2); -BEGIN - -- Make sure the opclass exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Operator class ' || quote_ident($1) || '.' || quote_ident($2) - || ' not found' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- opclass_owner_is( schema, opclass, user ) -CREATE OR REPLACE FUNCTION opclass_owner_is( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT opclass_owner_is( - $1, $2, $3, - 'Operator class ' || quote_ident($1) || '.' || quote_ident($2) || - ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- opclass_owner_is( opclass, user, description ) -CREATE OR REPLACE FUNCTION opclass_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_opclass_owner($1); -BEGIN - -- Make sure the opclass exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Operator class ' || quote_ident($1) || ' not found' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- opclass_owner_is( opclass, user ) -CREATE OR REPLACE FUNCTION opclass_owner_is( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT opclass_owner_is( - $1, $2, - 'Operator class ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _get_type_owner ( NAME, NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(t.typowner) - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = $1 - AND t.typname = $2 -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_type_owner ( NAME ) -RETURNS NAME AS $$ - SELECT pg_catalog.pg_get_userbyid(typowner) - FROM pg_catalog.pg_type - WHERE typname = $1 - AND pg_catalog.pg_type_is_visible(oid) -$$ LANGUAGE SQL; - --- type_owner_is ( schema, type, user, description ) -CREATE OR REPLACE FUNCTION type_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_type_owner($1, $2); -BEGIN - -- Make sure the type exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Type ' || quote_ident($1) || '.' || quote_ident($2) || ' not found' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- type_owner_is ( schema, type, user ) -CREATE OR REPLACE FUNCTION type_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT type_owner_is( - $1, $2, $3, - 'Type ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- type_owner_is ( type, user, description ) -CREATE OR REPLACE FUNCTION type_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_type_owner($1); -BEGIN - -- Make sure the type exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Type ' || quote_ident($1) || ' not found' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- type_owner_is ( type, user ) -CREATE OR REPLACE FUNCTION type_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT type_owner_is( - $1, $2, - 'Type ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - -CREATE OR REPLACE FUNCTION _assets_are ( text, text[], text[], TEXT ) -RETURNS TEXT AS $$ - SELECT _areni( - $1, - ARRAY( - SELECT UPPER($2[i]) AS thing - FROM generate_series(1, array_upper($2, 1)) s(i) - EXCEPT - SELECT $3[i] - FROM generate_series(1, array_upper($3, 1)) s(i) - ORDER BY thing - ), - ARRAY( - SELECT $3[i] AS thing - FROM generate_series(1, array_upper($3, 1)) s(i) - EXCEPT - SELECT UPPER($2[i]) - FROM generate_series(1, array_upper($2, 1)) s(i) - ORDER BY thing - ), - $4 - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_table_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := _table_privs(); - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - BEGIN - IF pg_catalog.has_table_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - EXCEPTION WHEN undefined_table THEN - -- Not a valid table name. - RETURN '{undefined_table}'; - WHEN undefined_object THEN - -- Not a valid role. - RETURN '{undefined_role}'; - WHEN invalid_parameter_value THEN - -- Not a valid permission on this version of PostgreSQL; ignore; - END; - END LOOP; - RETURN grants; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _table_privs() -RETURNS NAME[] AS $$ -DECLARE - pgversion INTEGER := pg_version_num(); -BEGIN - IF pgversion < 80200 THEN RETURN ARRAY[ - 'DELETE', 'INSERT', 'REFERENCES', 'RULE', 'SELECT', 'TRIGGER', 'UPDATE' - ]; - ELSIF pgversion < 80400 THEN RETURN ARRAY[ - 'DELETE', 'INSERT', 'REFERENCES', 'SELECT', 'TRIGGER', 'UPDATE' - ]; - ELSE RETURN ARRAY[ - 'DELETE', 'INSERT', 'REFERENCES', 'SELECT', 'TRIGGER', 'TRUNCATE', 'UPDATE' - ]; - END IF; -END; -$$ language plpgsql; - --- table_privs_are ( schema, table, user, privileges[], description ) -CREATE OR REPLACE FUNCTION table_privs_are ( NAME, NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_table_privs( $3, quote_ident($1) || '.' || quote_ident($2) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Role ' || quote_ident($3) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- table_privs_are ( schema, table, user, privileges[] ) -CREATE OR REPLACE FUNCTION table_privs_are ( NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT table_privs_are( - $1, $2, $3, $4, - 'Role ' || quote_ident($3) || ' should be granted ' - || CASE WHEN $4[1] IS NULL THEN 'no privileges' ELSE array_to_string($4, ', ') END - || ' on table ' || quote_ident($1) || '.' || quote_ident($2) - ); -$$ LANGUAGE SQL; - --- table_privs_are ( table, user, privileges[], description ) -CREATE OR REPLACE FUNCTION table_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_table_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- table_privs_are ( table, user, privileges[] ) -CREATE OR REPLACE FUNCTION table_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT table_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on table ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _db_privs() -RETURNS NAME[] AS $$ -DECLARE - pgversion INTEGER := pg_version_num(); -BEGIN - IF pgversion < 80200 THEN - RETURN ARRAY['CREATE', 'TEMPORARY']; - ELSE - RETURN ARRAY['CREATE', 'CONNECT', 'TEMPORARY']; - END IF; -END; -$$ language plpgsql; - -CREATE OR REPLACE FUNCTION _get_db_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := _db_privs(); - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - BEGIN - IF pg_catalog.has_database_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - EXCEPTION WHEN invalid_catalog_name THEN - -- Not a valid db name. - RETURN '{invalid_catalog_name}'; - WHEN undefined_object THEN - -- Not a valid role. - RETURN '{undefined_role}'; - WHEN invalid_parameter_value THEN - -- Not a valid permission on this version of PostgreSQL; ignore; - END; - END LOOP; - RETURN grants; -END; -$$ LANGUAGE plpgsql; - --- database_privs_are ( db, user, privileges[], description ) -CREATE OR REPLACE FUNCTION database_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_db_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'invalid_catalog_name' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Database ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- database_privs_are ( db, user, privileges[] ) -CREATE OR REPLACE FUNCTION database_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT database_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on database ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_func_privs(TEXT, TEXT) -RETURNS TEXT[] AS $$ -BEGIN - IF pg_catalog.has_function_privilege($1, $2, 'EXECUTE') THEN - RETURN '{EXECUTE}'; - ELSE - RETURN '{}'; - END IF; -EXCEPTION - -- Not a valid func name. - WHEN undefined_function THEN RETURN '{undefined_function}'; - -- Not a valid role. - WHEN undefined_object THEN RETURN '{undefined_role}'; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _fprivs_are ( TEXT, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_func_privs($2, $1); -BEGIN - IF grants[1] = 'undefined_function' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Function ' || $1 || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- function_privs_are ( schema, function, args[], user, privileges[], description ) -CREATE OR REPLACE FUNCTION function_privs_are ( NAME, NAME, NAME[], NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _fprivs_are( - quote_ident($1) || '.' || quote_ident($2) || '(' || array_to_string($3, ', ') || ')', - $4, $5, $6 - ); -$$ LANGUAGE SQL; - --- function_privs_are ( schema, function, args[], user, privileges[] ) -CREATE OR REPLACE FUNCTION function_privs_are ( NAME, NAME, NAME[], NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT function_privs_are( - $1, $2, $3, $4, $5, - 'Role ' || quote_ident($4) || ' should be granted ' - || CASE WHEN $5[1] IS NULL THEN 'no privileges' ELSE array_to_string($5, ', ') END - || ' on function ' || quote_ident($1) || '.' || quote_ident($2) - || '(' || array_to_string($3, ', ') || ')' - ); -$$ LANGUAGE SQL; - --- function_privs_are ( function, args[], user, privileges[], description ) -CREATE OR REPLACE FUNCTION function_privs_are ( NAME, NAME[], NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _fprivs_are( - quote_ident($1) || '(' || array_to_string($2, ', ') || ')', - $3, $4, $5 - ); -$$ LANGUAGE SQL; - --- function_privs_are ( function, args[], user, privileges[] ) -CREATE OR REPLACE FUNCTION function_privs_are ( NAME, NAME[], NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT function_privs_are( - $1, $2, $3, $4, - 'Role ' || quote_ident($3) || ' should be granted ' - || CASE WHEN $4[1] IS NULL THEN 'no privileges' ELSE array_to_string($4, ', ') END - || ' on function ' || quote_ident($1) || '(' || array_to_string($2, ', ') || ')' - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_lang_privs (NAME, TEXT) -RETURNS TEXT[] AS $$ -BEGIN - IF pg_catalog.has_language_privilege($1, $2, 'USAGE') THEN - RETURN '{USAGE}'; - ELSE - RETURN '{}'; - END IF; -EXCEPTION WHEN undefined_object THEN - -- Same error code for unknown user or language. So figure out which. - RETURN CASE WHEN SQLERRM LIKE '%' || $1 || '%' THEN - '{undefined_role}' - ELSE - '{undefined_language}' - END; -END; -$$ LANGUAGE plpgsql; - --- language_privs_are ( lang, user, privileges[], description ) -CREATE OR REPLACE FUNCTION language_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_lang_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_language' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Language ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- language_privs_are ( lang, user, privileges[] ) -CREATE OR REPLACE FUNCTION language_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT language_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on language ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_schema_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := ARRAY['CREATE', 'USAGE']; - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - IF pg_catalog.has_schema_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - END LOOP; - RETURN grants; -EXCEPTION - -- Not a valid schema name. - WHEN invalid_schema_name THEN RETURN '{invalid_schema_name}'; - -- Not a valid role. - WHEN undefined_object THEN RETURN '{undefined_role}'; -END; -$$ LANGUAGE plpgsql; - --- schema_privs_are ( schema, user, privileges[], description ) -CREATE OR REPLACE FUNCTION schema_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_schema_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'invalid_schema_name' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Schema ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- schema_privs_are ( schema, user, privileges[] ) -CREATE OR REPLACE FUNCTION schema_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT schema_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on schema ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_tablespaceprivs (NAME, TEXT) -RETURNS TEXT[] AS $$ -BEGIN - IF pg_catalog.has_tablespace_privilege($1, $2, 'CREATE') THEN - RETURN '{CREATE}'; - ELSE - RETURN '{}'; - END IF; -EXCEPTION WHEN undefined_object THEN - -- Same error code for unknown user or tablespace. So figure out which. - RETURN CASE WHEN SQLERRM LIKE '%' || $1 || '%' THEN - '{undefined_role}' - ELSE - '{undefined_tablespace}' - END; -END; -$$ LANGUAGE plpgsql; - --- tablespace_privs_are ( tablespace, user, privileges[], description ) -CREATE OR REPLACE FUNCTION tablespace_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_tablespaceprivs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_tablespace' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Tablespace ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- tablespace_privs_are ( tablespace, user, privileges[] ) -CREATE OR REPLACE FUNCTION tablespace_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT tablespace_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on tablespace ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_sequence_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := ARRAY['SELECT', 'UPDATE', 'USAGE']; - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - BEGIN - IF pg_catalog.has_sequence_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - EXCEPTION WHEN undefined_table THEN - -- Not a valid sequence name. - RETURN '{undefined_table}'; - WHEN undefined_object THEN - -- Not a valid role. - RETURN '{undefined_role}'; - WHEN invalid_parameter_value THEN - -- Not a valid permission on this version of PostgreSQL; ignore; - END; - END LOOP; - RETURN grants; -END; -$$ LANGUAGE plpgsql; - --- sequence_privs_are ( schema, sequence, user, privileges[], description ) -CREATE OR REPLACE FUNCTION sequence_privs_are ( NAME, NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_sequence_privs( $3, quote_ident($1) || '.' || quote_ident($2) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Sequence ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Role ' || quote_ident($3) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- sequence_privs_are ( schema, sequence, user, privileges[] ) -CREATE OR REPLACE FUNCTION sequence_privs_are ( NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT sequence_privs_are( - $1, $2, $3, $4, - 'Role ' || quote_ident($3) || ' should be granted ' - || CASE WHEN $4[1] IS NULL THEN 'no privileges' ELSE array_to_string($4, ', ') END - || ' on sequence '|| quote_ident($1) || '.' || quote_ident($2) - ); -$$ LANGUAGE SQL; - --- sequence_privs_are ( sequence, user, privileges[], description ) -CREATE OR REPLACE FUNCTION sequence_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_sequence_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Sequence ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- sequence_privs_are ( sequence, user, privileges[] ) -CREATE OR REPLACE FUNCTION sequence_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT sequence_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on sequence ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_ac_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := ARRAY['INSERT', 'REFERENCES', 'SELECT', 'UPDATE']; - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - BEGIN - IF pg_catalog.has_any_column_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - EXCEPTION WHEN undefined_table THEN - -- Not a valid table name. - RETURN '{undefined_table}'; - WHEN undefined_object THEN - -- Not a valid role. - RETURN '{undefined_role}'; - WHEN invalid_parameter_value THEN - -- Not a valid permission on this version of PostgreSQL; ignore; - END; - END LOOP; - RETURN grants; -END; -$$ LANGUAGE plpgsql; - --- any_column_privs_are ( schema, table, user, privileges[], description ) -CREATE OR REPLACE FUNCTION any_column_privs_are ( NAME, NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_ac_privs( $3, quote_ident($1) || '.' || quote_ident($2) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Role ' || quote_ident($3) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- any_column_privs_are ( schema, table, user, privileges[] ) -CREATE OR REPLACE FUNCTION any_column_privs_are ( NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT any_column_privs_are( - $1, $2, $3, $4, - 'Role ' || quote_ident($3) || ' should be granted ' - || CASE WHEN $4[1] IS NULL THEN 'no privileges' ELSE array_to_string($4, ', ') END - || ' on any column in '|| quote_ident($1) || '.' || quote_ident($2) - ); -$$ LANGUAGE SQL; - --- any_column_privs_are ( table, user, privileges[], description ) -CREATE OR REPLACE FUNCTION any_column_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_ac_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- any_column_privs_are ( table, user, privileges[] ) -CREATE OR REPLACE FUNCTION any_column_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT any_column_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on any column in ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_col_privs(NAME, TEXT, NAME) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := ARRAY['INSERT', 'REFERENCES', 'SELECT', 'UPDATE']; - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - IF pg_catalog.has_column_privilege($1, $2, $3, privs[i]) THEN - grants := grants || privs[i]; - END IF; - END LOOP; - RETURN grants; -EXCEPTION - -- Not a valid column name. - WHEN undefined_column THEN RETURN '{undefined_column}'; - -- Not a valid table name. - WHEN undefined_table THEN RETURN '{undefined_table}'; - -- Not a valid role. - WHEN undefined_object THEN RETURN '{undefined_role}'; -END; -$$ LANGUAGE plpgsql; - --- column_privs_are ( schema, table, column, user, privileges[], description ) -CREATE OR REPLACE FUNCTION column_privs_are ( NAME, NAME, NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_col_privs( $4, quote_ident($1) || '.' || quote_ident($2), $3 ); -BEGIN - IF grants[1] = 'undefined_column' THEN - RETURN ok(FALSE, $6) || E'\n' || diag( - ' Column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) - || ' does not exist' - ); - ELSIF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $6) || E'\n' || diag( - ' Table ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $6) || E'\n' || diag( - ' Role ' || quote_ident($4) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $5, $6); -END; -$$ LANGUAGE plpgsql; - --- column_privs_are ( schema, table, column, user, privileges[] ) -CREATE OR REPLACE FUNCTION column_privs_are ( NAME, NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT column_privs_are( - $1, $2, $3, $4, $5, - 'Role ' || quote_ident($4) || ' should be granted ' - || CASE WHEN $5[1] IS NULL THEN 'no privileges' ELSE array_to_string($5, ', ') END - || ' on column ' || quote_ident($1) || '.' || quote_ident($2) || '.' || quote_ident($3) - ); -$$ LANGUAGE SQL; - --- column_privs_are ( table, column, user, privileges[], description ) -CREATE OR REPLACE FUNCTION column_privs_are ( NAME, NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_col_privs( $3, quote_ident($1), $2 ); -BEGIN - IF grants[1] = 'undefined_column' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Column ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_table' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Table ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $5) || E'\n' || diag( - ' Role ' || quote_ident($3) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $4, $5); -END; -$$ LANGUAGE plpgsql; - --- column_privs_are ( table, column, user, privileges[] ) -CREATE OR REPLACE FUNCTION column_privs_are ( NAME, NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT column_privs_are( - $1, $2, $3, $4, - 'Role ' || quote_ident($3) || ' should be granted ' - || CASE WHEN $4[1] IS NULL THEN 'no privileges' ELSE array_to_string($4, ', ') END - || ' on column ' || quote_ident($1) || '.' || quote_ident($2) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_fdw_privs (NAME, TEXT) -RETURNS TEXT[] AS $$ -BEGIN - IF pg_catalog.has_foreign_data_wrapper_privilege($1, $2, 'USAGE') THEN - RETURN '{USAGE}'; - ELSE - RETURN '{}'; - END IF; -EXCEPTION WHEN undefined_object THEN - -- Same error code for unknown user or fdw. So figure out which. - RETURN CASE WHEN SQLERRM LIKE '%' || $1 || '%' THEN - '{undefined_role}' - ELSE - '{undefined_fdw}' - END; -END; -$$ LANGUAGE plpgsql; - --- fdw_privs_are ( fdw, user, privileges[], description ) -CREATE OR REPLACE FUNCTION fdw_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_fdw_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_fdw' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' FDW ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- fdw_privs_are ( fdw, user, privileges[] ) -CREATE OR REPLACE FUNCTION fdw_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT fdw_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on FDW ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - -CREATE OR REPLACE FUNCTION _get_schema_privs(NAME, TEXT) -RETURNS TEXT[] AS $$ -DECLARE - privs TEXT[] := ARRAY['CREATE', 'USAGE']; - grants TEXT[] := '{}'; -BEGIN - FOR i IN 1..array_upper(privs, 1) LOOP - IF pg_catalog.has_schema_privilege($1, $2, privs[i]) THEN - grants := grants || privs[i]; - END IF; - END LOOP; - RETURN grants; -EXCEPTION - -- Not a valid schema name. - WHEN invalid_schema_name THEN RETURN '{invalid_schema_name}'; - -- Not a valid role. - WHEN undefined_object THEN RETURN '{undefined_role}'; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION _get_server_privs (NAME, TEXT) -RETURNS TEXT[] AS $$ -BEGIN - IF pg_catalog.has_server_privilege($1, $2, 'USAGE') THEN - RETURN '{USAGE}'; - ELSE - RETURN '{}'; - END IF; -EXCEPTION WHEN undefined_object THEN - -- Same error code for unknown user or server. So figure out which. - RETURN CASE WHEN SQLERRM LIKE '%' || $1 || '%' THEN - '{undefined_role}' - ELSE - '{undefined_server}' - END; -END; -$$ LANGUAGE plpgsql; - --- server_privs_are ( server, user, privileges[], description ) -CREATE OR REPLACE FUNCTION server_privs_are ( NAME, NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ -DECLARE - grants TEXT[] := _get_server_privs( $2, quote_ident($1) ); -BEGIN - IF grants[1] = 'undefined_server' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Server ' || quote_ident($1) || ' does not exist' - ); - ELSIF grants[1] = 'undefined_role' THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - ' Role ' || quote_ident($2) || ' does not exist' - ); - END IF; - RETURN _assets_are('privileges', grants, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- server_privs_are ( server, user, privileges[] ) -CREATE OR REPLACE FUNCTION server_privs_are ( NAME, NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT server_privs_are( - $1, $2, $3, - 'Role ' || quote_ident($2) || ' should be granted ' - || CASE WHEN $3[1] IS NULL THEN 'no privileges' ELSE array_to_string($3, ', ') END - || ' on server ' || quote_ident($1) - ); -$$ LANGUAGE SQL; - --- materialized_views_are( schema, materialized_views, description ) -CREATE OR REPLACE FUNCTION materialized_views_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'Materialized views', _extras('m', $1, $2), _missing('m', $1, $2), $3); -$$ LANGUAGE SQL; - --- materialized_views_are( materialized_views, description ) -CREATE OR REPLACE FUNCTION materialized_views_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'Materialized views', _extras('m', $1), _missing('m', $1), $2); -$$ LANGUAGE SQL; - --- materialized_views_are( schema, materialized_views ) -CREATE OR REPLACE FUNCTION materialized_views_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'Materialized views', _extras('m', $1, $2), _missing('m', $1, $2), - 'Schema ' || quote_ident($1) || ' should have the correct materialized views' - ); -$$ LANGUAGE SQL; - --- materialized_views_are( materialized_views ) -CREATE OR REPLACE FUNCTION materialized_views_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'Materialized views', _extras('m', $1), _missing('m', $1), - 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct materialized views' - ); -$$ LANGUAGE SQL; - --- materialized_view_owner_is ( schema, materialized_view, user, description ) -CREATE OR REPLACE FUNCTION materialized_view_owner_is ( NAME, NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('m'::char, $1, $2); -BEGIN - -- Make sure the materialized view exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $4) || E'\n' || diag( - E' Materialized view ' || quote_ident($1) || '.' || quote_ident($2) || ' does not exist' - ); - END IF; - - RETURN is(owner, $3, $4); -END; -$$ LANGUAGE plpgsql; - --- materialized_view_owner_is ( schema, materialized_view, user ) -CREATE OR REPLACE FUNCTION materialized_view_owner_is ( NAME, NAME, NAME ) -RETURNS TEXT AS $$ - SELECT materialized_view_owner_is( - $1, $2, $3, - 'Materialized view ' || quote_ident($1) || '.' || quote_ident($2) || ' should be owned by ' || quote_ident($3) - ); -$$ LANGUAGE sql; - --- materialized_view_owner_is ( materialized_view, user, description ) -CREATE OR REPLACE FUNCTION materialized_view_owner_is ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ -DECLARE - owner NAME := _get_rel_owner('m'::char, $1); -BEGIN - -- Make sure the materialized view exists. - IF owner IS NULL THEN - RETURN ok(FALSE, $3) || E'\n' || diag( - E' Materialized view ' || quote_ident($1) || ' does not exist' - ); - END IF; - - RETURN is(owner, $2, $3); -END; -$$ LANGUAGE plpgsql; - --- materialized_view_owner_is ( materialized_view, user ) -CREATE OR REPLACE FUNCTION materialized_view_owner_is ( NAME, NAME ) -RETURNS TEXT AS $$ - SELECT materialized_view_owner_is( - $1, $2, - 'Materialized view ' || quote_ident($1) || ' should be owned by ' || quote_ident($2) - ); -$$ LANGUAGE sql; - --- has_materialized_view( schema, materialized_view, description ) -CREATE OR REPLACE FUNCTION has_materialized_view ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'm', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- has_materialized_view( materialized_view, description ) -CREATE OR REPLACE FUNCTION has_materialized_view ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( _rexists( 'm', $1 ), $2 ); -$$ LANGUAGE SQL; - --- has_materialized_view( materialized_view ) -CREATE OR REPLACE FUNCTION has_materialized_view ( NAME ) -RETURNS TEXT AS $$ - SELECT has_materialized_view( $1, 'Materialized view ' || quote_ident($1) || ' should exist' ); -$$ LANGUAGE SQL; - --- hasnt_materialized_view( schema, materialized_view, description ) -CREATE OR REPLACE FUNCTION hasnt_materialized_view ( NAME, NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'm', $1, $2 ), $3 ); -$$ LANGUAGE SQL; - --- hasnt_materialized_view( materialized_view, description ) -CREATE OR REPLACE FUNCTION hasnt_materialized_view ( NAME, TEXT ) -RETURNS TEXT AS $$ - SELECT ok( NOT _rexists( 'm', $1 ), $2 ); -$$ LANGUAGE SQL; - --- hasnt_materialized_view( materialized_view ) -CREATE OR REPLACE FUNCTION hasnt_materialized_view ( NAME ) -RETURNS TEXT AS $$ - SELECT hasnt_materialized_view( $1, 'Materialized view ' || quote_ident($1) || ' should not exist' ); -$$ LANGUAGE SQL; - - --- foreign_tables_are( schema, tables, description ) -CREATE OR REPLACE FUNCTION foreign_tables_are ( NAME, NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'foreign tables', _extras('f', $1, $2), _missing('f', $1, $2), $3); -$$ LANGUAGE SQL; - --- foreign_tables_are( tables, description ) -CREATE OR REPLACE FUNCTION foreign_tables_are ( NAME[], TEXT ) -RETURNS TEXT AS $$ - SELECT _are( 'foreign tables', _extras('f', $1), _missing('f', $1), $2); -$$ LANGUAGE SQL; - --- foreign_tables_are( schema, tables ) -CREATE OR REPLACE FUNCTION foreign_tables_are ( NAME, NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'foreign tables', _extras('f', $1, $2), _missing('f', $1, $2), - 'Schema ' || quote_ident($1) || ' should have the correct foreign tables' - ); -$$ LANGUAGE SQL; - --- foreign_tables_are( tables ) -CREATE OR REPLACE FUNCTION foreign_tables_are ( NAME[] ) -RETURNS TEXT AS $$ - SELECT _are( - 'foreign tables', _extras('f', $1), _missing('f', $1), - 'Search path ' || pg_catalog.current_setting('search_path') || ' should have the correct foreign tables' - ); -$$ LANGUAGE SQL; - -GRANT SELECT ON tap_funky TO PUBLIC; -GRANT SELECT ON pg_all_foreign_keys TO PUBLIC; diff --git a/test/helpers/setup.sql b/test/helpers/setup.sql deleted file mode 100644 index e5dae58..0000000 --- a/test/helpers/setup.sql +++ /dev/null @@ -1,13 +0,0 @@ -BEGIN; -\i test/helpers/tap_setup.sql - --- Put dependencies here ---CREATE EXTENSION IF NOT EXISTS variant; - -SET client_min_messages = WARNING; -CREATE SCHEMA IF NOT EXISTS :schema; -SET search_path = :schema; - --- No IF NOT EXISTS because we'll be confused if we're not loading the new stuff ---\i sql/count_nulls.sql -CREATE EXTENSION count_nulls; diff --git a/test/helpers/tap_setup.sql b/test/helpers/tap_setup.sql deleted file mode 100644 index 245e191..0000000 --- a/test/helpers/tap_setup.sql +++ /dev/null @@ -1,18 +0,0 @@ --- No status messages -\set QUIET true - --- Verbose error messages -\set VERBOSITY verbose - --- Revert all changes on failure. -\set ON_ERROR_ROLLBACK 1 -\set ON_ERROR_STOP true - -CREATE SCHEMA tap; -SET search_path = tap; -\i test/helpers/pgtap-core.sql -\i test/helpers/pgtap-schema.sql - -\pset format unaligned -\pset tuples_only true -\pset pager diff --git a/test/load.sql b/test/load.sql new file mode 100644 index 0000000..3cd70f6 --- /dev/null +++ b/test/load.sql @@ -0,0 +1,15 @@ +\i test/pgxntool/setup.sql + +SET search_path = tap, public; + +-- Don't use IF NOT EXISTS here; we want to ensure we always have the latest code +SET client_min_messages = WARNING; -- Squelch notices about dependent extensions + +CREATE SCHEMA IF NOT EXISTS :schema; +SET search_path = :schema; + +CREATE EXTENSION object_reference CASCADE; + +CREATE EXTENSION count_nulls; + +--SET client_min_messages = NOTICE; diff --git a/test/sql/extension_tests.sql b/test/sql/extension_tests.sql index edda42f..ba88447 100644 --- a/test/sql/extension_tests.sql +++ b/test/sql/extension_tests.sql @@ -1,7 +1,7 @@ \set ECHO none \set schema schema_to_load_count_nulls -\i test/helpers/setup.sql +\i test/load.sql \set schema public \i test/core/functions.sql diff --git a/test/sql/simple.sql b/test/sql/simple.sql index b73ae85..9406729 100644 --- a/test/sql/simple.sql +++ b/test/sql/simple.sql @@ -2,7 +2,7 @@ \set schema public -\i test/helpers/setup.sql +\i test/load.sql \i test/core/functions.sql From 057f49a1f5add95751f30b27c59b5a2d5f04f93d Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Wed, 4 Feb 2026 17:44:04 -0600 Subject: [PATCH 02/11] Squashed 'pgxntool/' changes from 51eca3d..3b8cb2a 3b8cb2a Stamp 1.1.0 550a901 Remove commit.md (maintained in pgxntool-test) d73ca93 Add unique test database names to prevent conflicts (#13) 9b344be Add update-setup-files.sh for 3-way merging after pgxntool-sync (#12) ab7f6e2 Stamp 1.0.0 3a571ba Add pg_tle support and modernize test infrastructure (#11) b96ea6d Add support for Claude code; build and doc improvements (#9) e9c24de Fix pg_regress on versions > 12 (#5) c0af00f Improvements to HISTORY.asc 6e8f2a7 Allow use of sudo when installing an extension 705f1ec Don't run clean as part of make test 370fa8e Create test/sql during setup 890053c Fix bug with DOCS 5c2173e Fix DOCS variable d8bf8ed Update HISTOROY f92d493 Add support for asciidoc dbdd978 Improve asciidoc handling 90bbfa5 Merge branch 'stable' 2f11ef0 Support 9.2 bceb84d Don't over-write DOC 1b97c8c Add pgtap as an installcheck dependency cd24b0f Reduce verbosity of test setup 48dce67 Install tap before runing deps.sql 6cb4302 Add input files as deps to installcheck c0e70ca Merge branch 'release' 899233d Merge branch 'master' into stable e8dff79 Update HISTORY f49e6e3 Fix version number handling ed515aa Remove errant . 8856f1e Decimal point doesn't work, just multiply MAJORVER by 10 35d13b3 Switch to leaving decimal point in MAJORVER 8c59619 Merge branch 'master' into stable 17ee75a Fix typo 5c3032f Add EXTENSION_SQL_FILES variable dcac898 Document the test targets e4884c2 Merge branch 'master' into stable c05f446 Clarify current version d2e6c79 Update HISTORY c3c1a08 Switch variable name for versioning aa93684 Merge branch 'release' 940f35b Merge branch 'master' into stable e6205f8 Remove merge conflict 1f5fcc5 Update HISTOROY 8b8e9b5 Remove invalid options from git subtree pull 2c1e313 Merge branch 'release' 227f558 Merge branch 'release' into stable e5f78b3 Update history e711eef Need to install upgrade scripts 102a856 Favor upstream changes in case of conflict eda5f38 Merge branch 'release' 18532a9 Consistent spelling of license 7d3f6ad History for 0.1.8 3264d28 Silence meta output a4ea40b Add expected/ as a dep 732f534 Handle alpha and beta versions 82216b6 Fix test directory handling 14e4ebc Distribution names can't contain spaces either 4aff20a Ignore meta.mk 04713aa Refactor; add PGXN/PGXNVERSION variables 02ae242 Move key to a safe location cbe8921 Change names to support new tools 3765700 Switch to generating a meta.mk file for Make dependencies on META.json 2e78459 Merge branch 'master' of github.com:decibel/pgxntool 2b8e24a History for release aac4331 Ignore ALL .asc files 2b46bda Merge pull request #3 from pnorman/patch-1 866bbe5 Use bash for setup.sh 97aaf7f Fix .gitattributes c8c4727 Merge branch 'stable' 4a57b43 Pull pgxntool from ../pgxntool stable b877657 Add note about what pgxntool is 13b0265 Test ignoring PGXNtool README b94e2f4 If output dir is needed but doesn't exist, create it. d173527 Fix directory name adc4348 Add test/sql as dep. Clean up directory creation. 0af7768 Create test output stuff if missing 798b6f4 Merge branch 'master' into stable cf42f7b Add git status output 3746eea Fix shell bug 616741a Ensure repo is empty c35e846 Add setup.sh to install instructions cf6fb04 Add make help 3b23055 Merge branch 'release' 905922e Merge branch 'master' into stable 93ff527 Leave pgTap being loaded by setup.sql c36180f Squashed commit of the following: 1dfff38 Improve warning f6464b9 Add option to sync stable 749ff01 Add test/..../finish.sql d71400c Merge branch 'release' 15d2567 Pull pgxntool from ../pgxntool master 732514e Fix $(DATA) 0c84d43 Improve print- recipe 6f7701e Merge branch 'master' into stable 5942cc9 Fix tap schema a8fa40e Move deps to a file c9a0b40 Add pgTap to deps a0ab465 Fix REGRESS rules 882447c Fix test file detection d152f6e Merge branch 'master' of github.com:decibel/pgxntool a7a7dfe Formatting 393f301 Unset MODULES if it's empty 84fa612 Always attempt to build source files 9922a17 Adjust META.in.json so it's valid 411dfaf .source inputs need to be in an input directory 03b542a Fix .source test file support cef41f7 Support .source test files 3a08445 Make testing more extensible Add TESTDIR and TESTOUT variables Explicitly tell pg_regress where to put output acc8c17 Minor tweak to refer to new features. ea3c67f Merge branch 'release' dce257a Merge branch 'test' 0596bdd Merge stable: Fix for pgtap, keep public in search path 5e6305e Merge branch 'master' into stable 4473ed5 Fix problem with pgtap dependencies. d043f2a Keep public in the search path for TAP c38a392 Merge branch 'master' into stable c6a31f0 Merge branch 'test' into stable ac48ccf Merge branch 'release' 6a10733 Merge back from release 8d9f9b6 Fix README.asc after merge aa3ea7f Merge branch 'test' b0bf5b1 0.1.2: Add a bunch of features 4fb946f Don't export README c17abbe Suggest initial setup is a squash commit. f99608e Ready to test 0.1.2 e895a08 Have setup add META.json to git 1629702 Fix off-by-1 in tail f133686 Deps issues ea3e515 Fix META.json build issues ecd0291 Make META.json variables recursive 5feee21 Copy META.in.json over. 979d836 Merge branch 'test' 77cd2da Support empty doc directory. Other improvements 1b32e06 Improve setup commit message 1ab4aa3 Add misssing file f3f28d2 First stab at build_meta.sh 963b040 Missing comma b984240 Don't puke if test/pgxntool already exists 5ade3be Fix file tests 9e97951 Better error handling; safecp() be55d12 Make sure we're not accidentally ignoring META.json 48df320 Add template META.in.json 5f0dcd9 Update final message 992dca3 Add git stuff c7bae04 Move .gitignore to a file c05317b Add target to list all targets da11d03 Add test helpers 1d1935f Add .gitignore 4632361 Fix typo 5ec6aa4 Add license and README d27b1e2 Add dist-only rule 57c52fa Fix variable reference bc0c837 Fix file path problem e4bafbb Add sync commands 5069d9c Add JSON.sh 2ade7d9 Numerous changes to support test_factory c556c41 Add .gitignore b7a9cac Add initial makefiles from test_factory git-subtree-dir: pgxntool git-subtree-split: 3b8cb2a96c2611bb44b1d69fd533fd0f23fa8995 --- .claude/settings.json | 19 + .gitattributes | 5 + .gitignore | 1 + CLAUDE.md | 255 +++++++ HISTORY.asc | 43 ++ LICENSE | 2 +- README.asc | 307 ++++++++- README.html | 1266 +++++++++++++++++++++++++++++++++++ _.gitignore | 10 +- base.mk | 230 ++++++- build_meta.sh | 30 +- control.mk.sh | 90 +++ lib.sh | 57 ++ make_results.sh | 28 + meta.mk.sh | 96 +-- pgtle.sh | 849 +++++++++++++++++++++++ pgtle_versions.md | 47 ++ safesed | 8 + setup.sh | 44 +- test/deps.sql | 14 +- test/pgxntool/setup.sql | 5 +- test/pgxntool/tap_setup.sql | 13 +- update-setup-files.sh | 176 +++++ 23 files changed, 3477 insertions(+), 118 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 README.html create mode 100755 control.mk.sh create mode 100644 lib.sh create mode 100755 make_results.sh create mode 100755 pgtle.sh create mode 100644 pgtle_versions.md create mode 100755 safesed create mode 100755 update-setup-files.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e7bf5a9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(make test:*)", + "Bash(tee:*)", + "Bash(echo:*)", + "Bash(git show:*)", + "Bash(git log:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(git checkout:*)", + "Bash(head:*)" + ], + "additionalDirectories": [ + "../pgxntool-test/" + ] + } +} diff --git a/.gitattributes b/.gitattributes index 78bb15c..a94d824 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ .gitattributes export-ignore +.claude/ export-ignore +*.md export-ignore +.DS_Store export-ignore *.asc export-ignore +*.adoc export-ignore +*.html export-ignore diff --git a/.gitignore b/.gitignore index a01ee28..5ffb236 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .*.swp +.claude/*.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d2ea214 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,255 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Git Commit Guidelines + +**IMPORTANT**: When creating commit messages, do not attribute commits to yourself (Claude). Commit messages should reflect the work being done without AI attribution in the message body. The standard Co-Authored-By trailer is acceptable. + +## Critical: What This Repo Actually Is + +**pgxntool is NOT a standalone project.** It is a meta-framework that exists ONLY to be embedded into PostgreSQL extension projects via `git subtree`. This repo cannot be built, tested, or run directly. + +**Think of it like this**: pgxntool is to PostgreSQL extensions what a Makefile template library is to C projects - it's infrastructure code that gets copied into other projects, not a project itself. + +## Critical: Directory Purity - NO Temporary Files + +**This directory contains ONLY files that get embedded into extension projects.** When extension developers run `git subtree add`, they pull the entire pgxntool directory into their project. + +**ABSOLUTE RULE**: NO temporary files, scratch work, or development tools may be added to this directory. + +**Examples of what NEVER belongs here:** +- Temporary files (scratch notes, test output, debugging artifacts) +- Development scripts or tools (these go in pgxntool-test/) +- Planning documents (PLAN-*.md files go in pgxntool-test/) +- Any file you wouldn't want in every extension project that uses pgxntool + +**CLAUDE.md exception**: CLAUDE.md exists here for AI assistant guidance, but is excluded from distributions via `.gitattributes export-ignore`. Same with `.claude/` directory. + +**Why this matters**: Any file you add here will be pulled into hundreds of extension projects via git subtree. Keep this directory lean and clean. + +## Development Workflow: Work from pgxntool-test + +**CRITICAL**: All development work on pgxntool should be done from the pgxntool-test repository, NOT from this repository. + +**For complete development workflow documentation, see:** +https://github.com/Postgres-Extensions/pgxntool-test + +## Two-Repository Development Pattern + +This codebase uses a two-repository pattern: + +1. **pgxntool/** (this repo) - The framework code that gets embedded into extension projects +2. **pgxntool-test** - The test harness that validates pgxntool functionality + +**For development and testing workflow, see:** +https://github.com/Postgres-Extensions/pgxntool-test + +## How Extension Developers Use pgxntool + +Extension projects include pgxntool via git subtree: + +```bash +git subtree add -P pgxntool --squash git@github.com:decibel/pgxntool.git release +pgxntool/setup.sh +``` + +After setup, their Makefile typically contains just: +```makefile +include pgxntool/base.mk +``` + +## Architecture: Two-Phase Build System + +### Phase 1: Meta Generation (`build_meta.sh`) +- Processes `META.in.json` (template with placeholders/empty values) +- Strips out X_comment fields and empty values +- Produces clean `META.json` + +### Phase 2: Variable Extraction (`meta.mk.sh`) +- Parses `META.json` using `JSON.sh` (a bash-based JSON parser) +- Generates `meta.mk` with Make variables: + - `PGXN` - distribution name + - `PGXNVERSION` - version number + - `EXTENSIONS` - list of extensions provided + - `EXTENSION_*_VERSION` - per-extension versions + - `EXTENSION_VERSION_FILES` - auto-generated versioned SQL files +- `base.mk` includes `meta.mk` via `-include` + +### The Magic of base.mk + +`base.mk` provides a complete PGXS-based build system: +- Auto-detects extension SQL files in `sql/` +- Auto-detects C modules in `src/*.c` +- Auto-detects tests in `test/sql/*.sql` +- Auto-generates versioned extension files (`extension--version.sql`) +- Handles Asciidoc → HTML conversion +- Integrates with PGXN distribution format +- Manages git tagging and release packaging + +## File Structure for Consumer Projects + +Projects using pgxntool follow this layout: +``` +project/ +├── Makefile # include pgxntool/base.mk +├── META.in.json # Template metadata (customize for your extension) +├── META.json # Auto-generated from META.in.json +├── extension.control # Standard PostgreSQL control file +├── pgxntool/ # This repo, embedded via git subtree +├── sql/ +│ └── extension.sql # Base extension SQL +├── src/ # Optional C code (*.c files) +├── test/ +│ ├── deps.sql # Load extension and test dependencies +│ ├── sql/*.sql # Test SQL files +│ └── expected/*.out # Expected test outputs +└── doc/ # Optional docs (*.adoc, *.asciidoc) +``` + +## Commands for Extension Developers (End Users) + +These are the commands extension developers use (documented for context): + +```bash +make # Build extension (generates versioned SQL, docs) +make test # Full test: testdeps → install → installcheck → show diffs +make results # Run tests and update expected output files +make html # Generate HTML from Asciidoc sources +make tag # Create git branch for current META.json version +make dist # Create PGXN .zip (auto-tags, places in ../) +make pgtle # Generate pg_tle registration SQL (see pg_tle Support below) +make check-pgtle # Check pg_tle installation and report version +make install-pgtle # Install pg_tle registration SQL files into database +make pgxntool-sync # Update to latest pgxntool via git subtree pull +``` + +## Testing with pgxntool + +### Critical Testing Rules + +**NEVER use `make installcheck` directly**. Always use `make test` instead. The `make test` target ensures: +- Clean builds before testing +- Proper test isolation +- Correct test dependency installation +- Proper cleanup and result comparison + +**Database Connection Requirement**: PostgreSQL must be running before executing `make test`. If you get connection errors (e.g., "could not connect to server"), stop and ask the user to start PostgreSQL. + +**Claude Code MUST NEVER run `make results`**. This target updates test expected output files and requires manual human verification of test changes before execution. + +**Claude Code MUST NEVER modify files in `test/expected/`**. These are expected test outputs that define correct behavior and must only be updated through the `make results` workflow. + +The workflow is: +1. Human runs `make test` and examines diffs +2. Human manually verifies changes are correct +3. Human manually runs `make results` to update expected files + +### Test Output Mechanics + +pgxntool uses PostgreSQL's pg_regress test framework: +- **Actual test output**: Written to `test/results/` directory +- **Expected output**: Stored in `test/expected/` directory +- **Test comparison**: pg_regress compares actual vs expected and generates diffs; `make test` displays them +- **Updating expectations**: `make results` copies `test/results/` → `test/expected/` + +When tests fail, examine the diff output carefully. The actual test output in `test/results/` shows what your code produced, while `test/expected/` shows what was expected. + +## Key Implementation Details + +### PostgreSQL Version Handling +- `MAJORVER` = version × 10 (e.g., 9.6 → 96, 13 → 130) +- Tests use `--load-language=plpgsql` for versions < 13 +- Version detection via `pg_config --version` + +### Test System (pg_regress based) +- Tests in `test/sql/*.sql`, outputs compared to `test/expected/*.out` +- Setup via `test/pgxntool/setup.sql` (loads pgTap and deps.sql) +- `.IGNORE: installcheck` allows `make test` to handle errors (show diffs, then exit with error status) +- `make results` updates expected outputs after test runs + +### Document Generation +- Auto-detects `asciidoctor` or `asciidoc` +- Generates HTML from `*.adoc` and `*.asciidoc` in `$(DOC_DIRS)` +- HTML required for `make dist`, optional for `make install` +- Template-based rules via `ASCIIDOC_template` + +### Distribution Packaging +- `make dist` creates `../PGXN-VERSION.zip` +- Always creates git branch tag matching version +- Uses `git archive` to package +- Validates repo is clean before tagging + +### Subtree Sync Support +- `make pgxntool-sync` pulls latest release +- Multiple sync targets: release, stable, local variants +- Uses `git subtree pull --squash` +- Requires clean repo (no uncommitted changes) + +### pg_tle Support + +pgxntool can generate pg_tle (Trusted Language Extensions) registration SQL for deploying extensions in AWS RDS/Aurora without filesystem access. + +**Usage:** `make pgtle` or `make pgtle PGTLE_VERSION=1.5.0+` + +**Output:** `pg_tle/{version_range}/{extension}.sql` + +**For version range details and API compatibility boundaries, see:** `pgtle_versions.md` + +**Installation targets:** + +- `make check-pgtle` - Checks if pg_tle is installed and reports the version. Reports version from `pg_extension` if extension has been created, or newest available version from `pg_available_extension_versions` if available but not created. Errors if pg_tle not available in cluster. Assumes `PG*` environment variables are configured. + +- `make install-pgtle` - Auto-detects pg_tle version and installs appropriate registration SQL files. Updates or creates pg_tle extension as needed. Determines which version range files to install based on detected version. Runs all generated SQL files via `psql` to register extensions with pg_tle. Assumes `PG*` environment variables are configured. + +**Version notation:** +- `X.Y.Z+` means >= X.Y.Z +- `X.Y.Z-A.B.C` means >= X.Y.Z and < A.B.C (note boundary) + +**Key implementation details:** +- Script: `pgxntool/pgtle-wrap.sh` (bash) +- Parses `.control` files for metadata (NOT META.json) +- Fixed delimiter: `$_pgtle_wrap_delimiter_$` (validated not in source) +- Each output file contains ALL versions and ALL upgrade paths +- Multi-extension support (multiple .control files) +- Output directory `pg_tle/` excluded from git +- Depends on `make all` to ensure versioned SQL files exist first +- Only processes versioned files (`sql/{ext}--{version}.sql`), not base files + +**SQL file handling:** +- **Version files** (`sql/{ext}--{version}.sql`): Generated automatically by `make all` from base `sql/{ext}.sql` file +- **Upgrade scripts** (`sql/{ext}--{v1}--{v2}.sql`): Created manually by users when adding new extension versions +- The script ensures the default_version file exists if the base file exists (creates it from base file if missing) +- All version files and upgrade scripts are discovered and included in the generated pg_tle registration SQL + +**Dependencies:** +Generated files depend on: +- Control file (metadata source) +- All SQL files (sql/{ext}--*.sql) - must run `make all` first +- Generator script itself + +**Limitations:** +- No C code support (pg_tle requires trusted languages only) +- PostgreSQL 14.5+ required (pg_tle not available on earlier versions) + +## Critical Gotchas + +1. **Empty Variables**: If `DOCS` or `MODULES` is empty, base.mk sets to empty to prevent PGXS errors +2. **testdeps Pattern**: Never add recipes to `testdeps` - create separate target and make it a prerequisite +3. **META.json is Generated**: Always edit `META.in.json`, never `META.json` directly +4. **Control File Versions**: No automatic validation that `.control` matches `META.json` version +5. **PGXNTOOL_NO_PGXS_INCLUDE**: Setting this skips PGXS inclusion (for special scenarios) +6. **Distribution Placement**: `.zip` files go in parent directory (`../`) to avoid repo clutter + +## Scripts + +- **setup.sh** - Initializes pgxntool in a new extension project (copies templates, creates directories) +- **build_meta.sh** - Strips empty fields from META.in.json to create META.json +- **meta.mk.sh** - Parses META.json via JSON.sh and generates meta.mk with Make variables +- **JSON.sh** - Third-party bash JSON parser (MIT licensed) +- **safesed** - Utility for safe sed operations + +## Related Repositories + +- **pgxntool-test** - Test harness for validating pgxntool functionality: https://github.com/Postgres-Extensions/pgxntool-test +- Never produce any kind of metrics or estimates unless you have data to back them up. If you do have data you MUST reference it. \ No newline at end of file diff --git a/HISTORY.asc b/HISTORY.asc index 3ddc7ef..4de9f70 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,3 +1,46 @@ +1.1.0 +----- +== Use unique database names for tests +Tests now use a unique database name based on the project name and a hash of the current directory. This prevents test conflicts when running tests for multiple projects in parallel. + +== Add 3-way merge support for setup files after pgxntool-sync +New `update-setup-files.sh` script handles merging changes to files initially copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have modified the same file, using git's native conflict markers for resolution. + +1.0.0 +----- +== Fix broken multi-extension support +Prior to this fix, distributions with multiple extensions or extensions with versions different from the PGXN distribution version were completely broken. Extension versions are now correctly read from each `.control` file's `default_version` instead of using META.json's distribution version. + +== Add pg_tle support +New `make pgtle` target generates pg_tle registration SQL for extensions. Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with appropriate API calls for each range. See README for usage. + +== Use git tags for distribution versioning +The `tag` and `rmtag` targets now create/delete git tags instead of branches. + +== Support 13+ +The `--load-language` option was removed from `pg_regress` in 13. + +== Reduce verbosity from test setup +As part of this change, you will want to review the changes to test/deps.sql. + +=== Support asciidoc documentation targets +By default, if asciidoctor or asciidoc exists on the system, any files in doc/ that end in .adoc or .asciidoc will be processed to html. +See the README for full details. + +=== Support 9.2 +CREATE SCHEMA IF NOT EXISTS doesn't work before 9.3. + +=== Make installcheck depend on test input files +If a test input file changes we certainly need to re-run tests. + +=== Have test/pgxntool/setup.sql install tap before running deps.sql + +=== Support other asciidoc extensions + +=== Create the test/sql/ directory during setup + +=== Use `--sudo` option when installing pgtap + 0.2.0 ----- ### Stop using $(VERSION) diff --git a/LICENSE b/LICENSE index 5a20925..4a507f7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Jim Nasby, Blue Treble Solutions +Copyright (c) 2015-2026, Jim Nasby, Blue Treble Solutions All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.asc b/README.asc index 483dfa7..bf3559d 100644 --- a/README.asc +++ b/README.asc @@ -1,5 +1,9 @@ = PGXNtool Easier PGXN development +:sectlinks: +:sectanchors: +:toc: +:numbered: PGXNtool is meant to make developing new Postgres extensions for http://pgxn.org[PGXN] easier. @@ -19,6 +23,10 @@ pgxntool/setup.sh TODO: Create a nice script that will init a new project for you. +== Development + +If you want to contribute to pgxntool development, work from the https://github.com/decibel/pgxntool-test[pgxntool-test] repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via `git subtree`. + == Usage Typically, you can just create a simple Makefile that does nothing but include base.mk: @@ -31,9 +39,14 @@ These are the make targets that are provided by base.mk NOTE: all the targets normally provided by Postgres http://www.postgresql.org/docs/current/static/extend-pgxs.html[PGXS] still work. +=== html +This will build any .html files that can be created. See <<_Document_Handling>>. + === test Runs unit tests via the PGXS `installcheck` target. Unlike a simple `make installcheck` though, the `test` rule has the following prerequisites: clean testdeps install installcheck. All of those are PGXS rules, except for `testdeps`. +NOTE: While you can still run `make installcheck` or any other valid PGXS make target directly, it's recommended to use `make test` when using pgxntool. The `test` target ensures clean builds, proper test isolation, and correct dependency installation. + === testdeps This rule allows you to ensure certain actions have taken place before running tests. By default it has a single prerequisite, `pgtap`, which will attempt to install http://pgtap.org[pgtap] from PGXN. This depneds on having the pgxn client installed. @@ -53,10 +66,18 @@ If you want to over-ride the default dependency on `pgtap` you should be able to WARNING: It will probably cause problems if you try to create a `testdeps` rule that has a recipe. Instead of doing that, put the recipe in a separate rule and make that rule a prerequisite of `testdeps` as show in the example. === results -Because `make test` ultimately runs `installcheck`, it's using the Postgres test suite. Unfortunately, that suite is based on running `diff` between a raw output file and expected results. I *STRONGLY* recommend you use http://pgtap.org[pgTap] instead! The extra effort of learning pgTap will quickly pay for itself. https://github.com/decibel/trunklet-format/blob/master/test/sql/base.sql[This example] might help get you started. +Because `make test` ultimately runs `installcheck`, it's using the Postgres test suite. Unfortunately, that suite is based on running `diff` between a raw output file and expected results. I *STRONGLY* recommend you use http://pgtap.org[pgTap] instead! With pgTap, it's MUCH easier to determine whether a test is passing or not - tests explicitly pass or fail rather than requiring you to examine diff output. The extra effort of learning pgTap will quickly pay for itself. https://github.com/decibel/trunklet-format/blob/master/test/sql/base.sql[This example] might help get you started. No matter what method you use, once you know that all your tests are passing correctly, you need to create or update the test output expected files. `make results` does that for you. +IMPORTANT: *`make results` requires manual verification first*. The correct workflow is: + +1. Run `make test` and examine the diff output +2. Manually verify that the differences are correct and expected +3. Only then run `make results` to update the expected output files in `test/expected/` + +Never run `make results` without first verifying the test changes are correct. The `results` target copies files from `test/results/` to `test/expected/`, so running it blindly will make incorrect output become the new expected behavior. + === tag `make tag` will create a git branch for the current version of your extension, as determined by the META.json file. The reason to do this is so you can always refer to the exact code that went into a released version. @@ -76,7 +97,289 @@ NOTE: Your repository must be clean (no modified files) in order to run this. Ru TIP: There is also a `pgxntool-sync-%` rule if you need to do more advanced things. +=== pgtle +Generates pg_tle (Trusted Language Extensions) registration SQL files for deploying extensions in managed environments like AWS RDS/Aurora. See <<_pg_tle_Support>> for complete documentation. + +`make pgtle` generates SQL files in `pg_tle/` subdirectories organized by pg_tle version ranges. For version range details, see `pgtle_versions.md`. + +=== check-pgtle +Checks if pg_tle is installed and reports the version. This target: +- Reports the version from `pg_extension` if `CREATE EXTENSION pg_tle` has been run in the database +- Errors if pg_tle is not available in the cluster + +This target assumes `PG*` environment variables are configured for `psql` connectivity. + +---- +make check-pgtle +---- + +=== run-pgtle +Registers all extensions with pg_tle by executing the generated pg_tle registration SQL files in a PostgreSQL database. This target: +- Requires pg_tle extension to be installed (checked via `check-pgtle`) +- Uses `pgtle.sh` to determine which version range directory to use based on the installed pg_tle version +- Runs all generated SQL files via `psql` to register your extensions with pg_tle + +This target assumes that running `psql` without any arguments will connect to the desired database. You can control this by setting the various PG* environment variables (and possibly using the `.pgpassword` file). See the PostgreSQL documentation for more details. + +NOTE: The `pgtle` target is a dependency, so `make run-pgtle` will automatically generate the SQL files if needed. + +---- +make run-pgtle +---- + +After running `make run-pgtle`, you can create your extension in the database: +---- +CREATE EXTENSION "your-extension-name"; +---- + +== Version-Specific SQL Files + +PGXNtool automatically generates version-specific SQL files from your base SQL file. These files follow the pattern `sql/{extension}--{version}.sql` and are used by PostgreSQL's extension system to install specific versions of your extension. + +=== How Version Files Are Generated + +When you run `make` (or `make all`), PGXNtool: + +1. Reads your `META.json` file to determine the extension version from `provides.{extension}.version` +2. Generates a Makefile rule that copies your base SQL file (`sql/{extension}.sql`) to the version-specific file (`sql/{extension}--{version}.sql`) +3. Executes this rule, creating the version-specific file with a header comment indicating it's auto-generated + +For example, if your `META.json` contains: +---- +"provides": { + "myext": { + "version": "1.2.3", + ... + } +} +---- + +Running `make` will create `sql/myext--1.2.3.sql` by copying `sql/myext.sql`. + +=== What Controls the Version Number + +The version number comes from `META.json` → `provides.{extension}.version`, *not* from your `.control` file's `default_version` field. The `.control` file's `default_version` is used by PostgreSQL to determine which version to install by default, but the actual version-specific file that gets generated is determined by what's in `META.json`. + +To change the version of your extension: +1. Update `provides.{extension}.version` in `META.json` +2. Run `make` to regenerate the version-specific file +3. Update `default_version` in your `.control` file to match (if needed) + +=== Committing Version Files + +Version-specific SQL files are now treated as permanent files that should be committed to your repository. This makes it much easier to test updates to extensions, as you can see exactly what SQL was included in each version. + +IMPORTANT: These files are auto-generated and include a header comment warning not to edit them. Any manual changes will be overwritten the next time you run `make`. To modify the extension, edit the base SQL file (`sql/{extension}.sql`) instead. + +=== Alternative: Ignoring Version Files + +If you prefer not to commit version-specific SQL files, you must add them to your `.gitignore` to prevent `make dist` from failing due to untracked files. Add the following to your `.gitignore`: + +---- +# Auto-generated version-specific SQL files (if not committing them) +sql/*--*.sql +!sql/*--*--*.sql +---- + +The second line (`!sql/*--*--*.sql`) ensures that upgrade scripts (which contain two version numbers and should be manually written) are still tracked. + +WARNING: If you ignore version files instead of committing them, they will NOT be included in your PGXN distribution (`make dist` uses `git archive`, which only includes tracked files). This means users installing your extension from PGXN will need `make` and PGXS available to build the extension - they cannot simply copy the SQL files into their PostgreSQL installation. For maximum compatibility, we recommend committing version files. + +=== Distribution Inclusion + +Version-specific files are included in distributions created by `make dist` only if they are committed to git. Since `make dist` uses `git archive`, only tracked files are included in the distribution archive. + +=== Multiple Versions + +If you need to support multiple versions of your extension: + +1. Create additional version-specific files manually (e.g., `sql/myext--1.0.0.sql`, `sql/myext--1.1.0.sql`) +2. Create upgrade scripts for version transitions (e.g., `sql/myext--1.0.0--1.1.0.sql`) +3. Update `META.json` to reflect the current version you're working on +4. Commit all version files and upgrade scripts to your repository + +The version file for the current version (specified in `META.json`) will be automatically regenerated when you run `make`, but other version files you create manually will be preserved. + +== Document Handling +PGXNtool supports generation and installation of document files. There are several variables and rules that control this behavior. + +It is recommended that you commit any generated documentation files (such as HTML generated from Asciidoc) into git. +That way users will have these files installed when they install your extension. +If any generated files are missing (or out-of-date) during installation, PGXNtool will build them if Asciidoc is present on the system. + +=== Document Variables +DOC_DIRS:: +Directories to look for documents in. +Defined as `+= doc`. +DOCS:: +PGXS variable. +See <<_the_docs_variable>> below. +DOCS_HTML:: +Document HTML files. +PGXNtool appends `$(ASCIIDOC_HTML) to this variable. + +ASCIIDOC:: +Location of `asciidoc` or equivalent executable. +If not set PGXNtool will search for first `asciidoctor`, then `asciidoc`. +ASCIIDOC_EXTS:: +File extensions to consider as Asciidoc. +Defined as `+= adoc asciidoc`. +ASCIIDOC_FILES:: +Asciidoc input files. +PGXNtool searches each `$(DOC_DIRS)` directory, looking for files with any `$(ASCIIDOC_EXTS)` extension. +Any files found are added to `ASCIIDOC_FILES` using `+=`. +ASCIIDOC_FLAGS:: +Additional flags to pass to Asciidoc. +ASCIIDOC_HTML:: +PGXNtool replaces each `$(ASCIIDOC_EXTS)` in `$(ASCIIDOC_FILES)` with `html`. +The result is appended to `ASCIIDOC_HTML` using `+=`. + +=== Document Rules +If Asciidoc is found (or `$(ASCIIDOC)` is set), the `html` rule will be added as a prerequisite to the `install` and `installchec` rules. +That will ensure that docs are generated for install and test, but only if Asciidoc is available. +The `dist` rule will always depend on `html` though, to ensure html files are up-to-date before creating a distribution. + +The `html` rule simply depends on `$(ASCIIDOC_HTML). +This rule is always present. + +For each Asciidoc extension in `$(ASCIIDOC_EXTS)` a rule is generated to build a .html file from that extension using `$(ASCIIDOC)`. +These rules are generated from `ASCIIDOC_template`: + +.ASCIIDOC_template +[source,Makefile] +---- +define ASCIIDOC_template +%.html: %.$(1) # <1> +ifndef ASCIIDOC + $$(warning Could not find "asciidoc" or "asciidoctor". Add one of them to your PATH,) + $$(warning or set ASCIIDOC to the correct location.) + $$(error Could not build %$$@) +endif # ifndef ASCIIDOC + $$(ASCIIDOC) $$(ASCIIDOC_FLAGS) $$< +endef # define ASCIIDOC_template +---- +<1> `$(1)` is replaced by the extension. + +These rules will *always* exist, even if `$(ASCIIDOC)` isn't set (ie: if Asciidoc wasn't found on the system). +These rules will throw an error if they are run if `$(ASCIIDOC)` isn't defined. +On a normal user system that should never happen, because the `html` rule won't be included in `install` or `installcheck`. + +=== The DOCS variable +This variable has special meaning to PGXS. +See the Postgres documentation for full details. + +If DOCS is defined when PGXS is included then rules will be added to install everything defined by $(DOCS) in `PREFIX/share/doc/extension`. + +NOTE: If DOCS is defined but empty some of the PGXS targets will error out. +Because of this, `base.mk` will forcibly define it to be NULL if it's empty. + +PGXNtool appends *all* files found in all `$(DOC_DIRS)` to `DOCS`. + +== pg_tle Support +[[_pg_tle_Support]] +pgxntool can generate link:https://github.com/aws/pg_tle[pg_tle (Trusted Language Extensions)] registration SQL for deploying PostgreSQL extensions in managed environments like AWS RDS and Aurora where filesystem access is not available. + +For make targets, see: <<_pgtle>>, <<_check_pgtle>>, <<_run_pgtle>>. + +=== What is pg_tle? + +pg_tle is an AWS open-source framework that enables developers to create and deploy PostgreSQL extensions without filesystem access. Traditional PostgreSQL extensions require `.control` and `.sql` files on the filesystem, which isn't possible in managed services like RDS and Aurora. + +pg_tle solves this by: +- Storing extension metadata and SQL in database tables +- Using the `pgtle_admin` role for administrative operations +- Enabling `CREATE EXTENSION` to work in managed environments + +=== Quick Start + +Generate pg_tle registration SQL for your extension: + +---- +make pgtle +---- + +This creates files in `pg_tle/` subdirectories organized by pg_tle version ranges. See `pgtle_versions.md` for complete version range details and API compatibility boundaries. + +=== Version Groupings + +pgxntool creates different sets of files for different pg_tle versions to handle backward-incompatible API changes. Each version boundary represents a change to pg_tle's API functions that we use. + +For details on version boundaries and API changes, see `pgtle_versions.md`. + +=== Installation Example + +IMPORTANT: This is only a basic example. Always refer to the link:https://github.com/aws/pg_tle[main pg_tle documentation] for complete installation instructions and best practices. + +Basic installation steps: + +. Ensure pg_tle is installed and grant the `pgtle_admin` role to your user +. Generate and run the pg_tle registration SQL files: ++ +---- +make run-pgtle +---- ++ +This automatically detects your pg_tle version and runs the appropriate SQL files. See `pgtle_versions.md` for version range details. +. Create your extension: `CREATE EXTENSION myextension;` + +=== Advanced Usage + +==== Multi-Extension Projects + +If your project has multiple extensions (multiple `.control` files), `make pgtle` generates files for all of them: + +---- +myproject/ +├── ext1.control +├── ext2.control +└── pg_tle/ + ├── 1.0.0-1.5.0/ + │ ├── ext1.sql + │ └── ext2.sql + └── 1.5.0+/ + ├── ext1.sql + └── ext2.sql +---- + +=== How It Works +`make pgtle` does the following: + +. Parses control file(s): Extracts `comment`, `default_version`, `requires`, and `schema` fields +. Discovers SQL files: Finds all versioned files (`sql/{ext}--{version}.sql`) and upgrade scripts (`sql/{ext}--{ver1}--{ver2}.sql`) +. Wraps SQL content: Uses a fixed dollar-quote delimiter (`$_pgtle_wrap_delimiter_$`) to wrap SQL for pg_tle functions +. Generates registration SQL: Creates `pgtle.install_extension()` calls for each version, `pgtle.install_update_path()` for upgrades, and `pgtle.set_default_version()` for the default +. Version-specific output: Generates separate files for different pg_tle capability levels + +Each generated SQL file is wrapped in a transaction (`BEGIN;` ... `COMMIT;`) to ensure atomic installation. + +=== Troubleshooting + +==== "No versioned SQL files found" + +*Problem*: The script can't find `sql/{ext}--{version}.sql` files. + +*Solution*: Run `make` first to generate versioned files from your base `sql/{ext}.sql` file. + +==== "Control file not found" + +*Problem*: The script can't find `{ext}.control` in the current directory. + +*Solution*: Run `make pgtle` from your extension's root directory (where the `.control` file is). + +==== "SQL file contains reserved pg_tle delimiter" + +*Problem*: Your SQL files contain the string `$_pgtle_wrap_delimiter_$` (extremely unlikely). + +*Solution*: Don't use that dollar-quote delimiter in your code. + +==== Extension uses C code + +*Problem*: Your control file has `module_pathname`, indicating C code. + +*Solution*: pg_tle only supports trusted languages. You cannot use C extensions with pg_tle. The script will warn you but still generate files (which won't work). + +NOTE: there are several untrusted languages (such as plpython), and the only tests for C. == Copyright -Copyright (c) 2015 Jim Nasby +Copyright (c) 2026 Jim Nasby PGXNtool is released under a https://github.com/decibel/pgxntool/blob/master/LICENCE[BSD license]. Note that it includes https://github.com/dominictarr/JSON.sh[JSON.sh], which is released under a https://github.com/decibel/pgxntool/blob/master/JSON.sh.LICENCE[MIT license]. diff --git a/README.html b/README.html new file mode 100644 index 0000000..41200aa --- /dev/null +++ b/README.html @@ -0,0 +1,1266 @@ + + + + + + + + +PGXNtool + + + + + +
+
+
+
+

PGXNtool is meant to make developing new Postgres extensions for PGXN easier.

+
+
+

Currently, it consists a base Makefile that you can include instead of writing your own, a template META.json, and some test framework. More features will be added over time.

+
+
+

If you find any bugs or have ideas for improvements, please open an issue.

+
+
+
+
+

1. Install

+
+
+

This assumes that you’ve already initialized your extension in git.

+
+
+ + + + + +
+
Note
+
+The --squash is important! Otherwise you’ll clutter your repo with a bunch of commits you probably don’t want. +
+
+
+
+
git subtree add -P pgxntool --squash git@github.com:decibel/pgxntool.git release
+pgxntool/setup.sh
+
+
+
+

TODO: Create a nice script that will init a new project for you.

+
+
+
+
+

2. Development

+
+
+

If you want to contribute to pgxntool development, work from the pgxntool-test repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via git subtree.

+
+
+
+
+

3. Usage

+
+
+

Typically, you can just create a simple Makefile that does nothing but include base.mk:

+
+
+
+
include pgxntool/base.mk
+
+
+
+
+
+

4. make targets

+
+
+

These are the make targets that are provided by base.mk

+
+
+ + + + + +
+
Note
+
+all the targets normally provided by Postgres PGXS still work. +
+
+
+

4.1. html

+
+

This will build any .html files that can be created. See [_Document_Handling].

+
+
+
+

4.2. test

+
+

Runs unit tests via the PGXS installcheck target. Unlike a simple make installcheck though, the test rule has the following prerequisites: clean testdeps install installcheck. All of those are PGXS rules, except for testdeps.

+
+
+ + + + + +
+
Note
+
+While you can still run make installcheck or any other valid PGXS make target directly, it’s recommended to use make test when using pgxntool. The test target ensures clean builds, proper test isolation, and correct dependency installation. +
+
+
+
+

4.3. testdeps

+
+

This rule allows you to ensure certain actions have taken place before running tests. By default it has a single prerequisite, pgtap, which will attempt to install pgtap from PGXN. This depneds on having the pgxn client installed.

+
+
+

You can add any other dependencies you want by simply adding another testdeps rule. For example:

+
+
+

testdeps example from test_factory

+
+
+
+
testdeps: check_control
+
+.PHONY: check_control
+check_control:
+	grep -q "requires = 'pgtap, test_factory'" test_factory_pgtap.control
+
+
+
+

If you want to over-ride the default dependency on pgtap you should be able to do that with a makefile override. If you need help with that, please open an issue.

+
+
+ + + + + +
+
Warning
+
+It will probably cause problems if you try to create a testdeps rule that has a recipe. Instead of doing that, put the recipe in a separate rule and make that rule a prerequisite of testdeps as show in the example. +
+
+
+
+

4.4. results

+
+

Because make test ultimately runs installcheck, it’s using the Postgres test suite. Unfortunately, that suite is based on running diff between a raw output file and expected results. I STRONGLY recommend you use pgTap instead! With pgTap, it’s MUCH easier to determine whether a test is passing or not - tests explicitly pass or fail rather than requiring you to examine diff output. The extra effort of learning pgTap will quickly pay for itself. This example might help get you started.

+
+
+

No matter what method you use, once you know that all your tests are passing correctly, you need to create or update the test output expected files. make results does that for you.

+
+
+ + + + + +
+
Important
+
+make results requires manual verification first. The correct workflow is: +
+
+
+
    +
  1. +

    Run make test and examine the diff output

    +
  2. +
  3. +

    Manually verify that the differences are correct and expected

    +
  4. +
  5. +

    Only then run make results to update the expected output files in test/expected/

    +
  6. +
+
+
+

Never run make results without first verifying the test changes are correct. The results target copies files from test/results/ to test/expected/, so running it blindly will make incorrect output become the new expected behavior.

+
+
+
+

4.5. tag

+
+

make tag will create a git branch for the current version of your extension, as determined by the META.json file. The reason to do this is so you can always refer to the exact code that went into a released version.

+
+
+

If there’s already a tag for the current version that probably means you forgot to update META.json, so you’ll get an error. If you’re certain you want to over-write the tag, you can do make forcetag, which removes the existing tag (via make rmtag) and creates a new one.

+
+
+ + + + + +
+
Warning
+
+You will be very unhappy if you forget to update the .control file for your extension! There is an open issue to improve this. +
+
+
+
+

4.6. dist

+
+

make dist will create a .zip file for your current version that you can upload to PGXN. The file is named after the PGXN name and version (the top-level "name" and "version" attributes in META.json). The .zip file is placed in the parent directory so as not to clutter up your git repo.

+
+
+ + + + + +
+
Note
+
+Part of the clean recipe is cleaning up these .zip files. If you accidentally clean before uploading, just run make dist-only. +
+
+
+
+

4.7. pgxntool-sync

+
+

This rule will pull down the latest released version of PGXNtool via git subtree pull.

+
+
+ + + + + +
+
Note
+
+Your repository must be clean (no modified files) in order to run this. Running this command will produce a git commit of the merge. +
+
+
+ + + + + +
+
Tip
+
+There is also a pgxntool-sync-% rule if you need to do more advanced things. +
+
+
+
+

4.8. pgtle

+
+

Generates pg_tle (Trusted Language Extensions) registration SQL files for deploying extensions in managed environments like AWS RDS/Aurora. See [_pg_tle_Support] for complete documentation.

+
+
+

make pgtle generates SQL files in pg_tle/ subdirectories organized by pg_tle version ranges. For version range details, see pgtle_versions.md.

+
+
+
+

4.9. check-pgtle

+
+

Checks if pg_tle is installed and reports the version. This target: +- Reports the version from pg_extension if CREATE EXTENSION pg_tle has been run in the database +- Errors if pg_tle is not available in the cluster

+
+
+

This target assumes PG* environment variables are configured for psql connectivity.

+
+
+
+
make check-pgtle
+
+
+
+
+

4.10. run-pgtle

+
+

Registers all extensions with pg_tle by executing the generated pg_tle registration SQL files in a PostgreSQL database. This target: +- Requires pg_tle extension to be installed (checked via check-pgtle) +- Uses pgtle.sh to determine which version range directory to use based on the installed pg_tle version +- Runs all generated SQL files via psql to register your extensions with pg_tle

+
+
+

This target assumes that running psql without any arguments will connect to the desired database. You can control this by setting the various PG* environment variables (and possibly using the .pgpassword file). See the PostgreSQL documentation for more details.

+
+
+ + + + + +
+
Note
+
+The pgtle target is a dependency, so make run-pgtle will automatically generate the SQL files if needed. +
+
+
+
+
make run-pgtle
+
+
+
+

After running make run-pgtle, you can create your extension in the database:

+
+
+
+
CREATE EXTENSION "your-extension-name";
+
+
+
+
+
+
+

5. Version-Specific SQL Files

+
+
+

PGXNtool automatically generates version-specific SQL files from your base SQL file. These files follow the pattern sql/{extension}--{version}.sql and are used by PostgreSQL’s extension system to install specific versions of your extension.

+
+
+

5.1. How Version Files Are Generated

+
+

When you run make (or make all), PGXNtool:

+
+
+
    +
  1. +

    Reads your META.json file to determine the extension version from provides.{extension}.version

    +
  2. +
  3. +

    Generates a Makefile rule that copies your base SQL file (sql/{extension}.sql) to the version-specific file (sql/{extension}--{version}.sql)

    +
  4. +
  5. +

    Executes this rule, creating the version-specific file with a header comment indicating it’s auto-generated

    +
  6. +
+
+
+

For example, if your META.json contains:

+
+
+
+
"provides": {
+  "myext": {
+    "version": "1.2.3",
+    ...
+  }
+}
+
+
+
+

Running make will create sql/myext—​1.2.3.sql by copying sql/myext.sql.

+
+
+
+

5.2. What Controls the Version Number

+
+

The version number comes from META.jsonprovides.{extension}.version, not from your .control file’s default_version field. The .control file’s default_version is used by PostgreSQL to determine which version to install by default, but the actual version-specific file that gets generated is determined by what’s in META.json.

+
+
+

To change the version of your extension: +1. Update provides.{extension}.version in META.json +2. Run make to regenerate the version-specific file +3. Update default_version in your .control file to match (if needed)

+
+
+
+

5.3. Committing Version Files

+
+

Version-specific SQL files are now treated as permanent files that should be committed to your repository. This makes it much easier to test updates to extensions, as you can see exactly what SQL was included in each version.

+
+
+ + + + + +
+
Important
+
+These files are auto-generated and include a header comment warning not to edit them. Any manual changes will be overwritten the next time you run make. To modify the extension, edit the base SQL file (sql/{extension}.sql) instead. +
+
+
+
+

5.4. Alternative: Ignoring Version Files

+
+

If you prefer not to commit version-specific SQL files, you must add them to your .gitignore to prevent make dist from failing due to untracked files. Add the following to your .gitignore:

+
+
+
+
# Auto-generated version-specific SQL files (if not committing them)
+sql/*--*.sql
+!sql/*--*--*.sql
+
+
+
+

The second line (!sql/----*.sql) ensures that upgrade scripts (which contain two version numbers and should be manually written) are still tracked.

+
+
+ + + + + +
+
Warning
+
+If you ignore version files instead of committing them, they will NOT be included in your PGXN distribution (make dist uses git archive, which only includes tracked files). This means users installing your extension from PGXN will need make and PGXS available to build the extension - they cannot simply copy the SQL files into their PostgreSQL installation. For maximum compatibility, we recommend committing version files. +
+
+
+
+

5.5. Distribution Inclusion

+
+

Version-specific files are included in distributions created by make dist only if they are committed to git. Since make dist uses git archive, only tracked files are included in the distribution archive.

+
+
+
+

5.6. Multiple Versions

+
+

If you need to support multiple versions of your extension:

+
+
+
    +
  1. +

    Create additional version-specific files manually (e.g., sql/myext—​1.0.0.sql, sql/myext—​1.1.0.sql)

    +
  2. +
  3. +

    Create upgrade scripts for version transitions (e.g., sql/myext—​1.0.0—​1.1.0.sql)

    +
  4. +
  5. +

    Update META.json to reflect the current version you’re working on

    +
  6. +
  7. +

    Commit all version files and upgrade scripts to your repository

    +
  8. +
+
+
+

The version file for the current version (specified in META.json) will be automatically regenerated when you run make, but other version files you create manually will be preserved.

+
+
+
+
+
+

6. Document Handling

+
+
+

PGXNtool supports generation and installation of document files. There are several variables and rules that control this behavior.

+
+
+

It is recommended that you commit any generated documentation files (such as HTML generated from Asciidoc) into git. +That way users will have these files installed when they install your extension. +If any generated files are missing (or out-of-date) during installation, PGXNtool will build them if Asciidoc is present on the system.

+
+
+

6.1. Document Variables

+
+
+
DOC_DIRS
+
+

Directories to look for documents in. +Defined as += doc.

+
+
DOCS
+
+

PGXS variable. +See The DOCS variable below.

+
+
DOCS_HTML
+
+

Document HTML files. +PGXNtool appends `$(ASCIIDOC_HTML) to this variable.

+
+
ASCIIDOC
+
+

Location of asciidoc or equivalent executable. +If not set PGXNtool will search for first asciidoctor, then asciidoc.

+
+
ASCIIDOC_EXTS
+
+

File extensions to consider as Asciidoc. +Defined as += adoc asciidoc.

+
+
ASCIIDOC_FILES
+
+

Asciidoc input files. +PGXNtool searches each $(DOC_DIRS) directory, looking for files with any $(ASCIIDOC_EXTS) extension. +Any files found are added to ASCIIDOC_FILES using +=.

+
+
ASCIIDOC_FLAGS
+
+

Additional flags to pass to Asciidoc.

+
+
ASCIIDOC_HTML
+
+

PGXNtool replaces each $(ASCIIDOC_EXTS) in $(ASCIIDOC_FILES) with html. +The result is appended to ASCIIDOC_HTML using +=.

+
+
+
+
+
+

6.2. Document Rules

+
+

If Asciidoc is found (or $(ASCIIDOC) is set), the html rule will be added as a prerequisite to the install and installchec rules. +That will ensure that docs are generated for install and test, but only if Asciidoc is available. +The dist rule will always depend on html though, to ensure html files are up-to-date before creating a distribution.

+
+
+

The html rule simply depends on `$(ASCIIDOC_HTML). +This rule is always present.

+
+
+

For each Asciidoc extension in $(ASCIIDOC_EXTS) a rule is generated to build a .html file from that extension using $(ASCIIDOC). +These rules are generated from ASCIIDOC_template:

+
+
+
ASCIIDOC_template
+
+
define ASCIIDOC_template
+%.html: %.$(1) # (1)
+ifndef ASCIIDOC
+	$$(warning Could not find "asciidoc" or "asciidoctor". Add one of them to your PATH,)
+	$$(warning or set ASCIIDOC to the correct location.)
+	$$(error Could not build %$$@)
+endif # ifndef ASCIIDOC
+	$$(ASCIIDOC) $$(ASCIIDOC_FLAGS) $$<
+endef # define ASCIIDOC_template
+
+
+
+
    +
  1. +

    $(1) is replaced by the extension.

    +
  2. +
+
+
+

These rules will always exist, even if $(ASCIIDOC) isn’t set (ie: if Asciidoc wasn’t found on the system). +These rules will throw an error if they are run if $(ASCIIDOC) isn’t defined. +On a normal user system that should never happen, because the html rule won’t be included in install or installcheck.

+
+
+
+

6.3. The DOCS variable

+
+

This variable has special meaning to PGXS. +See the Postgres documentation for full details.

+
+
+

If DOCS is defined when PGXS is included then rules will be added to install everything defined by $(DOCS) in PREFIX/share/doc/extension.

+
+
+ + + + + +
+
Note
+
+If DOCS is defined but empty some of the PGXS targets will error out. +Because of this, base.mk will forcibly define it to be NULL if it’s empty. +
+
+
+

PGXNtool appends all files found in all $(DOC_DIRS) to DOCS.

+
+
+
+
+
+

7. pg_tle Support

+
+
+

pgxntool can generate pg_tle (Trusted Language Extensions) registration SQL for deploying PostgreSQL extensions in managed environments like AWS RDS and Aurora where filesystem access is not available.

+
+
+

For make targets, see: pgtle, check-pgtle, run-pgtle.

+
+
+

7.1. What is pg_tle?

+
+

pg_tle is an AWS open-source framework that enables developers to create and deploy PostgreSQL extensions without filesystem access. Traditional PostgreSQL extensions require .control and .sql files on the filesystem, which isn’t possible in managed services like RDS and Aurora.

+
+
+

pg_tle solves this by: +- Storing extension metadata and SQL in database tables +- Using the pgtle_admin role for administrative operations +- Enabling CREATE EXTENSION to work in managed environments

+
+
+
+

7.2. Quick Start

+
+

Generate pg_tle registration SQL for your extension:

+
+
+
+
make pgtle
+
+
+
+

This creates files in pg_tle/ subdirectories organized by pg_tle version ranges. See pgtle_versions.md for complete version range details and API compatibility boundaries.

+
+
+
+

7.3. Version Groupings

+
+

pgxntool creates different sets of files for different pg_tle versions to handle backward-incompatible API changes. Each version boundary represents a change to pg_tle’s API functions that we use.

+
+
+

For details on version boundaries and API changes, see pgtle_versions.md.

+
+
+
+

7.4. Installation Example

+
+ + + + + +
+
Important
+
+This is only a basic example. Always refer to the main pg_tle documentation for complete installation instructions and best practices. +
+
+
+

Basic installation steps:

+
+
+
    +
  1. +

    Ensure pg_tle is installed and grant the pgtle_admin role to your user

    +
  2. +
  3. +

    Generate and run the pg_tle registration SQL files:

    +
    +
    +
    make run-pgtle
    +
    +
    +
    +

    This automatically detects your pg_tle version and runs the appropriate SQL files. See pgtle_versions.md for version range details.

    +
    +
  4. +
  5. +

    Create your extension: CREATE EXTENSION myextension;

    +
  6. +
+
+
+
+

7.5. Advanced Usage

+
+

7.5.1. Multi-Extension Projects

+
+

If your project has multiple extensions (multiple .control files), make pgtle generates files for all of them:

+
+
+
+
myproject/
+├── ext1.control
+├── ext2.control
+└── pg_tle/
+    ├── 1.0.0-1.5.0/
+    │   ├── ext1.sql
+    │   └── ext2.sql
+    └── 1.5.0+/
+        ├── ext1.sql
+        └── ext2.sql
+
+
+
+
+
+

7.6. How It Works

+
+

make pgtle does the following:

+
+
+
    +
  1. +

    Parses control file(s): Extracts comment, default_version, requires, and schema fields

    +
  2. +
  3. +

    Discovers SQL files: Finds all versioned files (sql/{ext}--{version}.sql) and upgrade scripts (sql/{ext}--{ver1}--{ver2}.sql)

    +
  4. +
  5. +

    Wraps SQL content: Uses a fixed dollar-quote delimiter ($pgtle_wrap_delimiter$) to wrap SQL for pg_tle functions

    +
  6. +
  7. +

    Generates registration SQL: Creates pgtle.install_extension() calls for each version, pgtle.install_update_path() for upgrades, and pgtle.set_default_version() for the default

    +
  8. +
  9. +

    Version-specific output: Generates separate files for different pg_tle capability levels

    +
  10. +
+
+
+

Each generated SQL file is wrapped in a transaction (BEGIN; …​ COMMIT;) to ensure atomic installation.

+
+
+
+

7.7. Troubleshooting

+
+

7.7.1. "No versioned SQL files found"

+
+

Problem: The script can’t find sql/{ext}--{version}.sql files.

+
+
+

Solution: Run make first to generate versioned files from your base sql/{ext}.sql file.

+
+
+
+

7.7.2. "Control file not found"

+
+

Problem: The script can’t find {ext}.control in the current directory.

+
+
+

Solution: Run make pgtle from your extension’s root directory (where the .control file is).

+
+
+
+

7.7.3. "SQL file contains reserved pg_tle delimiter"

+
+

Problem: Your SQL files contain the string $pgtle_wrap_delimiter$ (extremely unlikely).

+
+
+

Solution: Don’t use that dollar-quote delimiter in your code.

+
+
+
+

7.7.4. Extension uses C code

+
+

Problem: Your control file has module_pathname, indicating C code.

+
+
+

Solution: pg_tle only supports trusted languages. You cannot use C extensions with pg_tle. The script will warn you but still generate files (which won’t work).

+
+
+ + + + + +
+
Note
+
+there are several untrusted languages (such as plpython), and the only tests for C. +== Copyright +Copyright (c) 2026 Jim Nasby <Jim.Nasby@gmail.com> +
+
+
+

PGXNtool is released under a BSD license. Note that it includes JSON.sh, which is released under a MIT license.

+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/_.gitignore b/_.gitignore index 3eb345a..0c14928 100644 --- a/_.gitignore +++ b/_.gitignore @@ -1,11 +1,15 @@ # Editor files .*.swp +# Claude Code local settings +.claude/*.local.json + # Explicitly exclude META.json! !/META.json # Generated make files meta.mk +control.mk # Compiler output *.o @@ -13,8 +17,7 @@ meta.mk .deps/ # built targets -/sql/*--* -!/sql/*--*--*.sql +# Note: Version-specific files (sql/*--*.sql) are now tracked in git and should be committed # Test artifacts results/ @@ -24,3 +27,6 @@ regression.out # Misc tmp/ .DS_Store + +# pg_tle generated files +/pg_tle/ diff --git a/base.mk b/base.mk index d7637cf..5a4e232 100644 --- a/base.mk +++ b/base.mk @@ -1,5 +1,8 @@ PGXNTOOL_DIR := pgxntool +# Ensure 'all' is the default target (not META.json which happens to be first) +.DEFAULT_GOAL := all + # # META.json # @@ -10,35 +13,70 @@ META.json: META.in.json $(PGXNTOOL_DIR)/build_meta.sh # # meta.mk # -# Buind meta.mk, which contains info from META.json, and include it +# Build meta.mk, which contains PGXN distribution info from META.json PGXNTOOL_distclean += meta.mk meta.mk: META.json Makefile $(PGXNTOOL_DIR)/base.mk $(PGXNTOOL_DIR)/meta.mk.sh @$(PGXNTOOL_DIR)/meta.mk.sh $< >$@ -include meta.mk +# +# control.mk +# +# Build control.mk, which contains extension info from .control files +# This is separate from meta.mk because: +# - META.json specifies PGXN distribution metadata +# - .control files specify what PostgreSQL actually uses (e.g., default_version) +# These can differ, and PostgreSQL cares about the control file version. +# +# Find all control files first (needed for dependencies) +PGXNTOOL_CONTROL_FILES := $(wildcard *.control) +PGXNTOOL_distclean += control.mk +control.mk: $(PGXNTOOL_CONTROL_FILES) Makefile $(PGXNTOOL_DIR)/base.mk $(PGXNTOOL_DIR)/control.mk.sh + @$(PGXNTOOL_DIR)/control.mk.sh $(PGXNTOOL_CONTROL_FILES) >$@ + +-include control.mk + DATA = $(EXTENSION_VERSION_FILES) $(wildcard sql/*--*--*.sql) -DOCS = $(wildcard doc/*.asc) -ifeq ($(strip $(DOCS)),) -DOCS =# Set to NUL so PGXS doesn't puke -endif +DOC_DIRS += doc +# NOTE: if this is empty it gets forcibly defined to NUL before including PGXS +DOCS += $(foreach dir,$(DOC_DIRS),$(wildcard $(dir)/*)) + +# Find all asciidoc targets +ASCIIDOC ?= $(shell which asciidoctor 2>/dev/null || which asciidoc 2>/dev/null) +ASCIIDOC_EXTS += adoc asciidoc +ASCIIDOC_FILES += $(foreach dir,$(DOC_DIRS),$(foreach ext,$(ASCIIDOC_EXTS),$(wildcard $(dir)/*.$(ext)))) PG_CONFIG ?= pg_config TESTDIR ?= test TESTOUT ?= $(TESTDIR) -TEST_SOURCE_FILES += $(wildcard $(TESTDIR)/input/*.source) -TEST_OUT_FILES = $(subst input,output,$(TEST_SOURCE_FILES)) +# .source files are OPTIONAL - see "pg_regress workflow" comment below for details +TEST__SOURCE__INPUT_FILES += $(wildcard $(TESTDIR)/input/*.source) +TEST__SOURCE__OUTPUT_FILES += $(wildcard $(TESTDIR)/output/*.source) +TEST__SOURCE__INPUT_AS_OUTPUT = $(subst input,output,$(TEST__SOURCE__INPUT_FILES)) TEST_SQL_FILES += $(wildcard $(TESTDIR)/sql/*.sql) TEST_RESULT_FILES = $(patsubst $(TESTDIR)/sql/%.sql,$(TESTDIR)/expected/%.out,$(TEST_SQL_FILES)) -TEST_FILES = $(TEST_SOURCE_FILES) $(TEST_SQL_FILES) +TEST_FILES = $(TEST__SOURCE__INPUT_FILES) $(TEST_SQL_FILES) +# Ephemeral files generated from source files (should be cleaned) +# input/*.source → sql/*.sql (converted by pg_regress) +TEST__SOURCE__SQL_FILES = $(patsubst $(TESTDIR)/input/%.source,$(TESTDIR)/sql/%.sql,$(TEST__SOURCE__INPUT_FILES)) +# output/*.source → expected/*.out (converted by pg_regress) +TEST__SOURCE__EXPECTED_FILES = $(patsubst $(TESTDIR)/output/%.source,$(TESTDIR)/expected/%.out,$(TEST__SOURCE__OUTPUT_FILES)) REGRESS = $(sort $(notdir $(subst .source,,$(TEST_FILES:.sql=)))) # Sort is to get unique list -REGRESS_OPTS = --inputdir=$(TESTDIR) --outputdir=$(TESTOUT) --load-language=plpgsql +REGRESS_OPTS = --inputdir=$(TESTDIR) --outputdir=$(TESTOUT) # See additional setup below + +# Generate unique database name for tests to prevent conflicts across projects +# Uses project name + first 5 chars of md5 hash of current directory +# This prevents multiple test runs in different directories from clobbering each other +REGRESS_DBHASH := $(shell echo $(CURDIR) | (md5 2>/dev/null || md5sum) | cut -c1-5) +REGRESS_DBNAME := $(or $(PGXN),regression)_$(REGRESS_DBHASH) +REGRESS_OPTS += --dbname=$(REGRESS_DBNAME) MODULES = $(patsubst %.c,%,$(wildcard src/*.c)) ifeq ($(strip $(MODULES)),) MODULES =# Set to NUL so PGXS doesn't puke endif -EXTRA_CLEAN = $(wildcard ../$(PGXN)-*.zip) $(EXTENSION_VERSION_FILES) +EXTRA_CLEAN = $(wildcard ../$(PGXN)-*.zip) $(TEST__SOURCE__SQL_FILES) $(TEST__SOURCE__EXPECTED_FILES) pg_tle/ # Get Postgres version, as well as major (9.4, etc) version. # NOTE! In at least some versions, PGXS defines VERSION, so we intentionally don't use that variable @@ -53,8 +91,10 @@ GE91 = $(call test, $(MAJORVER), -ge, 91) ifeq ($(GE91),yes) all: $(EXTENSION_VERSION_FILES) +endif -#DATA = $(wildcard sql/*--*.sql) +ifeq ($($call test, $(MAJORVER), -lt 13), yes) + REGRESS_OPTS += --load-language=plpgsql endif PGXS := $(shell $(PG_CONFIG) --pgxs) @@ -64,7 +104,7 @@ DATA += $(wildcard *.control) # Don't have installcheck bomb on error .IGNORE: installcheck -installcheck: $(TEST_RESULT_FILES) $(TEST_OUT_FILES) +installcheck: $(TEST_RESULT_FILES) $(TEST_SQL_FILES) $(TEST__SOURCE__INPUT_FILES) | $(TESTDIR)/sql/ $(TESTDIR)/expected/ $(TESTOUT)/results/ # # TEST SUPPORT @@ -73,46 +113,155 @@ installcheck: $(TEST_RESULT_FILES) $(TEST_OUT_FILES) # make test: run any test dependencies, then do a `make install installcheck`. # If regressions are found, it will output them. +# +# This used to depend on clean as well, but that causes problems with +# watch-make if you're generating intermediate files. If tests end up needing +# clean it's an indication of a missing dependency anyway. .PHONY: test -test: clean testdeps install installcheck +test: testdeps install installcheck @if [ -r $(TESTOUT)/regression.diffs ]; then cat $(TESTOUT)/regression.diffs; fi # make results: runs `make test` and copy all result files to expected # DO NOT RUN THIS UNLESS YOU'RE CERTAIN ALL YOUR TESTS ARE PASSING! +# +# pg_regress workflow: +# 1. Converts input/*.source → sql/*.sql (with token substitution) +# 2. Converts output/*.source → expected/*.out (with token substitution) +# 3. Runs tests, saving actual output in results/ +# 4. Compares results/ with expected/ +# +# NOTE: Both input/*.source and output/*.source are COMPLETELY OPTIONAL and are +# very rarely needed. pg_regress does NOT create the input/ or output/ directories +# - these are optional INPUT directories that users create if they need them. +# Most extensions will never need these directories. +# +# CRITICAL: Do NOT copy files that have corresponding output/*.source files, because +# those are the source of truth and will be regenerated by pg_regress from the .source files. +# Only copy files from results/ that don't have output/*.source counterparts. .PHONY: results results: test - rsync -rlpgovP $(TESTOUT)/results/ $(TESTDIR)/expected + @# Copy .out files from results/ to expected/, excluding those with output/*.source counterparts + @# .out files with output/*.source counterparts are generated from .source files and should NOT be overwritten + @$(PGXNTOOL_DIR)/make_results.sh $(TESTDIR) $(TESTOUT) # testdeps is a generic dependency target that you can add targets to .PHONY: testdeps testdeps: pgtap +# +# pg_tle support - Generate pg_tle registration SQL +# + +# PGXNTOOL_CONTROL_FILES is defined above (for control.mk dependencies) +PGXNTOOL_EXTENSIONS = $(basename $(PGXNTOOL_CONTROL_FILES)) + +# Main target +# Depend on 'all' to ensure versioned SQL files are generated first +# Depend on control.mk (which defines EXTENSION_VERSION_FILES) +# Depend on control files explicitly so changes trigger rebuilds +# Generates all supported pg_tle versions for each extension +.PHONY: pgtle +pgtle: all control.mk $(PGXNTOOL_CONTROL_FILES) + @$(foreach ext,$(PGXNTOOL_EXTENSIONS),\ + $(PGXNTOOL_DIR)/pgtle.sh --extension $(ext);) + +# +# pg_tle installation support +# + +# Check if pg_tle is installed and report version +# Only reports version if CREATE EXTENSION pg_tle has been run +# Errors if pg_tle extension is not installed +# Uses pgtle.sh to get version (avoids code duplication) +.PHONY: check-pgtle +check-pgtle: + @echo "Checking pg_tle installation..." + @PGTLE_VERSION=$$($(PGXNTOOL_DIR)/pgtle.sh --get-version 2>/dev/null); \ + if [ -n "$$PGTLE_VERSION" ]; then \ + echo "pg_tle extension version: $$PGTLE_VERSION"; \ + exit 0; \ + fi; \ + echo "ERROR: pg_tle extension is not installed" >&2; \ + echo " Run 'CREATE EXTENSION pg_tle;' first" >&2; \ + exit 1 + +# Run pg_tle registration SQL files +# Requires pg_tle extension to be installed (checked via check-pgtle) +# Uses pgtle.sh to determine which version range directory to use +# Assumes PG* environment variables are configured +.PHONY: run-pgtle +run-pgtle: pgtle + @$(PGXNTOOL_DIR)/pgtle.sh --run + # These targets ensure all the relevant directories exist -$(TESTDIR)/sql: - @mkdir -p $@ -$(TESTDIR)/expected/: - @mkdir -p $@ -$(TEST_RESULT_FILES): $(TESTDIR)/expected/ - @touch $@ -$(TESTDIR)/output/: +$(TESTDIR)/sql $(TESTDIR)/expected/ $(TESTOUT)/results/: @mkdir -p $@ -$(TEST_OUT_FILES): $(TESTDIR)/output/ $(TESTDIR)/expected/ $(TESTDIR)/sql/ +$(TEST_RESULT_FILES): | $(TESTDIR)/expected/ @touch $@ +# +# DOC SUPPORT +# +ASCIIDOC_HTML += $(filter %.html,$(foreach ext,$(ASCIIDOC_EXTS),$(ASCIIDOC_FILES:.$(ext)=.html))) +DOCS_HTML += $(ASCIIDOC_HTML) + +# General ASCIIDOC template. This will be used to create rules for all ASCIIDOC_EXTS +define ASCIIDOC_template +%.html: %.$(1) +ifeq (,$(strip $(ASCIIDOC))) + $$(warning Could not find "asciidoc" or "asciidoctor". Add one of them to your PATH,) + $$(warning or set ASCIIDOC to the correct location.) + $$(error Could not build %$$@) +endif # ifeq ASCIIDOC + $$(ASCIIDOC) $$(ASCIIDOC_FLAGS) $$< +endef # define ASCIIDOC_template + +# Create the actual rules +$(foreach ext,$(ASCIIDOC_EXTS),$(eval $(call ASCIIDOC_template,$(ext)))) + +# Create the html target regardless of whether we have asciidoc, and make it a dependency of dist +html: $(ASCIIDOC_HTML) +dist: html + +# But don't add it as an install or test dependency unless we do have asciidoc +ifneq (,$(strip $(ASCIIDOC))) + +# Need to do this so install & co will pick up ALL targets. Unfortunately this can result in some duplication. +DOCS += $(ASCIIDOC_HTML) + +# Also need to add html as a dep to all (which will get picked up by install & installcheck +all: html + +endif # ASCIIDOC + +.PHONY: docclean +docclean: + $(RM) $(DOCS_HTML) + + # # TAGGING SUPPORT # rmtag: git fetch origin # Update our remotes - @test -z "$$(git branch --list $(PGXNVERSION))" || git branch -d $(PGXNVERSION) - @test -z "$$(git branch --list -r origin/$(PGXNVERSION))" || git push --delete origin $(PGXNVERSION) + @test -z "$$(git tag --list $(PGXNVERSION))" || git tag -d $(PGXNVERSION) + @test -z "$$(git ls-remote --tags origin $(PGXNVERSION) | grep -v '{}')" || git push --delete origin $(PGXNVERSION) -# TODO: Don't puke if tag already exists *and is the same* tag: @test -z "$$(git status --porcelain)" || (echo 'Untracked changes!'; echo; git status; exit 1) - git branch $(PGXNVERSION) - git push --set-upstream origin $(PGXNVERSION) + @# Skip if tag already exists and points to HEAD + @if git rev-parse $(PGXNVERSION) >/dev/null 2>&1; then \ + if [ "$$(git rev-parse $(PGXNVERSION))" = "$$(git rev-parse HEAD)" ]; then \ + echo "Tag $(PGXNVERSION) already exists at HEAD, skipping"; \ + else \ + echo "ERROR: Tag $(PGXNVERSION) exists but points to different commit" >&2; \ + exit 1; \ + fi; \ + else \ + git tag $(PGXNVERSION); \ + fi + git push origin $(PGXNVERSION) .PHONY: forcetag forcetag: rmtag tag @@ -121,6 +270,13 @@ forcetag: rmtag tag dist: tag dist-only dist-only: + @# Check if .gitattributes exists but isn't committed + @if [ -f .gitattributes ] && ! git ls-files --error-unmatch .gitattributes >/dev/null 2>&1; then \ + echo "ERROR: .gitattributes exists but is not committed to git." >&2; \ + echo " git archive only respects export-ignore for committed files." >&2; \ + echo " Please commit .gitattributes for export-ignore to take effect." >&2; \ + exit 1; \ + fi git archive --prefix=$(PGXN)-$(PGXNVERSION)/ -o ../$(PGXN)-$(PGXNVERSION).zip $(PGXNVERSION) .PHONY: forcedist @@ -142,22 +298,35 @@ print-% : ; $(info $* is $(flavor $*) variable set to "$($*)") @true # # This is setup to allow any number of pull targets by defining special # variables. pgxntool-sync-release is an example of this. -.PHONY: pgxn-sync-% +# +# After the subtree pull, we run update-setup-files.sh to handle files that +# were initially copied by setup.sh (like .gitignore). This script does a +# 3-way merge if both you and pgxntool changed the file. +.PHONY: pgxntool-sync-% pgxntool-sync-%: - git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@) + @old_commit=$$(git log -1 --format=%H -- pgxntool/); \ + git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@); \ + pgxntool/update-setup-files.sh "$$old_commit" pgxntool-sync: pgxntool-sync-release # DANGER! Use these with caution. They may add extra crap to your history and # could make resolving merges difficult! pgxntool-sync-release := git@github.com:decibel/pgxntool.git release pgxntool-sync-stable := git@github.com:decibel/pgxntool.git stable +pgxntool-sync-master := git@github.com:decibel/pgxntool.git master pgxntool-sync-local := ../pgxntool release # Not the same as PGXNTOOL_DIR! pgxntool-sync-local-stable := ../pgxntool stable # Not the same as PGXNTOOL_DIR! +pgxntool-sync-local-master := ../pgxntool master # Not the same as PGXNTOOL_DIR! distclean: rm -f $(PGXNTOOL_distclean) ifndef PGXNTOOL_NO_PGXS_INCLUDE + +ifeq (,$(strip $(DOCS))) +DOCS =# Set to NUL so PGXS doesn't puke +endif + include $(PGXS) # # pgtap @@ -167,9 +336,10 @@ include $(PGXS) # the META handling stuff is it's own makefile. # .PHONY: pgtap +installcheck: pgtap pgtap: $(DESTDIR)$(datadir)/extension/pgtap.control $(DESTDIR)$(datadir)/extension/pgtap.control: - pgxn install pgtap + pgxn install pgtap --sudo endif # fndef PGXNTOOL_NO_PGXS_INCLUDE diff --git a/build_meta.sh b/build_meta.sh index 70d2273..e0fd6b2 100755 --- a/build_meta.sh +++ b/build_meta.sh @@ -1,16 +1,28 @@ #!/bin/bash +# Build META.json from META.in.json template +# +# WHY META.in.json EXISTS: +# META.in.json serves as a template that: +# 1. Shows all possible PGXN metadata fields (both required and optional) with comments +# 2. Can have empty placeholder fields like "key": "" or "key": [ "", "" ] +# 3. Users edit this to fill in their extension's metadata +# +# WHY WE GENERATE META.json: +# The reason we generate META.json from a template is to eliminate empty fields that +# are optional; PGXN.org gets upset about them. In the future it's possible we'll do +# more here (for example, if we added more info to the template we could use it to +# generate control files). +# +# WHY WE COMMIT META.json: +# PGXN.org requires META.json to be present in submitted distributions. We choose +# to commit it to git instead of manually adding it to distributions for simplicity +# (and since it generally only changes once for each new version). + set -e -error () { - echo $@ >&2 -} -die () { - return=$1 - shift - error $@ - exit $return -} +BASEDIR=$(dirname "$0") +source "$BASEDIR/lib.sh" [ $# -eq 2 ] || die 2 Invalid number of arguments $# diff --git a/control.mk.sh b/control.mk.sh new file mode 100755 index 0000000..cc63cea --- /dev/null +++ b/control.mk.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# control.mk.sh - Generate Makefile rules from PostgreSQL extension control files +# +# This script parses .control files to extract extension metadata (particularly +# default_version) and generates Make variables and rules for building versioned +# SQL files. +# +# Usage: control.mk.sh [ ...] +# +# Output (to stdout, meant to be redirected to control.mk): +# EXTENSIONS += +# EXTENSION_SQL_FILES += sql/.sql +# EXTENSION__VERSION := +# EXTENSION__VERSION_FILE = sql/--.sql +# EXTENSION_VERSION_FILES += $(EXTENSION__VERSION_FILE) +# +# +# Why control files instead of META.json? +# META.json's "provides" section specifies versions for PGXN distribution metadata. +# But PostgreSQL uses the control file's default_version to determine which +# versioned SQL file to load. These can differ (e.g., PGXN distribution version +# might be updated independently of extension version). Using the control file +# ensures the generated SQL files match what PostgreSQL expects. + +set -o errexit -o errtrace -o pipefail + +BASEDIR=$(dirname "$0") +source "$BASEDIR/lib.sh" + +# Extract default_version from a PostgreSQL extension control file +# Usage: get_control_default_version +# Errors if: +# - Control file doesn't exist +# - default_version is not specified (pgxntool requires it) +# - Multiple default_version lines exist +get_control_default_version() { + local control_file="$1" + + if [ ! -f "$control_file" ]; then + die 2 "Control file '$control_file' not found" + fi + + # Count default_version lines + local count + count=$(grep -cE "^[[:space:]]*default_version[[:space:]]*=" "$control_file" 2>/dev/null) || count=0 + + if [ "$count" -eq 0 ]; then + die 2 "default_version not specified in '$control_file'. PostgreSQL allows extensions without a default_version, but pgxntool requires it to generate versioned SQL files." + fi + + if [ "$count" -gt 1 ]; then + die 2 "Multiple default_version lines found in '$control_file'. Control files must have exactly one default_version." + fi + + # Extract the version value + # Handles: default_version = '1.0', default_version = "1.0", trailing comments + local version=$(grep -E "^[[:space:]]*default_version[[:space:]]*=" "$control_file" | \ + sed -e "s/^[^=]*=[[:space:]]*//" \ + -e "s/[[:space:]]*#.*//" \ + -e "s/^['\"]//;s/['\"]$//" ) + + if [ -z "$version" ]; then + die 2 "Could not parse default_version value from '$control_file'" + fi + + echo "$version" +} + +# Main: process each control file passed as argument +if [ $# -eq 0 ]; then + die 1 "Usage: control.mk.sh [ ...]" +fi + +for control_file in "$@"; do + ext=$(basename "$control_file" .control) + version=$(get_control_default_version "$control_file") + + echo "EXTENSIONS += $ext" + echo "EXTENSION_SQL_FILES += sql/${ext}.sql" + echo "EXTENSION_${ext}_VERSION := ${version}" + echo "EXTENSION_${ext}_VERSION_FILE = sql/${ext}--\$(EXTENSION_${ext}_VERSION).sql" + echo "EXTENSION_VERSION_FILES += \$(EXTENSION_${ext}_VERSION_FILE)" + echo "\$(EXTENSION_${ext}_VERSION_FILE): sql/${ext}.sql ${control_file}" + echo " @echo '/* DO NOT EDIT - AUTO-GENERATED FILE */' > \$(EXTENSION_${ext}_VERSION_FILE)" + echo " @cat sql/${ext}.sql >> \$(EXTENSION_${ext}_VERSION_FILE)" + echo +done + +# vi: expandtab ts=2 sw=2 diff --git a/lib.sh b/lib.sh new file mode 100644 index 0000000..41de512 --- /dev/null +++ b/lib.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# lib.sh - Common utility functions for pgxntool scripts +# +# This file is meant to be sourced by other scripts, not executed directly. +# Usage: source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# ============================================================================= +# SETUP FILES CONFIGURATION +# ============================================================================= +# Files copied by setup.sh and tracked by update-setup-files.sh for sync updates. +# Format: "source_in_pgxntool:destination_in_project" +# ============================================================================= +SETUP_FILES=( + "_.gitignore:.gitignore" + "test/deps.sql:test/deps.sql" +) + +# Symlinks created by setup.sh and verified by update-setup-files.sh +# Format: "destination:target" +SETUP_SYMLINKS=( + "test/pgxntool:../pgxntool/test/pgxntool" +) + +# Error function - outputs to stderr but doesn't exit +# Usage: error "message" +error() { + echo "ERROR: $*" >&2 +} + +# Die function - outputs error message and exits with specified code +# Usage: die EXIT_CODE "message" +die() { + local exit_code=$1 + shift + error "$@" + exit $exit_code +} + +# Debug function +# Usage: debug LEVEL "message" +# Outputs message to stderr if DEBUG >= LEVEL +# Debug levels use multiples of 10 (10, 20, 30, 40, etc.) to allow for easy expansion +# - 10: Critical errors, important warnings +# - 20: Warnings, significant state changes +# - 30: General debugging, function entry/exit, array operations +# - 40: Verbose details, loop iterations +# - 50+: Maximum verbosity +# Enable with: DEBUG=30 scriptname.sh +debug() { + local level=$1 + shift + local message="$*" + + if [ "${DEBUG:-0}" -ge "$level" ]; then + echo "DEBUG[$level]: $message" >&2 + fi +} diff --git a/make_results.sh b/make_results.sh new file mode 100755 index 0000000..066e372 --- /dev/null +++ b/make_results.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Helper script for make results target +# Copies .out files from results/ to expected/, excluding those with output/*.source counterparts + +set -e + +TESTDIR="${1:-test}" +TESTOUT="${2:-${TESTDIR}}" + +mkdir -p "${TESTDIR}/expected" + +# Use nullglob so globs that don't match return nothing instead of the literal pattern +shopt -s nullglob + +for result_file in "${TESTOUT}/results"/*.out; do + test_name=$(basename "$result_file" .out) + + # Check if this file has a corresponding output/*.source file + # Only consider non-empty source files (empty files are likely leftovers from pg_regress) + if [ -f "${TESTDIR}/output/${test_name}.source" ] && [ -s "${TESTDIR}/output/${test_name}.source" ]; then + echo "WARNING: ${TESTOUT}/results/${test_name}.out exists but will NOT be copied" >&2 + echo " (excluded because ${TESTDIR}/output/${test_name}.source exists)" >&2 + else + # Copy the file - it doesn't have an output/*.source counterpart + cp "$result_file" "${TESTDIR}/expected/${test_name}.out" + fi +done + diff --git a/meta.mk.sh b/meta.mk.sh index a5da2ec..e6cecc5 100755 --- a/meta.mk.sh +++ b/meta.mk.sh @@ -1,25 +1,38 @@ -#! /usr/bin/env bash +#!/usr/bin/env bash +# +# meta.mk.sh - Generate Makefile variables from META.json +# +# This script parses META.json (PGXN distribution metadata) and generates +# Make variables for the distribution name and version. +# +# Usage: meta.mk.sh +# +# Output (to stdout, meant to be redirected to meta.mk): +# PGXN := +# PGXNVERSION := +# +# Note: Extension-specific variables (like EXTENSION_*_VERSION) are generated +# by control.mk.sh from .control files, not from META.json. This is because +# META.json specifies PGXN distribution metadata, while .control files specify +# what PostgreSQL actually uses. set -o errexit -o errtrace -o pipefail -trap 'echo "Error on line ${LINENO}" >&2' ERR -META=$1 -BASEDIR=`dirname $0` +BASEDIR=$(dirname "$0") +source "$BASEDIR/lib.sh" + JSON_SH=$BASEDIR/JSON.sh -error () { - echo $@ >&2 -} trap 'error "Error on line ${LINENO}"' ERR -die () { - local retval=$1 - shift - error $@ - exit $retval -} +META=$1 +if [ -z "$META" ]; then + die 1 "Usage: meta.mk.sh " +fi -REQUIRED='abstract maintainer license provides name version' +if [ ! -f "$META" ]; then + die 2 "META.json file '$META' not found" +fi #function to get value of specified key #returns empty string if not found @@ -27,7 +40,7 @@ REQUIRED='abstract maintainer license provides name version' #usage: VAR=$(getkey foo.bar) #get value of "bar" contained within "foo" # VAR=$(getkey foo[4].bar) #get value of "bar" contained in the array "foo" on position 4 # VAR=$(getkey [4].foo) #get value of "foo" contained in the root unnamed array on position 4 -function _getkey { +_getkey() { #reformat key string (parameter) to what JSON.sh uses KEYSTRING=$(sed -e 's/\[/\"\,/g' -e 's/^\"\,/\[/g' -e 's/\]\./\,\"/g' -e 's/\./\"\,\"/g' -e '/^\[/! s/^/\[\"/g' -e '/\]$/! s/$/\"\]/g' <<< "$@") #extract the key value @@ -37,60 +50,21 @@ function _getkey { FOUT="${FOUT%\"*}" echo "$FOUT" } -function getkeys { - KEYSTRING=$(sed -e 's/\[/\"\,/g' -e 's/^\"\,/\[/g' -e 's/\]\./\,\"/g' -e 's/\./\"\,\"/g' -e '/^\[/! s/^/\[\"/g' -e '/\",\"$/! s/$/\",\"/g' <<< "$@") - #extract the key value - FOUT=$(grep -F "$KEYSTRING" <<< "$JSON_PARSED") - FOUT="${FOUT%$'\t'*}" - echo "$FOUT" -} - -#function returning length of array -#returns zero if key in parameter does not exist or is not an array -#usage: VAR=$(getarrlen foo.bar) #get length of array "bar" contained within "foo" -# VAR=$(getarrlen) #get length of the root unnamed array -# VAR=$(getarrlen [2].foo.bar) #get length of array "bar" contained within "foo", which is stored in the root unnamed array on position 2 -function getarrlen { - #reformat key string (parameter) to what JSON.sh uses - KEYSTRING=$(gsed -e '/^\[/! s/\[/\"\,/g' -e 's/\]\./\,\"/g' -e 's/\./\"\,\"/g' -e '/^$/! {/^\[/! s/^/\[\"/g}' -e '/^$/! s/$/\"\,/g' -e 's/\[/\\\[/g' -e 's/\]/\\\]/g' -e 's/\,/\\\,/g' -e '/^$/ s/^/\\\[/g' <<< "$@") - #extract the key array length - get last index - LEN=$(grep -o "${KEYSTRING}[0-9]*" <<< "$JSON_PARSED" | tail -n -1 | grep -o "[0-9]*$") - #increment to get length, if empty => zero - if [ -n "$LEN" ]; then - LEN=$(($LEN+1)) - else - LEN="0" - fi - echo "$LEN" -} -JSON_PARSED=$(cat $META | $JSON_SH -l) - -function getkey { +getkey() { out=$(_getkey "$@") [ -n "$out" ] || die 2 "key $@ not found in $META" echo $out } -# Handle meta-spec specially -spec_version=`getkey meta-spec.version` -[ "$spec_version" == "1.0.0" ] || die 2 "Unknown meta-spec/version: $PGXN_meta-spec_version" +JSON_PARSED=$(cat "$META" | $JSON_SH -l) + +# Validate meta-spec version +spec_version=$(getkey meta-spec.version) +[ "$spec_version" == "1.0.0" ] || die 2 "Unknown meta-spec/version: $spec_version" +# Output distribution name and version echo "PGXN := $(getkey name)" echo "PGXNVERSION := $(getkey version)" -echo - -provides=$(getkeys provides | sed -e 's/\["provides","//' -e 's/",".*//' | uniq) -for ext in $provides; do - version=$(getkey provides.${ext}.version) - [ -n "$version" ] || die 2 "provides/${ext} does not specify a version number" - echo "EXTENSIONS += $ext" - echo "EXTENSION_SQL_FILES += sql/${ext}.sql" - echo "EXTENSION_${ext}_VERSION := ${version}" - echo "EXTENSION_${ext}_VERSION_FILE = sql/${ext}--\$(EXTENSION_${ext}_VERSION).sql" - echo "EXTENSION_VERSION_FILES += \$(EXTENSION_${ext}_VERSION_FILE)" - echo "\$(EXTENSION_${ext}_VERSION_FILE): sql/${ext}.sql META.json meta.mk" - echo ' cp $< $@' -done # vi: expandtab ts=2 sw=2 diff --git a/pgtle.sh b/pgtle.sh new file mode 100755 index 0000000..8fd2d17 --- /dev/null +++ b/pgtle.sh @@ -0,0 +1,849 @@ +#!/bin/bash +# +# pgtle.sh - Generate pg_tle registration SQL for PostgreSQL extensions +# +# Part of pgxntool: https://github.com/decibel/pgxntool +# +# SYNOPSIS +# pgtle.sh --extension EXTNAME [--pgtle-version VERSION] +# pgtle.sh --get-dir VERSION +# pgtle.sh --get-version +# pgtle.sh --run +# +# DESCRIPTION +# Generates pg_tle (Trusted Language Extensions) registration SQL from +# a pgxntool-based PostgreSQL extension. Reads the extension's .control +# file and SQL files, wrapping them for pg_tle deployment in managed +# environments like AWS RDS and Aurora. +# +# pg_tle enables extension installation without filesystem access by +# storing extension code in database tables. This script converts +# traditional PostgreSQL extensions into pg_tle-compatible SQL. +# +# OPTIONS +# --extension NAME +# Extension name (required). Must match a .control file basename +# in the current directory. +# +# --pgtle-version VERSION +# Generate for specific pg_tle version only (optional). +# Format: 1.0.0-1.4.0, 1.4.0-1.5.0, or 1.5.0+ +# Default: Generate all supported versions +# +# --get-dir VERSION +# Returns the directory path for the given pg_tle version. +# Format: VERSION is a version string like "1.5.2" +# Output: Directory path like "pg_tle/1.5.0+", "pg_tle/1.4.0-1.5.0", or "pg_tle/1.0.0-1.4.0" +# This option is used by make to determine which directory to use +# +# --get-version +# Returns the installed pg_tle version from the database. +# Output: Version string like "1.5.2" or empty if not installed +# Exit status: 0 if pg_tle is installed, 1 if not installed +# +# --run +# Runs the generated pg_tle registration SQL files. This option: +# - Detects the installed pg_tle version from the database +# - Determines the appropriate directory using --get-dir logic +# - Executes all SQL files in that directory via psql +# - Assumes PG* environment variables are configured for psql +# +# VERSION NOTATION +# X.Y.Z+ Works on pg_tle >= X.Y.Z +# X.Y.Z-A.B.C Works on pg_tle >= X.Y.Z and < A.B.C +# +# Note the boundary conditions: +# 1.5.0+ means >= 1.5.0 (includes 1.5.0) +# 1.4.0-1.5.0 means >= 1.4.0 and < 1.5.0 (excludes 1.5.0) +# 1.0.0-1.4.0 means >= 1.0.0 and < 1.4.0 (excludes 1.4.0) +# +# SUPPORTED VERSIONS +# 1.0.0-1.4.0 pg_tle 1.0.0 through 1.3.x (no uninstall function, no schema parameter) +# 1.4.0-1.5.0 pg_tle 1.4.0 through 1.4.x (has uninstall function, no schema parameter) +# 1.5.0+ pg_tle 1.5.0 and later (has uninstall function, schema parameter support) +# +# EXAMPLES +# # Generate all versions (default) +# pgtle.sh --extension myext +# +# # Generate only for pg_tle 1.5+ +# pgtle.sh --extension myext --pgtle-version 1.5.0+ +# +# # Get directory for a specific pg_tle version +# pgtle.sh --get-dir 1.5.2 +# # Output: pg_tle/1.5.0+ +# +# pgtle.sh --get-dir 1.4.2 +# # Output: pg_tle/1.4.0-1.5.0 +# +# # Get installed pg_tle version from database +# pgtle.sh --get-version +# # Output: 1.5.2 (or empty if not installed) +# +# # Run generated pg_tle registration SQL files +# pgtle.sh --run +# +# OUTPUT +# Creates files in version-specific subdirectories: +# pg_tle/1.0.0-1.4.0/{extension}.sql +# pg_tle/1.4.0-1.5.0/{extension}.sql +# pg_tle/1.5.0+/{extension}.sql +# +# Each file contains: +# - All versions of the extension +# - All upgrade paths between versions +# - Default version configuration +# - Complete installation instructions +# +# For --get-dir: Outputs the directory path to stdout. +# +# For --get-version: Outputs the installed pg_tle version to stdout, or empty if not installed. +# +# For --run: Executes SQL files and outputs progress messages to stderr. +# +# REQUIREMENTS +# - Must run from extension directory (where .control files are) +# - Extension must use only trusted languages (PL/pgSQL, SQL, PL/Perl, etc.) +# - No C code (module_pathname not supported by pg_tle) +# - Versioned SQL files must exist: sql/{ext}--{version}.sql +# +# EXIT STATUS +# 0 Success +# 1 Error (missing files, validation failure, C code detected, etc.) +# +# SEE ALSO +# pgxntool/README-pgtle.md - Complete user guide +# https://github.com/aws/pg_tle - pg_tle documentation +# + +set -eo pipefail + +# Source common library functions (error, die, debug) +PGXNTOOL_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$PGXNTOOL_DIR/lib.sh" + +# Constants +PGTLE_DELIMITER='$_pgtle_wrap_delimiter_$' +PGTLE_VERSIONS=("1.0.0-1.4.0" "1.4.0-1.5.0" "1.5.0+") + +# Supported pg_tle version ranges and their capabilities +# Use a function instead of associative array for compatibility with bash < 4.0 +get_pgtle_capability() { + local version="$1" + case "$version" in + "1.0.0-1.4.0") + echo "no_uninstall_no_schema" + ;; + "1.4.0-1.5.0") + echo "has_uninstall_no_schema" + ;; + "1.5.0+") + echo "has_uninstall_has_schema" + ;; + *) + echo "unknown" + ;; + esac +} + +# Global variables (populated from control file) +EXTENSION="" +DEFAULT_VERSION="" +COMMENT="" +REQUIRES="" +SCHEMA="" +MODULE_PATHNAME="" +VERSION_FILES=() +UPGRADE_FILES=() + +debug 30 "Global arrays initialized: VERSION_FILES=${#VERSION_FILES[@]}, UPGRADE_FILES=${#UPGRADE_FILES[@]}" +PGTLE_VERSION="" # Empty = generate all +GET_DIR_VERSION="" # For --get-dir option + +# Arrays (populated from SQL discovery) +VERSION_FILES=() +UPGRADE_FILES=() + +# Parse and validate a version string +# Extracts numeric version (major.minor.patch) from version strings +# Handles versions with suffixes like "1.5.0alpha1", "2.0beta", "1.2.3dev" +# Returns: numeric version string (e.g., "1.5.0") or exits with error +parse_version() { + local version="$1" + + if [ -z "$version" ]; then + die 1 "Version string is empty" + fi + + # Extract numeric version part (major.minor.patch) + # Matches: 1.5.0, 1.5, 10.2.1alpha, 2.0beta1, etc. + # Pattern: start of string, then digits, dot, digits, optionally (dot digits), then anything + local numeric_version + if [[ "$version" =~ ^([0-9]+\.[0-9]+(\.[0-9]+)?) ]]; then + numeric_version="${BASH_REMATCH[1]}" + else + die 1 "Cannot parse version string: '$version' + Expected format: major.minor[.patch][suffix] + Examples: 1.5.0, 1.5, 2.0alpha1, 10.2.3dev" + fi + + # Ensure we have at least major.minor (add .0 if needed) + if [[ ! "$numeric_version" =~ \. ]]; then + die 1 "Invalid version format: '$version' (need at least major.minor)" + fi + + # If we only have major.minor, add .0 for patch + if [[ ! "$numeric_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + numeric_version="${numeric_version}.0" + fi + + echo "$numeric_version" +} + +# Convert version string to comparable integer +# Takes a numeric version string (major.minor.patch) and converts to integer +# Example: "1.5.0" -> 1005000 +# Encoding scheme: major * 1000000 + minor * 1000 + patch +# This limits each component to 0-999 to prevent overflow +version_to_number() { + local version="$1" + + # Parse major.minor.patch + local major minor patch + if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + else + die 1 "version_to_number: Invalid numeric version format: '$version'" + fi + + # Check for overflow in encoding scheme + # Each component must be < 1000 to fit in the allocated space + if [ "$major" -ge 1000 ]; then + die 1 "version_to_number: Major version too large: $major (max 999) + Version: $version" + fi + if [ "$minor" -ge 1000 ]; then + die 1 "version_to_number: Minor version too large: $minor (max 999) + Version: $version" + fi + if [ "$patch" -ge 1000 ]; then + die 1 "version_to_number: Patch version too large: $patch (max 999) + Version: $version" + fi + + # Convert to comparable number: major * 1000000 + minor * 1000 + patch + echo $(( major * 1000000 + minor * 1000 + patch )) +} + +# Get directory for a given pg_tle version +# Takes a version string like "1.5.2" and returns the directory path +# Handles versions with suffixes (e.g., "1.5.0alpha1") +# Returns: "pg_tle/1.0.0-1.4.0", "pg_tle/1.4.0-1.5.0", or "pg_tle/1.5.0+" +get_version_dir() { + local version="$1" + + if [ -z "$version" ]; then + die 1 "Version required for --get-dir (got empty string)" + fi + + # Parse and validate version + local numeric_version + numeric_version=$(parse_version "$version") + + # Check if the original version has a pre-release suffix + # Pre-release versions (alpha, beta, rc, dev) are considered BEFORE the release + # Example: 1.4.0alpha1 comes BEFORE 1.4.0, so it should use the 1.0.0-1.4.0 range + local has_prerelease=0 + if [[ "$version" =~ (alpha|beta|rc|dev) ]]; then + has_prerelease=1 + fi + + # Convert versions to comparable numbers + local version_num + local threshold_1_4_num + local threshold_1_5_num + version_num=$(version_to_number "$numeric_version") + threshold_1_4_num=$(version_to_number "1.4.0") + threshold_1_5_num=$(version_to_number "1.5.0") + + # Compare and return appropriate directory: + # < 1.4.0 -> 1.0.0-1.4.0 + # >= 1.4.0 and < 1.5.0 -> 1.4.0-1.5.0 + # >= 1.5.0 -> 1.5.0+ + # + # Special handling for pre-release versions: + # If version equals a threshold but has a pre-release suffix, treat it as less than that threshold + # Example: 1.4.0alpha1 is treated as < 1.4.0, so it uses 1.0.0-1.4.0 + if [ "$version_num" -lt "$threshold_1_4_num" ]; then + echo "pg_tle/1.0.0-1.4.0" + elif [ "$version_num" -eq "$threshold_1_4_num" ] && [ "$has_prerelease" -eq 1 ]; then + # Pre-release of 1.4.0 is considered < 1.4.0 + echo "pg_tle/1.0.0-1.4.0" + elif [ "$version_num" -lt "$threshold_1_5_num" ]; then + echo "pg_tle/1.4.0-1.5.0" + elif [ "$version_num" -eq "$threshold_1_5_num" ] && [ "$has_prerelease" -eq 1 ]; then + # Pre-release of 1.5.0 is considered < 1.5.0 + echo "pg_tle/1.4.0-1.5.0" + else + echo "pg_tle/1.5.0+" + fi +} + +# Get pg_tle version from installed extension +# Returns version string or empty if not installed +get_pgtle_version() { + psql --no-psqlrc --tuples-only --no-align --command "SELECT extversion FROM pg_extension WHERE extname = 'pg_tle';" 2>/dev/null | tr -d '[:space:]' || echo "" +} + +# Run pg_tle registration SQL files +# Detects installed pg_tle version and runs appropriate SQL files +run_pgtle_sql() { + echo "Running pg_tle registration SQL files..." >&2 + + # Get version from installed extension + local pgtle_version=$(get_pgtle_version) + if [ -z "$pgtle_version" ]; then + die 1 "pg_tle extension is not installed + Run 'CREATE EXTENSION pg_tle;' first, or use 'make check-pgtle' to verify" + fi + + # Get directory for this version + local pgtle_dir=$(get_version_dir "$pgtle_version") + if [ -z "$pgtle_dir" ]; then + die 1 "Failed to determine pg_tle directory for version $pgtle_version" + fi + + echo "Using pg_tle files for version $pgtle_version (directory: $pgtle_dir)" >&2 + + # Check if directory exists + if [ ! -d "$pgtle_dir" ]; then + die 1 "pg_tle directory $pgtle_dir does not exist + Run 'make pgtle' first to generate files" + fi + + # Run all SQL files in the directory + local sql_file + local found=0 + for sql_file in "$pgtle_dir"/*.sql; do + if [ -f "$sql_file" ]; then + found=1 + echo "Running $sql_file..." >&2 + psql --no-psqlrc --file="$sql_file" || exit 1 + fi + done + + if [ "$found" -eq 0 ]; then + die 1 "No SQL files found in $pgtle_dir + Run 'make pgtle' first to generate files" + fi + + echo "pg_tle registration complete" >&2 +} + +# Main logic +main() { + # Handle --get-dir, --get-version, --test-function, and --run options first (early exit, before other validation) + local args=("$@") + local i=0 + while [ $i -lt ${#args[@]} ]; do + if [ "${args[$i]}" = "--get-dir" ] && [ $((i+1)) -lt ${#args[@]} ]; then + get_version_dir "${args[$((i+1))]}" + exit 0 + elif [ "${args[$i]}" = "--get-version" ]; then + local version=$(get_pgtle_version) + if [ -n "$version" ]; then + echo "$version" + exit 0 + else + exit 1 + fi + elif [ "${args[$i]}" = "--test-function" ] && [ $((i+1)) -lt ${#args[@]} ]; then + # Hidden option for testing internal functions + # NOT a supported public interface - used only by the test suite + # Usage: pgtle.sh --test-function FUNC_NAME [ARGS...] + local func_name="${args[$((i+1))]}" + shift $((i+2)) # Remove script name and --test-function and func_name + + # Check if function exists + if ! declare -f "$func_name" >/dev/null 2>&1; then + die 1 "Function '$func_name' does not exist" + fi + + # Call the function with remaining arguments + "$func_name" "${args[@]:$((i+2))}" + exit $? + elif [ "${args[$i]}" = "--run" ]; then + run_pgtle_sql + exit 0 + fi + i=$((i+1)) + done + + # Parse other arguments + parse_args "$@" + + validate_environment + parse_control_file + discover_sql_files + + if [ -z "$PGTLE_VERSION" ]; then + # Generate all versions + for version in "${PGTLE_VERSIONS[@]}"; do + generate_pgtle_sql "$version" + done + else + # Generate specific version + generate_pgtle_sql "$PGTLE_VERSION" + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --extension) + EXTENSION="$2" + shift 2 + ;; + --pgtle-version) + PGTLE_VERSION="$2" + shift 2 + ;; + --get-dir) # This case should ideally not be hit due to early exit + GET_DIR_VERSION="$2" + shift 2 + ;; + --get-version) # This case should ideally not be hit due to early exit + shift + ;; + --test-function) # Hidden option for testing - not documented, not supported + shift 2 # Skip function name and --test-function + ;; + --run) # This case should ideally not be hit due to early exit + shift + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + done + + if [ -z "$EXTENSION" ] && [ -z "$GET_DIR_VERSION" ]; then + die 1 "--extension is required (unless using --get-dir, --get-version, --test-function, or --run)" + fi +} + +validate_environment() { + # Check if control file exists + if [ ! -f "${EXTENSION}.control" ]; then + die 1 "Control file not found: ${EXTENSION}.control + Must run from extension directory" + fi +} + +parse_control_file() { + local control_file="${EXTENSION}.control" + + echo "Parsing control file: $control_file" >&2 + + # Parse key = value or key = 'value' format + while IFS= read -r line; do + # Skip comments and empty lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + + # Extract key = value + if [[ "$line" =~ ^[[:space:]]*([a-z_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then + local key="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + + # Strip quotes if present (both single and double) + value="${value#\'}" + value="${value%\'}" + value="${value#\"}" + value="${value%\"}" + + # Trim trailing whitespace/comments + value="${value%%#*}" # Remove trailing comments + value="${value%% }" # Trim trailing spaces + + # Store in global variables + case "$key" in + default_version) DEFAULT_VERSION="$value" ;; + comment) COMMENT="$value" ;; + requires) REQUIRES="$value" ;; + schema) SCHEMA="$value" ;; + module_pathname) MODULE_PATHNAME="$value" ;; + esac + fi + done < "$control_file" + + # Validate required fields + if [ -z "$DEFAULT_VERSION" ]; then + die 1 "Control file missing default_version" + fi + + if [ -z "$COMMENT" ]; then + echo "WARNING: Control file missing comment, using extension name" >&2 + COMMENT="$EXTENSION extension" + fi + + # Warn about C code + if [ -n "$MODULE_PATHNAME" ]; then + cat >&2 <<-EOF + WARNING: Extension uses module_pathname (C code) + pg_tle only supports trusted languages (PL/pgSQL, SQL, etc.) + Generated SQL will likely not work + EOF + fi + + echo " default_version: $DEFAULT_VERSION" >&2 + echo " comment: $COMMENT" >&2 + if [ -n "$REQUIRES" ]; then + echo " requires: $REQUIRES" >&2 + fi + if [ -n "$SCHEMA" ]; then + echo " schema: $SCHEMA" >&2 + fi +} + +discover_sql_files() { + echo "Discovering SQL files for extension: $EXTENSION" >&2 + debug 30 "discover_sql_files: Starting discovery for extension: $EXTENSION" + + # Ensure default_version file exists and has content if base file exists + # This handles the case where make all hasn't generated it yet, or it exists but is empty + local default_version_file="sql/${EXTENSION}--${DEFAULT_VERSION}.sql" + local base_file="sql/${EXTENSION}.sql" + if [ -f "$base_file" ] && ([ ! -f "$default_version_file" ] || [ ! -s "$default_version_file" ]); then + debug 30 "discover_sql_files: Creating default_version file from base file" + cp "$base_file" "$default_version_file" + fi + + # Find versioned files: sql/{ext}--{version}.sql + # Use find to get proper null-delimited output, then filter out upgrade scripts + VERSION_FILES=() # Reset array + debug 30 "discover_sql_files: Reset VERSION_FILES array" + while IFS= read -r -d '' file; do + local basename=$(basename "$file" .sql) + local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') + # Skip upgrade scripts (they have 2 dashes) + if [ "$dash_count" -ne 1 ]; then + continue + fi + # Error on empty version files + if [ ! -s "$file" ]; then + die 1 "Empty version file found: $file" + fi + VERSION_FILES+=("$file") + done < <(find sql/ -maxdepth 1 -name "${EXTENSION}--*.sql" -print0 2>/dev/null | sort -zV) + + # Find upgrade scripts: sql/{ext}--{ver1}--{ver2}.sql + # These have TWO occurrences of "--" in the filename + UPGRADE_FILES=() # Reset array + debug 30 "discover_sql_files: Reset UPGRADE_FILES array" + while IFS= read -r -d '' file; do + # Error on empty upgrade files + if [ ! -s "$file" ]; then + die 1 "Empty upgrade file found: $file" + fi + local basename=$(basename "$file" .sql) + local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') + if [ "$dash_count" -eq 2 ]; then + UPGRADE_FILES+=("$file") + fi + done < <(find sql/ -maxdepth 1 -name "${EXTENSION}--*--*.sql" -print0 2>/dev/null | sort -zV) + + if [ ${#VERSION_FILES[@]} -eq 0 ]; then + die 1 "No versioned SQL files found for $EXTENSION + Expected pattern: sql/${EXTENSION}--{version}.sql + Run 'make' first to generate versioned files from sql/${EXTENSION}.sql" + fi + + echo " Found ${#VERSION_FILES[@]} version file(s):" >&2 + for f in "${VERSION_FILES[@]}"; do + echo " - $f" >&2 + done + + debug 30 "discover_sql_files: Checking UPGRADE_FILES array, count=${#UPGRADE_FILES[@]:-0}" + if [ ${#UPGRADE_FILES[@]:-0} -gt 0 ]; then + echo " Found ${#UPGRADE_FILES[@]} upgrade script(s):" >&2 + debug 30 "discover_sql_files: Iterating over ${#UPGRADE_FILES[@]} upgrade files" + for f in "${UPGRADE_FILES[@]}"; do + echo " - $f" >&2 + done + else + debug 30 "discover_sql_files: No upgrade files found" + fi +} + +extract_version_from_filename() { + local filename="$1" + local basename=$(basename "$filename" .sql) + + # Match patterns: + # - ext--1.0.0 → FROM_VERSION=1.0.0, TO_VERSION="" + # - ext--1.0.0--2.0.0 → FROM_VERSION=1.0.0, TO_VERSION=2.0.0 + + if [[ "$basename" =~ ^${EXTENSION}--([0-9][0-9.]*)(--([0-9][0-9.]*))?$ ]]; then + FROM_VERSION="${BASH_REMATCH[1]}" + TO_VERSION="${BASH_REMATCH[3]}" # Empty for non-upgrade files + return 0 + else + die 1 "Cannot parse version from filename: $filename + Expected format: ${EXTENSION}--{version}.sql or ${EXTENSION}--{ver1}--{ver2}.sql" + fi +} + +validate_delimiter() { + local sql_file="$1" + + if grep -qF "$PGTLE_DELIMITER" "$sql_file"; then + die 1 "SQL file contains reserved pg_tle delimiter: $sql_file + Found: $PGTLE_DELIMITER + This delimiter is used internally by pgtle.sh to wrap SQL content. + You must modify your SQL to not contain this string. If this poses a + serious problem, please open an issue at https://github.com/decibel/pgxntool/issues" + fi +} + +wrap_sql_content() { + local sql_file="$1" + + validate_delimiter "$sql_file" + + # Output wrapped SQL with proper indentation + echo " ${PGTLE_DELIMITER}" + cat "$sql_file" + echo " ${PGTLE_DELIMITER}" +} + +build_requires_array() { + # Input: "plpgsql, other_ext, another" + # Output: 'plpgsql', 'other_ext', 'another' + + # Split on comma, trim whitespace, quote each element + REQUIRES_ARRAY=$(echo "$REQUIRES" | \ + sed 's/[[:space:]]*,[[:space:]]*/\n/g' | \ + sed "s/^[[:space:]]*//;s/[[:space:]]*$//" | \ + sed "s/^/'/;s/$/'/" | \ + paste -sd, -) +} + +generate_header() { + local pgtle_version="$1" + local output_file="$2" + local version_count=${#VERSION_FILES[@]:-0} + local upgrade_count=${#UPGRADE_FILES[@]:-0} + + # Determine version compatibility message + local compat_msg + if [[ "$pgtle_version" == *"+"* ]]; then + local base_version="${pgtle_version%+}" + compat_msg="-- Works on pg_tle >= ${base_version}" + else + local min_version="${pgtle_version%-*}" + local max_version="${pgtle_version#*-}" + compat_msg="-- Works on pg_tle >= ${min_version} and < ${max_version}" + fi + + cat < $to_ver" + echo "SELECT pgtle.install_update_path(" + echo " '${EXTENSION}'," + echo " '${from_ver}'," + echo " '${to_ver}'," + wrap_sql_content "$upgrade_file" + echo ");" + echo +} + +generate_pgtle_sql() { + local pgtle_version="$1" + debug 30 "generate_pgtle_sql: Starting for version $pgtle_version, extension $EXTENSION" + + # Get capability using function (compatible with bash < 4.0) + local capability=$(get_pgtle_capability "$pgtle_version") + local version_dir="pg_tle/${pgtle_version}" + local output_file="${version_dir}/${EXTENSION}.sql" + + # Ensure arrays are initialized (defensive programming) + # Arrays should already be initialized at top level, but ensure they exist + debug 30 "generate_pgtle_sql: Checking array initialization" + debug 30 "generate_pgtle_sql: VERSION_FILES is ${VERSION_FILES+set}, count=${#VERSION_FILES[@]:-0}" + debug 30 "generate_pgtle_sql: UPGRADE_FILES is ${UPGRADE_FILES+set}, count=${#UPGRADE_FILES[@]:-0}" + + if [ -z "${VERSION_FILES+set}" ]; then + echo "WARNING: VERSION_FILES not set, initializing" >&2 + VERSION_FILES=() + fi + if [ -z "${UPGRADE_FILES+set}" ]; then + echo "WARNING: UPGRADE_FILES not set, initializing" >&2 + UPGRADE_FILES=() + fi + + # Create version-specific output directory if needed + mkdir -p "$version_dir" + + echo "Generating: $output_file (pg_tle $pgtle_version)" >&2 + + # Generate SQL to file + { + generate_header "$pgtle_version" "$output_file" + + cat < "$output_file" + + echo " ✓ Generated: $output_file" >&2 +} + +main "$@" + diff --git a/pgtle_versions.md b/pgtle_versions.md new file mode 100644 index 0000000..d2c5c03 --- /dev/null +++ b/pgtle_versions.md @@ -0,0 +1,47 @@ +# pg_tle Version Support Matrix + +This file documents pg_tle version boundaries that affect pgxntool's pg_tle support code. Each boundary represents a backward-incompatible API change. + +## Version Ranges (pgxntool notation) + +### 1.0.0-1.4.0 +- **pg_tle versions:** 1.0.0 through 1.3.x +- **PostgreSQL support:** 11-17 +- **API:** No `pgtle.uninstall_extension()` function, no schema parameter +- **Features:** Basic extension management, custom data types, authentication hooks + +### 1.4.0-1.5.0 +- **pg_tle versions:** 1.4.0 through 1.4.x +- **PostgreSQL support:** 11-17 +- **API:** Added `pgtle.uninstall_extension()` function, no schema parameter +- **Features:** Custom alignment/storage, enhanced warnings + +### 1.5.0+ +- **pg_tle versions:** 1.5.0 and later (tested through 1.5.2) +- **PostgreSQL support:** 12-18 (dropped PG 11) +- **API:** BREAKING CHANGE - `pgtle.install_extension()` now requires schema parameter +- **Features:** Schema parameter support in installation + +## Key API Changes by Version + +**1.4.0:** Added `pgtle.uninstall_extension()` +- Versions before 1.4.0 cannot uninstall extensions + +**1.5.0:** Changed `pgtle.install_extension()` signature +- Added required `schema` parameter +- Dropped PostgreSQL 11 support + +## Version Notation + +- `X.Y.Z+` - Works on pg_tle >= X.Y.Z +- `X.Y.Z-A.B.C` - Works on pg_tle >= X.Y.Z and < A.B.C + +**Boundary conditions:** +- `1.5.0+` means >= 1.5.0 (includes 1.5.0) +- `1.4.0-1.5.0` means >= 1.4.0 and < 1.5.0 (excludes 1.5.0) +- `1.0.0-1.4.0` means >= 1.0.0 and < 1.4.0 (excludes 1.4.0) + +## For Complete Details + +- `pgtle.sh` (comments at top) +- https://github.com/aws/pg_tle diff --git a/safesed b/safesed new file mode 100755 index 0000000..b8833be --- /dev/null +++ b/safesed @@ -0,0 +1,8 @@ +#!/bin/sh + +file=$1 +shift +echo "editing file $file with options $@" +sed "$@" <<_EOF_ > $file +`cat $file` +_EOF_ diff --git a/setup.sh b/setup.sh index 3730a2b..0a67f0e 100755 --- a/setup.sh +++ b/setup.sh @@ -3,6 +3,10 @@ set -o errexit -o errtrace -o pipefail trap 'echo "Error on line ${LINENO}"' ERR +# Source common library functions (error, die, debug) +PGXNTOOL_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$PGXNTOOL_DIR/lib.sh" + [ -d .git ] || git init if ! git diff --cached --exit-code; then @@ -35,20 +39,44 @@ safecp () { fi } -safecp pgxntool/_.gitignore .gitignore -safecp pgxntool/META.in.json META.in.json +# ============================================================================= +# SETUP FILES +# ============================================================================= +# SETUP_FILES and SETUP_SYMLINKS are defined in lib.sh +# These are also used by update-setup-files.sh for sync updates. +# ============================================================================= + +# Copy tracked setup files (defined in lib.sh) +for entry in "${SETUP_FILES[@]}"; do + src="pgxntool/${entry%%:*}" + dest="${entry##*:}" + # Create parent directory if needed + mkdir -p "$(dirname "$dest")" + safecp "$src" "$dest" +done + +# Create tracked symlinks (defined in lib.sh) +for entry in "${SETUP_SYMLINKS[@]}"; do + dest="${entry%%:*}" + target="${entry##*:}" + mkdir -p "$(dirname "$dest")" + if [ ! -e "$dest" ]; then + echo "Creating symlink $dest -> $target" + ln -s "$target" "$dest" + git add "$dest" + else + echo "$dest already exists" + fi +done +# META.in.json and Makefile are NOT in SETUP_FILES because users heavily customize them +safecp pgxntool/META.in.json META.in.json safecreate Makefile include pgxntool/base.mk make META.json git add META.json -mkdir -p sql test src - -cd test -safecp ../pgxntool/test/deps.sql deps.sql -[ -d pgxntool ] || ln -s ../pgxntool/test/pgxntool . -git add pgxntool +mkdir -p sql test/sql src git status echo "If you won't be creating C code then you can: diff --git a/test/deps.sql b/test/deps.sql index daa5fd9..8bae2be 100644 --- a/test/deps.sql +++ b/test/deps.sql @@ -1,3 +1,15 @@ --- Note: pgTap is loaded by setup.sql +-- IF NOT EXISTS will emit NOTICEs, which is annoying +SET client_min_messages = WARNING; -- Add any test dependency statements here +-- Note: pgTap is loaded by setup.sql +--CREATE EXTENSION IF NOT EXISTS ...; +/* + * Now load our extension. We don't use IF NOT EXISTs here because we want an + * error if the extension is already loaded (because we want to ensure we're + * getting the very latest version). + */ +CREATE EXTENSION ...; + +-- Re-enable notices +SET client_min_messages = NOTICE; diff --git a/test/pgxntool/setup.sql b/test/pgxntool/setup.sql index 14c6ea0..0a8c18b 100644 --- a/test/pgxntool/setup.sql +++ b/test/pgxntool/setup.sql @@ -1,7 +1,6 @@ \i test/pgxntool/psql.sql BEGIN; --- I suspect it's a bad idea to have deps on pgTap... -\i test/deps.sql - \i test/pgxntool/tap_setup.sql +\i test/deps.sql + diff --git a/test/pgxntool/tap_setup.sql b/test/pgxntool/tap_setup.sql index dbf88c0..fffb430 100644 --- a/test/pgxntool/tap_setup.sql +++ b/test/pgxntool/tap_setup.sql @@ -1,8 +1,19 @@ \i test/pgxntool/psql.sql -CREATE SCHEMA IF NOT EXISTS tap; +SET client_min_messages = WARNING; + +DO $$ +BEGIN +IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='tap') THEN + CREATE SCHEMA tap; +END IF; +END$$; + SET search_path = tap, public; CREATE EXTENSION IF NOT EXISTS pgtap SCHEMA tap; +SET client_min_messages = NOTICE; \pset format unaligned \pset tuples_only true \pset pager + +-- vi: expandtab ts=2 sw=2 diff --git a/update-setup-files.sh b/update-setup-files.sh new file mode 100755 index 0000000..94ae75d --- /dev/null +++ b/update-setup-files.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# +# update-setup-files.sh - Update files that were initially copied by setup.sh +# +# This script handles the 3-way merge of setup files after a pgxntool subtree +# update. It compares the old pgxntool version, new pgxntool version, and +# user's current file to determine the appropriate action: +# +# 1. If pgxntool didn't change the file: skip (nothing to do) +# 2. If user hasn't modified the file: auto-update +# 3. If both changed: 3-way merge with conflict markers +# +# Usage: update-setup-files.sh +# +# The old commit is the pgxntool subtree commit BEFORE the sync. + +set -o errexit -o errtrace -o pipefail +trap 'echo "Error on line ${LINENO}"' ERR + +PGXNTOOL_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$PGXNTOOL_DIR/lib.sh" + +# SETUP_FILES and SETUP_SYMLINKS are defined in lib.sh + +# ============================================================================= +# Functions +# ============================================================================= + +usage() { + echo "Usage: $0 " + echo + echo "Updates setup files after a pgxntool subtree sync." + echo + echo "Arguments:" + echo " old-pgxntool-commit The pgxntool commit hash BEFORE the sync" + exit 1 +} + +# Get file content from a specific commit +# Usage: get_old_content +get_old_content() { + local commit=$1 + local path=$2 + git show "${commit}:pgxntool/${path}" 2>/dev/null +} + +# Get current file content from pgxntool directory +# Usage: get_new_content +get_new_content() { + local path=$1 + cat "pgxntool/${path}" 2>/dev/null +} + +# Process a single setup file +# Usage: process_file +process_file() { + local source=$1 + local dest=$2 + local old_commit=$3 + + # Get the three versions + local old_content new_content user_content + + old_content=$(get_old_content "$old_commit" "$source") || { + debug 20 "Could not get old version of $source (new file in pgxntool?)" + old_content="" + } + + new_content=$(get_new_content "$source") || { + error "Could not read pgxntool/$source" + return 1 + } + + # Check if destination exists + if [[ ! -e "$dest" ]]; then + echo " $dest: creating (file was missing)" + cp "pgxntool/$source" "$dest" + return 0 + fi + + user_content=$(cat "$dest") + + # Step 1: Did pgxntool change this file? + if [[ "$old_content" == "$new_content" ]]; then + debug 30 "$dest: pgxntool unchanged, skipping" + return 0 + fi + + # Step 2: Did user modify their copy? + if [[ "$user_content" == "$old_content" ]]; then + echo " $dest: updated (you hadn't modified it)" + cp "pgxntool/$source" "$dest" + return 0 + fi + + # Step 3: Both changed - need 3-way merge + echo " $dest: attempting 3-way merge..." + + # Create temp files for git merge-file + local tmp_old tmp_new + tmp_old=$(mktemp) + tmp_new=$(mktemp) + trap "rm -f '$tmp_old' '$tmp_new'" RETURN + + echo "$old_content" > "$tmp_old" + echo "$new_content" > "$tmp_new" + + # git merge-file modifies the first file in place + # Returns 0 on clean merge, >0 if conflicts (but still writes result) + if git merge-file -L "yours" -L "old pgxntool" -L "new pgxntool" \ + "$dest" "$tmp_old" "$tmp_new"; then + echo " $dest: merged cleanly (please review)" + else + echo " $dest: CONFLICTS - resolve manually" + fi +} + +# Process a symlink +# Usage: process_symlink +process_symlink() { + local dest=$1 + local target=$2 + + if [[ -L "$dest" ]]; then + local current_target + current_target=$(readlink "$dest") + if [[ "$current_target" == "$target" ]]; then + debug 30 "$dest: symlink unchanged" + else + echo " $dest: symlink points to '$current_target', expected '$target'" + echo " (not auto-fixing - please check manually)" + fi + elif [[ -e "$dest" ]]; then + echo " $dest: exists but is not a symlink (expected symlink to $target)" + else + echo " $dest: creating symlink to $target" + ln -s "$target" "$dest" + fi +} + +# ============================================================================= +# Main +# ============================================================================= + +[[ $# -eq 1 ]] || usage + +old_commit=$1 + +# Verify we're in a git repo with pgxntool subtree +[[ -d "pgxntool" ]] || die 1 "pgxntool directory not found. Run from project root." +[[ -d ".git" ]] || die 1 "Not in a git repository." + +# Verify the old commit is valid +if ! git cat-file -e "${old_commit}^{commit}" 2>/dev/null; then + die 1 "Invalid commit: $old_commit" +fi + +echo "Checking setup files for updates..." +echo + +# Process regular files +for entry in "${SETUP_FILES[@]}"; do + source="${entry%%:*}" + dest="${entry##*:}" + process_file "$source" "$dest" "$old_commit" +done + +# Process symlinks +for entry in "${SETUP_SYMLINKS[@]}"; do + dest="${entry%%:*}" + target="${entry##*:}" + process_symlink "$dest" "$target" +done + +echo +echo "Done. Review changes with 'git diff' and commit when ready." From 160d0cd0e9ca9baf88035e54a6f0ad09c5cc0a34 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Wed, 4 Feb 2026 17:47:11 -0600 Subject: [PATCH 03/11] Update to latest pgxntool standards Minor tweaks to test setup. Also, start keeping versioned extension files in git. --- .gitignore | 10 ++++- sql/count_nulls--0.9.6.sql | 88 ++++++++++++++++++++++++++++++++++++++ test/deps.sql | 18 +++++++- test/load.sql | 15 +------ 4 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 sql/count_nulls--0.9.6.sql diff --git a/.gitignore b/.gitignore index 3eb345a..0c14928 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ # Editor files .*.swp +# Claude Code local settings +.claude/*.local.json + # Explicitly exclude META.json! !/META.json # Generated make files meta.mk +control.mk # Compiler output *.o @@ -13,8 +17,7 @@ meta.mk .deps/ # built targets -/sql/*--* -!/sql/*--*--*.sql +# Note: Version-specific files (sql/*--*.sql) are now tracked in git and should be committed # Test artifacts results/ @@ -24,3 +27,6 @@ regression.out # Misc tmp/ .DS_Store + +# pg_tle generated files +/pg_tle/ diff --git a/sql/count_nulls--0.9.6.sql b/sql/count_nulls--0.9.6.sql new file mode 100644 index 0000000..63e6825 --- /dev/null +++ b/sql/count_nulls--0.9.6.sql @@ -0,0 +1,88 @@ +SET client_min_messages = WARNING; + +CREATE OR REPLACE FUNCTION null_count( + VARIADIC argument anyarray +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ + SELECT sum( CASE WHEN a IS NULL THEN 1 ELSE 0 END )::int + FROM unnest( $1 ) a +$body$; + +CREATE OR REPLACE FUNCTION null_count( + argument jsonb +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ +SELECT count(*)::int + FROM jsonb_each_text( $1 ) a + WHERE value IS NULL +$body$; +CREATE OR REPLACE FUNCTION null_count( + argument json +) RETURNS int LANGUAGE sql IMMUTABLE AS 'SELECT @extschema@.null_count($1::jsonb)'; + +CREATE OR REPLACE FUNCTION null_count_trigger( +) RETURNS trigger LANGUAGE plpgsql IMMUTABLE AS $body$ +DECLARE + c_msg CONSTANT text := coalesce( + nullif( TG_ARGV[1], 'null' ) + , format( '%s must contain %s NULL fields', TG_RELID::regclass, TG_ARGV[0] ) + ); +BEGIN + IF TG_NARGS NOT BETWEEN 1 AND 2 THEN + RAISE '% usage: number of NULL columns[, error message]', TG_NAME; + END IF; + -- Casing intentional for cut/paste/replace/ + IF nullif(TG_ARGV[0], 'null') IS null THEN + RAISE '%: first argument must not be null', TG_NAME; + END IF; + + IF @extschema@.null_count( row_to_json(NEW) ) <> TG_ARGV[0]::int THEN + RAISE '%', c_msg; + END IF; + + RETURN NEW; +END +$body$; + + +CREATE OR REPLACE FUNCTION not_null_count( + VARIADIC argument anyarray +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ + SELECT sum( CASE WHEN a IS NOT NULL THEN 1 ELSE 0 END )::int + FROM unnest( $1 ) a +$body$; + +CREATE OR REPLACE FUNCTION not_null_count( + argument jsonb +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ +SELECT count(*)::int + FROM jsonb_each_text( $1 ) a + WHERE value IS NOT NULL +$body$; +CREATE OR REPLACE FUNCTION not_null_count( + argument json +) RETURNS int LANGUAGE sql IMMUTABLE AS 'SELECT @extschema@.not_null_count($1::jsonb)'; + +CREATE OR REPLACE FUNCTION not_null_count_trigger( +) RETURNS trigger LANGUAGE plpgsql IMMUTABLE AS $body$ +DECLARE + c_msg CONSTANT text := coalesce( + nullif( TG_ARGV[1], 'null' ) + , format( '%s must contain %s NOT NULL fields', TG_RELID::regclass, TG_ARGV[0] ) + ); +BEGIN + IF TG_NARGS NOT BETWEEN 1 AND 2 THEN + RAISE '% usage: number of NOT NULL columns[, error message]', TG_NAME; + END IF; + -- Casing intentional for cut/paste/replace/ + IF nullif(TG_ARGV[0], 'null') IS null THEN + RAISE '%: first argument must not be null', TG_NAME; + END IF; + + IF @extschema@.not_null_count( row_to_json(NEW) ) <> TG_ARGV[0]::int THEN + RAISE '%', c_msg; + END IF; + + RETURN NEW; +END +$body$; + +-- vi: expandtab sw=2 ts=2 diff --git a/test/deps.sql b/test/deps.sql index daa5fd9..4f76ff1 100644 --- a/test/deps.sql +++ b/test/deps.sql @@ -1,3 +1,19 @@ --- Note: pgTap is loaded by setup.sql +-- IF NOT EXISTS will emit NOTICEs, which is annoying +SET client_min_messages = WARNING; -- Add any test dependency statements here +-- Note: pgTap is loaded by setup.sql +--CREATE EXTENSION IF NOT EXISTS ...; +/* + * Now load our extension. We don't use IF NOT EXISTs here because we want an + * error if the extension is already loaded (because we want to ensure we're + * getting the very latest version). + */ +CREATE SCHEMA IF NOT EXISTS :schema; +SET search_path = :schema; + +CREATE EXTENSION count_nulls; + + +-- Re-enable notices +SET client_min_messages = NOTICE; diff --git a/test/load.sql b/test/load.sql index 3cd70f6..1fbb73c 100644 --- a/test/load.sql +++ b/test/load.sql @@ -1,15 +1,2 @@ +-- Pulls in test/deps.sql as well \i test/pgxntool/setup.sql - -SET search_path = tap, public; - --- Don't use IF NOT EXISTS here; we want to ensure we always have the latest code -SET client_min_messages = WARNING; -- Squelch notices about dependent extensions - -CREATE SCHEMA IF NOT EXISTS :schema; -SET search_path = :schema; - -CREATE EXTENSION object_reference CASCADE; - -CREATE EXTENSION count_nulls; - ---SET client_min_messages = NOTICE; From 614b66c665711ede0c846840f305e45df42fc700 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 6 Feb 2026 13:59:03 -0600 Subject: [PATCH 04/11] Finish pgxntool update pgxntool now assumes versioned SQL files are checked in, so do that, but first bump our version number. --- META.in.json | 4 +- META.json | 4 +- count_nulls.control | 2 +- sql/count_nulls--0.9.6--1.0.0.sql | 1 + sql/count_nulls--1.0.0.sql | 89 +++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 sql/count_nulls--0.9.6--1.0.0.sql create mode 100644 sql/count_nulls--1.0.0.sql diff --git a/META.in.json b/META.in.json index 6bfa55c..c730255 100644 --- a/META.in.json +++ b/META.in.json @@ -16,7 +16,7 @@ "name": "count_nulls", "X_comment": "REQUIRED. Version of the distribution. http://pgxn.org/spec/#version", - "version": "0.9.7", + "version": "1.0.0", "X_comment": "REQUIRED. Short description of distribution.", "abstract": "Count the number of null arguments", @@ -43,7 +43,7 @@ "file": "sql/count_nulls.sql", "X_comment": "REQUIRED. Version the extension is at.", - "version": "0.9.6", + "version": "1.0.0", "X_comment": "Optional: \"abstract\": Description of the extension.", "abstract": "Count the number of null arguments", diff --git a/META.json b/META.json index 4bfc36b..19c84e1 100644 --- a/META.json +++ b/META.json @@ -16,7 +16,7 @@ "name": "count_nulls", "X_comment": "REQUIRED. Version of the distribution. http://pgxn.org/spec/#version", - "version": "0.9.7", + "version": "1.0.0", "X_comment": "REQUIRED. Short description of distribution.", "abstract": "Count the number of null arguments", @@ -43,7 +43,7 @@ "file": "sql/count_nulls.sql", "X_comment": "REQUIRED. Version the extension is at.", - "version": "0.9.6", + "version": "1.0.0", "X_comment": "Optional: \"abstract\": Description of the extension.", "abstract": "Count the number of null arguments", diff --git a/count_nulls.control b/count_nulls.control index 14eaa22..c5c5c28 100644 --- a/count_nulls.control +++ b/count_nulls.control @@ -1,4 +1,4 @@ # count_nulls extension comment = 'Count the number of null arguments' -default_version = '0.9.6' +default_version = '1.0.0' relocatable = false diff --git a/sql/count_nulls--0.9.6--1.0.0.sql b/sql/count_nulls--0.9.6--1.0.0.sql new file mode 100644 index 0000000..dc73d7b --- /dev/null +++ b/sql/count_nulls--0.9.6--1.0.0.sql @@ -0,0 +1 @@ +-- No actual SQL changes to this version bump diff --git a/sql/count_nulls--1.0.0.sql b/sql/count_nulls--1.0.0.sql new file mode 100644 index 0000000..b6dc949 --- /dev/null +++ b/sql/count_nulls--1.0.0.sql @@ -0,0 +1,89 @@ +/* DO NOT EDIT - AUTO-GENERATED FILE */ +SET client_min_messages = WARNING; + +CREATE OR REPLACE FUNCTION null_count( + VARIADIC argument anyarray +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ + SELECT sum( CASE WHEN a IS NULL THEN 1 ELSE 0 END )::int + FROM unnest( $1 ) a +$body$; + +CREATE OR REPLACE FUNCTION null_count( + argument jsonb +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ +SELECT count(*)::int + FROM jsonb_each_text( $1 ) a + WHERE value IS NULL +$body$; +CREATE OR REPLACE FUNCTION null_count( + argument json +) RETURNS int LANGUAGE sql IMMUTABLE AS 'SELECT @extschema@.null_count($1::jsonb)'; + +CREATE OR REPLACE FUNCTION null_count_trigger( +) RETURNS trigger LANGUAGE plpgsql IMMUTABLE AS $body$ +DECLARE + c_msg CONSTANT text := coalesce( + nullif( TG_ARGV[1], 'null' ) + , format( '%s must contain %s NULL fields', TG_RELID::regclass, TG_ARGV[0] ) + ); +BEGIN + IF TG_NARGS NOT BETWEEN 1 AND 2 THEN + RAISE '% usage: number of NULL columns[, error message]', TG_NAME; + END IF; + -- Casing intentional for cut/paste/replace/ + IF nullif(TG_ARGV[0], 'null') IS null THEN + RAISE '%: first argument must not be null', TG_NAME; + END IF; + + IF @extschema@.null_count( row_to_json(NEW) ) <> TG_ARGV[0]::int THEN + RAISE '%', c_msg; + END IF; + + RETURN NEW; +END +$body$; + + +CREATE OR REPLACE FUNCTION not_null_count( + VARIADIC argument anyarray +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ + SELECT sum( CASE WHEN a IS NOT NULL THEN 1 ELSE 0 END )::int + FROM unnest( $1 ) a +$body$; + +CREATE OR REPLACE FUNCTION not_null_count( + argument jsonb +) RETURNS int LANGUAGE sql IMMUTABLE AS $body$ +SELECT count(*)::int + FROM jsonb_each_text( $1 ) a + WHERE value IS NOT NULL +$body$; +CREATE OR REPLACE FUNCTION not_null_count( + argument json +) RETURNS int LANGUAGE sql IMMUTABLE AS 'SELECT @extschema@.not_null_count($1::jsonb)'; + +CREATE OR REPLACE FUNCTION not_null_count_trigger( +) RETURNS trigger LANGUAGE plpgsql IMMUTABLE AS $body$ +DECLARE + c_msg CONSTANT text := coalesce( + nullif( TG_ARGV[1], 'null' ) + , format( '%s must contain %s NOT NULL fields', TG_RELID::regclass, TG_ARGV[0] ) + ); +BEGIN + IF TG_NARGS NOT BETWEEN 1 AND 2 THEN + RAISE '% usage: number of NOT NULL columns[, error message]', TG_NAME; + END IF; + -- Casing intentional for cut/paste/replace/ + IF nullif(TG_ARGV[0], 'null') IS null THEN + RAISE '%: first argument must not be null', TG_NAME; + END IF; + + IF @extschema@.not_null_count( row_to_json(NEW) ) <> TG_ARGV[0]::int THEN + RAISE '%', c_msg; + END IF; + + RETURN NEW; +END +$body$; + +-- vi: expandtab sw=2 ts=2 From 8d54821488a996f3a65fc0e53c17ca596108d3dc Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 6 Feb 2026 14:20:02 -0600 Subject: [PATCH 05/11] Add github test workflow --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4aaec9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI +on: [push, pull_request] +jobs: + test: + strategy: + matrix: + pg: [17, 16, 15, 14, 13, 12, 11, 10] + name: 🐘 PostgreSQL ${{ matrix.pg }} + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + steps: + - name: Start PostgreSQL ${{ matrix.pg }} + run: pg-start ${{ matrix.pg }} + - name: Check out the repo + uses: actions/checkout@v4 + - name: Test on PostgreSQL ${{ matrix.pg }} + run: pg-build-test From 04520da177c3878f0ca509b8d98f5a604c223fa4 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 12 Feb 2026 12:55:46 -0600 Subject: [PATCH 06/11] Squashed 'pgxntool/' changes from 3b8cb2a..6ba3176 6ba3176 Fix pg_tle exception handler and empty upgrade files (#15) git-subtree-dir: pgxntool git-subtree-split: 6ba3176b21252b9d310ba0ede4c6a0d1d44093ec --- HISTORY.asc | 30 +++++++++++++++++++++++++----- base.mk | 4 ++-- pgtle.sh | 28 +++++++++++++--------------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/HISTORY.asc b/HISTORY.asc index 4de9f70..a47865e 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,18 +1,36 @@ +STABLE +------ +== Fix pg_tle exception handler and empty upgrade files +The exception handler for `uninstall_extension()` now correctly catches +`no_data_found` (P0002) instead of `undefined_object` (42704). Empty upgrade +files are now treated as valid no-op upgrades for version bumps. Added +`ON_ERROR_STOP=1` to `run_pgtle_sql()` so psql errors propagate correctly. + 1.1.0 ----- == Use unique database names for tests -Tests now use a unique database name based on the project name and a hash of the current directory. This prevents test conflicts when running tests for multiple projects in parallel. +Tests now use a unique database name based on the project name and a hash of the +current directory. This prevents test conflicts when running tests for multiple +projects in parallel. == Add 3-way merge support for setup files after pgxntool-sync -New `update-setup-files.sh` script handles merging changes to files initially copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have modified the same file, using git's native conflict markers for resolution. +New `update-setup-files.sh` script handles merging changes to files initially +copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make +pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have +modified the same file, using git's native conflict markers for resolution. 1.0.0 ----- == Fix broken multi-extension support -Prior to this fix, distributions with multiple extensions or extensions with versions different from the PGXN distribution version were completely broken. Extension versions are now correctly read from each `.control` file's `default_version` instead of using META.json's distribution version. +Prior to this fix, distributions with multiple extensions or extensions with +versions different from the PGXN distribution version were completely broken. +Extension versions are now correctly read from each `.control` file's +`default_version` instead of using META.json's distribution version. == Add pg_tle support -New `make pgtle` target generates pg_tle registration SQL for extensions. Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with appropriate API calls for each range. See README for usage. +New `make pgtle` target generates pg_tle registration SQL for extensions. +Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with +appropriate API calls for each range. See README for usage. == Use git tags for distribution versioning The `tag` and `rmtag` targets now create/delete git tags instead of branches. @@ -24,7 +42,9 @@ The `--load-language` option was removed from `pg_regress` in 13. As part of this change, you will want to review the changes to test/deps.sql. === Support asciidoc documentation targets -By default, if asciidoctor or asciidoc exists on the system, any files in doc/ that end in .adoc or .asciidoc will be processed to html. +By default, if asciidoctor or asciidoc exists on the system, any files in doc/ +that end in .adoc or .asciidoc will be processed to html. + See the README for full details. === Support 9.2 diff --git a/base.mk b/base.mk index 5a4e232..9b621df 100644 --- a/base.mk +++ b/base.mk @@ -304,8 +304,8 @@ print-% : ; $(info $* is $(flavor $*) variable set to "$($*)") @true # 3-way merge if both you and pgxntool changed the file. .PHONY: pgxntool-sync-% pgxntool-sync-%: - @old_commit=$$(git log -1 --format=%H -- pgxntool/); \ - git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@); \ + @old_commit=$$(git log -1 --format=%H -- pgxntool/) && \ + git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@) && \ pgxntool/update-setup-files.sh "$$old_commit" pgxntool-sync: pgxntool-sync-release diff --git a/pgtle.sh b/pgtle.sh index 8fd2d17..abbcfd4 100755 --- a/pgtle.sh +++ b/pgtle.sh @@ -330,7 +330,7 @@ run_pgtle_sql() { if [ -f "$sql_file" ]; then found=1 echo "Running $sql_file..." >&2 - psql --no-psqlrc --file="$sql_file" || exit 1 + psql --no-psqlrc -v ON_ERROR_STOP=1 --file="$sql_file" || exit 1 fi done @@ -545,10 +545,7 @@ discover_sql_files() { UPGRADE_FILES=() # Reset array debug 30 "discover_sql_files: Reset UPGRADE_FILES array" while IFS= read -r -d '' file; do - # Error on empty upgrade files - if [ ! -s "$file" ]; then - die 1 "Empty upgrade file found: $file" - fi + # Empty upgrade files are allowed (no-op upgrades) local basename=$(basename "$file" .sql) local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') if [ "$dash_count" -eq 2 ]; then @@ -615,6 +612,7 @@ wrap_sql_content() { validate_delimiter "$sql_file" # Output wrapped SQL with proper indentation + # Empty files are valid (no-op upgrades) echo " ${PGTLE_DELIMITER}" cat "$sql_file" echo " ${PGTLE_DELIMITER}" @@ -661,17 +659,17 @@ generate_header() { * - Upgrade paths: ${upgrade_count} path(s) * - Default version: ${DEFAULT_VERSION} * - * Installation instructions: - * 1. Ensure pg_tle is installed: - * CREATE EXTENSION pg_tle; + * Installation: + * Recommended: make run-pgtle + * (or: pgxntool/pgtle.sh --run) * - * 2. Ensure you have pgtle_admin role: - * GRANT pgtle_admin TO your_username; + * This automatically detects your pg_tle version and runs the correct file. * - * 3. Run this file: - * psql -f $(basename "$output_file") + * Prerequisites: + * - pg_tle extension installed (CREATE EXTENSION pg_tle;) + * - pgtle_admin role (GRANT pgtle_admin TO your_username;) * - * 4. Create the extension: + * After registration, create the extension: * CREATE EXTENSION ${EXTENSION}; * * Version compatibility: @@ -799,8 +797,8 @@ DO \$\$ BEGIN PERFORM pgtle.uninstall_extension('${EXTENSION}'); EXCEPTION - WHEN undefined_object THEN - -- Extension might not exist yet + WHEN no_data_found THEN + -- Extension not registered yet (pg_tle raises P0002, not 42704) NULL; END \$\$; From 4cec6fc0f670dbb993844bbf51366309b5d80857 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 19 Feb 2026 13:13:17 -0600 Subject: [PATCH 07/11] Squashed 'pgxntool/' changes from 6ba3176..3b8cb2a REVERT: 6ba3176 Fix pg_tle exception handler and empty upgrade files (#15) git-subtree-dir: pgxntool git-subtree-split: 3b8cb2a96c2611bb44b1d69fd533fd0f23fa8995 --- HISTORY.asc | 30 +++++------------------------- base.mk | 4 ++-- pgtle.sh | 28 +++++++++++++++------------- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/HISTORY.asc b/HISTORY.asc index a47865e..4de9f70 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,36 +1,18 @@ -STABLE ------- -== Fix pg_tle exception handler and empty upgrade files -The exception handler for `uninstall_extension()` now correctly catches -`no_data_found` (P0002) instead of `undefined_object` (42704). Empty upgrade -files are now treated as valid no-op upgrades for version bumps. Added -`ON_ERROR_STOP=1` to `run_pgtle_sql()` so psql errors propagate correctly. - 1.1.0 ----- == Use unique database names for tests -Tests now use a unique database name based on the project name and a hash of the -current directory. This prevents test conflicts when running tests for multiple -projects in parallel. +Tests now use a unique database name based on the project name and a hash of the current directory. This prevents test conflicts when running tests for multiple projects in parallel. == Add 3-way merge support for setup files after pgxntool-sync -New `update-setup-files.sh` script handles merging changes to files initially -copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make -pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have -modified the same file, using git's native conflict markers for resolution. +New `update-setup-files.sh` script handles merging changes to files initially copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have modified the same file, using git's native conflict markers for resolution. 1.0.0 ----- == Fix broken multi-extension support -Prior to this fix, distributions with multiple extensions or extensions with -versions different from the PGXN distribution version were completely broken. -Extension versions are now correctly read from each `.control` file's -`default_version` instead of using META.json's distribution version. +Prior to this fix, distributions with multiple extensions or extensions with versions different from the PGXN distribution version were completely broken. Extension versions are now correctly read from each `.control` file's `default_version` instead of using META.json's distribution version. == Add pg_tle support -New `make pgtle` target generates pg_tle registration SQL for extensions. -Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with -appropriate API calls for each range. See README for usage. +New `make pgtle` target generates pg_tle registration SQL for extensions. Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with appropriate API calls for each range. See README for usage. == Use git tags for distribution versioning The `tag` and `rmtag` targets now create/delete git tags instead of branches. @@ -42,9 +24,7 @@ The `--load-language` option was removed from `pg_regress` in 13. As part of this change, you will want to review the changes to test/deps.sql. === Support asciidoc documentation targets -By default, if asciidoctor or asciidoc exists on the system, any files in doc/ -that end in .adoc or .asciidoc will be processed to html. - +By default, if asciidoctor or asciidoc exists on the system, any files in doc/ that end in .adoc or .asciidoc will be processed to html. See the README for full details. === Support 9.2 diff --git a/base.mk b/base.mk index 9b621df..5a4e232 100644 --- a/base.mk +++ b/base.mk @@ -304,8 +304,8 @@ print-% : ; $(info $* is $(flavor $*) variable set to "$($*)") @true # 3-way merge if both you and pgxntool changed the file. .PHONY: pgxntool-sync-% pgxntool-sync-%: - @old_commit=$$(git log -1 --format=%H -- pgxntool/) && \ - git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@) && \ + @old_commit=$$(git log -1 --format=%H -- pgxntool/); \ + git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@); \ pgxntool/update-setup-files.sh "$$old_commit" pgxntool-sync: pgxntool-sync-release diff --git a/pgtle.sh b/pgtle.sh index abbcfd4..8fd2d17 100755 --- a/pgtle.sh +++ b/pgtle.sh @@ -330,7 +330,7 @@ run_pgtle_sql() { if [ -f "$sql_file" ]; then found=1 echo "Running $sql_file..." >&2 - psql --no-psqlrc -v ON_ERROR_STOP=1 --file="$sql_file" || exit 1 + psql --no-psqlrc --file="$sql_file" || exit 1 fi done @@ -545,7 +545,10 @@ discover_sql_files() { UPGRADE_FILES=() # Reset array debug 30 "discover_sql_files: Reset UPGRADE_FILES array" while IFS= read -r -d '' file; do - # Empty upgrade files are allowed (no-op upgrades) + # Error on empty upgrade files + if [ ! -s "$file" ]; then + die 1 "Empty upgrade file found: $file" + fi local basename=$(basename "$file" .sql) local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') if [ "$dash_count" -eq 2 ]; then @@ -612,7 +615,6 @@ wrap_sql_content() { validate_delimiter "$sql_file" # Output wrapped SQL with proper indentation - # Empty files are valid (no-op upgrades) echo " ${PGTLE_DELIMITER}" cat "$sql_file" echo " ${PGTLE_DELIMITER}" @@ -659,17 +661,17 @@ generate_header() { * - Upgrade paths: ${upgrade_count} path(s) * - Default version: ${DEFAULT_VERSION} * - * Installation: - * Recommended: make run-pgtle - * (or: pgxntool/pgtle.sh --run) + * Installation instructions: + * 1. Ensure pg_tle is installed: + * CREATE EXTENSION pg_tle; * - * This automatically detects your pg_tle version and runs the correct file. + * 2. Ensure you have pgtle_admin role: + * GRANT pgtle_admin TO your_username; * - * Prerequisites: - * - pg_tle extension installed (CREATE EXTENSION pg_tle;) - * - pgtle_admin role (GRANT pgtle_admin TO your_username;) + * 3. Run this file: + * psql -f $(basename "$output_file") * - * After registration, create the extension: + * 4. Create the extension: * CREATE EXTENSION ${EXTENSION}; * * Version compatibility: @@ -797,8 +799,8 @@ DO \$\$ BEGIN PERFORM pgtle.uninstall_extension('${EXTENSION}'); EXCEPTION - WHEN no_data_found THEN - -- Extension not registered yet (pg_tle raises P0002, not 42704) + WHEN undefined_object THEN + -- Extension might not exist yet NULL; END \$\$; From 18b94d842872bd35e5aeb64dcebb700d46b3732c Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 19 Feb 2026 13:27:16 -0600 Subject: [PATCH 08/11] Squashed 'pgxntool/' changes from 3b8cb2a..639756c 639756c Stamp 1.1.1 6ba3176 Fix pg_tle exception handler and empty upgrade files (#15) git-subtree-dir: pgxntool git-subtree-split: 639756c43a64717347b82b46acfec5be478a7bbf --- HISTORY.asc | 30 +++++++++++++++++++++++++----- base.mk | 4 ++-- pgtle.sh | 28 +++++++++++++--------------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/HISTORY.asc b/HISTORY.asc index 4de9f70..2b7d5e7 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,18 +1,36 @@ +1.1.1 +----- +== Fix pg_tle exception handler and empty upgrade files +The exception handler for `uninstall_extension()` now correctly catches +`no_data_found` (P0002) instead of `undefined_object` (42704). Empty upgrade +files are now treated as valid no-op upgrades for version bumps. Added +`ON_ERROR_STOP=1` to `run_pgtle_sql()` so psql errors propagate correctly. + 1.1.0 ----- == Use unique database names for tests -Tests now use a unique database name based on the project name and a hash of the current directory. This prevents test conflicts when running tests for multiple projects in parallel. +Tests now use a unique database name based on the project name and a hash of the +current directory. This prevents test conflicts when running tests for multiple +projects in parallel. == Add 3-way merge support for setup files after pgxntool-sync -New `update-setup-files.sh` script handles merging changes to files initially copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have modified the same file, using git's native conflict markers for resolution. +New `update-setup-files.sh` script handles merging changes to files initially +copied by `setup.sh` (`.gitignore`, `test/deps.sql`). After running `make +pgxntool-sync`, the script performs a 3-way merge if both you and pgxntool have +modified the same file, using git's native conflict markers for resolution. 1.0.0 ----- == Fix broken multi-extension support -Prior to this fix, distributions with multiple extensions or extensions with versions different from the PGXN distribution version were completely broken. Extension versions are now correctly read from each `.control` file's `default_version` instead of using META.json's distribution version. +Prior to this fix, distributions with multiple extensions or extensions with +versions different from the PGXN distribution version were completely broken. +Extension versions are now correctly read from each `.control` file's +`default_version` instead of using META.json's distribution version. == Add pg_tle support -New `make pgtle` target generates pg_tle registration SQL for extensions. Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with appropriate API calls for each range. See README for usage. +New `make pgtle` target generates pg_tle registration SQL for extensions. +Supports pg_tle version ranges (1.0.0-1.4.0, 1.4.0-1.5.0, 1.5.0+) with +appropriate API calls for each range. See README for usage. == Use git tags for distribution versioning The `tag` and `rmtag` targets now create/delete git tags instead of branches. @@ -24,7 +42,9 @@ The `--load-language` option was removed from `pg_regress` in 13. As part of this change, you will want to review the changes to test/deps.sql. === Support asciidoc documentation targets -By default, if asciidoctor or asciidoc exists on the system, any files in doc/ that end in .adoc or .asciidoc will be processed to html. +By default, if asciidoctor or asciidoc exists on the system, any files in doc/ +that end in .adoc or .asciidoc will be processed to html. + See the README for full details. === Support 9.2 diff --git a/base.mk b/base.mk index 5a4e232..9b621df 100644 --- a/base.mk +++ b/base.mk @@ -304,8 +304,8 @@ print-% : ; $(info $* is $(flavor $*) variable set to "$($*)") @true # 3-way merge if both you and pgxntool changed the file. .PHONY: pgxntool-sync-% pgxntool-sync-%: - @old_commit=$$(git log -1 --format=%H -- pgxntool/); \ - git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@); \ + @old_commit=$$(git log -1 --format=%H -- pgxntool/) && \ + git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@) && \ pgxntool/update-setup-files.sh "$$old_commit" pgxntool-sync: pgxntool-sync-release diff --git a/pgtle.sh b/pgtle.sh index 8fd2d17..abbcfd4 100755 --- a/pgtle.sh +++ b/pgtle.sh @@ -330,7 +330,7 @@ run_pgtle_sql() { if [ -f "$sql_file" ]; then found=1 echo "Running $sql_file..." >&2 - psql --no-psqlrc --file="$sql_file" || exit 1 + psql --no-psqlrc -v ON_ERROR_STOP=1 --file="$sql_file" || exit 1 fi done @@ -545,10 +545,7 @@ discover_sql_files() { UPGRADE_FILES=() # Reset array debug 30 "discover_sql_files: Reset UPGRADE_FILES array" while IFS= read -r -d '' file; do - # Error on empty upgrade files - if [ ! -s "$file" ]; then - die 1 "Empty upgrade file found: $file" - fi + # Empty upgrade files are allowed (no-op upgrades) local basename=$(basename "$file" .sql) local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') if [ "$dash_count" -eq 2 ]; then @@ -615,6 +612,7 @@ wrap_sql_content() { validate_delimiter "$sql_file" # Output wrapped SQL with proper indentation + # Empty files are valid (no-op upgrades) echo " ${PGTLE_DELIMITER}" cat "$sql_file" echo " ${PGTLE_DELIMITER}" @@ -661,17 +659,17 @@ generate_header() { * - Upgrade paths: ${upgrade_count} path(s) * - Default version: ${DEFAULT_VERSION} * - * Installation instructions: - * 1. Ensure pg_tle is installed: - * CREATE EXTENSION pg_tle; + * Installation: + * Recommended: make run-pgtle + * (or: pgxntool/pgtle.sh --run) * - * 2. Ensure you have pgtle_admin role: - * GRANT pgtle_admin TO your_username; + * This automatically detects your pg_tle version and runs the correct file. * - * 3. Run this file: - * psql -f $(basename "$output_file") + * Prerequisites: + * - pg_tle extension installed (CREATE EXTENSION pg_tle;) + * - pgtle_admin role (GRANT pgtle_admin TO your_username;) * - * 4. Create the extension: + * After registration, create the extension: * CREATE EXTENSION ${EXTENSION}; * * Version compatibility: @@ -799,8 +797,8 @@ DO \$\$ BEGIN PERFORM pgtle.uninstall_extension('${EXTENSION}'); EXCEPTION - WHEN undefined_object THEN - -- Extension might not exist yet + WHEN no_data_found THEN + -- Extension not registered yet (pg_tle raises P0002, not 42704) NULL; END \$\$; From 42a57e9cfbedfb3536b17c3854e5104779dd27b2 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 7 Apr 2026 18:12:07 -0500 Subject: [PATCH 09/11] =?UTF-8?q?Fix=20invalid=20bash=20array=20syntax=20i?= =?UTF-8?q?n=20pgtle.sh=20(${#arr[@]:-0}=20=E2=86=92=20${#arr[@]})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pgxntool/pgtle.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pgxntool/pgtle.sh b/pgxntool/pgtle.sh index abbcfd4..4227f00 100755 --- a/pgxntool/pgtle.sh +++ b/pgxntool/pgtle.sh @@ -564,8 +564,8 @@ discover_sql_files() { echo " - $f" >&2 done - debug 30 "discover_sql_files: Checking UPGRADE_FILES array, count=${#UPGRADE_FILES[@]:-0}" - if [ ${#UPGRADE_FILES[@]:-0} -gt 0 ]; then + debug 30 "discover_sql_files: Checking UPGRADE_FILES array, count=${#UPGRADE_FILES[@]}" + if [ ${#UPGRADE_FILES[@]} -gt 0 ]; then echo " Found ${#UPGRADE_FILES[@]} upgrade script(s):" >&2 debug 30 "discover_sql_files: Iterating over ${#UPGRADE_FILES[@]} upgrade files" for f in "${UPGRADE_FILES[@]}"; do @@ -633,8 +633,8 @@ build_requires_array() { generate_header() { local pgtle_version="$1" local output_file="$2" - local version_count=${#VERSION_FILES[@]:-0} - local upgrade_count=${#UPGRADE_FILES[@]:-0} + local version_count=${#VERSION_FILES[@]} + local upgrade_count=${#UPGRADE_FILES[@]} # Determine version compatibility message local compat_msg @@ -760,8 +760,8 @@ generate_pgtle_sql() { # Ensure arrays are initialized (defensive programming) # Arrays should already be initialized at top level, but ensure they exist debug 30 "generate_pgtle_sql: Checking array initialization" - debug 30 "generate_pgtle_sql: VERSION_FILES is ${VERSION_FILES+set}, count=${#VERSION_FILES[@]:-0}" - debug 30 "generate_pgtle_sql: UPGRADE_FILES is ${UPGRADE_FILES+set}, count=${#UPGRADE_FILES[@]:-0}" + debug 30 "generate_pgtle_sql: VERSION_FILES is ${VERSION_FILES+set}, count=${#VERSION_FILES[@]}" + debug 30 "generate_pgtle_sql: UPGRADE_FILES is ${UPGRADE_FILES+set}, count=${#UPGRADE_FILES[@]}" if [ -z "${VERSION_FILES+set}" ]; then echo "WARNING: VERSION_FILES not set, initializing" >&2 @@ -819,7 +819,7 @@ EOF fi # Install all upgrade paths - local upgrade_count=${#UPGRADE_FILES[@]:-0} + local upgrade_count=${#UPGRADE_FILES[@]} debug 30 "generate_pgtle_sql: upgrade_count=$upgrade_count" if [ "$upgrade_count" -gt 0 ]; then debug 30 "generate_pgtle_sql: Processing $upgrade_count upgrade path(s)" From bf7f9aec1dbda1d341fee61c148e55a0001f78e7 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 16 Jul 2026 12:37:23 -0500 Subject: [PATCH 10/11] Squashed 'pgxntool/' changes from 639756c..eb84bc6 eb84bc6 Stamp 2.1.0 9fbe6f4 Fix results ordering, control file whitespace, ENABLE_* override, debug levels (#31) 88bb4f2 Add Claude Code GitHub Actions workflows (#41) af5bbbb ci: pass repo owners to run-tests.yml for fork-account matching (#40) c7928af Fix repo-root guard to work inside a git worktree (#39) b062fca ci: point reusable test workflow at @master b6cdbfd Add CI workflows and multi-session PR guard (#33) 1ba0987 Stamp 2.0.3 1931cbe Fix pgxntool-sync remote and make it runnable without make (#37) 8176304 Stamp 2.0.2 3e142ab Fix parse_control_file: remove comments before stripping quotes (#27) cacc301 Stamp 2.0.1 bf1db6b Fix bash 3.2 / Linux compatibility issues (#26) 62d0fcb Fix broken ifeq for --load-language=plpgsql on PG < 13 (#24) 121f0b3 Stamp 2.0.0 ad3ca7e Remove .source support; add test/install, test/build, and verify-results (#18) c010cf8 Fix bash 3.2 compatibility (#23) abeb9d3 Remove .source file support from pg_regress integration (#22) 08c1879 Stamp 1.1.2 6e0dad2 Fix double --dbname bug that defeated unique test database names git-subtree-dir: pgxntool git-subtree-split: eb84bc6e87e21f2ced11ff1b8ddb4028b7c67c8f --- .claude/CLAUDE.md | 28 ++ .claude/development.md | 47 ++++ .claude/settings.json | 19 -- .gitattributes | 1 + .github/workflows/CLAUDE.md | 61 +++++ .github/workflows/ci.yml | 294 ++++++++++++++++++++ .github/workflows/claude-code-review.yml | 102 +++++++ .github/workflows/claude.yml | 46 ++++ .github/workflows/protect-label.yml | 146 ++++++++++ CLAUDE.md | 35 ++- HISTORY.asc | 74 +++++- README.asc | 154 ++++++++++- README.html | 296 +++++++++++++++++++-- _.gitignore | 8 + base.mk | 324 +++++++++++++++++++---- build_meta.sh | 2 +- lib.sh | 59 ++++- make_results.sh | 28 -- pgtle.sh | 53 ++-- pgxntool-sync.sh | 50 ++++ run-test-build.sh | 47 ++++ setup.sh | 4 +- update-setup-files.sh | 9 +- verify-results-pgtap.sh | 47 ++++ 24 files changed, 1776 insertions(+), 158 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/development.md delete mode 100644 .claude/settings.json create mode 100644 .github/workflows/CLAUDE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/protect-label.yml delete mode 100755 make_results.sh create mode 100755 pgxntool-sync.sh create mode 100755 run-test-build.sh create mode 100755 verify-results-pgtap.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..e2e74fe --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,28 @@ +# Claude Development Notes + +This file contains guidance for Claude Code when working in this repository. +It is excluded from distributions via `.gitattributes export-ignore`. + +## CI Monitoring After Every Push + +**REQUIRED**: After every `git push`, immediately start a background task to +monitor the CI run for that push. If you pushed to both pgxntool and +pgxntool-test, start a background task for each repo — do not monitor them +sequentially. + +Use `gh run watch` or poll with `gh run list` / `gh pr checks` in the +background task. Report failures to the user as soon as they are detected; +do not wait for all jobs to finish before reporting. + +## Multiple Concurrent Sessions + +It is common to have multiple Claude Code sessions open simultaneously across +pgxntool and pgxntool-test. To avoid cross-session interference: + +**If you are asked to do something on an existing PR that you did not open or +are not already working on in this session, immediately ask for confirmation +before proceeding.** For example: "I see PR #32 exists. Were you asking me to +work on that, or did you mean to send this to a different session?" + +This applies to: editing PR branches, pushing to them, closing/reopening them, +adding commits, modifying PR descriptions, or any other PR-level action. diff --git a/.claude/development.md b/.claude/development.md new file mode 100644 index 0000000..886484a --- /dev/null +++ b/.claude/development.md @@ -0,0 +1,47 @@ +# pgxntool Development Guidelines + +**THIS FILE IS FOR PGXNTOOL DEVELOPERS ONLY.** + +If you are an extension developer using pgxntool in your project, this file does not +apply to you. See the top-level `CLAUDE.md` instead. + +## Critical: Work from pgxntool-test, Not Here + +**NEVER make changes to pgxntool directly from this repository.** + +pgxntool development must be done from a checkout of **pgxntool-test**, which contains +the full test infrastructure. Working here directly means you cannot run tests, and +any changes you commit cannot be validated before merging. + +**Correct workflow:** +1. Clone or use an existing checkout of `pgxntool-test` +2. Work in a worktree: both `pgxntool/` and `pgxntool-test/` will be siblings +3. Make changes to `pgxntool/` from within that pgxntool-test context +4. Run the test suite via `make test` in pgxntool-test before committing + +**See:** https://github.com/Postgres-Extensions/pgxntool-test for the full development +workflow. + +--- + +## Makefile Variable Assignment Rules + +**RULE: Do not use `:=` (simply expanded) unless you have a specific need for immediate evaluation.** + +Use `=` (recursively expanded) for standard variable assignments. Reserve `:=` for cases where the right-hand side must be evaluated exactly once at assignment time — for example, when assigning the result of a `$(call ...)` function that references the variable being set (which would cause infinite recursion with `=`). + +When a variable must also override command-line values, combine `override` with `:=` — but only where `override` is genuinely needed. + +## Debug Level Rules (lib.sh `debug` function) + +`debug LEVEL "msg"` prints when `DEBUG >= LEVEL`. LEVEL encodes how noisy/esoteric a message is — how far you'd crank `DEBUG` before you'd want to see it — **not** code nesting depth. A top-level line can warrant a high level if it's esoteric, and loop-body detail is usually high precisely because it's noisy. Judge by signal-to-noise. + +The tiers are anchors, not strict multiples of 10 — any value in range is fine, leaving room to fine-tune between existing calls without renumbering: + +- **10**: Critical errors, important warnings +- **20**: Warnings, significant state changes +- **30**: General debugging, function entry/exit, array operations +- **40**: Verbose details, loop iterations +- **50+**: Maximum verbosity (per-iteration innards) + +Note: The BATS test helper `debug` function (in `tests/lib/helpers.bash` in pgxntool-test) uses a separate 1–5 scale controlled by `$TESTDEBUG`. The two systems are independent. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e7bf5a9..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cat:*)", - "Bash(make test:*)", - "Bash(tee:*)", - "Bash(echo:*)", - "Bash(git show:*)", - "Bash(git log:*)", - "Bash(ls:*)", - "Bash(find:*)", - "Bash(git checkout:*)", - "Bash(head:*)" - ], - "additionalDirectories": [ - "../pgxntool-test/" - ] - } -} diff --git a/.gitattributes b/.gitattributes index a94d824..8dc1599 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ .gitattributes export-ignore .claude/ export-ignore +.github/ export-ignore *.md export-ignore .DS_Store export-ignore *.asc export-ignore diff --git a/.github/workflows/CLAUDE.md b/.github/workflows/CLAUDE.md new file mode 100644 index 0000000..48c749c --- /dev/null +++ b/.github/workflows/CLAUDE.md @@ -0,0 +1,61 @@ +# .github/workflows — CI Architecture + +## Workflow files + +- **`ci.yml`** — main CI for pgxntool pull requests. Runs `check-test-pr` (verifies + the paired pgxntool-test PR's CI passed), then optionally runs `test` (only for the + commit-with-no-tests path — see below). +- **`protect-label.yml`** — enforces that only maintainers with write access can apply + or remove the `commit-with-no-tests` label. + +## Normal CI flow (paired test PR exists) + +When a pgxntool PR has a corresponding open PR in pgxntool-test with the same branch +name, the `check-test-pr` job polls (up to 20 minutes) for that test PR's CI to +complete and pass. If it passes, pgxntool CI passes — **no tests run here**. Tests run +exactly once, in pgxntool-test's own CI. + +## commit-with-no-tests path + +When a maintainer applies the `commit-with-no-tests` label (and no paired test PR +exists), the `test` job runs tests directly in pgxntool CI against pgxntool-test/master. +This is the rare exception, not the norm. + +## Cross-repo reusable workflow — tradeoffs and constraints + +The `test` job calls a reusable workflow from pgxntool-test: +```yaml +uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@ +``` + +GitHub Actions requires the `uses:` ref to be a **static string** — expressions like +`${{ }}` are not supported in the repo/path portion or the `@ref` suffix in practice. + +### The @branch → @master ref + +While developing on a feature branch where pgxntool-test also has changes, this ref +is set to `@` so CI can find `run-tests.yml` before it lands on master. + +**IMPORTANT**: This ref must be updated to `@master` before pgxntool merges. The +correct merge order is: **pgxntool-test merges first**, then update this ref to +`@master`, then pgxntool merges. + +**For Claude**: Do NOT leave a `@` ref without explicit user approval. The +user merges directly from the PR page — there are no manual steps between merges. +See `.github/workflows/CLAUDE.md` in pgxntool-test for the full picture. + +### Changes to run-tests.yml + +`run-tests.yml` lives in pgxntool-test and is the single source of truth for all test +steps. If it changes, pgxntool's CI uses `@master` — so it won't see the new version +until pgxntool-test merges. This is acceptable because: +- Changes to `run-tests.yml` require a paired test PR (not commit-with-no-tests) +- When a paired test PR exists, pgxntool's `test` job is skipped anyway +- The two scenarios are mutually exclusive in practice + +## Label name + +The label `commit-with-no-tests` is defined as a const (`NO_TEST_LABEL`) in `ci.yml` +and as `LABEL` in `protect-label.yml`. The job-level `if:` condition in +`protect-label.yml` must also use the literal string (YAML can't reference JS consts) +— keep these in sync if the label name ever changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e3ce90 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,294 @@ +name: CI + +on: + pull_request: + # We use 'pull_request' (not 'pull_request_target') deliberately. + # 'pull_request_target' runs with write access to the base repo, which is + # a security risk for untrusted fork code. Since this workflow only reads + # from other public repos (no secrets needed), 'pull_request' is correct + # and safe even for fork PRs. + +permissions: + contents: read # required by actions/checkout in the reusable test workflow + pull-requests: read + checks: read + +concurrency: + group: ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-test-pr: + name: Check for paired pgxntool-test PR + runs-on: ubuntu-latest + # This check polls until the paired pgxntool-test CI run completes + # (up to 20 minutes). The job timeout gives a few minutes of headroom. + timeout-minutes: 25 + outputs: + run-tests: ${{ steps.check.outputs.run_tests }} + test-ref: ${{ steps.check.outputs.test_ref }} + + steps: + - name: Find paired pgxntool-test PR or check commit-with-no-tests label + id: check + # Pinned to an immutable SHA (supply-chain hardening); comment tracks the tag. + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + # GITHUB_TOKEN is sufficient for reading public repos. If these repos + # are ever made private, replace with a PAT stored as a secret with + # 'repo' scope on both repos. Note: PAT expiration causes silent + # failures here — the API returns 401 and the job errors out instead + # of failing gracefully with a useful message. + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const branch = context.payload.pull_request.head.ref; + const prNumber = context.payload.pull_request.number; + // Single source of truth for the label name. Must also match the + // literal string in the protect-label.yml job-level `if:` condition + // (YAML expressions can't reference JS constants). + const NO_TEST_LABEL = 'commit-with-no-tests'; + + // master-to-master PRs have no paired test PR by convention. + // Run tests against pgxntool-test/master directly. + // + // If a fork PR's branch is named 'master', that's almost certainly + // a mistake (contributors should use a feature branch), but we + // don't block it — just warn visibly as an annotation on the run. + // Note: pull_request gives a read-only token for fork PRs, so we + // can't post a PR comment back to the upstream repo from here. + // Gate on the BASE branch too: this shortcut is only for + // master-to-master PRs. A PR from master into some other base must + // still go through the normal paired-test lookup below. + if (branch === 'master' && context.payload.pull_request.base.ref === 'master') { + const headRepo = context.payload.pull_request.head.repo; + const isBaseRepo = + headRepo?.owner?.login === context.repo.owner && + headRepo?.name === context.repo.repo; + if (!isBaseRepo) { + core.warning( + `PR head branch is named 'master' but comes from a fork ` + + `(${headRepo?.full_name ?? 'unknown'}). Contributors should ` + + `use a feature branch, not master. Proceeding with tests ` + + `against pgxntool-test/master.` + ); + } + core.setOutput('run_tests', 'true'); + core.setOutput('test_ref', 'master'); + return; + } + + // The owner of this PR's head repo — the contributor's fork owner + // for fork PRs, or the base repo owner for maintainer PRs. + // The paired pgxntool-test PR must come from the SAME owner. + // We never cross-match PRs across different contributors' forks. + const prOwner = context.payload.pull_request.head.repo?.owner?.login; + + // Look for open pgxntool-test PRs with the SAME branch name AND + // the same fork owner. Branch names must match exactly. + // + // The GitHub API's 'head' filter requires "owner:branch" format. + // We list all open PRs and filter locally — safe for repos with + // few open PRs, and avoids needing to know the fork repo name. + // paginate() fetches all pages automatically, so this is correct + // even if pgxntool-test ever exceeds 100 open PRs (the per_page cap). + const prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: 'pgxntool-test', + state: 'open', + per_page: 100 + }); + + const matching = prs.filter(pr => + pr.head.ref === branch && + pr.head.repo?.owner?.login === prOwner + ); + if (matching.length > 1) { + core.setFailed( + `Multiple open pgxntool-test PRs from ${prOwner} match branch ` + + `'${branch}'. Cannot determine which one to use.\n\n` + + `Close all but one, then re-run this check.` + ); + return; + } + + const testPR = matching.length === 1 ? matching[0] : null; + + if (testPR) { + // Error if the no-test label is also set — that's contradictory. + // Re-fetch the PR live (not from payload) in case the label was + // added after this workflow was triggered. + const { data: currentPR } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + if (currentPR.labels.some(l => l.name === NO_TEST_LABEL)) { + core.setFailed( + `PR has the '${NO_TEST_LABEL}' label, but a paired ` + + `pgxntool-test PR #${testPR.number} exists on branch '${branch}'.\n\n` + + `Remove the '${NO_TEST_LABEL}' label — it should only be used ` + + `when there is genuinely no paired test PR.` + ); + return; + } + + // A paired test PR exists. Verify its CI passed for the exact + // current HEAD SHA and that the run is recent enough to be valid. + const sha = testPR.head.sha; + const testPRUrl = + `https://github.com/${context.repo.owner}/pgxntool-test/pull/${testPR.number}`; + const recheckUrl = + `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}/checks`; + + core.info(`Found pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)})`); + + // Poll until all check runs for the exact HEAD SHA complete. + // Using 'ref: sha' (not branch name) ensures we only see runs for + // this commit — never stale runs from an older push on the same branch. + // + // We poll rather than fail immediately because both repos are often + // pushed close together. When that happens, pgxntool CI starts while + // pgxntool-test CI may not have queued yet. We wait up to 20 minutes. + const POLL_INTERVAL_MS = 30 * 1000; + const MAX_WAIT_MS = 20 * 60 * 1000; + const waitStart = Date.now(); + let runs; + + while (true) { + // per_page: 100 is intentional here — a single commit will + // not realistically have 100+ CI check runs, so pagination + // is unnecessary. (pulls.list uses paginate() above because + // an active repo could have many open PRs.) + const { data: checks } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: 'pgxntool-test', + ref: sha, + per_page: 100 + }); + runs = checks.check_runs; + + const incomplete = runs.filter(r => r.status !== 'completed'); + if (runs.length > 0 && incomplete.length === 0) break; + + const elapsed = Date.now() - waitStart; + if (elapsed >= MAX_WAIT_MS) { + const mins = Math.round(elapsed / 60000); + if (runs.length === 0) { + core.setFailed( + `pgxntool-test PR #${testPR.number} has no CI runs for ` + + `SHA ${sha.slice(0, 7)} after waiting ${mins} min.\n\n` + + `Push a commit (or manually re-run CI) on the test PR:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + } else { + const names = incomplete.map(r => r.name).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI did not finish within ` + + `${mins} min for SHA ${sha.slice(0, 7)}: ${names}\n\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + } + return; + } + + if (runs.length === 0) { + core.info(`No CI runs yet for pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)}); waiting 30s...`); + } else { + const names = incomplete.map(r => r.name).join(', '); + core.info(`pgxntool-test CI still running (${names}); waiting 30s...`); + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + // All checks complete — look for failures. + // 'success', 'skipped', 'neutral' are non-blocking. + const failed = runs.filter( + r => !['success', 'skipped', 'neutral'].includes(r.conclusion) + ); + if (failed.length > 0) { + const names = failed.map(r => `${r.name} (${r.conclusion})`).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI failed for ` + + `SHA ${sha.slice(0, 7)}: ${names}\n\n` + + `Fix the test PR CI, then re-run this check:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + return; + } + + core.info( + `pgxntool-test PR #${testPR.number} CI passed for ` + + `SHA ${sha.slice(0, 7)} — tests run there, not here.` + ); + core.setOutput('run_tests', 'false'); + core.setOutput('test_ref', sha); + return; + } + + // No paired test PR found. Check for the NO_TEST_LABEL label, + // which a maintainer can apply when a pgxntool change genuinely + // needs no test changes (unusual). + // + // We make a live API call rather than reading from the event + // payload. The payload is a snapshot from when this workflow was + // triggered — a maintainer may have added the label after that. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + if (pr.labels.some(l => l.name === NO_TEST_LABEL)) { + core.info( + `'${NO_TEST_LABEL}' label is present; running tests ` + + "against pgxntool-test/master. The protect-label workflow " + + "ensures only maintainers can apply this label." + ); + core.setOutput('run_tests', 'true'); + core.setOutput('test_ref', 'master'); + return; + } + + // Neither a paired test PR nor the override label was found. + // Fail with a clear, actionable message. + core.setFailed( + `No paired pgxntool-test PR found for branch '${branch}', ` + + `and no '${NO_TEST_LABEL}' label on this PR.\n\n` + + `pgxntool changes should always be paired with matching test\n` + + `changes in pgxntool-test. This check enforces that pairing.\n\n` + + `To resolve:\n` + + ` 1. Open a PR in pgxntool-test from the SAME account (${prOwner}),\n` + + ` on a branch ALSO named '${branch}'. Both the branch name and\n` + + ` the head owner must match exactly for the pairing to work.\n\n` + + ` 2. If this pgxntool change truly needs no test updates (unusual),\n` + + ` ask a maintainer to apply the '${NO_TEST_LABEL}' label.\n` + + ` Only maintainers can apply this label. It is not a normal\n` + + ` shortcut — most pgxntool changes require test updates.\n\n` + + `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` + ); + + test: + needs: check-test-pr + if: needs.check-test-pr.outputs.run-tests == 'true' + # ----------------------------------------------------------------------- + # CROSS-REPO REUSABLE WORKFLOW — READ BEFORE CHANGING THIS REF + # See: .github/workflows/CLAUDE.md for full architecture notes. + # + # The ref must be a static string — GitHub Actions does not support + # expressions in uses:. It points at pgxntool-test's run-tests.yml on + # master. (During feature-branch development this is temporarily set to + # @ so CI can find run-tests.yml before it lands on master, and + # flipped back to @master once pgxntool-test/ has merged.) + # ----------------------------------------------------------------------- + uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@master + with: + # pgxntool: this PR's own branch, on its own account (a fork for fork PRs). + pgxntool-owner: ${{ github.event.pull_request.head.repo.owner.login }} + pgxntool-branch: ${{ github.event.pull_request.head.ref }} + # pgxntool-test: no paired test PR in this path, so use canonical master + # from Postgres-Extensions only (never a fork's master). + pgxntool-test-owner: Postgres-Extensions + pgxntool-test-ref: master diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..fc7e26c --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,102 @@ +name: Claude Code Review + +# Runs on PRs INTO this repo. We use pull_request_target (not pull_request) so +# that PRs from a fork can access CLAUDE_CODE_OAUTH_TOKEN — GitHub withholds +# secrets from `pull_request` runs triggered by forks, which is why the plain +# `pull_request` version never worked for fork PRs. +# +# SECURITY: pull_request_target runs in the BASE repo with secrets and a +# write-capable token. The job is gated to PRs from the trusted `jnasbyupgrade` +# fork only — an arbitrary external fork can never trigger this secret-bearing +# job. The workflow file always comes from the base branch (master), so a PR +# cannot modify the reviewer that runs on it. We check out the PR head only for +# read context (persist-credentials: false) and never build or execute PR code. +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + claude-review: + # Trusted fork only, and skip drafts (don't spend API/CI on unfinished PRs). + # To add more trusted owners, extend the head-owner check. + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.owner.login == 'jnasbyupgrade' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + pull-requests: write # post the review comments + checks: read # read sibling check-runs for the cost gate + steps: + # COST GATE: the paid Claude review is the last thing to run. Wait for the + # PR head's OTHER check-runs to finish and only proceed if they are clean. + # If any sibling check failed we skip the review to avoid spending money + # reviewing a PR that is already known-broken. Uniform across all repos: + # it discovers sibling checks dynamically (no per-repo workflow names). + # - decision=run : all sibling checks completed with a good conclusion, + # OR no sibling checks exist after a short grace window + # (nothing to gate on), OR the poll timed out is treated + # as skip (see below). + # - decision=skip : at least one sibling check failed/cancelled/etc, or + # we timed out waiting for still-pending checks. + # We exclude this workflow's own check-run (job name `claude-review`) so the + # gate never waits on or fails because of itself. + - name: Wait for CI; skip the paid review if any check failed + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + decision=skip + for i in $(seq 1 72); do # ~24 min max + json=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \ + --jq '[.check_runs[] | select(.name != "claude-review")]' 2>/dev/null) || json='' + [ -z "$json" ] && { sleep 20; continue; } + total=$(jq 'length' <<<"$json") + if [ "$total" -eq 0 ]; then + [ "$i" -ge 9 ] && { decision=run; break; } # ~3 min grace: nothing to gate on + sleep 20; continue + fi + pending=$(jq '[.[]|select(.status!="completed")]|length' <<<"$json") + if [ "$pending" -eq 0 ]; then + bad=$(jq '[.[]|select((.conclusion//"")|test("^(failure|cancelled|timed_out|action_required|stale)$"))]|length' <<<"$json") + [ "$bad" -eq 0 ] && decision=run || decision=skip + break + fi + sleep 20 + done + echo "decision=$decision" >> "$GITHUB_OUTPUT" + echo "gate decision: $decision" + + - name: Check out PR head (read-only context) + if: steps.gate.outputs.decision == 'run' + # Intentionally tracks the major-version tag (not a pinned SHA) so + # upstream fixes are picked up automatically. + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code Review + if: steps.gate.outputs.decision == 'run' + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Provide github_token so the action uses it directly for GitHub API + # calls instead of the OIDC->GitHub-App-token exchange, which 401s under + # pull_request_target. GITHUB_TOKEN is repo/workflow-scoped (independent + # of the actor's role) and has pull-requests: write here. + github_token: ${{ secrets.GITHUB_TOKEN }} + # NOTE: plugin_marketplaces can't be pinned — it tracks the + # marketplace repo's default branch (upstream anthropics/claude-code). + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..c85ec00 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,46 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +# No concurrency limit: @claude mentions are independent, read-only requests; +# serializing would only delay responses and cancelling would drop them. +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + # Intentionally tracks the major-version tag (not a pinned SHA) so + # upstream fixes are picked up automatically. + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Allows Claude to read CI results on PRs + additional_permissions: | + actions: read diff --git a/.github/workflows/protect-label.yml b/.github/workflows/protect-label.yml new file mode 100644 index 0000000..de71ea3 --- /dev/null +++ b/.github/workflows/protect-label.yml @@ -0,0 +1,146 @@ +name: Protect 'commit-with-no-tests' label + +on: + # IMPORTANT: Must use pull_request_target, NOT pull_request. + # + # 'pull_request' from a fork runs with a read-only GITHUB_TOKEN scoped to + # the fork. It cannot add or remove labels on the upstream repo (write + # operation), and cannot call getCollaboratorPermissionLevel (requires write + # permission to the target repo). + # + # 'pull_request_target' runs in the base repo's context with a token that + # has write access — exactly what we need here. + # + # Security: because pull_request_target has write access, never check out + # or execute code from the PR head in this workflow. This workflow only calls + # the GitHub API via actions/github-script and is safe. + pull_request_target: + types: [labeled, unlabeled] + +jobs: + protect: + # Only fire for the label we care about. All other label changes are + # unaffected by this workflow. + # Note: this literal must match the LABEL const defined in the script below. + if: github.event.label.name == 'commit-with-no-tests' + runs-on: ubuntu-latest + permissions: + pull-requests: write # To add/remove labels + issues: write # GitHub label API goes through the issues endpoint + + steps: + - name: Enforce write-access-only on 'commit-with-no-tests' label + # Pinned to an immutable SHA: this workflow runs as pull_request_target + # with write access, so a moved upstream tag must not change what runs. + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const actor = context.actor; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; // 'labeled' or 'unlabeled' + // Single source of truth for the label name within this script. + // Must also match the literal in the job-level `if:` condition above + // (YAML expressions can't reference JS constants). + const LABEL = 'commit-with-no-tests'; + + // When this workflow re-adds or removes the label itself, that fires + // this event again with actor = 'github-actions[bot]'. Without this + // guard the job loops forever. We match any '[bot]' suffix to also + // cover other automation (Dependabot, Renovate, etc.). + if (actor.endsWith('[bot]')) { + core.info(`Actor is a bot (${actor}); skipping permission check`); + return; + } + + // Check the actor's effective permission level in this repo. + // + // EDGE CASE — 404 for non-collaborators: This API returns 404 when + // the user is not an explicit collaborator. This is the normal case + // for contributors who forked and opened a PR. If we don't catch + // this error, the job crashes with an unhandled exception and the + // label stays in whatever state the contributor put it in — + // defeating the entire protection. + // + // EDGE CASE — org team members: Users with write access via org + // team membership (not a direct collaborator invite) correctly show + // as 'write' here because the API returns effective permission. + // Exception: if the org has "private member visibility" set and the + // token can't enumerate team membership, they may get a 404 instead. + // If that becomes an issue, add a fallback to + // github.rest.orgs.getMembershipForUser(). + // + // EDGE CASE — other errors: Network blips, API outages, and rate + // limiting all throw here. We fail safe by treating any unexpected + // error as "no write access" and logging for debugging. + let hasWrite = false; + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: actor + }); + hasWrite = ['admin', 'write'].includes(perm.permission); + } catch (e) { + if (e.status === 404) { + // Not a collaborator — no write access. Expected and normal. + hasWrite = false; + } else { + core.warning( + `Unexpected error checking permissions for ${actor} ` + + `(HTTP ${e.status}): ${e.message}. Treating as no write access.` + ); + hasWrite = false; + } + } + + if (action === 'labeled' && !hasWrite) { + core.info(`${actor} lacks write access; removing '${LABEL}' label`); + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: LABEL + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: + `@${actor} The \`commit-with-no-tests\` label can only be applied by ` + + `maintainers with write access to this repository.\n\n` + + `If you believe no test changes are needed for this PR, please ask a ` + + `maintainer to apply the label after reviewing. Note that most pgxntool ` + + `changes do require paired test updates — this label should be used sparingly.` + }); + + } else if (action === 'unlabeled' && !hasWrite) { + // Non-writer removed the label. Put it back. + // + // EDGE CASE — brief label-absent window: There is a short window + // between removal and this workflow re-adding the label. During + // that window the label genuinely does not exist. This is harmless + // in practice: the ci.yml workflow reads labels via a live API + // call (not from its cached payload), so a re-run after the label + // is restored will pick it up correctly. + core.info(`${actor} lacks write access; re-adding '${LABEL}' label`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [LABEL] + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: + `@${actor} The \`commit-with-no-tests\` label can only be removed by ` + + `maintainers with write access to this repository.\n\n` + + `Contact a maintainer if you believe this label was applied in error.` + }); + + } else if (hasWrite) { + core.info( + `${actor} has write access; '${action}' on '${LABEL}' label is approved` + ); + } diff --git a/CLAUDE.md b/CLAUDE.md index d2ea214..75c9d16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## CI Monitoring After Every Push + +**REQUIRED**: After every `git push`, immediately start a background task to +monitor the CI run for that push. If you pushed to both pgxntool and +pgxntool-test, start a background task for each repo — do not monitor them +sequentially. + +The CI monitor lives in the pgxntool-test checkout: run +`bash ../pgxntool-test/.claude/skills/ci/scripts/monitor-ci.sh` (the `/ci` +skill). It monitors both repos and derives the owner from the current repo. +Pass the exact push SHA when available — `gh run list --branch` has a race +condition: if two pushes land close together on the same branch, `--branch` +may pick up the wrong run. `--commit SHA` targets the exact push and avoids it. + +## Scope of This File + +**CLAUDE.md is for people USING pgxntool** — extension developers who have embedded +pgxntool into their project via `git subtree`. It documents the build system, available +commands, and how pgxntool works. + +**If you are making changes to pgxntool itself**, stop — you are in the wrong place. +See `.claude/` in this directory for developer guidelines. More importantly, pgxntool +development must be done from the **pgxntool-test** repository, not from here. See the +`Development Workflow` section below. + +Any agent working in an extension project should always defer to that project's own +CLAUDE.md and instructions over anything stated here. + ## Git Commit Guidelines **IMPORTANT**: When creating commit messages, do not attribute commits to yourself (Claude). Commit messages should reflect the work being done without AI attribution in the message body. The standard Co-Authored-By trailer is acceptable. @@ -181,9 +209,10 @@ When tests fail, examine the diff output carefully. The actual test output in `t - Validates repo is clean before tagging ### Subtree Sync Support -- `make pgxntool-sync` pulls latest release -- Multiple sync targets: release, stable, local variants -- Uses `git subtree pull --squash` +- `make pgxntool-sync` pulls the latest release (the `release` tag) from the canonical repo +- `pgxntool/pgxntool-sync.sh [ []]` does the work and can be run without make +- `make pgxntool-sync-` pulls from the `pgxntool-sync-` variable (` `) +- Uses `git subtree pull --squash`, then `update-setup-files.sh` for a 3-way merge of copied files - Requires clean repo (no uncommitted changes) ### pg_tle Support diff --git a/HISTORY.asc b/HISTORY.asc index 2b7d5e7..b0894da 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,3 +1,75 @@ +2.1.0 +----- +== Fix setup.sh / pgxntool-sync.sh / update-setup-files.sh inside a git worktree +These scripts guarded the project root with `[ -d .git ]`. In a linked worktree +`.git` is a file, not a directory, so the check failed: setup.sh would wrongly +re-run `git init`, and the sync scripts aborted with "Not in a git repository." +They now detect the repo with `git rev-parse --git-dir`, which works in both a +normal clone and a worktree. + +== Fix `verify-results` checking stale results in `make results` +`make results` ran `verify-results` before `make test`, so it checked stale +`regression.diffs` from a prior run. Reordered so `verify-results` always +checks the fresh results. + +== Fix `PGXNTOOL_ENABLE_TEST_BUILD`/`PGXNTOOL_ENABLE_TEST_INSTALL` ignoring command-line values +Without `override`, the `pgxntool_validate_yesno` normalization was silently +skipped when these variables were set on the command line. + +2.0.3 +----- +== Fix pgxntool-sync remote, and make it runnable without make +`make pgxntool-sync` pointed at the old SSH URL, which fails without +GitHub SSH keys; it now uses `https://github.com/Postgres-Extensions/pgxntool.git` +over HTTPS (still the `release` tag). The subtree pull + `update-setup-files.sh` +logic moved into `pgxntool/pgxntool-sync.sh`, which the make targets now wrap, so +you can also sync without `make` by running it directly. + +2.0.2 +----- +== Fix parse_control_file corrupting values with trailing comments +Control file values like `default_version = '1.0.0' # comment` were parsed +incorrectly — the trailing quote was left in the value due to comment removal +happening after quote stripping. Fixed by removing comments first. + +2.0.1 +----- +== Improve bash compatibility (specifically for Mac OS) +Mac OS uses the (very old) bash 3.2 as the default shell; fix a few compatibility bugs. + +2.0.0 +----- +== Remove .source file support +PostgreSQL removed `.source` file processing from `pg_regress` in PG15. +The `input/*.source` → `sql/*.sql` and `output/*.source` → +`expected/*.out` conversion mechanism no longer functions. All related +variables (`TEST__SOURCE__*`), the `make_results.sh` helper script, and +the special-case logic in `make results` have been removed. Extensions +that used `.source` files should convert them to regular `test/sql/*.sql` +and `test/expected/*.out` files using relative paths. + +== Add test-build for pre-test validation +When CREATE EXTENSION fails due to a SQL syntax error, PostgreSQL reports only a cryptic error with limited context. test-build runs your extension SQL directly through pg_regress first, so syntax errors show the exact file, line, and position — cutting debugging time significantly. Place SQL files in `test/build/` to enable; auto-detects based on file presence. + +== Add test/install for one-time test setup +Extensions that install dependencies or run expensive setup in every test file pay that cost once per test. test/install runs setup SQL once before the entire test suite, and all regular tests share the resulting database state. This can dramatically speed up test suites that install extensions or load fixtures. Place SQL files in `test/install/` to enable; auto-detects based on file presence. + +== Add verify-results safeguard for make results +`make results` now refuses to run when tests are failing (detected via `regression.diffs`). Prevents accidentally blessing incorrect output as the new expected results. Enabled by default; disable with `PGXNTOOL_ENABLE_VERIFY_RESULTS=no`. + +== Fix bash 3.2 compatibility and shebang portability +`${#ARRAY[@]:-0}` is a syntax error in bash 3.2; replaced with `${#ARRAY[@]}`. +Shell scripts now use `#!/usr/bin/env bash`. + +1.1.2 +----- +== Fix double --dbname bug that defeated unique test database names +The unique database naming introduced in 1.1.0 was ineffective because +base.mk added --dbname=$(REGRESS_DBNAME) to REGRESS_OPTS while PGXS +also appends --dbname=$(CONTRIB_TESTDB). The second --dbname caused +pg_regress to create a contrib_regression database that collided across +projects. Fixed by overriding CONTRIB_TESTDB after include $(PGXS) instead. + 1.1.1 ----- == Fix pg_tle exception handler and empty upgrade files @@ -72,7 +144,7 @@ VERSION is defined by PGXS itself, so trying to use it causes problems. Old code didn't deal with the lack of a . that can appear in a 10+ version. 0.1.10 ------- +----- ### Remove invalid `git subtree pull` options 0.1.9 diff --git a/README.asc b/README.asc index bf3559d..db6162f 100644 --- a/README.asc +++ b/README.asc @@ -25,7 +25,9 @@ TODO: Create a nice script that will init a new project for you. == Development -If you want to contribute to pgxntool development, work from the https://github.com/decibel/pgxntool-test[pgxntool-test] repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via `git subtree`. +If you want to contribute to pgxntool development, work from the https://github.com/Postgres-Extensions/pgxntool-test[pgxntool-test] repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via `git subtree`. + +Changes are normally paired across both repos: a `pgxntool` change should come with a matching branch (same name, on the same account) and PR in `pgxntool-test`, and CI enforces this pairing. See the https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing[CI and Contributing] section in pgxntool-test for the full workflow. == Usage Typically, you can just create a simple Makefile that does nothing but include base.mk: @@ -47,6 +49,120 @@ Runs unit tests via the PGXS `installcheck` target. Unlike a simple `make instal NOTE: While you can still run `make installcheck` or any other valid PGXS make target directly, it's recommended to use `make test` when using pgxntool. The `test` target ensures clean builds, proper test isolation, and correct dependency installation. +=== test-build +Validates that extension SQL files are syntactically correct before running the full test suite. This feature runs SQL files from `test/build/` through `pg_regress`, providing better error messages than `CREATE EXTENSION` failures when there are syntax errors in your extension code. + +**How it works:** + +1. Place SQL files in `test/build/*.sql` +2. Place expected output in `test/build/expected/*.out` +3. These files run through `pg_regress` before `make test` runs the main test suite +4. If any build test fails, the test run stops immediately with clear error messages + +**Directory structure:** + +---- +test/build/ +├── *.sql # SQL test files (checked in) +├── expected/ # Expected output files (checked in) +│ └── *.out +└── sql/ # GENERATED - do not edit or check in + └── *.sql # Synced from *.sql above +---- + +The `sql/` subdirectory is generated automatically by `make test-build`. It is listed in `.gitignore` and removed by `make clean`. Do not place files directly in `test/build/sql/`. + +**Configuration:** + +The feature auto-detects based on whether `test/build/*.sql` files exist: + +- Files present → feature enabled automatically +- No files → feature disabled (no impact on existing projects) + +You can override auto-detection by setting `PGXNTOOL_ENABLE_TEST_BUILD`: +---- +# In your Makefile +PGXNTOOL_ENABLE_TEST_BUILD = yes # or no +---- + +**Example: Validate extension SQL compiles** + +Create `test/build/build.sql` to run your extension's SQL directly: + +---- +\set ECHO none +-- Sets ON_ERROR_STOP, VERBOSITY verbose, and ON_ERROR_ROLLBACK +\i test/pgxntool/psql.sql +-- Suppress column headers and row counts for cleaner expected output +\t + +BEGIN; +SET client_min_messages = WARNING; + +-- Install dependencies your extension requires +CREATE EXTENSION IF NOT EXISTS pgtap CASCADE; + +-- Clean slate +DROP EXTENSION IF EXISTS myext; +DROP SCHEMA IF EXISTS myext; +CREATE SCHEMA myext; + +-- Run the actual extension SQL (not CREATE EXTENSION) +-- psql.sql above ensures errors abort immediately with clear messages +\i sql/myext.sql + +-- If we get here, the build succeeded; ON_ERROR_STOP would have aborted on any error above +\echo # BUILD TEST SUCCEEDED +ROLLBACK; +---- + +This approach catches SQL syntax errors *before* running `CREATE EXTENSION`, giving clearer error messages with line numbers. The `ROLLBACK` ensures nothing persists—this is purely validation. + +**Why use `\i` instead of `CREATE EXTENSION`?** + +When `CREATE EXTENSION` fails, PostgreSQL shows only "syntax error" with limited context. Running the SQL directly via `\i` shows the exact line and position of errors, making debugging much faster. + +=== test/install +Runs setup files before the main test suite within the same `pg_regress` invocation. This allows expensive one-time operations (like extension installation) to set up state that persists into the regular test files. + +**How it works:** + +1. Place SQL files in `test/install/*.sql` +2. Place expected output alongside as `test/install/*.out` +3. A schedule file is auto-generated that lists install files with `../install/` relative paths +4. `pg_regress` processes the install schedule first, then runs regular test files — all in one invocation, so database state persists + +**Directory structure:** + +---- +test/install/ +├── *.sql # SQL setup files (checked in) +├── *.out # Expected output (checked in, alongside .sql) +├── .gitignore # Ignores pg_regress artifacts (*.out.diff) +└── schedule # GENERATED - auto-created by make +---- + +The `schedule` file is generated automatically and listed in `.gitignore`. Do not edit it. + +**Configuration:** + +The feature auto-detects based on whether `test/install/*.sql` files exist: + +- Files present → feature enabled automatically +- No files → feature disabled (no impact on existing projects) + +You can override auto-detection by setting `PGXNTOOL_ENABLE_TEST_INSTALL`: +---- +# In your Makefile +PGXNTOOL_ENABLE_TEST_INSTALL = yes # or no +---- + +**Why this is useful:** + +Without `test/install`, each test file typically needs to run `CREATE EXTENSION` in its setup, which adds overhead and doesn't allow validating the installation step separately. With `test/install`, setup runs once before all tests, and any state it creates (tables, extensions, etc.) is available to every subsequent test file. + +**Key detail:** Install files and regular tests run in a single `pg_regress` invocation. This means the database is NOT dropped between install and test phases — state created by install files persists into the main test suite. + === testdeps This rule allows you to ensure certain actions have taken place before running tests. By default it has a single prerequisite, `pgtap`, which will attempt to install http://pgtap.org[pgtap] from PGXN. This depneds on having the pgxn client installed. @@ -78,6 +194,24 @@ IMPORTANT: *`make results` requires manual verification first*. The correct work Never run `make results` without first verifying the test changes are correct. The `results` target copies files from `test/results/` to `test/expected/`, so running it blindly will make incorrect output become the new expected behavior. +==== verify-results safeguard +By default, `make results` will refuse to run if `test/results/regression.diffs` exists (indicating failing tests). This prevents accidentally updating expected files with incorrect output. + +If tests are failing, you'll see: +---- +ERROR: Tests are failing. Cannot run 'make results'. +Fix test failures first, then run 'make results'. +---- + +To disable this safeguard (not recommended): +---- +# In your Makefile +PGXNTOOL_ENABLE_VERIFY_RESULTS = no + +# Or on the command line +make PGXNTOOL_ENABLE_VERIFY_RESULTS=no results +---- + === tag `make tag` will create a git branch for the current version of your extension, as determined by the META.json file. The reason to do this is so you can always refer to the exact code that went into a released version. @@ -91,11 +225,23 @@ WARNING: You will be very unhappy if you forget to update the .control file for NOTE: Part of the `clean` recipe is cleaning up these .zip files. If you accidentally clean before uploading, just run `make dist-only`. === pgxntool-sync -This rule will pull down the latest released version of PGXNtool via `git subtree pull`. +This rule will pull down the latest released version of PGXNtool via `git subtree pull` and then reconcile the files `setup.sh` copied into your project (`.gitignore`, `test/deps.sql`) with a 3-way merge. NOTE: Your repository must be clean (no modified files) in order to run this. Running this command will produce a git commit of the merge. -TIP: There is also a `pgxntool-sync-%` rule if you need to do more advanced things. +TIP: The actual work is done by `pgxntool/pgxntool-sync.sh`, so you can run it directly (`pgxntool/pgxntool-sync.sh`) if you'd rather not go through `make`. It optionally takes `` and `` arguments to pull from somewhere other than the default. + +TIP: There is also a `pgxntool-sync-%` rule if you need to do more advanced things. `make pgxntool-sync-` pulls from the ` ` defined by the `pgxntool-sync-` make variable. + +=== distclean +`make distclean` removes generated configuration files (`META.json`, `meta.mk`, `control.mk`) that survive a normal `make clean`. + +NOTE: PGXS doesn't provide any special support for `distclean` — its built-in `distclean` target simply depends on `clean`. PGXNtool uses its own `PGXNTOOL_distclean` variable to track files that should only be removed by `distclean`, not `clean`. + +If your extension generates additional files that should be removed by `distclean` but not `clean`, you can add them: +---- +PGXNTOOL_distclean += my_generated_config.mk +---- === pgtle Generates pg_tle (Trusted Language Extensions) registration SQL files for deploying extensions in managed environments like AWS RDS/Aurora. See <<_pg_tle_Support>> for complete documentation. @@ -223,7 +369,7 @@ Location of `asciidoc` or equivalent executable. If not set PGXNtool will search for first `asciidoctor`, then `asciidoc`. ASCIIDOC_EXTS:: File extensions to consider as Asciidoc. -Defined as `+= adoc asciidoc`. +Defined as `+= adoc asciidoc asc`. ASCIIDOC_FILES:: Asciidoc input files. PGXNtool searches each `$(DOC_DIRS)` directory, looking for files with any `$(ASCIIDOC_EXTS)` extension. diff --git a/README.html b/README.html index 41200aa..431358f 100644 --- a/README.html +++ b/README.html @@ -451,14 +451,17 @@

PGXNtool

  • 5. Version-Specific SQL Files @@ -539,7 +542,10 @@

    2. Development

    -

    If you want to contribute to pgxntool development, work from the pgxntool-test repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via git subtree.

    +

    If you want to contribute to pgxntool development, work from the pgxntool-test repository, not from this repository. That repository contains the test infrastructure and development tools needed to validate changes to pgxntool. This repository contains only the framework files that get embedded into extension projects via git subtree.

    +
    +
    +

    Changes are normally paired across both repos: a pgxntool change should come with a matching branch (same name, on the same account) and PR in pgxntool-test, and CI enforces this pairing. See the CI and Contributing section in pgxntool-test for the full workflow.

    @@ -599,7 +605,190 @@

    -

    4.3. testdeps

    +

    4.3. test-build

    +
    +

    Validates that extension SQL files are syntactically correct before running the full test suite. This feature runs SQL files from test/build/ through pg_regress, providing better error messages than CREATE EXTENSION failures when there are syntax errors in your extension code.

    +
    +
    +

    How it works:

    +
    +
    +
      +
    1. +

      Place SQL files in test/build/*.sql

      +
    2. +
    3. +

      Place expected output in test/build/expected/*.out

      +
    4. +
    5. +

      These files run through pg_regress before make test runs the main test suite

      +
    6. +
    7. +

      If any build test fails, the test run stops immediately with clear error messages

      +
    8. +
    +
    +
    +

    Directory structure:

    +
    +
    +
    +
    test/build/
    +├── *.sql              # SQL test files (checked in)
    +├── expected/          # Expected output files (checked in)
    +│   └── *.out
    +└── sql/               # GENERATED - do not edit or check in
    +    └── *.sql          # Synced from *.sql above
    +
    +
    +
    +

    The sql/ subdirectory is generated automatically by make test-build. It is listed in .gitignore and removed by make clean. Do not place files directly in test/build/sql/.

    +
    +
    +

    Configuration:

    +
    +
    +

    The feature auto-detects based on whether test/build/*.sql files exist:

    +
    +
    +
      +
    • +

      Files present → feature enabled automatically

      +
    • +
    • +

      No files → feature disabled (no impact on existing projects)

      +
    • +
    +
    +
    +

    You can override auto-detection by setting PGXNTOOL_ENABLE_TEST_BUILD:

    +
    +
    +
    +
    # In your Makefile
    +PGXNTOOL_ENABLE_TEST_BUILD = yes  # or no
    +
    +
    +
    +

    Example: Validate extension SQL compiles

    +
    +
    +

    Create test/build/build.sql to run your extension’s SQL directly:

    +
    +
    +
    +
    \set ECHO none
    +-- Sets ON_ERROR_STOP, VERBOSITY verbose, and ON_ERROR_ROLLBACK
    +\i test/pgxntool/psql.sql
    +-- Suppress column headers and row counts for cleaner expected output
    +\t
    +
    +BEGIN;
    +SET client_min_messages = WARNING;
    +
    +-- Install dependencies your extension requires
    +CREATE EXTENSION IF NOT EXISTS pgtap CASCADE;
    +
    +-- Clean slate
    +DROP EXTENSION IF EXISTS myext;
    +DROP SCHEMA IF EXISTS myext;
    +CREATE SCHEMA myext;
    +
    +-- Run the actual extension SQL (not CREATE EXTENSION)
    +-- psql.sql above ensures errors abort immediately with clear messages
    +\i sql/myext.sql
    +
    +-- If we get here, the build succeeded; ON_ERROR_STOP would have aborted on any error above
    +\echo # BUILD TEST SUCCEEDED
    +ROLLBACK;
    +
    +
    +
    +

    This approach catches SQL syntax errors before running CREATE EXTENSION, giving clearer error messages with line numbers. The ROLLBACK ensures nothing persists—this is purely validation.

    +
    +
    +

    Why use \i instead of CREATE EXTENSION?

    +
    +
    +

    When CREATE EXTENSION fails, PostgreSQL shows only "syntax error" with limited context. Running the SQL directly via \i shows the exact line and position of errors, making debugging much faster.

    +
    +
    +
    +

    4.4. test/install

    +
    +

    Runs setup files before the main test suite within the same pg_regress invocation. This allows expensive one-time operations (like extension installation) to set up state that persists into the regular test files.

    +
    +
    +

    How it works:

    +
    +
    +
      +
    1. +

      Place SQL files in test/install/*.sql

      +
    2. +
    3. +

      Place expected output alongside as test/install/*.out

      +
    4. +
    5. +

      A schedule file is auto-generated that lists install files with ../install/ relative paths

      +
    6. +
    7. +

      pg_regress processes the install schedule first, then runs regular test files — all in one invocation, so database state persists

      +
    8. +
    +
    +
    +

    Directory structure:

    +
    +
    +
    +
    test/install/
    +├── *.sql              # SQL setup files (checked in)
    +├── *.out              # Expected output (checked in, alongside .sql)
    +├── .gitignore         # Ignores pg_regress artifacts (*.out.diff)
    +└── schedule           # GENERATED - auto-created by make
    +
    +
    +
    +

    The schedule file is generated automatically and listed in .gitignore. Do not edit it.

    +
    +
    +

    Configuration:

    +
    +
    +

    The feature auto-detects based on whether test/install/*.sql files exist:

    +
    +
    +
      +
    • +

      Files present → feature enabled automatically

      +
    • +
    • +

      No files → feature disabled (no impact on existing projects)

      +
    • +
    +
    +
    +

    You can override auto-detection by setting PGXNTOOL_ENABLE_TEST_INSTALL:

    +
    +
    +
    +
    # In your Makefile
    +PGXNTOOL_ENABLE_TEST_INSTALL = yes  # or no
    +
    +
    +
    +

    Why this is useful:

    +
    +
    +

    Without test/install, each test file typically needs to run CREATE EXTENSION in its setup, which adds overhead and doesn’t allow validating the installation step separately. With test/install, setup runs once before all tests, and any state it creates (tables, extensions, etc.) is available to every subsequent test file.

    +
    +
    +

    Key detail: Install files and regular tests run in a single pg_regress invocation. This means the database is NOT dropped between install and test phases — state created by install files persists into the main test suite.

    +
    +
    +
    +

    4.5. testdeps

    This rule allows you to ensure certain actions have taken place before running tests. By default it has a single prerequisite, pgtap, which will attempt to install pgtap from PGXN. This depneds on having the pgxn client installed.

    @@ -635,7 +824,7 @@

    -

    4.4. results

    +

    4.6. results

    Because make test ultimately runs installcheck, it’s using the Postgres test suite. Unfortunately, that suite is based on running diff between a raw output file and expected results. I STRONGLY recommend you use pgTap instead! With pgTap, it’s MUCH easier to determine whether a test is passing or not - tests explicitly pass or fail rather than requiring you to examine diff output. The extra effort of learning pgTap will quickly pay for itself. This example might help get you started.

    @@ -670,9 +859,36 @@

    Never run make results without first verifying the test changes are correct. The results target copies files from test/results/ to test/expected/, so running it blindly will make incorrect output become the new expected behavior.

    +
    +

    4.6.1. verify-results safeguard

    +
    +

    By default, make results will refuse to run if test/results/regression.diffs exists (indicating failing tests). This prevents accidentally updating expected files with incorrect output.

    +
    +
    +

    If tests are failing, you’ll see:

    +
    +
    +
    +
    ERROR: Tests are failing. Cannot run 'make results'.
    +Fix test failures first, then run 'make results'.
    +
    +
    +
    +

    To disable this safeguard (not recommended):

    +
    +
    +
    +
    # In your Makefile
    +PGXNTOOL_ENABLE_VERIFY_RESULTS = no
    +
    +# Or on the command line
    +make PGXNTOOL_ENABLE_VERIFY_RESULTS=no results
    +
    +
    +
    -

    4.5. tag

    +

    4.7. tag

    make tag will create a git branch for the current version of your extension, as determined by the META.json file. The reason to do this is so you can always refer to the exact code that went into a released version.

    @@ -693,7 +909,7 @@

    4.

    -

    4.6. dist

    +

    4.8. dist

    make dist will create a .zip file for your current version that you can upload to PGXN. The file is named after the PGXN name and version (the top-level "name" and "version" attributes in META.json). The .zip file is placed in the parent directory so as not to clutter up your git repo.

    @@ -711,9 +927,9 @@

    -

    4.7. pgxntool-sync

    +

    4.9. pgxntool-sync

    -

    This rule will pull down the latest released version of PGXNtool via git subtree pull.

    +

    This rule will pull down the latest released version of PGXNtool via git subtree pull and then reconcile the files setup.sh copied into your project (.gitignore, test/deps.sql) with a 3-way merge.

    @@ -734,14 +950,52 @@

    Tip

    + +
    -There is also a pgxntool-sync-% rule if you need to do more advanced things. +The actual work is done by pgxntool/pgxntool-sync.sh, so you can run it directly (pgxntool/pgxntool-sync.sh) if you’d rather not go through make. It optionally takes <repo> and <ref> arguments to pull from somewhere other than the default. +
    +
    +
    + + + + + +
    +
    Tip
    +
    +There is also a pgxntool-sync-% rule if you need to do more advanced things. make pgxntool-sync-<name> pulls from the <repo> <ref> defined by the pgxntool-sync-<name> make variable. +
    +
    +
    +
    +

    4.10. distclean

    +
    +

    make distclean removes generated configuration files (META.json, meta.mk, control.mk) that survive a normal make clean.

    +
    +
    + + + +
    +
    Note
    +
    +PGXS doesn’t provide any special support for distclean — its built-in distclean target simply depends on clean. PGXNtool uses its own PGXNTOOL_distclean variable to track files that should only be removed by distclean, not clean.
    +
    +

    If your extension generates additional files that should be removed by distclean but not clean, you can add them:

    +
    +
    +
    +
    PGXNTOOL_distclean += my_generated_config.mk
    +
    +
    -

    4.8. pgtle

    +

    4.11. pgtle

    Generates pg_tle (Trusted Language Extensions) registration SQL files for deploying extensions in managed environments like AWS RDS/Aurora. See [_pg_tle_Support] for complete documentation.

    @@ -750,7 +1004,7 @@

    -

    4.9. check-pgtle

    +

    4.12. check-pgtle

    Checks if pg_tle is installed and reports the version. This target: - Reports the version from pg_extension if CREATE EXTENSION pg_tle has been run in the database @@ -766,7 +1020,7 @@

    -

    4.10. run-pgtle

    +

    4.13. run-pgtle

    Registers all extensions with pg_tle by executing the generated pg_tle registration SQL files in a PostgreSQL database. This target: - Requires pg_tle extension to be installed (checked via check-pgtle) @@ -974,7 +1228,7 @@

    <
    ASCIIDOC_EXTS

    File extensions to consider as Asciidoc. -Defined as += adoc asciidoc.

    +Defined as += adoc asciidoc asc.

    ASCIIDOC_FILES
    @@ -1259,7 +1513,7 @@

    diff --git a/_.gitignore b/_.gitignore index 0c14928..1873c2c 100644 --- a/_.gitignore +++ b/_.gitignore @@ -24,6 +24,14 @@ results/ regression.diffs regression.out +# Generated sql/ directory for test/build +# Created by make test-build. See README.asc for details. +test/build/sql/ + +# Auto-generated schedule file for test/install +# Created by make when test/install/*.sql files exist. +test/install/schedule + # Misc tmp/ .DS_Store diff --git a/base.mk b/base.mk index 9b621df..a7ab93c 100644 --- a/base.mk +++ b/base.mk @@ -44,39 +44,155 @@ DOCS += $(foreach dir,$(DOC_DIRS),$(wildcard $(dir)/*)) # Find all asciidoc targets ASCIIDOC ?= $(shell which asciidoctor 2>/dev/null || which asciidoc 2>/dev/null) -ASCIIDOC_EXTS += adoc asciidoc +ASCIIDOC_EXTS += adoc asciidoc asc ASCIIDOC_FILES += $(foreach dir,$(DOC_DIRS),$(foreach ext,$(ASCIIDOC_EXTS),$(wildcard $(dir)/*.$(ext)))) PG_CONFIG ?= pg_config TESTDIR ?= test TESTOUT ?= $(TESTDIR) -# .source files are OPTIONAL - see "pg_regress workflow" comment below for details -TEST__SOURCE__INPUT_FILES += $(wildcard $(TESTDIR)/input/*.source) -TEST__SOURCE__OUTPUT_FILES += $(wildcard $(TESTDIR)/output/*.source) -TEST__SOURCE__INPUT_AS_OUTPUT = $(subst input,output,$(TEST__SOURCE__INPUT_FILES)) TEST_SQL_FILES += $(wildcard $(TESTDIR)/sql/*.sql) TEST_RESULT_FILES = $(patsubst $(TESTDIR)/sql/%.sql,$(TESTDIR)/expected/%.out,$(TEST_SQL_FILES)) -TEST_FILES = $(TEST__SOURCE__INPUT_FILES) $(TEST_SQL_FILES) -# Ephemeral files generated from source files (should be cleaned) -# input/*.source → sql/*.sql (converted by pg_regress) -TEST__SOURCE__SQL_FILES = $(patsubst $(TESTDIR)/input/%.source,$(TESTDIR)/sql/%.sql,$(TEST__SOURCE__INPUT_FILES)) -# output/*.source → expected/*.out (converted by pg_regress) -TEST__SOURCE__EXPECTED_FILES = $(patsubst $(TESTDIR)/output/%.source,$(TESTDIR)/expected/%.out,$(TEST__SOURCE__OUTPUT_FILES)) -REGRESS = $(sort $(notdir $(subst .source,,$(TEST_FILES:.sql=)))) # Sort is to get unique list +TEST_FILES = $(TEST_SQL_FILES) +REGRESS = $(sort $(notdir $(TEST_FILES:.sql=))) REGRESS_OPTS = --inputdir=$(TESTDIR) --outputdir=$(TESTOUT) # See additional setup below +# +# OPTIONAL TEST FEATURES +# +# These sections configure optional test features. Each feature can be enabled/disabled +# via a makefile variable. If not explicitly set, features auto-detect based on +# directory existence or default behavior. The actual feature implementation is +# located later in this file (see test-build target, schedule file generation, etc.). +# + +# Helper function: normalize a yes/no variable to lowercase and validate. +# Usage: $(call pgxntool_validate_yesno,VALUE,VARIABLE_NAME) +# Returns the lowercase value ("yes" or "no"), or errors if invalid. +pgxntool_validate_yesno = $(strip \ + $(if $(filter yes no,$(shell echo "$(1)" | tr '[:upper:]' '[:lower:]')),\ + $(shell echo "$(1)" | tr '[:upper:]' '[:lower:]'),\ + $(error $(2) must be "yes" or "no", got "$(1)"))) + +# ------------------------------------------------------------------------------ +# test-build: Sanity check extension files before running full test suite +# ------------------------------------------------------------------------------ +# Purpose: Validates that extension SQL files are syntactically correct by running +# files from test/build/ through pg_regress. This provides better error +# messages than CREATE EXTENSION failures. +# +# Variable: PGXNTOOL_ENABLE_TEST_BUILD +# - Can be set manually in Makefile or command line +# - Allowed values: "yes" or "no" (case-insensitive) +# - If not set: Auto-detects based on existence of test/build/*.sql files +# - Set to "yes" explicitly to get an error if test/build/ has no SQL files +# (useful to catch accidental deletion of test/build/ contents) +# - Set to "no" explicitly to disable even when test/build/ has SQL files +# +# Implementation: See test-build target definition (search for "test-build:" in this file) +# +TEST_BUILD_SQL_FILES = $(wildcard $(TESTDIR)/build/*.sql) +TEST_BUILD_FILES = $(TEST_BUILD_SQL_FILES) +ifdef PGXNTOOL_ENABLE_TEST_BUILD + # override needed so command-line values (make VAR=YES) are normalized, not silently ignored. + # := needed for immediate evaluation of the function call (avoids infinite recursion with =). + override PGXNTOOL_ENABLE_TEST_BUILD := $(call pgxntool_validate_yesno,$(PGXNTOOL_ENABLE_TEST_BUILD),PGXNTOOL_ENABLE_TEST_BUILD) +else + # Auto-detect: enable if test/build/ directory has SQL files + ifneq ($(strip $(TEST_BUILD_FILES)),) + PGXNTOOL_ENABLE_TEST_BUILD = yes + else + PGXNTOOL_ENABLE_TEST_BUILD = no + endif +endif + +# ------------------------------------------------------------------------------ +# test/install: Run setup files before all tests in the same pg_regress session +# ------------------------------------------------------------------------------ +# Purpose: Runs files from test/install/ before all test/sql/ files within a +# SINGLE pg_regress invocation via schedule files. This ensures that +# state created by install files (tables, extensions, etc.) persists +# into the main test suite. +# +# Variable: PGXNTOOL_ENABLE_TEST_INSTALL +# - Can be set manually in Makefile or command line +# - Allowed values: "yes" or "no" (case-insensitive) +# - If not set: Auto-detects based on existence of test/install/*.sql files +# - Set to "yes" explicitly to get an error if test/install/ has no SQL files +# (useful to catch accidental deletion of test/install/ contents) +# - Set to "no" explicitly to disable even when test/install/ has SQL files +# +# Directory layout (follows ~/code/extensions/archive/ pattern): +# test/install/*.sql - Install SQL files +# test/install/*.out - Expected output (lives alongside .sql files) +# test/install/schedule - Auto-generated schedule file +# test/sql/schedule - Auto-generated schedule file for regular tests +# +# The schedule files use relative paths (../install/testname) so pg_regress +# resolves install files from their original location without copying. +# +# NOTE: The variable normalization pattern below (ifdef/NORM/error/override) is +# identical to test-build and verify-results. Refactoring options: +# 1. A $(call normalize_bool_var,VAR,DEFAULT) Make function +# 2. A small include fragment (e.g. pgxntool/mk/bool-var.mk) +# Either approach would eliminate the ~10-line block repeated for each feature. +TEST_INSTALL_SQL_FILES = $(wildcard $(TESTDIR)/install/*.sql) +ifdef PGXNTOOL_ENABLE_TEST_INSTALL + # override needed so command-line values (make VAR=YES) are normalized, not silently ignored. + # := needed for immediate evaluation of the function call (avoids infinite recursion with =). + override PGXNTOOL_ENABLE_TEST_INSTALL := $(call pgxntool_validate_yesno,$(PGXNTOOL_ENABLE_TEST_INSTALL),PGXNTOOL_ENABLE_TEST_INSTALL) +else + # Auto-detect: enable if test/install/ directory has SQL files + ifneq ($(strip $(TEST_INSTALL_SQL_FILES)),) + PGXNTOOL_ENABLE_TEST_INSTALL = yes + else + PGXNTOOL_ENABLE_TEST_INSTALL = no + endif +endif + +# ------------------------------------------------------------------------------ +# verify-results: Safeguard for make results +# ------------------------------------------------------------------------------ +# Purpose: Prevents accidentally running 'make results' when tests are failing. +# +# Variable: PGXNTOOL_ENABLE_VERIFY_RESULTS +# - Can be set manually in Makefile or command line +# - Allowed values: "yes" or "no" (case-insensitive) +# - Setting to empty on the command line (e.g. PGXNTOOL_ENABLE_VERIFY_RESULTS=) also disables the feature +# - If not set: Defaults to "yes" (enabled by default for all pgxntool projects) +# - Usage: Controls whether verify-results target exists and blocks make results +# +# Variable: PGXNTOOL_VERIFY_RESULTS_MODE +# - Controls how verify-results detects test failures +# - "pgtap" (default): scans test/results/*.out for "not ok" lines and plan +# mismatches (TAP failures). Also checks regression.diffs as a fallback. +# Use this mode when your test suite uses pgTap. +# - "diffs": checks only for regression.diffs existence (classic pg_regress behavior) +# Use this mode when your tests use plain SQL expected-output comparison only. +# +# Implementation: See verify-results target definition and results target modification +# (search for "verify-results" and "results:" in this file) +# +ifdef PGXNTOOL_ENABLE_VERIFY_RESULTS + override PGXNTOOL_ENABLE_VERIFY_RESULTS := $(call pgxntool_validate_yesno,$(PGXNTOOL_ENABLE_VERIFY_RESULTS),PGXNTOOL_ENABLE_VERIFY_RESULTS) +else + # Default to yes (enabled by default for all pgxntool projects) + PGXNTOOL_ENABLE_VERIFY_RESULTS = yes +endif + +# Default mode: pgtap (scans results/*.out for TAP failures) +PGXNTOOL_VERIFY_RESULTS_MODE ?= pgtap + # Generate unique database name for tests to prevent conflicts across projects # Uses project name + first 5 chars of md5 hash of current directory # This prevents multiple test runs in different directories from clobbering each other REGRESS_DBHASH := $(shell echo $(CURDIR) | (md5 2>/dev/null || md5sum) | cut -c1-5) REGRESS_DBNAME := $(or $(PGXN),regression)_$(REGRESS_DBHASH) -REGRESS_OPTS += --dbname=$(REGRESS_DBNAME) MODULES = $(patsubst %.c,%,$(wildcard src/*.c)) ifeq ($(strip $(MODULES)),) MODULES =# Set to NUL so PGXS doesn't puke endif -EXTRA_CLEAN = $(wildcard ../$(PGXN)-*.zip) $(TEST__SOURCE__SQL_FILES) $(TEST__SOURCE__EXPECTED_FILES) pg_tle/ +EXTRA_CLEAN = $(wildcard ../$(PGXN)-*.zip) pg_tle/ # Get Postgres version, as well as major (9.4, etc) version. # NOTE! In at least some versions, PGXS defines VERSION, so we intentionally don't use that variable @@ -93,8 +209,37 @@ ifeq ($(GE91),yes) all: $(EXTENSION_VERSION_FILES) endif -ifeq ($($call test, $(MAJORVER), -lt 13), yes) - REGRESS_OPTS += --load-language=plpgsql +ifeq ($(call test, $(MAJORVER), -lt, 130), yes) +REGRESS_OPTS += --load-language=plpgsql +endif + +# +# test/install: Schedule-based approach +# +# When enabled, generates a schedule file listing install files, and adds it +# to REGRESS_OPTS. pg_regress processes --schedule tests before command-line +# test names, so install files run first in the SAME pg_regress invocation. +# This ensures state created by install files persists into the main test suite. +# +# The schedule uses relative paths (../install/testname) so pg_regress finds +# install files in their original location without copying. +# +ifeq ($(PGXNTOOL_ENABLE_TEST_INSTALL),yes) +PGXNTOOL_INSTALL_SCHEDULE = $(TESTDIR)/install/schedule +EXTRA_CLEAN += $(PGXNTOOL_INSTALL_SCHEDULE) + +# Add install schedule; REGRESS stays as-is (regular tests run after schedule) +REGRESS_OPTS += --schedule=$(PGXNTOOL_INSTALL_SCHEDULE) + +# Always regenerate schedule file to catch added/removed files +.PHONY: $(PGXNTOOL_INSTALL_SCHEDULE) +$(PGXNTOOL_INSTALL_SCHEDULE): + @echo "# Auto-generated - DO NOT EDIT" > $@ + @for f in $(notdir $(basename $(TEST_INSTALL_SQL_FILES))); do \ + echo "test: ../install/$$f" >> $@; \ + done + +installcheck: $(PGXNTOOL_INSTALL_SCHEDULE) endif PGXS := $(shell $(PG_CONFIG) --pgxs) @@ -104,7 +249,7 @@ DATA += $(wildcard *.control) # Don't have installcheck bomb on error .IGNORE: installcheck -installcheck: $(TEST_RESULT_FILES) $(TEST_SQL_FILES) $(TEST__SOURCE__INPUT_FILES) | $(TESTDIR)/sql/ $(TESTDIR)/expected/ $(TESTOUT)/results/ +installcheck: $(TEST_RESULT_FILES) $(TEST_SQL_FILES) | $(TESTDIR)/sql/ $(TESTDIR)/expected/ $(TESTOUT)/results/ # # TEST SUPPORT @@ -118,31 +263,55 @@ installcheck: $(TEST_RESULT_FILES) $(TEST_SQL_FILES) $(TEST__SOURCE__INPUT_FILES # watch-make if you're generating intermediate files. If tests end up needing # clean it's an indication of a missing dependency anyway. .PHONY: test -test: testdeps install installcheck +# Build test dependencies list based on enabled features +TEST_DEPS = testdeps +ifeq ($(PGXNTOOL_ENABLE_TEST_BUILD),yes) +TEST_DEPS += test-build +endif +TEST_DEPS += install installcheck +test: $(TEST_DEPS) @if [ -r $(TESTOUT)/regression.diffs ]; then cat $(TESTOUT)/regression.diffs; fi -# make results: runs `make test` and copy all result files to expected -# DO NOT RUN THIS UNLESS YOU'RE CERTAIN ALL YOUR TESTS ARE PASSING! # -# pg_regress workflow: -# 1. Converts input/*.source → sql/*.sql (with token substitution) -# 2. Converts output/*.source → expected/*.out (with token substitution) -# 3. Runs tests, saving actual output in results/ -# 4. Compares results/ with expected/ +# verify-results: Safeguard for make results # -# NOTE: Both input/*.source and output/*.source are COMPLETELY OPTIONAL and are -# very rarely needed. pg_regress does NOT create the input/ or output/ directories -# - these are optional INPUT directories that users create if they need them. -# Most extensions will never need these directories. +# Checks if tests are passing before allowing make results to proceed +ifeq ($(PGXNTOOL_ENABLE_VERIFY_RESULTS),yes) +.PHONY: verify-results +ifeq ($(PGXNTOOL_VERIFY_RESULTS_MODE),pgtap) +verify-results: + @$(PGXNTOOL_DIR)/verify-results-pgtap.sh $(TESTOUT) +else +verify-results: + @if [ -r $(TESTOUT)/regression.diffs ]; then \ + echo "ERROR: Tests are failing. Cannot run 'make results'."; \ + echo "Fix test failures first, then run 'make results'."; \ + echo ""; \ + echo "See $(TESTOUT)/regression.diffs for details:"; \ + cat $(TESTOUT)/regression.diffs; \ + exit 1; \ + fi +endif +endif + +# make results: runs `make test` and copies all result files to expected. +# DO NOT RUN THIS UNLESS YOU'RE CERTAIN ALL YOUR TESTS ARE PASSING! # -# CRITICAL: Do NOT copy files that have corresponding output/*.source files, because -# those are the source of truth and will be regenerated by pg_regress from the .source files. -# Only copy files from results/ that don't have output/*.source counterparts. +# Dependency chain (verify-results: test) guarantees test completes before verify-results +# checks regression.diffs, even under make -j. Listing both as independent prerequisites +# of results would allow them to run concurrently, letting verify-results see stale state. .PHONY: results +ifeq ($(PGXNTOOL_ENABLE_VERIFY_RESULTS),yes) +verify-results: test +results: verify-results +else results: test - @# Copy .out files from results/ to expected/, excluding those with output/*.source counterparts - @# .out files with output/*.source counterparts are generated from .source files and should NOT be overwritten - @$(PGXNTOOL_DIR)/make_results.sh $(TESTDIR) $(TESTOUT) +endif + @mkdir -p $(TESTDIR)/expected + @for f in $(TESTOUT)/results/*.out; do \ + [ -f "$$f" ] || continue; \ + cp "$$f" $(TESTDIR)/expected/$$(basename "$$f"); \ + done # testdeps is a generic dependency target that you can add targets to .PHONY: testdeps @@ -196,9 +365,34 @@ run-pgtle: pgtle # These targets ensure all the relevant directories exist $(TESTDIR)/sql $(TESTDIR)/expected/ $(TESTOUT)/results/: @mkdir -p $@ +# pg_regress aborts with "could not open file" if an expected output file is +# missing, so create empty placeholders for any test that lacks one. $(TEST_RESULT_FILES): | $(TESTDIR)/expected/ + @# Create empty expected file so pg_regress doesn't abort with "file not found". + @# pg_regress requires an expected/*.out file to exist for each test; without it + @# it stops immediately rather than running the test and showing the diff. @touch $@ +# +# test-build: Sanity check extension files in test/build/ +# +# The sql/ subdirectory is generated - files are synced from test/build/*.sql. +# This directory should be in .gitignore and is cleaned by make clean. +# +ifeq ($(PGXNTOOL_ENABLE_TEST_BUILD),yes) +TEST_BUILD_SQL_DIR = $(TESTDIR)/build/sql +TEST_BUILD_REGRESS = $(sort $(notdir $(basename $(TEST_BUILD_SQL_FILES)))) +.PHONY: test-build +test-build: install + @$(PGXNTOOL_DIR)/run-test-build.sh $(TESTDIR) + $(MAKE) -C . REGRESS="$(TEST_BUILD_REGRESS)" REGRESS_OPTS="--inputdir=$(TESTDIR)/build --outputdir=$(TESTDIR)/build" installcheck + @if [ -r $(TESTDIR)/build/regression.diffs ]; then \ + echo "test-build failed - see $(TESTDIR)/build/regression.diffs"; \ + cat $(TESTDIR)/build/regression.diffs; \ + exit 1; \ + fi +endif + # # DOC SUPPORT @@ -227,8 +421,9 @@ dist: html # But don't add it as an install or test dependency unless we do have asciidoc ifneq (,$(strip $(ASCIIDOC))) -# Need to do this so install & co will pick up ALL targets. Unfortunately this can result in some duplication. -DOCS += $(ASCIIDOC_HTML) +# Add HTML to DOCS for install, deduplicating against any HTML already picked +# up by the wildcard (e.g. pre-built HTML committed to the repo). +DOCS := $(sort $(filter-out $(ASCIIDOC_HTML),$(DOCS)) $(ASCIIDOC_HTML)) # Also need to add html as a dep to all (which will get picked up by install & installcheck all: html @@ -296,28 +491,33 @@ print-% : ; $(info $* is $(flavor $*) variable set to "$($*)") @true # # subtree sync support # -# This is setup to allow any number of pull targets by defining special -# variables. pgxntool-sync-release is an example of this. +# All the real work (git subtree pull + update-setup-files.sh) lives in +# pgxntool/pgxntool-sync.sh so it can be run directly, without make. These +# targets are thin wrappers around that script. +# +# `make pgxntool-sync` pulls the latest released version from the canonical +# repository (the script's built-in default). # -# After the subtree pull, we run update-setup-files.sh to handle files that -# were initially copied by setup.sh (like .gitignore). This script does a -# 3-way merge if both you and pgxntool changed the file. -.PHONY: pgxntool-sync-% +# `make pgxntool-sync-` pulls from the " " defined by the +# pgxntool-sync- variable, allowing any number of custom pull sources. +.PHONY: pgxntool-sync pgxntool-sync-% +pgxntool-sync: + @pgxntool/pgxntool-sync.sh pgxntool-sync-%: - @old_commit=$$(git log -1 --format=%H -- pgxntool/) && \ - git subtree pull -P pgxntool --squash -m "Pull pgxntool from $($@)" $($@) && \ - pgxntool/update-setup-files.sh "$$old_commit" -pgxntool-sync: pgxntool-sync-release + @pgxntool/pgxntool-sync.sh $($@) # DANGER! Use these with caution. They may add extra crap to your history and # could make resolving merges difficult! -pgxntool-sync-release := git@github.com:decibel/pgxntool.git release -pgxntool-sync-stable := git@github.com:decibel/pgxntool.git stable -pgxntool-sync-master := git@github.com:decibel/pgxntool.git master -pgxntool-sync-local := ../pgxntool release # Not the same as PGXNTOOL_DIR! -pgxntool-sync-local-stable := ../pgxntool stable # Not the same as PGXNTOOL_DIR! -pgxntool-sync-local-master := ../pgxntool master # Not the same as PGXNTOOL_DIR! - +# `pgxntool-sync` (no suffix) already pulls the canonical release; these are the +# alternatives. `-master` pulls the bleeding edge; `-local*` pull from a sibling +# ../pgxntool checkout (not the same as PGXNTOOL_DIR!). +pgxntool-sync-master := https://github.com/Postgres-Extensions/pgxntool.git master +pgxntool-sync-local := ../pgxntool release +pgxntool-sync-local-master := ../pgxntool master + +# PGXS doesn't provide any special support for distclean (it just depends on +# clean), so we roll our own. Files that should only be removed by distclean +# (not clean) are added to PGXNTOOL_distclean near their build rules above. distclean: rm -f $(PGXNTOOL_distclean) @@ -328,6 +528,22 @@ DOCS =# Set to NUL so PGXS doesn't puke endif include $(PGXS) + +# Override CONTRIB_TESTDB (set unconditionally by PGXS) with our unique database +# name. This must be after include $(PGXS) because PGXS uses = (not ?=). +# PGXS appends --dbname=$(CONTRIB_TESTDB) to REGRESS_OPTS, so overriding +# CONTRIB_TESTDB is the correct way to control the database name — adding our +# own --dbname would result in two --dbname flags passed to pg_regress. +CONTRIB_TESTDB = $(REGRESS_DBNAME) + +# Clean generated sql/ directory for test-build +ifeq ($(PGXNTOOL_ENABLE_TEST_BUILD),yes) +.PHONY: clean-test-build +clean-test-build: + rm -rf $(TEST_BUILD_SQL_DIR) +clean: clean-test-build +endif + # # pgtap # diff --git a/build_meta.sh b/build_meta.sh index e0fd6b2..d4ea9fa 100755 --- a/build_meta.sh +++ b/build_meta.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Build META.json from META.in.json template # diff --git a/lib.sh b/lib.sh index 41de512..3cbeb0e 100644 --- a/lib.sh +++ b/lib.sh @@ -1,4 +1,3 @@ -#!/bin/bash # lib.sh - Common utility functions for pgxntool scripts # # This file is meant to be sourced by other scripts, not executed directly. @@ -36,15 +35,46 @@ die() { exit $exit_code } +# Returns true if an array isn't empty. +# +# array_not_empty "${#errors[@]}" +# +# BUT WHY ON EARTH DO THIS?? +# +# This wraps a one-liner intentionally. The function forces any reader +# (human or AI agent) to navigate here and read this comment before +# "simplifying" the call site. Without it, the natural next step is to +# inline the expression — and the natural inline form breaks bash 3.2. +# +# On bash 3.2 (Mac OS default), when using `set -u`, expanding "${arr[@]}" +# on an empty array triggers "unbound variable" even when the array was +# explicitly initialized with arr=(). +# +# The comment inside the function body exists to catch any agent or human who +# navigates to the function without reading this comment first. +array_not_empty() { + # DO NOT EDIT THIS FUNCTION! DO NOT REMOVE THIS COMMENT! (see main function comment) + [ "${1:-0}" -gt 0 ] +} + # Debug function # Usage: debug LEVEL "message" # Outputs message to stderr if DEBUG >= LEVEL -# Debug levels use multiples of 10 (10, 20, 30, 40, etc.) to allow for easy expansion +# +# LEVEL encodes how noisy/esoteric a message is -- roughly, how far you'd crank +# DEBUG before you'd actually want to see it. Higher = noisier, more rarely +# useful. This is signal-to-noise, NOT code nesting depth: a top-level line can +# warrant a high level if it's esoteric, and loop-body detail is usually high +# precisely because it's noisy. +# +# The tiers below are anchors, not strict multiples -- pick any value in range +# to fine-tune between existing calls without renumbering: # - 10: Critical errors, important warnings # - 20: Warnings, significant state changes # - 30: General debugging, function entry/exit, array operations # - 40: Verbose details, loop iterations -# - 50+: Maximum verbosity +# - 50+: Maximum verbosity (per-iteration innards) +# # Enable with: DEBUG=30 scriptname.sh debug() { local level=$1 @@ -55,3 +85,26 @@ debug() { echo "DEBUG[$level]: $message" >&2 fi } + +# Remove pgxntool's own dev-only directories from a consuming project. +# +# `git subtree` copies the ENTIRE pgxntool tree into the consumer, including +# dev-only dirs like .github/ (pgxntool's CI) and .claude/. Those are +# export-ignored from `make dist` and don't belong in a project that merely +# embeds pgxntool. (GitHub only runs workflows at the repo root, so a consumer's +# pgxntool/.github never executes anyway — but it's still clutter.) git subtree +# doesn't honor export-ignore, so we prune them here after a sync. +# +# Must be run from the project root (the dir containing pgxntool/). Safe to call +# repeatedly; a no-op once the dirs are gone. +prune_pgxntool_dev_dirs() { + local d + for d in .github .claude; do + [ -e "pgxntool/$d" ] || continue + echo " pgxntool/$d: pruning (pgxntool dev-only, not for embedding projects)" + # Stage the removal if tracked; rm -rf guarantees it's gone even if not. + # || : keeps this best-effort under `set -e` (rm -rf is the real cleanup). + git rm -rq --ignore-unmatch "pgxntool/$d" >/dev/null 2>&1 || : + rm -rf "pgxntool/$d" + done +} diff --git a/make_results.sh b/make_results.sh deleted file mode 100755 index 066e372..0000000 --- a/make_results.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Helper script for make results target -# Copies .out files from results/ to expected/, excluding those with output/*.source counterparts - -set -e - -TESTDIR="${1:-test}" -TESTOUT="${2:-${TESTDIR}}" - -mkdir -p "${TESTDIR}/expected" - -# Use nullglob so globs that don't match return nothing instead of the literal pattern -shopt -s nullglob - -for result_file in "${TESTOUT}/results"/*.out; do - test_name=$(basename "$result_file" .out) - - # Check if this file has a corresponding output/*.source file - # Only consider non-empty source files (empty files are likely leftovers from pg_regress) - if [ -f "${TESTDIR}/output/${test_name}.source" ] && [ -s "${TESTDIR}/output/${test_name}.source" ]; then - echo "WARNING: ${TESTOUT}/results/${test_name}.out exists but will NOT be copied" >&2 - echo " (excluded because ${TESTDIR}/output/${test_name}.source exists)" >&2 - else - # Copy the file - it doesn't have an output/*.source counterpart - cp "$result_file" "${TESTDIR}/expected/${test_name}.out" - fi -done - diff --git a/pgtle.sh b/pgtle.sh index abbcfd4..124ca84 100755 --- a/pgtle.sh +++ b/pgtle.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # # pgtle.sh - Generate pg_tle registration SQL for PostgreSQL extensions # @@ -459,16 +459,26 @@ parse_control_file() { local key="${BASH_REMATCH[1]}" local value="${BASH_REMATCH[2]}" + # Trim trailing comments and whitespace FIRST, then strip quotes. + # Order matters: stripping quotes before removing comments leaves a rogue + # trailing quote for values like 'version' # note. See issue #25. + value="${value%%#*}" # Remove trailing comments + # Trim all trailing whitespace (spaces and tabs). The prior %% pattern with + # a literal space only removed one character; multiple spaces or a tab before + # the comment (e.g., 'value' # note or 'value'$'\t'# note) would leave + # stray whitespace that breaks the quote-strip below. + if [[ "$value" =~ ^(.*[^[:space:]])[[:space:]]*$ ]]; then + value="${BASH_REMATCH[1]}" + else + value="" + fi + # Strip quotes if present (both single and double) value="${value#\'}" value="${value%\'}" value="${value#\"}" value="${value%\"}" - # Trim trailing whitespace/comments - value="${value%%#*}" # Remove trailing comments - value="${value%% }" # Trim trailing spaces - # Store in global variables case "$key" in default_version) DEFAULT_VERSION="$value" ;; @@ -518,14 +528,14 @@ discover_sql_files() { local default_version_file="sql/${EXTENSION}--${DEFAULT_VERSION}.sql" local base_file="sql/${EXTENSION}.sql" if [ -f "$base_file" ] && ([ ! -f "$default_version_file" ] || [ ! -s "$default_version_file" ]); then - debug 30 "discover_sql_files: Creating default_version file from base file" + debug 40 "discover_sql_files: Creating default_version file from base file" cp "$base_file" "$default_version_file" fi # Find versioned files: sql/{ext}--{version}.sql # Use find to get proper null-delimited output, then filter out upgrade scripts VERSION_FILES=() # Reset array - debug 30 "discover_sql_files: Reset VERSION_FILES array" + debug 40 "discover_sql_files: Reset VERSION_FILES array" while IFS= read -r -d '' file; do local basename=$(basename "$file" .sql) local dash_count=$(echo "$basename" | grep -o -- "--" | wc -l | tr -d '[:space:]') @@ -543,7 +553,7 @@ discover_sql_files() { # Find upgrade scripts: sql/{ext}--{ver1}--{ver2}.sql # These have TWO occurrences of "--" in the filename UPGRADE_FILES=() # Reset array - debug 30 "discover_sql_files: Reset UPGRADE_FILES array" + debug 40 "discover_sql_files: Reset UPGRADE_FILES array" while IFS= read -r -d '' file; do # Empty upgrade files are allowed (no-op upgrades) local basename=$(basename "$file" .sql) @@ -564,8 +574,8 @@ discover_sql_files() { echo " - $f" >&2 done - debug 30 "discover_sql_files: Checking UPGRADE_FILES array, count=${#UPGRADE_FILES[@]:-0}" - if [ ${#UPGRADE_FILES[@]:-0} -gt 0 ]; then + debug 40 "discover_sql_files: Checking UPGRADE_FILES array, count=${#UPGRADE_FILES[@]}" + if array_not_empty "${#UPGRADE_FILES[@]}"; then echo " Found ${#UPGRADE_FILES[@]} upgrade script(s):" >&2 debug 30 "discover_sql_files: Iterating over ${#UPGRADE_FILES[@]} upgrade files" for f in "${UPGRADE_FILES[@]}"; do @@ -633,8 +643,8 @@ build_requires_array() { generate_header() { local pgtle_version="$1" local output_file="$2" - local version_count=${#VERSION_FILES[@]:-0} - local upgrade_count=${#UPGRADE_FILES[@]:-0} + local version_count=${#VERSION_FILES[@]} + local upgrade_count=${#UPGRADE_FILES[@]} # Determine version compatibility message local compat_msg @@ -759,9 +769,9 @@ generate_pgtle_sql() { # Ensure arrays are initialized (defensive programming) # Arrays should already be initialized at top level, but ensure they exist - debug 30 "generate_pgtle_sql: Checking array initialization" - debug 30 "generate_pgtle_sql: VERSION_FILES is ${VERSION_FILES+set}, count=${#VERSION_FILES[@]:-0}" - debug 30 "generate_pgtle_sql: UPGRADE_FILES is ${UPGRADE_FILES+set}, count=${#UPGRADE_FILES[@]:-0}" + debug 40 "generate_pgtle_sql: Checking array initialization" + debug 30 "generate_pgtle_sql: VERSION_FILES is ${VERSION_FILES+set}, count=${#VERSION_FILES[@]}" + debug 30 "generate_pgtle_sql: UPGRADE_FILES is ${UPGRADE_FILES+set}, count=${#UPGRADE_FILES[@]}" if [ -z "${VERSION_FILES+set}" ]; then echo "WARNING: VERSION_FILES not set, initializing" >&2 @@ -807,7 +817,7 @@ EOF fi # Install base version (first version file) - if [ ${#VERSION_FILES[@]} -gt 0 ]; then + if array_not_empty "${#VERSION_FILES[@]}"; then generate_install_extension "${VERSION_FILES[0]}" "$capability" fi @@ -819,14 +829,15 @@ EOF fi # Install all upgrade paths - local upgrade_count=${#UPGRADE_FILES[@]:-0} - debug 30 "generate_pgtle_sql: upgrade_count=$upgrade_count" + local upgrade_count=${#UPGRADE_FILES[@]} + debug 40 "generate_pgtle_sql: upgrade_count=$upgrade_count" if [ "$upgrade_count" -gt 0 ]; then debug 30 "generate_pgtle_sql: Processing $upgrade_count upgrade path(s)" - local i - for ((i=0; i (3-way merge files setup.sh copied out, +# like .gitignore and test/deps.sql) +# +# It is invoked by the `make pgxntool-sync` targets, but can also be run directly +# so you never need make to update pgxntool. +# +# Usage: pgxntool-sync.sh [ []] +# +# repo Git URL (or path) to pull pgxntool from. Defaults to the canonical +# pgxntool repository. +# ref Branch, tag, or commit to pull. Defaults to the `release` tag, which +# always points at the latest released version. +# +# Run from the root of your project (the directory containing pgxntool/). + +set -o errexit -o errtrace -o pipefail +trap 'echo "Error on line ${LINENO}"' ERR + +PGXNTOOL_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$PGXNTOOL_DIR/lib.sh" + +# Canonical source for pgxntool. `release` is a floating tag that the release +# process force-moves to each new version, so it always names the latest +# released version -- exactly what a plain sync should track. +DEFAULT_REPO="https://github.com/Postgres-Extensions/pgxntool.git" +DEFAULT_REF="release" + +repo=${1:-$DEFAULT_REPO} +ref=${2:-$DEFAULT_REF} + +# We must run from the project root: git subtree pull operates on the pgxntool/ +# prefix and update-setup-files.sh resolves paths relative to the current dir. +[[ -d "pgxntool" ]] || die 1 "pgxntool directory not found. Run from your project root." +# Use rev-parse, not [ -d .git ]: in a worktree .git is a file, not a directory. +git rev-parse --git-dir >/dev/null 2>&1 || die 1 "Not in a git repository. Run from your project root." + +# The old commit is the pgxntool subtree HEAD before the pull; update-setup-files.sh +# needs it as the merge base for files that were copied out of pgxntool. +old_commit=$(git log -1 --format=%H -- pgxntool/) + +git subtree pull -P pgxntool --squash -m "Pull pgxntool from $repo $ref" "$repo" "$ref" + +"$PGXNTOOL_DIR/update-setup-files.sh" "$old_commit" diff --git a/run-test-build.sh b/run-test-build.sh new file mode 100755 index 0000000..86a61eb --- /dev/null +++ b/run-test-build.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# pgxntool/run-test-build.sh - Prepare test/build/ for the test-build target +# +# Sets up the generated sql/ directory and ensures expected/*.out files exist +# so pg_regress can run without aborting on missing files. +# +# Usage: run-test-build.sh TESTDIR +# +# Called by the test-build target in base.mk before running installcheck. + +set -e + +TESTDIR="${1:?Usage: run-test-build.sh TESTDIR}" +BUILD_DIR="$TESTDIR/build" +SQL_DIR="$BUILD_DIR/sql" +EXPECTED_DIR="$BUILD_DIR/expected" + +mkdir -p "$SQL_DIR" +mkdir -p "$EXPECTED_DIR" + +# Verify .sql files exist. This script is only called when test-build is +# enabled, so missing files indicate a misconfiguration. +files=("$BUILD_DIR"/*.sql) +if [ ! -f "${files[0]}" ]; then + echo "ERROR: no .sql files found in $BUILD_DIR/" >&2 + exit 1 +fi + +# Sync .sql files to sql/ directory for pg_regress. +# --checksum: compare by content, not size+mtime. rsync's default "quick check" +# assumes equal size+mtime means files are identical, which isn't safe here — +# builds can produce identical-sized files with different content. Checksum +# comparison also avoids unnecessary writes that could trigger antivirus. +# --times: preserve source mtimes on destination files so make's dependency +# tracking works correctly. +# --delete: remove files from sql/ that no longer exist in build/. +# --include/--exclude: select only *.sql from the directory source +# (--delete requires a directory transfer, not individual file arguments). +rsync -r --checksum --times --delete --include='*.sql' --exclude='*' "$BUILD_DIR/" "$SQL_DIR/" + +# Create empty expected/*.out files for .sql tests (if not already present). +# pg_regress requires an expected file to exist for each test; without it +# pg_regress stops immediately rather than running the test and showing the diff. +for file in "$BUILD_DIR"/*.sql; do + out="$EXPECTED_DIR/$(basename "$file" .sql).out" + [ -f "$out" ] || touch "$out" +done diff --git a/setup.sh b/setup.sh index 0a67f0e..54b3633 100755 --- a/setup.sh +++ b/setup.sh @@ -7,7 +7,9 @@ trap 'echo "Error on line ${LINENO}"' ERR PGXNTOOL_DIR="$(dirname "${BASH_SOURCE[0]}")" source "$PGXNTOOL_DIR/lib.sh" -[ -d .git ] || git init +# Use rev-parse, not [ -d .git ]: in a worktree .git is a file, not a directory, +# so the old check would wrongly re-run `git init` inside a valid working tree. +git rev-parse --git-dir >/dev/null 2>&1 || git init if ! git diff --cached --exit-code; then echo "Git repository is not clean; please commit and try again." >&2 diff --git a/update-setup-files.sh b/update-setup-files.sh index 94ae75d..f814605 100755 --- a/update-setup-files.sh +++ b/update-setup-files.sh @@ -146,9 +146,10 @@ process_symlink() { old_commit=$1 -# Verify we're in a git repo with pgxntool subtree +# Verify we're in a git repo with pgxntool subtree. +# Use rev-parse, not [ -d .git ]: in a worktree .git is a file, not a directory. [[ -d "pgxntool" ]] || die 1 "pgxntool directory not found. Run from project root." -[[ -d ".git" ]] || die 1 "Not in a git repository." +git rev-parse --git-dir >/dev/null 2>&1 || die 1 "Not in a git repository." # Verify the old commit is valid if ! git cat-file -e "${old_commit}^{commit}" 2>/dev/null; then @@ -172,5 +173,9 @@ for entry in "${SETUP_SYMLINKS[@]}"; do process_symlink "$dest" "$target" done +# Prune pgxntool's own dev-only dirs (.github/, .claude/) that the subtree pull +# re-introduces but that don't belong in a consuming project (see lib.sh). +prune_pgxntool_dev_dirs + echo echo "Done. Review changes with 'git diff' and commit when ready." diff --git a/verify-results-pgtap.sh b/verify-results-pgtap.sh new file mode 100755 index 0000000..dd8d7e9 --- /dev/null +++ b/verify-results-pgtap.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# pgxntool/verify-results-pgtap.sh - Check pgtap results before 'make results' +# +# Scans pgtap output files for failures and plan mismatches, then checks +# regression.diffs as a fallback. Exits non-zero if any problems are found. +# +# Usage: verify-results-pgtap.sh TESTOUT +# +# Called by the verify-results target in base.mk (pgtap mode). + +set -e + +TESTOUT="${1:?Usage: verify-results-pgtap.sh TESTOUT}" + +# Check for pgtap failures in result files (excluding TODO items) +failed=0 +for f in "$TESTOUT"/results/*.out; do + [ -f "$f" ] || continue + if grep -q '^not ok' "$f"; then + notok=$(grep '^not ok' "$f" | grep -v '# TODO' || true) + if [ -n "$notok" ]; then + echo "ERROR: pgtap failure detected in $f" + echo "$notok" + failed=1 + fi + fi + if grep -q 'Looks like you planned' "$f"; then + echo "ERROR: pgtap plan mismatch in $f" + grep 'Looks like you planned' "$f" + failed=1 + fi +done +if [ $failed -ne 0 ]; then + echo + echo "pgtap failures detected. Cannot run 'make results'." + exit 1 +fi + +# Also check regression.diffs (output mismatch even if pgtap all passed) +if [ -r "$TESTOUT/regression.diffs" ]; then + echo "ERROR: Tests are failing. Cannot run 'make results'." + echo "Fix test failures first, then run 'make results'." + echo + echo "See $TESTOUT/regression.diffs for details:" + cat "$TESTOUT/regression.diffs" + exit 1 +fi From cc5b12f2924d2f650b17dc29eea114d3be1ba6a0 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Thu, 16 Jul 2026 12:39:07 -0500 Subject: [PATCH 11/11] Update .gitignore from pgxntool template --- .gitignore | 8 + pgxntool/.claude/CLAUDE.md | 28 -- pgxntool/.claude/development.md | 47 --- pgxntool/.github/workflows/CLAUDE.md | 61 ---- pgxntool/.github/workflows/ci.yml | 294 ------------------ .../.github/workflows/claude-code-review.yml | 102 ------ pgxntool/.github/workflows/claude.yml | 46 --- pgxntool/.github/workflows/protect-label.yml | 146 --------- 8 files changed, 8 insertions(+), 724 deletions(-) delete mode 100644 pgxntool/.claude/CLAUDE.md delete mode 100644 pgxntool/.claude/development.md delete mode 100644 pgxntool/.github/workflows/CLAUDE.md delete mode 100644 pgxntool/.github/workflows/ci.yml delete mode 100644 pgxntool/.github/workflows/claude-code-review.yml delete mode 100644 pgxntool/.github/workflows/claude.yml delete mode 100644 pgxntool/.github/workflows/protect-label.yml diff --git a/.gitignore b/.gitignore index 0c14928..1873c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,14 @@ results/ regression.diffs regression.out +# Generated sql/ directory for test/build +# Created by make test-build. See README.asc for details. +test/build/sql/ + +# Auto-generated schedule file for test/install +# Created by make when test/install/*.sql files exist. +test/install/schedule + # Misc tmp/ .DS_Store diff --git a/pgxntool/.claude/CLAUDE.md b/pgxntool/.claude/CLAUDE.md deleted file mode 100644 index e2e74fe..0000000 --- a/pgxntool/.claude/CLAUDE.md +++ /dev/null @@ -1,28 +0,0 @@ -# Claude Development Notes - -This file contains guidance for Claude Code when working in this repository. -It is excluded from distributions via `.gitattributes export-ignore`. - -## CI Monitoring After Every Push - -**REQUIRED**: After every `git push`, immediately start a background task to -monitor the CI run for that push. If you pushed to both pgxntool and -pgxntool-test, start a background task for each repo — do not monitor them -sequentially. - -Use `gh run watch` or poll with `gh run list` / `gh pr checks` in the -background task. Report failures to the user as soon as they are detected; -do not wait for all jobs to finish before reporting. - -## Multiple Concurrent Sessions - -It is common to have multiple Claude Code sessions open simultaneously across -pgxntool and pgxntool-test. To avoid cross-session interference: - -**If you are asked to do something on an existing PR that you did not open or -are not already working on in this session, immediately ask for confirmation -before proceeding.** For example: "I see PR #32 exists. Were you asking me to -work on that, or did you mean to send this to a different session?" - -This applies to: editing PR branches, pushing to them, closing/reopening them, -adding commits, modifying PR descriptions, or any other PR-level action. diff --git a/pgxntool/.claude/development.md b/pgxntool/.claude/development.md deleted file mode 100644 index 886484a..0000000 --- a/pgxntool/.claude/development.md +++ /dev/null @@ -1,47 +0,0 @@ -# pgxntool Development Guidelines - -**THIS FILE IS FOR PGXNTOOL DEVELOPERS ONLY.** - -If you are an extension developer using pgxntool in your project, this file does not -apply to you. See the top-level `CLAUDE.md` instead. - -## Critical: Work from pgxntool-test, Not Here - -**NEVER make changes to pgxntool directly from this repository.** - -pgxntool development must be done from a checkout of **pgxntool-test**, which contains -the full test infrastructure. Working here directly means you cannot run tests, and -any changes you commit cannot be validated before merging. - -**Correct workflow:** -1. Clone or use an existing checkout of `pgxntool-test` -2. Work in a worktree: both `pgxntool/` and `pgxntool-test/` will be siblings -3. Make changes to `pgxntool/` from within that pgxntool-test context -4. Run the test suite via `make test` in pgxntool-test before committing - -**See:** https://github.com/Postgres-Extensions/pgxntool-test for the full development -workflow. - ---- - -## Makefile Variable Assignment Rules - -**RULE: Do not use `:=` (simply expanded) unless you have a specific need for immediate evaluation.** - -Use `=` (recursively expanded) for standard variable assignments. Reserve `:=` for cases where the right-hand side must be evaluated exactly once at assignment time — for example, when assigning the result of a `$(call ...)` function that references the variable being set (which would cause infinite recursion with `=`). - -When a variable must also override command-line values, combine `override` with `:=` — but only where `override` is genuinely needed. - -## Debug Level Rules (lib.sh `debug` function) - -`debug LEVEL "msg"` prints when `DEBUG >= LEVEL`. LEVEL encodes how noisy/esoteric a message is — how far you'd crank `DEBUG` before you'd want to see it — **not** code nesting depth. A top-level line can warrant a high level if it's esoteric, and loop-body detail is usually high precisely because it's noisy. Judge by signal-to-noise. - -The tiers are anchors, not strict multiples of 10 — any value in range is fine, leaving room to fine-tune between existing calls without renumbering: - -- **10**: Critical errors, important warnings -- **20**: Warnings, significant state changes -- **30**: General debugging, function entry/exit, array operations -- **40**: Verbose details, loop iterations -- **50+**: Maximum verbosity (per-iteration innards) - -Note: The BATS test helper `debug` function (in `tests/lib/helpers.bash` in pgxntool-test) uses a separate 1–5 scale controlled by `$TESTDEBUG`. The two systems are independent. diff --git a/pgxntool/.github/workflows/CLAUDE.md b/pgxntool/.github/workflows/CLAUDE.md deleted file mode 100644 index 48c749c..0000000 --- a/pgxntool/.github/workflows/CLAUDE.md +++ /dev/null @@ -1,61 +0,0 @@ -# .github/workflows — CI Architecture - -## Workflow files - -- **`ci.yml`** — main CI for pgxntool pull requests. Runs `check-test-pr` (verifies - the paired pgxntool-test PR's CI passed), then optionally runs `test` (only for the - commit-with-no-tests path — see below). -- **`protect-label.yml`** — enforces that only maintainers with write access can apply - or remove the `commit-with-no-tests` label. - -## Normal CI flow (paired test PR exists) - -When a pgxntool PR has a corresponding open PR in pgxntool-test with the same branch -name, the `check-test-pr` job polls (up to 20 minutes) for that test PR's CI to -complete and pass. If it passes, pgxntool CI passes — **no tests run here**. Tests run -exactly once, in pgxntool-test's own CI. - -## commit-with-no-tests path - -When a maintainer applies the `commit-with-no-tests` label (and no paired test PR -exists), the `test` job runs tests directly in pgxntool CI against pgxntool-test/master. -This is the rare exception, not the norm. - -## Cross-repo reusable workflow — tradeoffs and constraints - -The `test` job calls a reusable workflow from pgxntool-test: -```yaml -uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@ -``` - -GitHub Actions requires the `uses:` ref to be a **static string** — expressions like -`${{ }}` are not supported in the repo/path portion or the `@ref` suffix in practice. - -### The @branch → @master ref - -While developing on a feature branch where pgxntool-test also has changes, this ref -is set to `@` so CI can find `run-tests.yml` before it lands on master. - -**IMPORTANT**: This ref must be updated to `@master` before pgxntool merges. The -correct merge order is: **pgxntool-test merges first**, then update this ref to -`@master`, then pgxntool merges. - -**For Claude**: Do NOT leave a `@` ref without explicit user approval. The -user merges directly from the PR page — there are no manual steps between merges. -See `.github/workflows/CLAUDE.md` in pgxntool-test for the full picture. - -### Changes to run-tests.yml - -`run-tests.yml` lives in pgxntool-test and is the single source of truth for all test -steps. If it changes, pgxntool's CI uses `@master` — so it won't see the new version -until pgxntool-test merges. This is acceptable because: -- Changes to `run-tests.yml` require a paired test PR (not commit-with-no-tests) -- When a paired test PR exists, pgxntool's `test` job is skipped anyway -- The two scenarios are mutually exclusive in practice - -## Label name - -The label `commit-with-no-tests` is defined as a const (`NO_TEST_LABEL`) in `ci.yml` -and as `LABEL` in `protect-label.yml`. The job-level `if:` condition in -`protect-label.yml` must also use the literal string (YAML can't reference JS consts) -— keep these in sync if the label name ever changes. diff --git a/pgxntool/.github/workflows/ci.yml b/pgxntool/.github/workflows/ci.yml deleted file mode 100644 index 9e3ce90..0000000 --- a/pgxntool/.github/workflows/ci.yml +++ /dev/null @@ -1,294 +0,0 @@ -name: CI - -on: - pull_request: - # We use 'pull_request' (not 'pull_request_target') deliberately. - # 'pull_request_target' runs with write access to the base repo, which is - # a security risk for untrusted fork code. Since this workflow only reads - # from other public repos (no secrets needed), 'pull_request' is correct - # and safe even for fork PRs. - -permissions: - contents: read # required by actions/checkout in the reusable test workflow - pull-requests: read - checks: read - -concurrency: - group: ci-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - check-test-pr: - name: Check for paired pgxntool-test PR - runs-on: ubuntu-latest - # This check polls until the paired pgxntool-test CI run completes - # (up to 20 minutes). The job timeout gives a few minutes of headroom. - timeout-minutes: 25 - outputs: - run-tests: ${{ steps.check.outputs.run_tests }} - test-ref: ${{ steps.check.outputs.test_ref }} - - steps: - - name: Find paired pgxntool-test PR or check commit-with-no-tests label - id: check - # Pinned to an immutable SHA (supply-chain hardening); comment tracks the tag. - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - # GITHUB_TOKEN is sufficient for reading public repos. If these repos - # are ever made private, replace with a PAT stored as a secret with - # 'repo' scope on both repos. Note: PAT expiration causes silent - # failures here — the API returns 401 and the job errors out instead - # of failing gracefully with a useful message. - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const branch = context.payload.pull_request.head.ref; - const prNumber = context.payload.pull_request.number; - // Single source of truth for the label name. Must also match the - // literal string in the protect-label.yml job-level `if:` condition - // (YAML expressions can't reference JS constants). - const NO_TEST_LABEL = 'commit-with-no-tests'; - - // master-to-master PRs have no paired test PR by convention. - // Run tests against pgxntool-test/master directly. - // - // If a fork PR's branch is named 'master', that's almost certainly - // a mistake (contributors should use a feature branch), but we - // don't block it — just warn visibly as an annotation on the run. - // Note: pull_request gives a read-only token for fork PRs, so we - // can't post a PR comment back to the upstream repo from here. - // Gate on the BASE branch too: this shortcut is only for - // master-to-master PRs. A PR from master into some other base must - // still go through the normal paired-test lookup below. - if (branch === 'master' && context.payload.pull_request.base.ref === 'master') { - const headRepo = context.payload.pull_request.head.repo; - const isBaseRepo = - headRepo?.owner?.login === context.repo.owner && - headRepo?.name === context.repo.repo; - if (!isBaseRepo) { - core.warning( - `PR head branch is named 'master' but comes from a fork ` + - `(${headRepo?.full_name ?? 'unknown'}). Contributors should ` + - `use a feature branch, not master. Proceeding with tests ` + - `against pgxntool-test/master.` - ); - } - core.setOutput('run_tests', 'true'); - core.setOutput('test_ref', 'master'); - return; - } - - // The owner of this PR's head repo — the contributor's fork owner - // for fork PRs, or the base repo owner for maintainer PRs. - // The paired pgxntool-test PR must come from the SAME owner. - // We never cross-match PRs across different contributors' forks. - const prOwner = context.payload.pull_request.head.repo?.owner?.login; - - // Look for open pgxntool-test PRs with the SAME branch name AND - // the same fork owner. Branch names must match exactly. - // - // The GitHub API's 'head' filter requires "owner:branch" format. - // We list all open PRs and filter locally — safe for repos with - // few open PRs, and avoids needing to know the fork repo name. - // paginate() fetches all pages automatically, so this is correct - // even if pgxntool-test ever exceeds 100 open PRs (the per_page cap). - const prs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: 'pgxntool-test', - state: 'open', - per_page: 100 - }); - - const matching = prs.filter(pr => - pr.head.ref === branch && - pr.head.repo?.owner?.login === prOwner - ); - if (matching.length > 1) { - core.setFailed( - `Multiple open pgxntool-test PRs from ${prOwner} match branch ` + - `'${branch}'. Cannot determine which one to use.\n\n` + - `Close all but one, then re-run this check.` - ); - return; - } - - const testPR = matching.length === 1 ? matching[0] : null; - - if (testPR) { - // Error if the no-test label is also set — that's contradictory. - // Re-fetch the PR live (not from payload) in case the label was - // added after this workflow was triggered. - const { data: currentPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - if (currentPR.labels.some(l => l.name === NO_TEST_LABEL)) { - core.setFailed( - `PR has the '${NO_TEST_LABEL}' label, but a paired ` + - `pgxntool-test PR #${testPR.number} exists on branch '${branch}'.\n\n` + - `Remove the '${NO_TEST_LABEL}' label — it should only be used ` + - `when there is genuinely no paired test PR.` - ); - return; - } - - // A paired test PR exists. Verify its CI passed for the exact - // current HEAD SHA and that the run is recent enough to be valid. - const sha = testPR.head.sha; - const testPRUrl = - `https://github.com/${context.repo.owner}/pgxntool-test/pull/${testPR.number}`; - const recheckUrl = - `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}/checks`; - - core.info(`Found pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)})`); - - // Poll until all check runs for the exact HEAD SHA complete. - // Using 'ref: sha' (not branch name) ensures we only see runs for - // this commit — never stale runs from an older push on the same branch. - // - // We poll rather than fail immediately because both repos are often - // pushed close together. When that happens, pgxntool CI starts while - // pgxntool-test CI may not have queued yet. We wait up to 20 minutes. - const POLL_INTERVAL_MS = 30 * 1000; - const MAX_WAIT_MS = 20 * 60 * 1000; - const waitStart = Date.now(); - let runs; - - while (true) { - // per_page: 100 is intentional here — a single commit will - // not realistically have 100+ CI check runs, so pagination - // is unnecessary. (pulls.list uses paginate() above because - // an active repo could have many open PRs.) - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: 'pgxntool-test', - ref: sha, - per_page: 100 - }); - runs = checks.check_runs; - - const incomplete = runs.filter(r => r.status !== 'completed'); - if (runs.length > 0 && incomplete.length === 0) break; - - const elapsed = Date.now() - waitStart; - if (elapsed >= MAX_WAIT_MS) { - const mins = Math.round(elapsed / 60000); - if (runs.length === 0) { - core.setFailed( - `pgxntool-test PR #${testPR.number} has no CI runs for ` + - `SHA ${sha.slice(0, 7)} after waiting ${mins} min.\n\n` + - `Push a commit (or manually re-run CI) on the test PR:\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - } else { - const names = incomplete.map(r => r.name).join(', '); - core.setFailed( - `pgxntool-test PR #${testPR.number} CI did not finish within ` + - `${mins} min for SHA ${sha.slice(0, 7)}: ${names}\n\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - } - return; - } - - if (runs.length === 0) { - core.info(`No CI runs yet for pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)}); waiting 30s...`); - } else { - const names = incomplete.map(r => r.name).join(', '); - core.info(`pgxntool-test CI still running (${names}); waiting 30s...`); - } - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - // All checks complete — look for failures. - // 'success', 'skipped', 'neutral' are non-blocking. - const failed = runs.filter( - r => !['success', 'skipped', 'neutral'].includes(r.conclusion) - ); - if (failed.length > 0) { - const names = failed.map(r => `${r.name} (${r.conclusion})`).join(', '); - core.setFailed( - `pgxntool-test PR #${testPR.number} CI failed for ` + - `SHA ${sha.slice(0, 7)}: ${names}\n\n` + - `Fix the test PR CI, then re-run this check:\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - return; - } - - core.info( - `pgxntool-test PR #${testPR.number} CI passed for ` + - `SHA ${sha.slice(0, 7)} — tests run there, not here.` - ); - core.setOutput('run_tests', 'false'); - core.setOutput('test_ref', sha); - return; - } - - // No paired test PR found. Check for the NO_TEST_LABEL label, - // which a maintainer can apply when a pgxntool change genuinely - // needs no test changes (unusual). - // - // We make a live API call rather than reading from the event - // payload. The payload is a snapshot from when this workflow was - // triggered — a maintainer may have added the label after that. - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - if (pr.labels.some(l => l.name === NO_TEST_LABEL)) { - core.info( - `'${NO_TEST_LABEL}' label is present; running tests ` + - "against pgxntool-test/master. The protect-label workflow " + - "ensures only maintainers can apply this label." - ); - core.setOutput('run_tests', 'true'); - core.setOutput('test_ref', 'master'); - return; - } - - // Neither a paired test PR nor the override label was found. - // Fail with a clear, actionable message. - core.setFailed( - `No paired pgxntool-test PR found for branch '${branch}', ` + - `and no '${NO_TEST_LABEL}' label on this PR.\n\n` + - `pgxntool changes should always be paired with matching test\n` + - `changes in pgxntool-test. This check enforces that pairing.\n\n` + - `To resolve:\n` + - ` 1. Open a PR in pgxntool-test from the SAME account (${prOwner}),\n` + - ` on a branch ALSO named '${branch}'. Both the branch name and\n` + - ` the head owner must match exactly for the pairing to work.\n\n` + - ` 2. If this pgxntool change truly needs no test updates (unusual),\n` + - ` ask a maintainer to apply the '${NO_TEST_LABEL}' label.\n` + - ` Only maintainers can apply this label. It is not a normal\n` + - ` shortcut — most pgxntool changes require test updates.\n\n` + - `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` - ); - - test: - needs: check-test-pr - if: needs.check-test-pr.outputs.run-tests == 'true' - # ----------------------------------------------------------------------- - # CROSS-REPO REUSABLE WORKFLOW — READ BEFORE CHANGING THIS REF - # See: .github/workflows/CLAUDE.md for full architecture notes. - # - # The ref must be a static string — GitHub Actions does not support - # expressions in uses:. It points at pgxntool-test's run-tests.yml on - # master. (During feature-branch development this is temporarily set to - # @ so CI can find run-tests.yml before it lands on master, and - # flipped back to @master once pgxntool-test/ has merged.) - # ----------------------------------------------------------------------- - uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@master - with: - # pgxntool: this PR's own branch, on its own account (a fork for fork PRs). - pgxntool-owner: ${{ github.event.pull_request.head.repo.owner.login }} - pgxntool-branch: ${{ github.event.pull_request.head.ref }} - # pgxntool-test: no paired test PR in this path, so use canonical master - # from Postgres-Extensions only (never a fork's master). - pgxntool-test-owner: Postgres-Extensions - pgxntool-test-ref: master diff --git a/pgxntool/.github/workflows/claude-code-review.yml b/pgxntool/.github/workflows/claude-code-review.yml deleted file mode 100644 index fc7e26c..0000000 --- a/pgxntool/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Claude Code Review - -# Runs on PRs INTO this repo. We use pull_request_target (not pull_request) so -# that PRs from a fork can access CLAUDE_CODE_OAUTH_TOKEN — GitHub withholds -# secrets from `pull_request` runs triggered by forks, which is why the plain -# `pull_request` version never worked for fork PRs. -# -# SECURITY: pull_request_target runs in the BASE repo with secrets and a -# write-capable token. The job is gated to PRs from the trusted `jnasbyupgrade` -# fork only — an arbitrary external fork can never trigger this secret-bearing -# job. The workflow file always comes from the base branch (master), so a PR -# cannot modify the reviewer that runs on it. We check out the PR head only for -# read context (persist-credentials: false) and never build or execute PR code. -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -concurrency: - group: claude-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - claude-review: - # Trusted fork only, and skip drafts (don't spend API/CI on unfinished PRs). - # To add more trusted owners, extend the head-owner check. - if: >- - github.event.pull_request.draft == false && - github.event.pull_request.head.repo.owner.login == 'jnasbyupgrade' - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read - pull-requests: write # post the review comments - checks: read # read sibling check-runs for the cost gate - steps: - # COST GATE: the paid Claude review is the last thing to run. Wait for the - # PR head's OTHER check-runs to finish and only proceed if they are clean. - # If any sibling check failed we skip the review to avoid spending money - # reviewing a PR that is already known-broken. Uniform across all repos: - # it discovers sibling checks dynamically (no per-repo workflow names). - # - decision=run : all sibling checks completed with a good conclusion, - # OR no sibling checks exist after a short grace window - # (nothing to gate on), OR the poll timed out is treated - # as skip (see below). - # - decision=skip : at least one sibling check failed/cancelled/etc, or - # we timed out waiting for still-pending checks. - # We exclude this workflow's own check-run (job name `claude-review`) so the - # gate never waits on or fails because of itself. - - name: Wait for CI; skip the paid review if any check failed - id: gate - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - SHA: ${{ github.event.pull_request.head.sha }} - run: | - decision=skip - for i in $(seq 1 72); do # ~24 min max - json=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \ - --jq '[.check_runs[] | select(.name != "claude-review")]' 2>/dev/null) || json='' - [ -z "$json" ] && { sleep 20; continue; } - total=$(jq 'length' <<<"$json") - if [ "$total" -eq 0 ]; then - [ "$i" -ge 9 ] && { decision=run; break; } # ~3 min grace: nothing to gate on - sleep 20; continue - fi - pending=$(jq '[.[]|select(.status!="completed")]|length' <<<"$json") - if [ "$pending" -eq 0 ]; then - bad=$(jq '[.[]|select((.conclusion//"")|test("^(failure|cancelled|timed_out|action_required|stale)$"))]|length' <<<"$json") - [ "$bad" -eq 0 ] && decision=run || decision=skip - break - fi - sleep 20 - done - echo "decision=$decision" >> "$GITHUB_OUTPUT" - echo "gate decision: $decision" - - - name: Check out PR head (read-only context) - if: steps.gate.outputs.decision == 'run' - # Intentionally tracks the major-version tag (not a pinned SHA) so - # upstream fixes are picked up automatically. - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 1 - persist-credentials: false - - - name: Run Claude Code Review - if: steps.gate.outputs.decision == 'run' - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # Provide github_token so the action uses it directly for GitHub API - # calls instead of the OIDC->GitHub-App-token exchange, which 401s under - # pull_request_target. GITHUB_TOKEN is repo/workflow-scoped (independent - # of the actor's role) and has pull-requests: write here. - github_token: ${{ secrets.GITHUB_TOKEN }} - # NOTE: plugin_marketplaces can't be pinned — it tracks the - # marketplace repo's default branch (upstream anthropics/claude-code). - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/pgxntool/.github/workflows/claude.yml b/pgxntool/.github/workflows/claude.yml deleted file mode 100644 index c85ec00..0000000 --- a/pgxntool/.github/workflows/claude.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -# No concurrency limit: @claude mentions are independent, read-only requests; -# serializing would only delay responses and cancelling would drop them. -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - # Intentionally tracks the major-version tag (not a pinned SHA) so - # upstream fixes are picked up automatically. - uses: actions/checkout@v4 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # Allows Claude to read CI results on PRs - additional_permissions: | - actions: read diff --git a/pgxntool/.github/workflows/protect-label.yml b/pgxntool/.github/workflows/protect-label.yml deleted file mode 100644 index de71ea3..0000000 --- a/pgxntool/.github/workflows/protect-label.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Protect 'commit-with-no-tests' label - -on: - # IMPORTANT: Must use pull_request_target, NOT pull_request. - # - # 'pull_request' from a fork runs with a read-only GITHUB_TOKEN scoped to - # the fork. It cannot add or remove labels on the upstream repo (write - # operation), and cannot call getCollaboratorPermissionLevel (requires write - # permission to the target repo). - # - # 'pull_request_target' runs in the base repo's context with a token that - # has write access — exactly what we need here. - # - # Security: because pull_request_target has write access, never check out - # or execute code from the PR head in this workflow. This workflow only calls - # the GitHub API via actions/github-script and is safe. - pull_request_target: - types: [labeled, unlabeled] - -jobs: - protect: - # Only fire for the label we care about. All other label changes are - # unaffected by this workflow. - # Note: this literal must match the LABEL const defined in the script below. - if: github.event.label.name == 'commit-with-no-tests' - runs-on: ubuntu-latest - permissions: - pull-requests: write # To add/remove labels - issues: write # GitHub label API goes through the issues endpoint - - steps: - - name: Enforce write-access-only on 'commit-with-no-tests' label - # Pinned to an immutable SHA: this workflow runs as pull_request_target - # with write access, so a moved upstream tag must not change what runs. - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const actor = context.actor; - const prNumber = context.payload.pull_request.number; - const action = context.payload.action; // 'labeled' or 'unlabeled' - // Single source of truth for the label name within this script. - // Must also match the literal in the job-level `if:` condition above - // (YAML expressions can't reference JS constants). - const LABEL = 'commit-with-no-tests'; - - // When this workflow re-adds or removes the label itself, that fires - // this event again with actor = 'github-actions[bot]'. Without this - // guard the job loops forever. We match any '[bot]' suffix to also - // cover other automation (Dependabot, Renovate, etc.). - if (actor.endsWith('[bot]')) { - core.info(`Actor is a bot (${actor}); skipping permission check`); - return; - } - - // Check the actor's effective permission level in this repo. - // - // EDGE CASE — 404 for non-collaborators: This API returns 404 when - // the user is not an explicit collaborator. This is the normal case - // for contributors who forked and opened a PR. If we don't catch - // this error, the job crashes with an unhandled exception and the - // label stays in whatever state the contributor put it in — - // defeating the entire protection. - // - // EDGE CASE — org team members: Users with write access via org - // team membership (not a direct collaborator invite) correctly show - // as 'write' here because the API returns effective permission. - // Exception: if the org has "private member visibility" set and the - // token can't enumerate team membership, they may get a 404 instead. - // If that becomes an issue, add a fallback to - // github.rest.orgs.getMembershipForUser(). - // - // EDGE CASE — other errors: Network blips, API outages, and rate - // limiting all throw here. We fail safe by treating any unexpected - // error as "no write access" and logging for debugging. - let hasWrite = false; - try { - const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: actor - }); - hasWrite = ['admin', 'write'].includes(perm.permission); - } catch (e) { - if (e.status === 404) { - // Not a collaborator — no write access. Expected and normal. - hasWrite = false; - } else { - core.warning( - `Unexpected error checking permissions for ${actor} ` + - `(HTTP ${e.status}): ${e.message}. Treating as no write access.` - ); - hasWrite = false; - } - } - - if (action === 'labeled' && !hasWrite) { - core.info(`${actor} lacks write access; removing '${LABEL}' label`); - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: LABEL - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: - `@${actor} The \`commit-with-no-tests\` label can only be applied by ` + - `maintainers with write access to this repository.\n\n` + - `If you believe no test changes are needed for this PR, please ask a ` + - `maintainer to apply the label after reviewing. Note that most pgxntool ` + - `changes do require paired test updates — this label should be used sparingly.` - }); - - } else if (action === 'unlabeled' && !hasWrite) { - // Non-writer removed the label. Put it back. - // - // EDGE CASE — brief label-absent window: There is a short window - // between removal and this workflow re-adding the label. During - // that window the label genuinely does not exist. This is harmless - // in practice: the ci.yml workflow reads labels via a live API - // call (not from its cached payload), so a re-run after the label - // is restored will pick it up correctly. - core.info(`${actor} lacks write access; re-adding '${LABEL}' label`); - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: [LABEL] - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: - `@${actor} The \`commit-with-no-tests\` label can only be removed by ` + - `maintainers with write access to this repository.\n\n` + - `Contact a maintainer if you believe this label was applied in error.` - }); - - } else if (hasWrite) { - core.info( - `${actor} has write access; '${action}' on '${LABEL}' label is approved` - ); - }