From 0e91039d61f64974b49175f385aa0e740b292146 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Fri, 10 Jul 2026 01:34:47 -0700
Subject: [PATCH v2 2/4] Add regression tests for DDL deparse.

The previous commit added the deparser with no caller in the tree, so
nothing checks that what it produces rebuilds what the original command
built.  The test module does that by replacing each supported command
with its deparsed reconstruction and letting the regression tests judge
the result.  test_ddl_deparse intercepts a top-level command in a
ProcessUtility hook, runs the original in an internal subtransaction
that is rolled back, deparses the commands collected for it from a
ddl_command_end event trigger, and then executes the reconstruction for
real.  A divergence between the two forms therefore shows up as an
ordinary regression diff, at the statement that caused it.

The trigger function needs the collected commands as CollectedCommand,
not as the rows pg_event_trigger_ddl_commands() returns, so
EventTriggerGetCollectedCommands() is added for it in event_trigger.c.

001_deparse_regress.pl replays the whole core regression suite that
way, against a cluster with the module preloaded.  That is where the
coverage of ordinary syntax comes from, and it is also how a gap
becomes visible: a command the deparser declines executes unchanged and
is therefore not exercised at all, so the module logs it and the test
reports the counts per command tag.  We print those counts rather than
assert on them, since they move with every change to the core tests and
since a declined command is not by itself a defect.  The event_trigger
test is excluded from the replay, because it lists every event trigger
in the database and the module installs one.

Author:
Reviewed-by:
Discussion: https://postgr.es/m/
---
 src/backend/commands/event_trigger.c          |  18 +
 src/include/commands/event_trigger.h          |   1 +
 src/test/modules/test_ddl_deparse/Makefile    |  14 +-
 .../expected/deparse_create_table.out         | 350 ++++++++++++
 src/test/modules/test_ddl_deparse/meson.build |   7 +
 .../sql/deparse_create_table.sql              | 216 ++++++++
 .../test_ddl_deparse/t/001_deparse_regress.pl | 130 +++++
 .../test_ddl_deparse--1.0.sql                 |  21 +-
 .../test_ddl_deparse/test_ddl_deparse.c       | 521 ++++++++++++++++++
 9 files changed, 1273 insertions(+), 5 deletions(-)
 create mode 100644 src/test/modules/test_ddl_deparse/expected/deparse_create_table.out
 create mode 100644 src/test/modules/test_ddl_deparse/sql/deparse_create_table.sql
 create mode 100644 src/test/modules/test_ddl_deparse/t/001_deparse_regress.pl

diff --git a/src/backend/commands/event_trigger.c b/src/backend/commands/event_trigger.c
index 08f8f4c3f24..004960df065 100644
--- a/src/backend/commands/event_trigger.c
+++ b/src/backend/commands/event_trigger.c
@@ -1708,6 +1708,24 @@ EventTriggerUndoInhibitCommandCollection(void)
 	currentEventTriggerState->commandCollectionInhibited = false;
 }
 
