From cd05ac6a9df49f648545009899773a64b183f516 Mon Sep 17 00:00:00 2001 From: Phil Alger Date: Fri, 3 Jul 2026 15:33:19 -0500 Subject: [PATCH v9] Add pg_get_trigger_ddl function Provide pg_get_trigger_ddl(regclass, name, [pretty]) in the pg_get_*_ddl family. It returns the CREATE TRIGGER statement for the named trigger with a trailing semicolon, based on the canonical pg_get_triggerdef() output with the relation name always schema-qualified. The pretty option lays the statement out over indented lines. The formatting is applied in ddlutils.c by a single-pass scanner over the deparsed statement; ruleutils.c is unchanged apart from a new thin pg_get_triggerdef_string() wrapper, so existing consumers of pg_get_triggerdef() are unaffected. --- doc/src/sgml/func/func-info.sgml | 20 +++ src/backend/utils/adt/ddlutils.c | 177 ++++++++++++++++++- src/backend/utils/adt/ruleutils.c | 11 ++ src/include/catalog/pg_proc.dat | 6 + src/include/utils/ruleutils.h | 1 + src/test/modules/test_misc/t/012_ddlutils.pl | 160 ++++++++++++++++- 6 files changed, 371 insertions(+), 4 deletions(-) diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml index 69ef3857cf..37ede43f5c 100644 --- a/doc/src/sgml/func/func-info.sgml +++ b/doc/src/sgml/func/func-info.sgml @@ -3960,6 +3960,26 @@ acl | {postgres=arwdDxtm/postgres,foo=r/postgres} is false, the OWNER clause is omitted. + + + + pg_get_trigger_ddl + + pg_get_trigger_ddl + ( relation regclass, + trigger name + , pretty boolean + DEFAULT false ) + setof text + + + Reconstructs the CREATE + TRIGGER statement for the specified trigger on the + specified relation. The relation name is always schema-qualified. + When pretty is true, the output is + pretty-printed. + + diff --git a/src/backend/utils/adt/ddlutils.c b/src/backend/utils/adt/ddlutils.c index a70f1c2865..14b8cd2680 100644 --- a/src/backend/utils/adt/ddlutils.c +++ b/src/backend/utils/adt/ddlutils.c @@ -5,8 +5,8 @@ * * This file contains the pg_get_*_ddl family of functions that generate * DDL statements to recreate database objects such as roles, tablespaces, - * and databases, along with common infrastructure for option parsing and - * pretty-printing. + * databases, and triggers, along with common infrastructure for option + * parsing and pretty-printing. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -28,6 +28,7 @@ #include "catalog/pg_db_role_setting.h" #include "catalog/pg_tablespace.h" #include "commands/tablespace.h" +#include "commands/trigger.h" #include "common/relpath.h" #include "funcapi.h" #include "mb/pg_wchar.h" @@ -56,6 +57,8 @@ static List *pg_get_tablespace_ddl_internal(Oid tsid, bool pretty, bool no_owner static Datum pg_get_tablespace_ddl_srf(FunctionCallInfo fcinfo, Oid tsid); static List *pg_get_database_ddl_internal(Oid dbid, bool pretty, bool no_owner, bool no_tablespace); +static char *pretty_format_trigger_ddl(const char *def); +static List *pg_get_trigger_ddl_internal(Oid trigid, bool pretty); /* @@ -975,3 +978,173 @@ pg_get_database_ddl(PG_FUNCTION_ARGS) SRF_RETURN_DONE(funcctx); } } + +/* + * pretty_format_trigger_ddl + * Lay a deparsed CREATE TRIGGER statement out over multiple lines. + */ +static char * +pretty_format_trigger_ddl(const char *def) +{ + static const char *const markers[] = { + "BEFORE", + "AFTER", + "INSTEAD OF", + "FROM", + "NOT DEFERRABLE", + "DEFERRABLE", + "REFERENCING", + "FOR EACH", + "WHEN", + "EXECUTE FUNCTION", + }; + StringInfoData buf; + int parendepth = 0; + const char *p = def; + + initStringInfo(&buf); + + while (*p) + { + char c = *p; + + if (c == '"' || c == '\'') + { + /* Copy verbatim; a doubled quote is an escape. */ + appendStringInfoChar(&buf, c); + p++; + while (*p) + { + appendStringInfoChar(&buf, *p); + if (*p == c) + { + if (p[1] == c) + { + appendStringInfoChar(&buf, p[1]); + p += 2; + continue; + } + p++; + break; + } + p++; + } + continue; + } + + if (c == '(') + parendepth++; + else if (c == ')' && parendepth > 0) + parendepth--; + else if (c == ' ' && parendepth == 0) + { + const char *marker = NULL; + + for (int i = 0; i < lengthof(markers); i++) + { + size_t len = strlen(markers[i]); + + if (strncmp(p + 1, markers[i], len) == 0 && + (p[len + 1] == ' ' || p[len + 1] == '(')) + { + marker = markers[i]; + break; + } + } + + if (marker != NULL) + { + appendStringInfoString(&buf, "\n "); + appendStringInfoString(&buf, marker); + p += strlen(marker) + 1; + continue; + } + } + + appendStringInfoChar(&buf, c); + p++; + } + + return buf.data; +} + +/* + * pg_get_trigger_ddl_internal + * Generate DDL statements to recreate a trigger. + * + * Returns a List of strings. The single element is the CREATE + * TRIGGER statement, with a trailing semicolon. + */ +static List * +pg_get_trigger_ddl_internal(Oid trigid, bool pretty) +{ + char *def; + char *pretty_def; + + def = pg_get_triggerdef_string(trigid); + if (def == NULL) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("trigger with OID %u does not exist", trigid))); + + if (pretty) + { + pretty_def = pretty_format_trigger_ddl(def); + pfree(def); + def = pretty_def; + } + + return list_make1(psprintf("%s;", def)); +} + +/* + * pg_get_trigger_ddl + * Return DDL to recreate a trigger, identified by relation and + * trigger name. + */ +Datum +pg_get_trigger_ddl(PG_FUNCTION_ARGS) +{ + FuncCallContext *funcctx; + List *statements; + + if (SRF_IS_FIRSTCALL()) + { + MemoryContext oldcontext; + Oid relid; + Name trigname; + Oid trigid; + bool pretty; + + funcctx = SRF_FIRSTCALL_INIT(); + oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx); + + relid = PG_GETARG_OID(0); + trigname = PG_GETARG_NAME(1); + pretty = PG_GETARG_BOOL(2); + trigid = get_trigger_oid(relid, NameStr(*trigname), false); + + statements = pg_get_trigger_ddl_internal(trigid, pretty); + funcctx->user_fctx = statements; + funcctx->max_calls = list_length(statements); + + MemoryContextSwitchTo(oldcontext); + } + + funcctx = SRF_PERCALL_SETUP(); + statements = (List *) funcctx->user_fctx; + + if (funcctx->call_cntr < funcctx->max_calls) + { + char *stmt; + + stmt = list_nth(statements, funcctx->call_cntr); + + SRF_RETURN_NEXT(funcctx, CStringGetTextDatum(stmt)); + } + else + { + list_free_deep(statements); + SRF_RETURN_DONE(funcctx); + } +} diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index 0ea38c18ca..6313ac7e42 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -1175,6 +1175,17 @@ pg_get_triggerdef_worker(Oid trigid, bool pretty) return buf.data; } +/* + * pg_get_triggerdef_string + * Return the CREATE TRIGGER statement for a trigger, as a + * string, or NULL if there is no such trigger. + */ +char * +pg_get_triggerdef_string(Oid trigid) +{ + return pg_get_triggerdef_worker(trigid, false); +} + /* ---------- * pg_get_indexdef - Get the definition of an index * diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 1c55a4dea3..cb56f97b94 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -8631,6 +8631,12 @@ proargtypes => 'regdatabase bool bool bool', proargnames => '{database,pretty,owner,tablespace}', proargdefaults => '{false,true,true}', prosrc => 'pg_get_database_ddl' }, +{ oid => '8593', descr => 'get DDL to recreate a trigger', + proname => 'pg_get_trigger_ddl', prorows => '10', proretset => 't', + provolatile => 's', pronargdefaults => '1', prorettype => 'text', + proargtypes => 'regclass name bool', + proargnames => '{relation,trigger,pretty}', proargdefaults => '{false}', + prosrc => 'pg_get_trigger_ddl' }, { oid => '2509', descr => 'deparse an encoded expression with pretty-print option', proname => 'pg_get_expr', provolatile => 's', prorettype => 'text', diff --git a/src/include/utils/ruleutils.h b/src/include/utils/ruleutils.h index 25c05e2f64..2bba5eb2e7 100644 --- a/src/include/utils/ruleutils.h +++ b/src/include/utils/ruleutils.h @@ -54,5 +54,6 @@ extern char *get_range_partbound_string(List *bound_datums); extern void get_reloptions(StringInfo buf, Datum reloptions); extern char *pg_get_statisticsobjdef_string(Oid statextid); +extern char *pg_get_triggerdef_string(Oid trigid); #endif /* RULEUTILS_H */ diff --git a/src/test/modules/test_misc/t/012_ddlutils.pl b/src/test/modules/test_misc/t/012_ddlutils.pl index c409687992..8ca4b6a7d2 100644 --- a/src/test/modules/test_misc/t/012_ddlutils.pl +++ b/src/test/modules/test_misc/t/012_ddlutils.pl @@ -1,8 +1,8 @@ # Copyright (c) 2026, PostgreSQL Global Development Group -# Tests for pg_get_database_ddl(), pg_get_tablespace_ddl(), and -# pg_get_role_ddl(). These are TAP tests rather than plain regression +# Tests for pg_get_database_ddl(), pg_get_tablespace_ddl(), pg_get_role_ddl(), +# and pg_get_trigger_ddl(). These are TAP tests rather than plain regression # tests because they create databases and tablespaces, which are # heavyweight operations that should run only once rather than being # repeated with every invocation of the core regression suite. @@ -336,6 +336,162 @@ $node->safe_psql( 'postgres', q{ GRANT SELECT ON pg_tablespace TO PUBLIC}); +######################################################################## +# pg_get_trigger_ddl tests +######################################################################## + +$node->safe_psql( + 'postgres', q{ + CREATE FUNCTION regress_trg_ddl_func() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + CREATE TABLE regress_trg_ddl_tab (id int, note text, "before" int); + CREATE TRIGGER regress_trg_ddl_basic + BEFORE INSERT ON regress_trg_ddl_tab + FOR EACH ROW EXECUTE FUNCTION regress_trg_ddl_func(); + CREATE TRIGGER regress_trg_ddl_when + BEFORE UPDATE OF id ON regress_trg_ddl_tab + FOR EACH ROW WHEN (OLD.id IS DISTINCT FROM NEW.id) + EXECUTE FUNCTION regress_trg_ddl_func()}); + +my $trig_oid = $node->safe_psql( + 'postgres', q{ + SELECT t.oid FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + WHERE t.tgname = 'regress_trg_ddl_basic' + AND c.relname = 'regress_trg_ddl_tab'}); + +# Explicit pretty => false gives the canonical pg_get_triggerdef form +# plus a trailing semicolon. +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_basic', false)}); +my $plain = $node->safe_psql('postgres', + qq{SELECT pg_get_triggerdef($trig_oid, false) || ';'}); +is($result, $plain, 'trigger DDL matches pg_get_triggerdef plus semicolon'); +like($result, qr/ ON public\.regress_trg_ddl_tab /, + 'trigger DDL schema-qualifies the table'); + +# Explicit regclass cast works too +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab'::regclass, 'regress_trg_ddl_basic', false)}); +is($result, $plain, 'trigger DDL by regclass matches'); + +# Omitting pretty defaults to false +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_basic')}); +is($result, $plain, 'omitting pretty defaults to false'); + +# Non-existent trigger name errors +($ret, $stdout, $stderr) = $node->psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_missing')}); +isnt($ret, 0, 'non-existent trigger name errors'); +like($stderr, qr/does not exist/, 'non-existent trigger error message'); + +# NULL inputs return no rows +$result = $node->safe_psql('postgres', + q{SELECT count(*) FROM pg_get_trigger_ddl(NULL::regclass, 'regress_trg_ddl_basic')}); +is($result, '0', 'NULL relation returns no rows'); +$result = $node->safe_psql('postgres', + q{SELECT count(*) FROM pg_get_trigger_ddl('regress_trg_ddl_tab', NULL::name)}); +is($result, '0', 'NULL trigger name returns no rows'); + + +# --- pretty layout --- + +$node->safe_psql( + 'postgres', q{ + CREATE TABLE regress_trg_ddl_ref (id int PRIMARY KEY); + CREATE VIEW regress_trg_ddl_view AS SELECT * FROM regress_trg_ddl_tab; + CREATE TRIGGER regress_trg_ddl_trans + AFTER INSERT ON regress_trg_ddl_tab + REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION regress_trg_ddl_func(); + CREATE CONSTRAINT TRIGGER regress_trg_ddl_con + AFTER INSERT ON regress_trg_ddl_tab + FROM regress_trg_ddl_ref + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION regress_trg_ddl_func(); + CREATE TRIGGER regress_trg_ddl_instead + INSTEAD OF INSERT ON regress_trg_ddl_view + FOR EACH ROW EXECUTE FUNCTION regress_trg_ddl_func(); + CREATE TRIGGER "when" + BEFORE UPDATE OF "before" ON regress_trg_ddl_tab + FOR EACH ROW WHEN (NEW.note <> ' EXECUTE FUNCTION ') + EXECUTE FUNCTION regress_trg_ddl_func('FOR EACH ')}); + +# Exact pretty layout for a simple trigger +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_basic', true)}); +is( $result, + qq{CREATE TRIGGER regress_trg_ddl_basic + BEFORE INSERT ON public.regress_trg_ddl_tab + FOR EACH ROW + EXECUTE FUNCTION regress_trg_ddl_func();}, + 'pretty trigger DDL layout for simple trigger'); + +# 'pretty' accepts an explicit false +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_basic', false)}); +is($result, $plain, 'pretty => false gives the single-line form'); + +# Invariant: collapsing pretty whitespace reproduces the canonical form. +# This holds for every trigger, so check it across all of them. +foreach my $pair ( + [ 'regress_trg_ddl_tab', 'regress_trg_ddl_basic' ], + [ 'regress_trg_ddl_tab', 'regress_trg_ddl_when' ], + [ 'regress_trg_ddl_tab', 'regress_trg_ddl_trans' ], + [ 'regress_trg_ddl_tab', 'regress_trg_ddl_con' ], + [ 'regress_trg_ddl_view', 'regress_trg_ddl_instead' ], + [ 'regress_trg_ddl_tab', 'when' ]) +{ + my ($rel, $tg) = @$pair; + my $pretty = $node->safe_psql('postgres', + qq{SELECT * FROM pg_get_trigger_ddl('$rel', '$tg', true)}); + my $single = $node->safe_psql('postgres', + qq{SELECT * FROM pg_get_trigger_ddl('$rel', '$tg', false)}); + (my $collapsed = $pretty) =~ s/\n / /g; + is($collapsed, $single, "pretty output for $tg collapses to canonical form"); +} + +# WHEN clause: the IS DISTINCT FROM inside parens must not be split +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_when', true)}); +like($result, qr/\n WHEN \(/, 'pretty WHEN clause on its own line'); +like($result, qr/IS DISTINCT FROM new\.id/, + 'FROM inside WHEN expression not split'); + +# Transition tables and constraint trigger clauses each get a line +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_trans', true)}); +like($result, qr/\n REFERENCING NEW TABLE AS newtab/, + 'pretty REFERENCING clause on its own line'); +like($result, qr/\n FOR EACH STATEMENT/, + 'pretty FOR EACH STATEMENT on its own line'); +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'regress_trg_ddl_con', true)}); +like($result, qr/\n FROM .*regress_trg_ddl_ref/, + 'pretty FROM clause on its own line'); +like($result, qr/\n DEFERRABLE INITIALLY DEFERRED/, + 'pretty DEFERRABLE clause on its own line'); + +# INSTEAD OF triggers (on a view) +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_view', 'regress_trg_ddl_instead', true)}); +like($result, qr/\n INSTEAD OF INSERT ON public\.regress_trg_ddl_view/, + 'pretty INSTEAD OF clause on its own line'); + +# Adversarial: keyword-named identifiers are quoted and must not attract +# breaks; clause keywords inside string literals must survive intact. +$result = $node->safe_psql('postgres', + q{SELECT * FROM pg_get_trigger_ddl('regress_trg_ddl_tab', 'when', true)}); +like($result, qr/^CREATE TRIGGER "when"$/m, + 'quoted keyword trigger name stays on the first line'); +like($result, qr/\n BEFORE UPDATE OF before ON public\.regress_trg_ddl_tab/, + 'lowercase unreserved-keyword column does not attract a line break'); +like($result, qr/ EXECUTE FUNCTION '/, + 'marker text inside WHEN string literal not split'); +like($result, qr/\('FOR EACH '\)/, + 'marker text inside trigger argument literal not split'); + $node->stop; done_testing(); -- 2.50.1 (Apple Git-155)