From cd05ac6a9df49f648545009899773a64b183f516 Mon Sep 17 00:00:00 2001
From: Phil Alger <paalger0@gmail.com>
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 <literal>OWNER</literal> clause is omitted.
        </para></entry>
       </row>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_get_trigger_ddl</primary>
+        </indexterm>
+        <function>pg_get_trigger_ddl</function>
+        ( <parameter>relation</parameter> <type>regclass</type>,
+        <parameter>trigger</parameter> <type>name</type>
+        <optional>, <parameter>pretty</parameter> <type>boolean</type>
+        <literal>DEFAULT</literal> false</optional> )
+        <returnvalue>setof text</returnvalue>
+       </para>
+       <para>
+        Reconstructs the <link linkend="sql-createtrigger"><command>CREATE
+        TRIGGER</command></link> statement for the specified trigger on the
+        specified relation.  The relation name is always schema-qualified.
+        When <parameter>pretty</parameter> is true, the output is
+        pretty-printed.
+       </para></entry>
+      </row>
      </tbody>
     </tgroup>
    </table>
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)