+/*
+ * Return the commands collected for the complete query now running, a list of
+ * CollectedCommand.
+ *
+ * This is the C-level counterpart of pg_event_trigger_ddl_commands(), for
+ * consumers that need the commands as they are rather than as a set of rows;
+ * deparsing them is the motivating case.  The list belongs to the event
+ * trigger state and is only valid until the current complete query ends.
+ */
+List *
+EventTriggerGetCollectedCommands(void)
+{
+	if (!currentEventTriggerState)
+		return NIL;
+
+	return currentEventTriggerState->commandList;
+}
+
 /*
  * EventTriggerCollectSimpleCommand
  *		Save data about a simple DDL command that was just executed
diff --git a/src/include/commands/event_trigger.h b/src/include/commands/event_trigger.h
index 27340655061..9126aafd113 100644
--- a/src/include/commands/event_trigger.h
+++ b/src/include/commands/event_trigger.h
@@ -72,6 +72,7 @@ extern void EventTriggerSQLDropAddObject(const ObjectAddress *object,
 
 extern void EventTriggerInhibitCommandCollection(void);
 extern void EventTriggerUndoInhibitCommandCollection(void);
+extern List *EventTriggerGetCollectedCommands(void);
 
 extern void EventTriggerCollectSimpleCommand(ObjectAddress address,
 											 ObjectAddress secondaryObject,
diff --git a/src/test/modules/test_ddl_deparse/Makefile b/src/test/modules/test_ddl_deparse/Makefile
index f91a78d8d92..9ee45557338 100644
--- a/src/test/modules/test_ddl_deparse/Makefile
+++ b/src/test/modules/test_ddl_deparse/Makefile
@@ -1,6 +1,9 @@
 # src/test/modules/test_ddl_deparse/Makefile
 
-MODULES = test_ddl_deparse
+MODULE_big = test_ddl_deparse
+OBJS = \
+	$(WIN32RES) \
+	test_ddl_deparse.o
 PGFILEDESC = "test_ddl_deparse - regression testing for DDL deparsing"
 
 EXTENSION = test_ddl_deparse
@@ -28,10 +31,17 @@ REGRESS = test_ddl_deparse \
 	opfamily \
 	defprivs \
 	textsearch \
-	matviews
+	matviews \
+	deparse_create_table
+
+TAP_TESTS = 1
 
 EXTRA_INSTALL = contrib/pg_stat_statements
 
+# needed by the TAP test to locate regress.so for the core regression run
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
 ifdef USE_PGXS
 PG_CONFIG = pg_config
 PGXS := $(shell $(PG_CONFIG) --pgxs)
diff --git a/src/test/modules/test_ddl_deparse/expected/deparse_create_table.out b/src/test/modules/test_ddl_deparse/expected/deparse_create_table.out
new file mode 100644
index 00000000000..42441925d41
--- /dev/null
+++ b/src/test/modules/test_ddl_deparse/expected/deparse_create_table.out
@@ -0,0 +1,350 @@
+--
+-- Corner cases for DDL deparsing of CREATE TABLE.
+--
+-- test_ddl_deparse intercepts each supported command, runs the original in a
+-- subtransaction that is rolled back, deparses the collected command, and
+-- executes the reconstruction for real.  The deparsed text is reported as a
+-- NOTICE, so the expected output pins it.
+--
+-- Ordinary syntax is not repeated here.  001_deparse_regress.pl replays the
+-- whole core regression suite through the deparser and covers that, reporting
+-- any divergence as a regression diff.  What a replay cannot see is a change
+-- in the deparsed text that leaves the catalogs as they were, since the state
+-- it arrives at is the same either way.  The cases below are the ones whose
+-- rule is of that kind, plus the few the core tests contain no statement for.
+--
+-- The event triggers created by the test_ddl_deparse script, which runs
+-- first in this schedule, are still installed.  Their "DDL test:" NOTICEs
+-- appear below too.
+--
+LOAD 'test_ddl_deparse';
+SET test_ddl_deparse.execute_deparsed_ddl = on;
+SET test_ddl_deparse.print_deparsed_ddl = text;
+CREATE SCHEMA deparse_tests;
+NOTICE:  DDL test: type simple, tag CREATE SCHEMA
+-- A serial column is emitted as the user spelled it.  Expanding it into an
+-- integer column plus a sequence, or normalizing serial4 to serial, arrives at
+-- the same catalog state.
+CREATE TABLE deparse_tests.t_serial (
+a serial,
+b serial4,
+c bigserial,
+d smallserial
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_serial (a serial, b serial4, c bigserial, d smallserial)
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+-- COMPRESSION and STORAGE are emitted only where the user wrote them.
+-- Emitting the effective value instead would leave attcompression and
+-- attstorage exactly as they are.
+CREATE TABLE deparse_tests.t_options (
+a text COMPRESSION pglz,
+b text COMPRESSION default,
+c text STORAGE external,
+d text STORAGE main,
+e text
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_options (a pg_catalog.text COMPRESSION pglz, b pg_catalog.text COMPRESSION "default", c pg_catalog.text STORAGE external, d pg_catalog.text STORAGE main, e pg_catalog.text)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+SELECT attname, attcompression, attstorage
+  FROM pg_attribute
+  WHERE attrelid = 'deparse_tests.t_options'::regclass AND attnum > 0
+  ORDER BY attnum;
+ attname | attcompression | attstorage 
+---------+----------------+------------
+ a       | p              | x
+ b       |                | x
+ c       |                | e
+ d       |                | m
+ e       |                | x
+(5 rows)
+
+-- A constraint the user did not name comes back unnamed, so that the replay
+-- derives the name the way the original did.  The derived name is the same
+-- either way, so the catalogs match whether or not the name is emitted.
+-- Inline constraints are normalized to table constraints.
+CREATE TABLE deparse_tests.t_constr (
+a int PRIMARY KEY,
+b int REFERENCES deparse_tests.t_constr (a) NOT NULL,
+c text DEFAULT 'x' CHECK (c <> ''),
+CONSTRAINT named_check CHECK (a > 0)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_constr (a pg_catalog.int4, b pg_catalog.int4 NOT NULL, c pg_catalog.text DEFAULT 'x'::text, CHECK ((c <> ''::text)), CONSTRAINT named_check CHECK ((a > 0)), PRIMARY KEY (a), FOREIGN KEY (b) REFERENCES deparse_tests.t_constr(a))
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+NOTICE:  DDL test: type alter table, tag ALTER TABLE
+NOTICE:    subcommand: type ADD CONSTRAINT (and recurse) desc constraint t_constr_b_fkey on table deparse_tests.t_constr
+SELECT conname, contype, pg_get_constraintdef(oid)
+  FROM pg_constraint
+  WHERE conrelid = 'deparse_tests.t_constr'::regclass
+  ORDER BY conname COLLATE "C";
+       conname       | contype |                 pg_get_constraintdef                 
+---------------------+---------+------------------------------------------------------
+ named_check         | c       | CHECK ((a > 0))
+ t_constr_a_not_null | n       | NOT NULL a
+ t_constr_b_fkey     | f       | FOREIGN KEY (b) REFERENCES deparse_tests.t_constr(a)
+ t_constr_b_not_null | n       | NOT NULL b
+ t_constr_c_check    | c       | CHECK ((c <> ''::text))
+ t_constr_pkey       | p       | PRIMARY KEY (a)
+(6 rows)
+
+-- A DEFAULT whose cooked expression is a plain NULL constant is not stored in
+-- pg_attrdef, so dropping it from the output would leave atthasdef unset too.
+CREATE TABLE deparse_tests.t_defnull (a int DEFAULT NULL, b text DEFAULT NULL::text);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_defnull (a pg_catalog.int4 DEFAULT NULL, b pg_catalog.text DEFAULT NULL)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+-- The temp schema is emitted as pg_temp rather than as this backend's own
+-- pg_temp_<n>, which would resolve to the same schema on replay.
+CREATE TEMP TABLE t_temp (a int);
+NOTICE:  deparsed DDL: CREATE TEMPORARY TABLE pg_temp.t_temp (a pg_catalog.int4)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+DROP TABLE t_temp;
+-- Identity sequence options that parse analysis consumes are still in the raw
+-- tree.  SEQUENCE NAME has to be emitted; OWNED BY is dropped, since
+-- execution overrides it.  The core tests use neither.
+CREATE TABLE deparse_tests.t_seqname (
+id int GENERATED ALWAYS AS IDENTITY
+	(SEQUENCE NAME deparse_tests.t_seqname_seq START WITH 5)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_seqname (id pg_catalog.int4 GENERATED ALWAYS AS IDENTITY (SEQUENCE NAME deparse_tests.t_seqname_seq START WITH 5))
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+SELECT relname FROM pg_class WHERE oid = 'deparse_tests.t_seqname_seq'::regclass;
+    relname    
+---------------
+ t_seqname_seq
+(1 row)
+
+CREATE TABLE deparse_tests.t_ownedby (
+id int GENERATED BY DEFAULT AS IDENTITY (OWNED BY NONE)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_ownedby (id pg_catalog.int4 GENERATED BY DEFAULT AS IDENTITY)
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+-- Types whose typmod is spelled inside the name.  The core tests have no
+-- column of either type.
+CREATE TABLE deparse_tests.t_types (
+a interval hour to minute,
+b timestamp(3) with time zone
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_types (a INTERVAL hour to minute, b TIMESTAMP(3) with time zone)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+-- A LIKE clause is expanded against the source table before execution, so the
+-- columns it contributes come back from the catalog.  Emitting the LIKE clause
+-- verbatim instead would build the same table wherever the source exists.
+CREATE TABLE deparse_tests.t_like_src (
+a int NOT NULL,
+b text COLLATE "C",
+c numeric(10,2) STORAGE plain,
+d int DEFAULT 7,
+e int GENERATED ALWAYS AS (d * 2) STORED
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like_src (a pg_catalog.int4 NOT NULL, b pg_catalog.text COLLATE pg_catalog."C", c pg_catalog."numeric"(10,2) STORAGE plain, d pg_catalog.int4 DEFAULT 7, e pg_catalog.int4 GENERATED ALWAYS AS ((d * 2)) STORED)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+CREATE TABLE deparse_tests.t_like (LIKE deparse_tests.t_like_src);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like (a pg_catalog.int4 CONSTRAINT t_like_src_a_not_null NOT NULL, b pg_catalog.text COLLATE pg_catalog."C", c pg_catalog."numeric"(10,2), d pg_catalog.int4, e pg_catalog.int4)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+-- written-out and LIKE columns interleave in catalog order
+CREATE TABLE deparse_tests.t_like_mixed (
+x serial,
+LIKE deparse_tests.t_like_src,
+y int DEFAULT 1
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like_mixed (x serial, a pg_catalog.int4 CONSTRAINT t_like_src_a_not_null NOT NULL, b pg_catalog.text COLLATE pg_catalog."C", c pg_catalog."numeric"(10,2), d pg_catalog.int4, e pg_catalog.int4, y pg_catalog.int4 DEFAULT 1)
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+-- the options that land in the table definition are all recovered
+CREATE TABLE deparse_tests.t_like_incl (
+LIKE deparse_tests.t_like_src
+	INCLUDING DEFAULTS INCLUDING GENERATED INCLUDING CONSTRAINTS
+	INCLUDING STORAGE INCLUDING COMPRESSION
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like_incl (a pg_catalog.int4 CONSTRAINT t_like_src_a_not_null NOT NULL, b pg_catalog.text COLLATE pg_catalog."C", c pg_catalog."numeric"(10,2) STORAGE PLAIN, d pg_catalog.int4 DEFAULT 7, e pg_catalog.int4 GENERATED ALWAYS AS ((d * 2)) STORED)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type alter table, tag ALTER TABLE
+NOTICE:    subcommand: type ALTER COLUMN SET DEFAULT (precooked) desc column d of table deparse_tests.t_like_incl
+NOTICE:    subcommand: type ALTER COLUMN SET DEFAULT (precooked) desc column e of table deparse_tests.t_like_incl
+-- identity comes back with the sequence parameters that were copied
+CREATE TABLE deparse_tests.t_like_idsrc (
+a int GENERATED BY DEFAULT AS IDENTITY (START WITH 10 INCREMENT BY 5)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like_idsrc (a pg_catalog.int4 GENERATED BY DEFAULT AS IDENTITY (START WITH 10 INCREMENT BY 5))
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+CREATE TABLE deparse_tests.t_like_ident
+  (LIKE deparse_tests.t_like_idsrc INCLUDING IDENTITY);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_like_ident (a pg_catalog.int4 CONSTRAINT t_like_idsrc_a_not_null NOT NULL GENERATED BY DEFAULT AS IDENTITY (CACHE 1 NO CYCLE INCREMENT BY 5 MAXVALUE 2147483647 MINVALUE 1 START WITH 10))
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+-- LIKE options that copy a separate object are declined
+CREATE INDEX ON deparse_tests.t_like_src (a);
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+CREATE TABLE deparse_tests.t_like_idx
+  (LIKE deparse_tests.t_like_src INCLUDING INDEXES);
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+CREATE TABLE deparse_tests.t_like_all
+  (LIKE deparse_tests.t_like_src INCLUDING ALL);
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type alter table, tag ALTER TABLE
+NOTICE:    subcommand: type ALTER COLUMN SET DEFAULT (precooked) desc column d of table deparse_tests.t_like_all
+NOTICE:    subcommand: type ALTER COLUMN SET DEFAULT (precooked) desc column e of table deparse_tests.t_like_all
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+-- USING INDEX TABLESPACE stays with the constraint that carried it, including
+-- after parse analysis has merged redundant constraints.  Every index here
+-- lands in pg_default either way, so a misattribution is invisible in the
+-- catalogs.
+CREATE TABLE deparse_tests.t_idxtblspc (
+a int,
+b int,
+c int,
+UNIQUE (a),
+UNIQUE (b) USING INDEX TABLESPACE pg_default,
+PRIMARY KEY (c)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_idxtblspc (a pg_catalog.int4, b pg_catalog.int4, c pg_catalog.int4, PRIMARY KEY (c), UNIQUE (a), UNIQUE (b) USING INDEX TABLESPACE pg_default)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+CREATE TABLE deparse_tests.t_dedup (
+a int,
+b int,
+UNIQUE (a),
+UNIQUE (a),
+UNIQUE (b) USING INDEX TABLESPACE pg_default,
+PRIMARY KEY (a)
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_dedup (a pg_catalog.int4, b pg_catalog.int4, PRIMARY KEY (a), UNIQUE (b) USING INDEX TABLESPACE pg_default)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+-- Index storage parameters of an index-backed constraint, which
+-- pg_get_constraintdef omits for PRIMARY KEY and UNIQUE but already includes
+-- for EXCLUDE.  No constraint in the core tests carries them.
+CREATE TABLE deparse_tests.t_idxopts (
+a int,
+b int,
+UNIQUE (a) WITH (fillfactor = 90),
+PRIMARY KEY (b) WITH (fillfactor = 80, deduplicate_items = off)
+	USING INDEX TABLESPACE pg_default DEFERRABLE
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_idxopts (a pg_catalog.int4, b pg_catalog.int4, PRIMARY KEY (b) WITH (fillfactor = '80', deduplicate_items = 'off') USING INDEX TABLESPACE pg_default DEFERRABLE, UNIQUE (a) WITH (fillfactor = '90'))
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+SELECT c.relname, c.reloptions
+  FROM pg_class c
+  WHERE c.relname IN ('t_idxopts_a_key', 't_idxopts_pkey')
+  ORDER BY 1;
+     relname     |              reloptions               
+-----------------+---------------------------------------
+ t_idxopts_a_key | {fillfactor=90}
+ t_idxopts_pkey  | {fillfactor=80,deduplicate_items=off}
+(2 rows)
+
+CREATE TABLE deparse_tests.t_exclopts (
+c circle,
+EXCLUDE USING gist (c WITH &&) WITH (fillfactor = 70)
+	USING INDEX TABLESPACE pg_default DEFERRABLE
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_exclopts (c pg_catalog.circle, EXCLUDE USING gist (c WITH &&) WITH (fillfactor='70') USING INDEX TABLESPACE pg_default DEFERRABLE)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+SELECT c.relname, c.reloptions
+  FROM pg_class c
+  JOIN pg_constraint con ON con.conindid = c.oid
+  WHERE con.conrelid = 'deparse_tests.t_exclopts'::regclass;
+      relname      |   reloptions    
+-------------------+-----------------
+ t_exclopts_c_excl | {fillfactor=70}
+(1 row)
+
+-- An exclusion predicate that ruleutils renders across several lines, which
+-- the deparser has to split off the definition and put back.  The core tests
+-- have no such predicate.
+CREATE TABLE deparse_tests.t_exclcase (
+c circle,
+n int,
+EXCLUDE USING gist (c WITH &&)
+	USING INDEX TABLESPACE pg_default
+	WHERE (CASE WHEN n > 0 THEN true ELSE false END)
+	DEFERRABLE
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_exclcase (c pg_catalog.circle, n pg_catalog.int4, EXCLUDE USING gist (c WITH &&) USING INDEX TABLESPACE pg_default WHERE (
+CASE
+    WHEN (n > 0) THEN true
+    ELSE false
+END) DEFERRABLE)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag CREATE INDEX
+-- Format-string metacharacters and embedded quotes must pass through the fmt
+-- expansion inertly.  No name in the core tests contains them.
+CREATE TABLE deparse_tests."evil %{name}I table" (
+"a""b" int,
+"%{col}s" text DEFAULT 'O''Reilly',
+"100%" numeric
+);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests."evil %{name}I table" ("a""b" pg_catalog.int4, "%{col}s" pg_catalog.text DEFAULT 'O''Reilly'::text, "100%" pg_catalog."numeric")
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+-- IF NOT EXISTS on an existing relation deparses to nothing
+CREATE TABLE IF NOT EXISTS deparse_tests.t_serial (a int);
+NOTICE:  relation "t_serial" already exists, skipping
+-- printing works whether or not the deparsed version is executed
+SET test_ddl_deparse.execute_deparsed_ddl = off;
+CREATE TABLE deparse_tests.t_printonly (a int);
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_printonly (a pg_catalog.int4)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+-- the JSON blob itself can be printed, alone or along with the command
+SET test_ddl_deparse.print_deparsed_ddl = json;
+CREATE TABLE deparse_tests.t_json (id serial);
+NOTICE:  deparsed JSON: {"tag": "CREATE TABLE", "command": {"fmt": "CREATE%{persistence}s TABLE%{if_not_exists}s %{identity}D%{of_type}s%{partition_of}s%{table_elements}s%{inherits}s%{partition_bound}s%{partition_by}s%{access_method}s%{with_clause}s%{on_commit}s%{tablespace}s", "of_type": null, "identity": {"objname": "t_json", "schemaname": "deparse_tests"}, "inherits": null, "on_commit": null, "tablespace": null, "persistence": null, "with_clause": null, "partition_by": null, "partition_of": null, "access_method": null, "if_not_exists": null, "table_elements": {"fmt": " (%{elements:, }s)", "elements": [{"fmt": "%{name}I %{coltype}T%{storage}s%{compression}s%{collation}s%{not_null}s%{default}s%{identity_column}s%{generated_column}s", "name": "id", "type": "column", "coltype": {"typmod": "", "typarray": false, "typename": "serial", "schemaname": ""}, "default": null, "storage": null, "not_null": null, "collation": null, "compression": null, "identity_column": null, "generated_column": null}]}, "partition_bound": null}}
+NOTICE:  DDL test: type simple, tag CREATE SEQUENCE
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+NOTICE:  DDL test: type simple, tag ALTER SEQUENCE
+SET test_ddl_deparse.print_deparsed_ddl = 'both';
+SET test_ddl_deparse.execute_deparsed_ddl = on;
+CREATE TABLE deparse_tests.t_both (a int DEFAULT 42);
+NOTICE:  deparsed JSON: {"tag": "CREATE TABLE", "command": {"fmt": "CREATE%{persistence}s TABLE%{if_not_exists}s %{identity}D%{of_type}s%{partition_of}s%{table_elements}s%{inherits}s%{partition_bound}s%{partition_by}s%{access_method}s%{with_clause}s%{on_commit}s%{tablespace}s", "of_type": null, "identity": {"objname": "t_both", "schemaname": "deparse_tests"}, "inherits": null, "on_commit": null, "tablespace": null, "persistence": null, "with_clause": null, "partition_by": null, "partition_of": null, "access_method": null, "if_not_exists": null, "table_elements": {"fmt": " (%{elements:, }s)", "elements": [{"fmt": "%{name}I %{coltype}T%{storage}s%{compression}s%{collation}s%{not_null}s%{default}s%{identity_column}s%{generated_column}s", "name": "a", "type": "column", "coltype": {"typmod": "", "typarray": false, "typename": "int4", "schemaname": "pg_catalog"}, "default": {"fmt": " DEFAULT %{default_expr}s", "default_expr": "42"}, "storage": null, "not_null": null, "collation": null, "compression": null, "identity_column": null, "generated_column": null}]}, "partition_bound": null}}
+NOTICE:  deparsed DDL: CREATE TABLE deparse_tests.t_both (a pg_catalog.int4 DEFAULT 42)
+NOTICE:  DDL test: type simple, tag CREATE TABLE
+RESET test_ddl_deparse.print_deparsed_ddl;
+RESET test_ddl_deparse.execute_deparsed_ddl;
+DROP SCHEMA deparse_tests CASCADE;
+NOTICE:  drop cascades to 24 other objects
+DETAIL:  drop cascades to table deparse_tests.t_serial
+drop cascades to table deparse_tests.t_options
+drop cascades to table deparse_tests.t_constr
+drop cascades to table deparse_tests.t_defnull
+drop cascades to table deparse_tests.t_seqname
+drop cascades to table deparse_tests.t_ownedby
+drop cascades to table deparse_tests.t_types
+drop cascades to table deparse_tests.t_like_src
+drop cascades to table deparse_tests.t_like
+drop cascades to table deparse_tests.t_like_mixed
+drop cascades to table deparse_tests.t_like_incl
+drop cascades to table deparse_tests.t_like_idsrc
+drop cascades to table deparse_tests.t_like_ident
+drop cascades to table deparse_tests.t_like_idx
+drop cascades to table deparse_tests.t_like_all
+drop cascades to table deparse_tests.t_idxtblspc
+drop cascades to table deparse_tests.t_dedup
+drop cascades to table deparse_tests.t_idxopts
+drop cascades to table deparse_tests.t_exclopts
+drop cascades to table deparse_tests.t_exclcase
+drop cascades to table deparse_tests."evil %{name}I table"
+drop cascades to table deparse_tests.t_printonly
+drop cascades to table deparse_tests.t_json
+drop cascades to table deparse_tests.t_both
diff --git a/src/test/modules/test_ddl_deparse/meson.build b/src/test/modules/test_ddl_deparse/meson.build
index 85decdf1f2b..a7469c6c86d 100644
--- a/src/test/modules/test_ddl_deparse/meson.build
+++ b/src/test/modules/test_ddl_deparse/meson.build
@@ -49,6 +49,13 @@ tests += {
       'defprivs',
       'textsearch',
       'matviews',
+      'deparse_create_table',
     ],
   },
+  'tap': {
+    'tests': [
+      't/001_deparse_regress.pl',
+    ],
+    'test_kwargs': {'priority': 50}
+  },
 }
diff --git a/src/test/modules/test_ddl_deparse/sql/deparse_create_table.sql b/src/test/modules/test_ddl_deparse/sql/deparse_create_table.sql
new file mode 100644
index 00000000000..4a1626b7a49
--- /dev/null
+++ b/src/test/modules/test_ddl_deparse/sql/deparse_create_table.sql
@@ -0,0 +1,216 @@
+--
+-- Corner cases for DDL deparsing of CREATE TABLE.
+--
+-- test_ddl_deparse intercepts each supported command, runs the original in a
+-- subtransaction that is rolled back, deparses the collected command, and
+-- executes the reconstruction for real.  The deparsed text is reported as a
+-- NOTICE, so the expected output pins it.
+--
+-- Ordinary syntax is not repeated here.  001_deparse_regress.pl replays the
+-- whole core regression suite through the deparser and covers that, reporting
+-- any divergence as a regression diff.  What a replay cannot see is a change
+-- in the deparsed text that leaves the catalogs as they were, since the state
+-- it arrives at is the same either way.  The cases below are the ones whose
+-- rule is of that kind, plus the few the core tests contain no statement for.
+--
+-- The event triggers created by the test_ddl_deparse script, which runs
+-- first in this schedule, are still installed.  Their "DDL test:" NOTICEs
+-- appear below too.
+--
+LOAD 'test_ddl_deparse';
+SET test_ddl_deparse.execute_deparsed_ddl = on;
+SET test_ddl_deparse.print_deparsed_ddl = text;
+
+CREATE SCHEMA deparse_tests;
+
+-- A serial column is emitted as the user spelled it.  Expanding it into an
+-- integer column plus a sequence, or normalizing serial4 to serial, arrives at
+-- the same catalog state.
+CREATE TABLE deparse_tests.t_serial (
+a serial,
+b serial4,
+c bigserial,
+d smallserial
+);
+
+-- COMPRESSION and STORAGE are emitted only where the user wrote them.
+-- Emitting the effective value instead would leave attcompression and
+-- attstorage exactly as they are.
+CREATE TABLE deparse_tests.t_options (
+a text COMPRESSION pglz,
+b text COMPRESSION default,
+c text STORAGE external,
+d text STORAGE main,
+e text
+);
+SELECT attname, attcompression, attstorage
+  FROM pg_attribute
+  WHERE attrelid = 'deparse_tests.t_options'::regclass AND attnum > 0
+  ORDER BY attnum;
+
+-- A constraint the user did not name comes back unnamed, so that the replay
+-- derives the name the way the original did.  The derived name is the same
+-- either way, so the catalogs match whether or not the name is emitted.
+-- Inline constraints are normalized to table constraints.
+CREATE TABLE deparse_tests.t_constr (
+a int PRIMARY KEY,
+b int REFERENCES deparse_tests.t_constr (a) NOT NULL,
+c text DEFAULT 'x' CHECK (c <> ''),
+CONSTRAINT named_check CHECK (a > 0)
+);
+SELECT conname, contype, pg_get_constraintdef(oid)
+  FROM pg_constraint
+  WHERE conrelid = 'deparse_tests.t_constr'::regclass
+  ORDER BY conname COLLATE "C";
+
+-- A DEFAULT whose cooked expression is a plain NULL constant is not stored in
+-- pg_attrdef, so dropping it from the output would leave atthasdef unset too.
+CREATE TABLE deparse_tests.t_defnull (a int DEFAULT NULL, b text DEFAULT NULL::text);
+
+-- The temp schema is emitted as pg_temp rather than as this backend's own
+-- pg_temp_<n>, which would resolve to the same schema on replay.
+CREATE TEMP TABLE t_temp (a int);
+DROP TABLE t_temp;
+
+-- Identity sequence options that parse analysis consumes are still in the raw
+-- tree.  SEQUENCE NAME has to be emitted; OWNED BY is dropped, since
+-- execution overrides it.  The core tests use neither.
+CREATE TABLE deparse_tests.t_seqname (
+id int GENERATED ALWAYS AS IDENTITY
+	(SEQUENCE NAME deparse_tests.t_seqname_seq START WITH 5)
+);
+SELECT relname FROM pg_class WHERE oid = 'deparse_tests.t_seqname_seq'::regclass;
+CREATE TABLE deparse_tests.t_ownedby (
+id int GENERATED BY DEFAULT AS IDENTITY (OWNED BY NONE)
+);
+
+-- Types whose typmod is spelled inside the name.  The core tests have no
+-- column of either type.
+CREATE TABLE deparse_tests.t_types (
+a interval hour to minute,
+b timestamp(3) with time zone
+);
+
+-- A LIKE clause is expanded against the source table before execution, so the
+-- columns it contributes come back from the catalog.  Emitting the LIKE clause
+-- verbatim instead would build the same table wherever the source exists.
+CREATE TABLE deparse_tests.t_like_src (
+a int NOT NULL,
+b text COLLATE "C",
+c numeric(10,2) STORAGE plain,
+d int DEFAULT 7,
+e int GENERATED ALWAYS AS (d * 2) STORED
+);
+CREATE TABLE deparse_tests.t_like (LIKE deparse_tests.t_like_src);
+
+-- written-out and LIKE columns interleave in catalog order
+CREATE TABLE deparse_tests.t_like_mixed (
+x serial,
+LIKE deparse_tests.t_like_src,
+y int DEFAULT 1
+);
+
+-- the options that land in the table definition are all recovered
+CREATE TABLE deparse_tests.t_like_incl (
+LIKE deparse_tests.t_like_src
+	INCLUDING DEFAULTS INCLUDING GENERATED INCLUDING CONSTRAINTS
+	INCLUDING STORAGE INCLUDING COMPRESSION
+);
+
+-- identity comes back with the sequence parameters that were copied
+CREATE TABLE deparse_tests.t_like_idsrc (
+a int GENERATED BY DEFAULT AS IDENTITY (START WITH 10 INCREMENT BY 5)
+);
+CREATE TABLE deparse_tests.t_like_ident
+  (LIKE deparse_tests.t_like_idsrc INCLUDING IDENTITY);
+
+-- LIKE options that copy a separate object are declined
+CREATE INDEX ON deparse_tests.t_like_src (a);
+CREATE TABLE deparse_tests.t_like_idx
+  (LIKE deparse_tests.t_like_src INCLUDING INDEXES);
+CREATE TABLE deparse_tests.t_like_all
+  (LIKE deparse_tests.t_like_src INCLUDING ALL);
+
+-- USING INDEX TABLESPACE stays with the constraint that carried it, including
+-- after parse analysis has merged redundant constraints.  Every index here
+-- lands in pg_default either way, so a misattribution is invisible in the
+-- catalogs.
+CREATE TABLE deparse_tests.t_idxtblspc (
+a int,
+b int,
+c int,
+UNIQUE (a),
+UNIQUE (b) USING INDEX TABLESPACE pg_default,
+PRIMARY KEY (c)
+);
+CREATE TABLE deparse_tests.t_dedup (
+a int,
+b int,
+UNIQUE (a),
+UNIQUE (a),
+UNIQUE (b) USING INDEX TABLESPACE pg_default,
+PRIMARY KEY (a)
+);
+
+-- Index storage parameters of an index-backed constraint, which
+-- pg_get_constraintdef omits for PRIMARY KEY and UNIQUE but already includes
+-- for EXCLUDE.  No constraint in the core tests carries them.
+CREATE TABLE deparse_tests.t_idxopts (
+a int,
+b int,
+UNIQUE (a) WITH (fillfactor = 90),
+PRIMARY KEY (b) WITH (fillfactor = 80, deduplicate_items = off)
+	USING INDEX TABLESPACE pg_default DEFERRABLE
+);
+SELECT c.relname, c.reloptions
+  FROM pg_class c
+  WHERE c.relname IN ('t_idxopts_a_key', 't_idxopts_pkey')
+  ORDER BY 1;
+CREATE TABLE deparse_tests.t_exclopts (
+c circle,
+EXCLUDE USING gist (c WITH &&) WITH (fillfactor = 70)
+	USING INDEX TABLESPACE pg_default DEFERRABLE
+);
+SELECT c.relname, c.reloptions
+  FROM pg_class c
+  JOIN pg_constraint con ON con.conindid = c.oid
+  WHERE con.conrelid = 'deparse_tests.t_exclopts'::regclass;
+
+-- An exclusion predicate that ruleutils renders across several lines, which
+-- the deparser has to split off the definition and put back.  The core tests
+-- have no such predicate.
+CREATE TABLE deparse_tests.t_exclcase (
+c circle,
+n int,
+EXCLUDE USING gist (c WITH &&)
+	USING INDEX TABLESPACE pg_default
+	WHERE (CASE WHEN n > 0 THEN true ELSE false END)
+	DEFERRABLE
+);
+
+-- Format-string metacharacters and embedded quotes must pass through the fmt
+-- expansion inertly.  No name in the core tests contains them.
+CREATE TABLE deparse_tests."evil %{name}I table" (
+"a""b" int,
+"%{col}s" text DEFAULT 'O''Reilly',
+"100%" numeric
+);
+
+-- IF NOT EXISTS on an existing relation deparses to nothing
+CREATE TABLE IF NOT EXISTS deparse_tests.t_serial (a int);
+
+-- printing works whether or not the deparsed version is executed
+SET test_ddl_deparse.execute_deparsed_ddl = off;
+CREATE TABLE deparse_tests.t_printonly (a int);
+
+-- the JSON blob itself can be printed, alone or along with the command
+SET test_ddl_deparse.print_deparsed_ddl = json;
+CREATE TABLE deparse_tests.t_json (id serial);
+SET test_ddl_deparse.print_deparsed_ddl = 'both';
+SET test_ddl_deparse.execute_deparsed_ddl = on;
+CREATE TABLE deparse_tests.t_both (a int DEFAULT 42);
+
+RESET test_ddl_deparse.print_deparsed_ddl;
+RESET test_ddl_deparse.execute_deparsed_ddl;
+
+DROP SCHEMA deparse_tests CASCADE;
diff --git a/src/test/modules/test_ddl_deparse/t/001_deparse_regress.pl b/src/test/modules/test_ddl_deparse/t/001_deparse_regress.pl
new file mode 100644
index 00000000000..964bf6e1177
--- /dev/null
+++ b/src/test/modules/test_ddl_deparse/t/001_deparse_regress.pl
@@ -0,0 +1,130 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Run the core regression tests while replacing supported DDL commands with
+# their deparsed reconstruction (see test_ddl_deparse.execute_deparsed_ddl).
+# This is meant to reliably detect deparsing support gaps for new syntax:
+# any divergence between a command and its deparsed form shows up as a
+# regression diff.
+use strict;
+use warnings FATAL => 'all';
+
+use Cwd            qw(abs_path);
+use File::Basename qw(dirname);
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Tests that are incompatible with DDL replacement can be excluded here,
+# with a comment explaining why.
+my @excluded_tests = (
+	# This test lists every event trigger in the database, and the capture
+	# trigger the test_ddl_deparse extension installs shows up there.  It
+	# also installs sql_drop/ddl_command_end event triggers whose functions
+	# reference objects by unqualified name, which fail when the replayed
+	# DDL -- executed with search_path cleared, as an apply worker would --
+	# fires them.
+	'event_trigger',
+);
+
+# Initialize the primary node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init();
+
+$node->append_conf('postgresql.conf', <<EOM);
+shared_preload_libraries = 'test_ddl_deparse'
+test_ddl_deparse.execute_deparsed_ddl = on
+test_ddl_deparse.log_declined_commands = on
+wal_level = replica
+EOM
+$node->start;
+
+my $srcdir = abs_path("../../../..");
+
+# --dlpath is needed to be able to find the location of regress.so
+# and any libraries the regression tests require.
+my $dlpath = dirname($ENV{REGRESS_SHLIB});
+
+# --outputdir points to the path where to place the output files.
+my $outputdir = $PostgreSQL::Test::Utils::tmp_check;
+
+# --inputdir points to the path of the input files.
+my $inputdir = "$srcdir/src/test/regress";
+
+# Build the schedule, dropping any excluded tests.
+my $schedule = "$outputdir/deparse_schedule";
+open my $in, '<', "$inputdir/parallel_schedule"
+  or die "could not open parallel_schedule: $!";
+open my $out, '>', $schedule
+  or die "could not create $schedule: $!";
+while (my $line = <$in>)
+{
+	if ($line =~ /^test:\s+(.*)$/)
+	{
+		my @tests = grep {
+			my $t = $_;
+			!grep { $t eq $_ } @excluded_tests
+		} split(/\s+/, $1);
+		next unless @tests;
+		$line = "test: " . join(' ', @tests) . "\n";
+	}
+	print $out $line;
+}
+close $in;
+close $out;
+
+# Run the tests.
+my $rc =
+  system($ENV{PG_REGRESS} . " "
+	  . "--bindir= "
+	  . "--dlpath=\"$dlpath\" "
+	  . "--load-extension=test_ddl_deparse "
+	  . "--host="
+	  . $node->host . " "
+	  . "--port="
+	  . $node->port . " "
+	  . "--schedule=$schedule "
+	  . "--max-concurrent-tests=20 "
+	  . "--inputdir=\"$inputdir\" "
+	  . "--expecteddir=\"$inputdir\" "
+	  . "--outputdir=\"$outputdir\"");
+
+# Dump out the regression diffs file, if there is one
+if ($rc != 0)
+{
+	my $diffs = "$outputdir/regression.diffs";
+	if (-e $diffs)
+	{
+		print "=== dumping $diffs ===\n";
+		print slurp_file($diffs);
+		print "=== EOF ===\n";
+	}
+}
+
+# Report the commands the deparser declined, grouped by command tag.  These
+# executed unchanged and were therefore not exercised by this run; the list is
+# how a deparser gap becomes visible instead of just being absent from the
+# coverage.  It is printed rather than asserted on, since the counts move with
+# every change to the core tests, and since a command being declined is not by
+# itself a defect.
+my %declined;
+for my $line (split /\n/, slurp_file($node->logfile))
+{
+	next unless $line =~ /test_ddl_deparse: declined ([A-Z][A-Z ]*[A-Z]):/;
+	$declined{$1}++;
+}
+if (%declined)
+{
+	note("commands declined by the deparser during this run:");
+	note(sprintf("  %-40s %d", $_, $declined{$_}))
+	  for sort { $declined{$b} <=> $declined{$a} || $a cmp $b } keys %declined;
+}
+else
+{
+	note("the deparser declined no command during this run");
+}
+
+# Report results
+is($rc, 0, 'regression tests pass');
+
+done_testing();
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse--1.0.sql b/src/test/modules/test_ddl_deparse/test_ddl_deparse--1.0.sql
index 861d843690b..ca649d48b74 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse--1.0.sql
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse--1.0.sql
@@ -4,15 +4,30 @@
 \echo Use "CREATE EXTENSION test_ddl_deparse" to load this file. \quit
 
 CREATE FUNCTION get_command_type(pg_ddl_command)
-  RETURNS text IMMUTABLE STRICT
+  RETURNS text IMMUTABLE STRICT PARALLEL SAFE
   AS 'MODULE_PATHNAME' LANGUAGE C;
 
 CREATE FUNCTION get_command_tag(pg_ddl_command)
-  RETURNS text IMMUTABLE STRICT
+  RETURNS text IMMUTABLE STRICT PARALLEL SAFE
   AS 'MODULE_PATHNAME' LANGUAGE C;
 
 CREATE FUNCTION get_altertable_subcmdinfo(IN cmd pg_ddl_command,
     OUT cmdtype text,
     OUT objdesc text)
-  RETURNS SETOF record IMMUTABLE STRICT
+  RETURNS SETOF record IMMUTABLE STRICT PARALLEL SAFE
   AS 'MODULE_PATHNAME' LANGUAGE C;
+
+-- Deparse each supported DDL command as it finishes executing.  The event
+-- trigger is created here, rather than by the regression scripts, so that
+-- pg_regress --load-extension is enough to install it for a whole run.
+CREATE FUNCTION capture_deparsed_ddl()
+  RETURNS event_trigger
+  AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE EVENT TRIGGER capture_deparsed_ddl
+  ON ddl_command_end EXECUTE FUNCTION capture_deparsed_ddl();
+
+-- An origin event trigger does not fire while session_replication_role is
+-- 'replica', which would silently stop the capture just where a replication
+-- apply worker runs; the module must keep deparsing there too.
+ALTER EVENT TRIGGER capture_deparsed_ddl ENABLE ALWAYS;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..6e70718f569 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -10,11 +10,19 @@
  */
 #include "postgres.h"
 
+#include "access/xact.h"
+#include "catalog/namespace.h"
+#include "commands/ddl_deparse.h"
+#include "commands/event_trigger.h"
 #include "funcapi.h"
 #include "nodes/execnodes.h"
 #include "tcop/deparse_utility.h"
+#include "tcop/tcopprot.h"
 #include "tcop/utility.h"
 #include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+#include "utils/resowner.h"
 #include "utils/tuplestore.h"
 
 PG_MODULE_MAGIC;
@@ -22,6 +30,519 @@ PG_MODULE_MAGIC;
 PG_FUNCTION_INFO_V1(get_command_type);
 PG_FUNCTION_INFO_V1(get_command_tag);
 PG_FUNCTION_INFO_V1(get_altertable_subcmdinfo);
+PG_FUNCTION_INFO_V1(capture_deparsed_ddl);
+
+typedef struct TestDDLDeparseErrorInfo
+{
+	const char *orig_ddl;
+	const char *deparsed_ddl;
+} TestDDLDeparseErrorInfo;
+
+typedef enum
+{
+	PRINT_DEPARSED_OFF,
+	PRINT_DEPARSED_JSON,
+	PRINT_DEPARSED_TEXT,
+	PRINT_DEPARSED_BOTH,
+} PrintDeparsedDDL;
+
+static const struct config_enum_entry print_deparsed_ddl_options[] = {
+	{"off", PRINT_DEPARSED_OFF, false},
+	{"json", PRINT_DEPARSED_JSON, false},
+	{"text", PRINT_DEPARSED_TEXT, false},
+	{"both", PRINT_DEPARSED_BOTH, false},
+	{NULL, 0, false}
+};
+
+/* GUCs */
+static bool execute_deparsed_ddl = false;
+static int	print_deparsed_ddl = PRINT_DEPARSED_OFF;
+static bool log_declined_commands = false;
+
+static ProcessUtility_hook_type next_ProcessUtility_hook = NULL;
+
+/*
+ * The statement currently being captured, as the user wrote it, or NULL when
+ * no capture is in progress.  The deparser works from the raw statement, but
+ * execution rewrites it in place (transformCreateStmt updates relpersistence,
+ * expands serial, and so on), so the module runs every captured statement
+ * with readOnlyTree forced on: what ProcessUtilitySlow scribbles is then a
+ * copy of its own, and the tree this points at stays pristine.
+ *
+ * Being non-NULL is also what tells the event trigger below that it is firing
+ * for a statement this module intercepted, rather than for some unrelated
+ * DDL, and that the raw statement therefore matches the commands it is about
+ * to deparse.
+ */
+static const Node *capture_parsetree = NULL;
+
+/*
+ * JSON blob deparsed for that statement; allocated in TopTransactionContext
+ * so it survives the rollback of the internal subtransaction the trial run
+ * executes in.  Only valid while capture_parsetree is set.
+ *
+ * A single pointer, not a list: one intercepted statement is one complete
+ * query, which fires the capture event trigger exactly once (sub-commands
+ * such as the internal ALTER TABLE ADD CONSTRAINT generated by a CREATE
+ * TABLE with a foreign key are collected into that one firing, not fired
+ * separately), and one firing deparses to exactly one command.  The trigger
+ * errors out if that invariant is ever broken.
+ */
+static char *captured_json = NULL;
+
+/* Are we re-executing a deparsed command ourselves? */
+static bool in_roundtrip = false;
+
+static void test_ddl_deparse_utility_command(PlannedStmt *pstmt,
+											 const char *queryString,
+											 bool readOnlyTree,
+											 ProcessUtilityContext context,
+											 ParamListInfo params,
+											 QueryEnvironment *queryEnv,
+											 DestReceiver *dest,
+											 QueryCompletion *qc);
+static void test_ddl_deparse_error_callback(void *arg);
+static void report_declined_command(Node *stmt, const char *queryString);
+static bool noop_allowed(Node *stmt);
+
+void
+_PG_init(void)
+{
+	DefineCustomBoolVariable("test_ddl_deparse.execute_deparsed_ddl",
+							 "Replace supported DDL commands with their deparsed version.",
+							 NULL,
+							 &execute_deparsed_ddl,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL, NULL, NULL);
+
+	DefineCustomEnumVariable("test_ddl_deparse.print_deparsed_ddl",
+							 "Report the deparsed DDL command as a NOTICE, in the given form.",
+							 NULL,
+							 &print_deparsed_ddl,
+							 PRINT_DEPARSED_OFF,
+							 print_deparsed_ddl_options,
+							 PGC_USERSET,
+							 0,
+							 NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("test_ddl_deparse.log_declined_commands",
+							 "Log DDL commands the deparser declined to reproduce.",
+							 NULL,
+							 &log_declined_commands,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("test_ddl_deparse");
+
+	next_ProcessUtility_hook = ProcessUtility_hook;
+	ProcessUtility_hook = test_ddl_deparse_utility_command;
+}
+
+/*
+ * ddl_command_end event trigger: deparse the commands collected for the
+ * statement being captured, and remember the result for replay.
+ *
+ * This is where the deparser has to run: the collected commands live in the
+ * event trigger state, which ProcessUtilitySlow discards as soon as the
+ * complete query is done, so there is no later moment at which to ask for
+ * them.  The event trigger machinery has already done a
+ * CommandCounterIncrement, so the catalog entries the deparser reads are
+ * visible.
+ *
+ * For a statement whose deparsed form is to be executed, this runs inside the
+ * internal subtransaction started by test_ddl_deparse_utility_command, so the
+ * JSON string is copied into TopTransactionContext, which survives that
+ * subtransaction's rollback.
+ *
+ * With print_deparsed_ddl enabled, the deparsed command is reported as a
+ * NOTICE whether or not it is going to be executed in place of the original.
+ */
+Datum
+capture_deparsed_ddl(PG_FUNCTION_ARGS)
+{
+	char	   *json;
+
+	if (!CALLED_AS_EVENT_TRIGGER(fcinfo))
+		elog(ERROR, "not fired by event trigger manager");
+
+	/*
+	 * Do nothing unless a statement of ours is being captured.  This is the
+	 * common case: the trigger is installed for the whole session, but only
+	 * top-level statements the module intercepts have a raw parse tree saved
+	 * for the deparser to work from.
+	 */
+	if (capture_parsetree == NULL)
+		PG_RETURN_NULL();
+
+	/*
+	 * A second firing while one statement is being captured means a nested
+	 * complete query (say, DDL run by another event trigger function) also
+	 * produced a supported command.  Its raw statement is not the one saved
+	 * above, and replaying both would double-execute the nested one when the
+	 * replayed outer statement triggers it again, so refuse loudly.
+	 */
+	if (captured_json != NULL)
+		elog(ERROR, "multiple DDL deparse captures for a single statement");
+
+	json = deparse_ddl_command(capture_parsetree,
+							   EventTriggerGetCollectedCommands());
+	if (json == NULL)
+		PG_RETURN_NULL();
+
+	if (print_deparsed_ddl == PRINT_DEPARSED_JSON ||
+		print_deparsed_ddl == PRINT_DEPARSED_BOTH)
+		ereport(NOTICE,
+				(errmsg("deparsed JSON: %s", json),
+				 errhidestmt(true),
+				 errhidecontext(true)));
+
+	if (print_deparsed_ddl == PRINT_DEPARSED_TEXT ||
+		print_deparsed_ddl == PRINT_DEPARSED_BOTH)
+		ereport(NOTICE,
+				(errmsg("deparsed DDL: %s", deparse_ddl_json_to_string(json)),
+				 errhidestmt(true),
+				 errhidecontext(true)));
+
+	captured_json = MemoryContextStrdup(TopTransactionContext, json);
+
+	PG_RETURN_NULL();
+}
+
+/*
+ * Continue with the rest of the ProcessUtility hook chain (or standard
+ * processing if we are the last hook).  Used for every execution this module
+ * initiates, so that other loaded modules' hooks are not bypassed; re-entry
+ * into our own interception is prevented by the in_roundtrip flag and by the
+ * fact that this call sits below our hook in the chain.
+ */
+static void
+call_next_ProcessUtility(PlannedStmt *pstmt,
+						 const char *queryString,
+						 bool readOnlyTree,
+						 ProcessUtilityContext context,
+						 ParamListInfo params,
+						 QueryEnvironment *queryEnv,
+						 DestReceiver *dest, QueryCompletion *qc)
+{
+	if (next_ProcessUtility_hook)
+		next_ProcessUtility_hook(pstmt, queryString, readOnlyTree,
+								 context, params, queryEnv, dest, qc);
+	else
+		standard_ProcessUtility(pstmt, queryString, readOnlyTree,
+								context, params, queryEnv, dest, qc);
+}
+
+/*
+ * Parse and execute one deparsed DDL command.
+ *
+ * The whole point of this module is to surface deparser bugs, so any shape
+ * surprise in the deparsed string is reported as an error (with the string
+ * in the error context), not assumed away: one DDL command must deparse to
+ * exactly one DDL command.
+ *
+ */
+static void
+execute_deparsed_command(const char *sql,
+						 ProcessUtilityContext context,
+						 ParamListInfo params,
+						 QueryEnvironment *queryEnv,
+						 DestReceiver *dest, QueryCompletion *qc)
+{
+	List	   *parsetree_list;
+	RawStmt    *rs;
+	List	   *querytree_list;
+	PlannedStmt *pstmt;
+	Query	   *query;
+	int			save_nestlevel;
+
+	save_nestlevel = NewGUCNestLevel();
+
+	/*
+	 * Messages that the replayed command would emit have already been emitted
+	 * by the original execution, so suppress them.
+	 */
+	(void) set_config_option("client_min_messages", "error",
+							 PGC_USERSET, PGC_S_SESSION,
+							 GUC_ACTION_SAVE, true, 0, false);
+
+	/*
+	 * Execute the deparsed command under a restricted search_path, the same
+	 * environment the deparser itself produces its output under.  Deparsed
+	 * DDL is supposed to be safe to execute regardless of the search_path in
+	 * effect (everything schema-qualified except pg_catalog references);
+	 * replaying it this way makes any missing qualification fail visibly
+	 * instead of silently resolving through the session's own search_path.
+	 * The surrounding GUC nest level undoes this below.
+	 */
+	RestrictSearchPath();
+
+	parsetree_list = pg_parse_query(sql);
+	if (list_length(parsetree_list) != 1)
+		elog(ERROR, "deparsed DDL is not a single command: %s", sql);
+
+	rs = linitial_node(RawStmt, parsetree_list);
+	querytree_list = pg_analyze_and_rewrite_fixedparams(rs, sql, NULL, 0,
+														NULL);
+	if (list_length(querytree_list) != 1)
+		elog(ERROR, "deparsed DDL did not analyze to a single query: %s", sql);
+
+	query = linitial_node(Query, querytree_list);
+	if (query->commandType != CMD_UTILITY)
+		elog(ERROR, "deparsed DDL is not a utility command: %s", sql);
+
+	pstmt = makeNode(PlannedStmt);
+	pstmt->commandType = CMD_UTILITY;
+	pstmt->canSetTag = true;
+	pstmt->utilityStmt = query->utilityStmt;
+	pstmt->stmt_location = rs->stmt_location;
+	pstmt->stmt_len = rs->stmt_len;
+
+	call_next_ProcessUtility(pstmt, sql, false,
+							 context, params, queryEnv,
+							 dest, qc);
+
+	AtEOXact_GUC(true, save_nestlevel);
+}
+
+/*
+ * Is it legitimate for this statement to have collected nothing?
+ *
+ * A command that names a missing object under IF EXISTS, or an existing one
+ * under IF NOT EXISTS, does nothing, so nothing is collected and the deparser
+ * has nothing to produce.  Any other empty result is a deparser bug, and is
+ * reported as one by the caller.
+ *
+ * Every statement type ddl_deparse_command_supported() accepts is listed
+ * here, as in report_declined_command(): the guard field sits in a different
+ * place in each node, and a type that has no such clause must not be excused.
+ */
+static bool
+noop_allowed(Node *stmt)
+{
+	switch (nodeTag(stmt))
+	{
+		case T_CreateStmt:
+			return ((CreateStmt *) stmt)->if_not_exists;
+		default:
+			return false;
+	}
+}
+
+/*
+ * ProcessUtility hook: capture supported DDL commands and, when
+ * execute_deparsed_ddl is set, replace them with their deparsed
+ * reconstruction.
+ *
+ * Capturing a statement means saving it for the event trigger above and
+ * running it with readOnlyTree forced on, so that the saved tree stays as the
+ * user wrote it while the copy execution works on is rewritten in place.
+ *
+ * For the replacement, the original statement is executed in an internal
+ * subtransaction that is rolled back afterwards; its execution fires the
+ * capture event trigger, which deparses the collected commands.  The
+ * reconstructed command is then executed for real.  Divergence between the
+ * two forms thus shows up in the regular regression output.
+ */
+static void
+test_ddl_deparse_utility_command(PlannedStmt *pstmt,
+								 const char *queryString,
+								 bool readOnlyTree,
+								 ProcessUtilityContext context,
+								 ParamListInfo params,
+								 QueryEnvironment *queryEnv,
+								 DestReceiver *dest, QueryCompletion *qc)
+{
+	/*
+	 * Only top-level statements are captured, and only when something is
+	 * going to be done with the result.  Our own replay of a deparsed command
+	 * must not be captured in turn.
+	 */
+	if (context != PROCESS_UTILITY_TOPLEVEL || in_roundtrip ||
+		(!execute_deparsed_ddl && print_deparsed_ddl == PRINT_DEPARSED_OFF))
+	{
+		call_next_ProcessUtility(pstmt, queryString, readOnlyTree,
+								 context, params, queryEnv, dest, qc);
+		return;
+	}
+
+	/*
+	 * A command the deparser declines executes unchanged.  That is the right
+	 * thing for a test run -- one gap must not stop the rest of the suite --
+	 * but it means the command is simply not tested, and nothing says so.
+	 * Report it, so that a deparser gap is a thing one can go and look at
+	 * rather than an absence nobody notices.
+	 */
+	if (!ddl_deparse_command_supported(pstmt->utilityStmt))
+	{
+		report_declined_command(pstmt->utilityStmt, queryString);
+		call_next_ProcessUtility(pstmt, queryString, readOnlyTree,
+								 context, params, queryEnv, dest, qc);
+		return;
+	}
+
+	captured_json = NULL;
+	capture_parsetree = pstmt->utilityStmt;
+
+	PG_TRY();
+	{
+		if (!execute_deparsed_ddl)
+		{
+			/*
+			 * Nothing to replace: run the command for real, captured only so
+			 * that the event trigger reports its deparsed form.
+			 */
+			call_next_ProcessUtility(pstmt, queryString, true,
+									 context, params, queryEnv, dest, qc);
+		}
+		else
+		{
+			MemoryContext oldcontext = CurrentMemoryContext;
+			ResourceOwner oldowner = CurrentResourceOwner;
+
+			/*
+			 * Run the trial in an internal subtransaction, following the
+			 * standard idiom (cf. plpgsql): save and restore the caller's
+			 * memory context and resource owner around it, so the replay
+			 * below runs in the same environment the statement arrived in.
+			 */
+			BeginInternalSubTransaction("test_ddl_deparse");
+			MemoryContextSwitchTo(oldcontext);
+			PG_TRY(2);
+			{
+				call_next_ProcessUtility(pstmt, queryString, true,
+										 context, params, queryEnv, dest, qc);
+
+				RollbackAndReleaseCurrentSubTransaction();
+				MemoryContextSwitchTo(oldcontext);
+				CurrentResourceOwner = oldowner;
+			}
+			PG_CATCH(2);
+			{
+				RollbackAndReleaseCurrentSubTransaction();
+				MemoryContextSwitchTo(oldcontext);
+				CurrentResourceOwner = oldowner;
+				PG_RE_THROW();
+			}
+			PG_END_TRY(2);
+
+			/*
+			 * The capture window closes with the trial: whatever DDL runs
+			 * from here on -- the replay itself, or the original command in
+			 * the fallback below -- fires the event trigger again, and must
+			 * not be taken for this statement.
+			 */
+			capture_parsetree = NULL;
+
+			if (captured_json == NULL)
+			{
+				/*
+				 * The deparser produced nothing.  The benign case is a
+				 * command that did nothing (the trial execution has already
+				 * emitted the "skipping" notice), so there is nothing to
+				 * replay; anything else is unexpected, so warn and run the
+				 * original command for real rather than silently losing it.
+				 */
+				if (!noop_allowed(pstmt->utilityStmt))
+				{
+					ereport(WARNING,
+							(errmsg("could not deparse DDL command, executing the original"),
+							 errdetail("Command: %s", queryString)));
+					call_next_ProcessUtility(pstmt, queryString, readOnlyTree,
+											 context, params, queryEnv, dest, qc);
+				}
+			}
+			else
+			{
+				ErrorContextCallback errcallback;
+				TestDDLDeparseErrorInfo errinfo;
+				char	   *sql;
+
+				sql = deparse_ddl_json_to_string(captured_json);
+
+				errinfo.orig_ddl = queryString;
+				errinfo.deparsed_ddl = sql;
+
+				errcallback.callback = test_ddl_deparse_error_callback;
+				errcallback.previous = error_context_stack;
+				errcallback.arg = &errinfo;
+				error_context_stack = &errcallback;
+
+				in_roundtrip = true;
+				PG_TRY(2);
+				{
+					execute_deparsed_command(sql, context, params, queryEnv,
+											 dest, qc);
+				}
+				PG_FINALLY(2);
+				{
+					in_roundtrip = false;
+				}
+				PG_END_TRY(2);
+
+				error_context_stack = errcallback.previous;
+			}
+		}
+	}
+	PG_FINALLY();
+	{
+		capture_parsetree = NULL;
+		/* don't leave a dangling pointer into transaction-lifetime memory */
+		captured_json = NULL;
+	}
+	PG_END_TRY();
+}
+
+/*
+ * Report a top-level command that the deparser declined.
+ *
+ * Only statement types deparse_ddl_command() has a case for are reported: for
+ * those, a decline is a gap in the deparser, whereas for anything else it
+ * just means the statement is not this deparser's business.  Note that even
+ * so a report is not by itself a defect -- ALTER FUNCTION ... RENAME is
+ * declined because RENAME is only supported for tables and columns, and that
+ * is by design.  What matters is that the set of reports is visible, so that
+ * one that ought not to be there can be noticed.
+ *
+ * This goes to the server log rather than to the client so that enabling it
+ * does not perturb the output of the regression tests being replayed.
+ */
+static void
+report_declined_command(Node *stmt, const char *queryString)
+{
+	if (!log_declined_commands)
+		return;
+
+	switch (nodeTag(stmt))
+	{
+		case T_CreateStmt:
+		case T_AlterTableStmt:
+		case T_RenameStmt:
+		case T_AlterObjectSchemaStmt:
+			break;
+		default:
+			return;
+	}
+
+	ereport(LOG,
+			(errmsg("test_ddl_deparse: declined %s: %s",
+					CreateCommandName(stmt), queryString),
+			 errhidestmt(true),
+			 errhidecontext(true)));
+}
+
+static void
+test_ddl_deparse_error_callback(void *arg)
+{
+	TestDDLDeparseErrorInfo *errinfo = (TestDDLDeparseErrorInfo *) arg;
+
+	errcontext("original DDL \"%s\", deparsed DDL \"%s\"",
+			   errinfo->orig_ddl,
+			   errinfo->deparsed_ddl ? errinfo->deparsed_ddl : "(none)");
+}
 
 /*
  * Return the textual representation of the struct type used to represent a
-- 
2.56.0

