From fd2e29dd8b21d4a93de722eab171ec4e565a8000 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Fri, 10 Jul 2026 01:34:14 -0700
Subject: [PATCH v1 1/4] Support DDL deparse for CREATE TABLE command.

This adds a deparser that turns an executed DDL command back into a
command reproducing it, as a JSON representation that an extension can
inspect and transform before it is expanded into SQL text
again. CREATE TABLE is what it covers.

The deparser works from the raw parse tree together with the catalogs
rather than from the catalogs alone, because what it has to reproduce
is the command the user ran, not the state that command left behind.
A clause the user did not write is not emitted, so that a replay
adopts whatever the default is where it runs; a serial column comes
back as serial rather than as an integer with a nextval() default; and
one command deparses to exactly one command.  Names are
schema-qualified and spellings normalized throughout, so that the
result replays under a restricted search_path.  Expressions and
constraint definitions are never rendered here: they come from
ruleutils, through pg_get_expr and pg_get_constraintdef.

deparse_ddl_command() returns NULL when the command left nothing to
reproduce -- an IF NOT EXISTS guard that did nothing, or a command run
by an extension script -- and raises an error when asked for one it
does not support.  The two must not be confused: a consumer that
cannot tell them apart drops an unsupported command without a trace,
which is how a deparser gap becomes a silent divergence at whatever
consumes the result.  Every caller therefore asks
ddl_deparse_command_supported() first and applies its own policy to
the answer, since what to do with a command that cannot be reproduced
-- execute it unchanged, warn, or refuse it -- differs between
consumers and is no business of the deparser's.  For the same reason
that predicate errs towards reporting a command unsupported.

A LIKE clause is expanded against the source table before the
statement executes, so the columns it contributes are not in the raw
statement. They are recovered from the catalogs: where the raw element
list holds a LIKE clause the column list is driven by the relation's
tuple descriptor instead, which also keeps the columns in the order
the command produced them, and a column the user did write is still
deparsed from what they wrote.  For a copied column there is no user
intent to consult, so the rule becomes the closest equivalent: a
clause is emitted where the column's property differs from what a
column of that type would get by default, which is exactly when it has
to be stated for the replayed column to come out the same.  Constraint
names need more care, since a LIKE clause copies them verbatim,
including the ones derived from the source table's name; every
constraint of a table created with LIKE is therefore emitted with its
name.

The LIKE options that copy an object needing a statement of its own --
INCLUDING INDEXES, COMMENTS and STATISTICS, and so INCLUDING ALL --
are declined, since CREATE INDEX, COMMENT ON and CREATE STATISTICS are
not deparsable.  The check is a static test on the clause's options,
so it names its own blockers.

Author:
Reviewed-by:
Discussion: https://postgr.es/m/
---
 src/backend/commands/Makefile       |    2 +
 src/backend/commands/ddl_deparse.c  | 2236 +++++++++++++++++++++++++++
 src/backend/commands/ddl_json.c     |  747 +++++++++
 src/backend/commands/meson.build    |    2 +
 src/backend/utils/adt/format_type.c |  110 ++
 src/include/commands/ddl_deparse.h  |   25 +
 src/include/utils/builtins.h        |    3 +
 src/tools/pgindent/typedefs.list    |    4 +
 8 files changed, 3129 insertions(+)
 create mode 100644 src/backend/commands/ddl_deparse.c
 create mode 100644 src/backend/commands/ddl_json.c
 create mode 100644 src/include/commands/ddl_deparse.h

diff --git a/src/backend/commands/Makefile b/src/backend/commands/Makefile
index 5b9d084977e..9779a1b2075 100644
--- a/src/backend/commands/Makefile
+++ b/src/backend/commands/Makefile
@@ -28,6 +28,8 @@ OBJS = \
 	copyto.o \
 	createas.o \
 	dbcommands.o \
+	ddl_deparse.o \
+	ddl_json.o \
 	define.o \
 	discard.o \
 	dropcmds.o \
diff --git a/src/backend/commands/ddl_deparse.c b/src/backend/commands/ddl_deparse.c
new file mode 100644
index 00000000000..c7620680a8f
--- /dev/null
+++ b/src/backend/commands/ddl_deparse.c
@@ -0,0 +1,2236 @@
+/*-------------------------------------------------------------------------
+ *
+ * ddl_deparse.c
+ *	  Functions to convert utility commands to machine-parseable
+ *	  representation
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * NOTES
+ *
+ * This is intended to provide JSON blobs representing DDL commands, which
+ * can later be re-processed into plain strings by well-defined sprintf-like
+ * expansion (see ddl_json.c). These JSON objects are intended to allow for
+ * machine-editing of the commands, by replacing certain nodes within the
+ * objects.
+ *
+ * The input is the raw (untransformed) parse tree of the command plus the
+ * list of CollectedCommand it produced when it was executed (which supplies
+ * the OID of the created object).
+ *
+ * Two rules decide what the output contains. They divide the state the
+ * original command produced between them: the state that came from a
+ * default, and the state the command itself determined.
+ *
+ * The first rule is that the output reproduces the command the user wrote,
+ * not the state it left behind. The parse tree therefore determines which
+ * clauses appear: a clause the user did not write is not emitted, so that
+ * when the reconstructed command is replayed -- possibly on a newer major
+ * version -- the then-current defaults apply. A column with no COMPRESSION
+ * clause is emitted without one, and a serial column is emitted as serial
+ * rather than being expanded into an integer column plus a sequence.
+ *
+ * The second rule is that replaying the output has to leave the catalogs in
+ * the state the original command left them in, defaults aside. What the
+ * command determined and a replay would not arrive at on its own is emitted
+ * even where the user did not write it: names are schema-qualified, so that
+ * the result does not depend on the search_path in effect, and a constraint
+ * whose name the command fixed is emitted with that name -- the one a LIKE
+ * clause copied from the source table, or the one given on a redundant NOT
+ * NULL specification -- because a constraint left unnamed is named after
+ * the table it ends up on, which is not the same name.
+ *
+ * The definitions themselves (data types, expressions, constraint
+ * definitions) are obtained from the system catalogs rather than from the
+ * parse tree, as it is impossible to reliably construct a fully-specified
+ * command (i.e. one not dependent on search_path etc.) looking only at the
+ * parse node.
+ *
+ * Normalizations that change the spelling but not the effect are allowed:
+ * type names are spelled canonically, and constraints given with a column
+ * definition are emitted as table constraints.
+ *
+ * Deparsed JsonbValue objects are built with the append_jsonb_pairN()
+ * helpers, passing N (key, value) pairs whose values come from
+ * jbv_str()/jbv_bool()/jbv_null(), e.g.
+ *
+ *		append_jsonb_pair1(state, "fmt", jbv_str("..."));
+ *
+ * Object/array nesting is delimited by begin_jsonb_object()/
+ * end_jsonb_object() and begin_jsonb_array()/end_jsonb_array().
+ *
+ * XXX: This module supports CREATE TABLE command with an exception of some
+ * LIKE clause that requires deparsing CREATE INDEX etc. See
+ * ddl_deparse_command_supported() for supported statements.
+ *
+ * XXX: For LIKE clause, DDL deparse expands the LIKE clause to the actual
+ * columns to satisfy the second rule also in case where the deparsed DDL
+ * is replay in a different environment. However, it might be worth considering
+ * an option to prioritize the first rule that preserves the user intent
+ * and simply reproduce the LIKE clause in the deparsed DDL.
+ *
+ * IDENTIFICATION
+ *	  src/backend/commands/ddl_deparse.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/genam.h"
+#include "access/htup_details.h"
+#include "access/relation.h"
+#include "access/toast_compression.h"
+#include "access/table.h"
+#include "catalog/namespace.h"
+#include "catalog/objectaddress.h"
+#include "catalog/pg_attrdef.h"
+#include "catalog/pg_collation.h"
+#include "catalog/pg_constraint.h"
+#include "catalog/pg_index.h"
+#include "catalog/pg_inherits.h"
+#include "commands/ddl_deparse.h"
+#include "commands/defrem.h"
+#include "commands/sequence.h"
+#include "commands/tablespace.h"
+#include "parser/parse_type.h"
+#include "tcop/deparse_utility.h"
+#include "tcop/utility.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
+#include "utils/fmgrprotos.h"
+#include "utils/guc.h"
+#include "utils/jsonb.h"
+#include "utils/lsyscache.h"
+#include "utils/memutils.h"
+#include "utils/rel.h"
+#include "utils/ruleutils.h"
+#include "utils/syscache.h"
+
+/* Estimated length of the generated jsonb string */
+#define JSONB_ESTIMATED_LEN 128
+
+static void deparse_DefElem(JsonbInState *state, DefElem *elem,
+							bool is_reset);
+static IndexStmt *find_collected_index_stmt(List *cmds, Oid indexId);
+
+/*
+ * Insert JsonbValue key to the output parse state.
+ */
+static void
+insert_jsonb_key(JsonbInState *state, char *name)
+{
+	JsonbValue	key;
+
+	/* Push the key */
+	key.type = jbvString;
+	key.val.string.val = name;
+	key.val.string.len = strlen(name);
+	pushJsonbValue(state, WJB_KEY, &key);
+}
+
+/*
+ * Value constructors: build a JsonbValue of the right type. The type lives
+ * in the returned value, so call sites are type-checked at construction (you
+ * can't pass a bool where jbv_str expects a char *, etc).
+ */
+static inline JsonbValue
+jbv_str(const char *value)
+{
+	JsonbValue	v = {0};
+
+	v.type = jbvString;
+	v.val.string.val = pstrdup(value);
+	v.val.string.len = strlen(v.val.string.val);
+	return v;
+}
+
+static inline JsonbValue
+jbv_bool(bool value)
+{
+	JsonbValue	v = {0};
+
+	v.type = jbvBool;
+	v.val.boolean = value;
+	return v;
+}
+
+static inline JsonbValue
+jbv_null(void)
+{
+	JsonbValue	v = {0};
+
+	v.type = jbvNull;
+	return v;
+}
+
+/*
+ * Append "numobjs" (key, JsonbValue) pairs from the varargs to the output
+ * parse state. Values are built with jbv_str()/jbv_bool()/jbv_null().
+ */
+static void
+new_jsonb_VA(JsonbInState *state, int numobjs, ...)
+{
+	va_list		args;
+
+	va_start(args, numobjs);
+
+	for (int i = 0; i < numobjs; i++)
+	{
+		char	   *name = va_arg(args, char *);
+		JsonbValue	val = va_arg(args, JsonbValue);
+
+		insert_jsonb_key(state, name);
+		pushJsonbValue(state, WJB_VALUE, &val);
+	}
+
+	va_end(args);
+}
+
+/*
+ * append_jsonb_pairN(state, k1, v1, ...): append exactly N (key, JsonbValue)
+ * pairs (values built with jbv_*()). Thin macros over new_jsonb_VA() so the
+ * count lives in the name and there is no sentinel to forget.
+ */
+#define append_jsonb_pair1(state, k1, v1)	\
+	new_jsonb_VA(state, 1, (k1), (v1))
+#define append_jsonb_pair2(state, k1, v1, k2, v2)	\
+	new_jsonb_VA(state, 2, (k1), (v1), (k2), (v2))
+#define append_jsonb_pair3(state, k1, v1, k2, v2, k3, v3)	\
+	new_jsonb_VA(state, 3, (k1), (v1), (k2), (v2), (k3), (v3))
+#define append_jsonb_pair4(state, k1, v1, k2, v2, k3, v3, k4, v4)	\
+	new_jsonb_VA(state, 4, (k1), (v1), (k2), (v2), (k3), (v3), (k4), (v4))
+#define append_jsonb_pair5(state, k1, v1, k2, v2, k3, v3, k4, v4, k5, v5)	\
+	new_jsonb_VA(state, 5, (k1), (v1), (k2), (v2), (k3), (v3), (k4), (v4), \
+				 (k5), (v5))
+
+/* Convenience: append a JSON null under "key". */
+static inline void
+new_jsonb_null(JsonbInState *state, char *key)
+{
+	append_jsonb_pair1(state, key, jbv_null());
+}
+
+/*
+ * Thin wrappers around pushJsonbValue for object/array delimiters, so the
+ * begin/end pairing is visible and self-documenting at call sites.
+ */
+static inline void
+begin_jsonb_object(JsonbInState *state)
+{
+	pushJsonbValue(state, WJB_BEGIN_OBJECT, NULL);
+}
+
+static inline void
+end_jsonb_object(JsonbInState *state)
+{
+	pushJsonbValue(state, WJB_END_OBJECT, NULL);
+}
+
+static inline void
+begin_jsonb_array(JsonbInState *state)
+{
+	pushJsonbValue(state, WJB_BEGIN_ARRAY, NULL);
+}
+
+static inline void
+end_jsonb_array(JsonbInState *state)
+{
+	pushJsonbValue(state, WJB_END_ARRAY, NULL);
+}
+
+/*
+ * Open a clause sub-object under "key": insert the key, begin the object,
+ * and emit its "fmt". The caller adds any further data (e.g. a nested type
+ * or qualified-name object) and then calls end_jsonb_clause().
+ *
+ * A clause is only emitted when the user actually wrote it; a clause that
+ * was not written (or does not apply) is emitted as a JSON null
+ * (new_jsonb_null) instead, which expands to nothing. The caller typically
+ * writes:
+ *
+ *		if (user wrote it)
+ *			... begin_jsonb_clause(state, key, fmt) ... end_jsonb_clause() ...
+ *		else
+ *			new_jsonb_null(state, key);
+ *
+ * The leading space of each clause lives in its own "fmt", so enclosing
+ * format strings reference bare %{x}s slots.
+ */
+static void
+begin_jsonb_clause(JsonbInState *state, char *key, char *fmt)
+{
+	insert_jsonb_key(state, key);
+	begin_jsonb_object(state);
+	append_jsonb_pair1(state, "fmt", jbv_str(fmt));
+}
+
+static inline void
+end_jsonb_clause(JsonbInState *state)
+{
+	end_jsonb_object(state);
+}
+
+/* Emit a clause sub-object holding just a "fmt" */
+static void
+new_jsonb_clause(JsonbInState *state, char *key, char *fmt)
+{
+	begin_jsonb_clause(state, key, fmt);
+	end_jsonb_clause(state);
+}
+
+/*
+ * Emit a clause sub-object holding a "fmt" and a single string value: the
+ * common
+ *		begin_jsonb_clause(state, key, fmt);
+ *		append_jsonb_pair1(state, valkey, jbv_str(val));
+ *		end_jsonb_clause(state);
+ * idiom, where fmt references the value as %{valkey}X.
+ */
+static void
+new_jsonb_string_clause(JsonbInState *state, char *key, char *fmt,
+						char *valkey, const char *val)
+{
+	begin_jsonb_clause(state, key, fmt);
+	append_jsonb_pair1(state, valkey, jbv_str(val));
+	end_jsonb_clause(state);
+}
+
+/*
+ * A helper routine to insert jsonb for typId to the output parse state.
+ */
+static void
+new_jsonb_for_type(JsonbInState *state, char *parentKey,
+				   Oid typId, int32 typmod)
+{
+	Oid			typnspid;
+	char	   *type_nsp;
+	char	   *type_name = NULL;
+	char	   *typmodstr;
+	bool		type_array;
+
+	Assert(parentKey);
+
+	format_type_detailed(typId, typmod, &typnspid, &type_name, &typmodstr,
+						 &type_array);
+
+	if (OidIsValid(typnspid))
+		type_nsp = get_namespace_name_or_temp(typnspid);
+	else
+		type_nsp = pstrdup("");
+
+	insert_jsonb_key(state, parentKey);
+	begin_jsonb_object(state);
+	append_jsonb_pair4(state,
+					   "schemaname", jbv_str(type_nsp),
+					   "typename", jbv_str(type_name),
+					   "typmod", jbv_str(typmodstr),
+					   "typarray", jbv_bool(type_array));
+	end_jsonb_object(state);
+}
+
+/*
+ * A helper routine to set up schemaname and objname.
+ *
+ * If the namespace OID corresponds to a temp schema, that's set to
+ * "pg_temp".
+ */
+static void
+new_jsonb_for_qualname(JsonbInState *state, Oid nspid, char *objName,
+					   char *keyName, bool createObject)
+{
+	char	   *namespace;
+
+	if (isAnyTempNamespace(nspid))
+		namespace = pstrdup("pg_temp");
+	else
+		namespace = get_namespace_name(nspid);
+
+	/* Push the key first */
+	if (keyName)
+		insert_jsonb_key(state, keyName);
+
+	if (createObject)
+		begin_jsonb_object(state);
+
+	append_jsonb_pair2(state,
+					   "schemaname", jbv_str(namespace),
+					   "objname", jbv_str(objName));
+
+	if (createObject)
+		end_jsonb_object(state);
+}
+
+/*
+ * A helper routine to set up schemaname and objname by the given classId
+ * and objectId.
+ */
+static void
+new_jsonb_for_qualname_id(JsonbInState *state, Oid classId, Oid objectId,
+						  char *keyName, bool createObject)
+{
+	Relation	catalog;
+	HeapTuple	catobj;
+	Datum		obj_nsp;
+	Datum		obj_name;
+	AttrNumber	Anum_name;
+	AttrNumber	Anum_namespace;
+	AttrNumber	Anum_oid = get_object_attnum_oid(classId);
+	bool		isnull;
+
+	catalog = table_open(classId, AccessShareLock);
+
+	catobj = get_catalog_object_by_oid(catalog, Anum_oid, objectId);
+	if (!catobj)
+		elog(ERROR, "cache lookup failed for object with OID %u of catalog \"%s\"",
+			 objectId, RelationGetRelationName(catalog));
+	Anum_name = get_object_attnum_name(classId);
+	Anum_namespace = get_object_attnum_namespace(classId);
+
+	obj_nsp = heap_getattr(catobj, Anum_namespace, RelationGetDescr(catalog),
+						   &isnull);
+	if (isnull)
+		elog(ERROR, "null namespace for object %u", objectId);
+
+	obj_name = heap_getattr(catobj, Anum_name, RelationGetDescr(catalog),
+							&isnull);
+	if (isnull)
+		elog(ERROR, "null attribute name for object %u", objectId);
+
+	new_jsonb_for_qualname(state, DatumGetObjectId(obj_nsp),
+						   NameStr(*DatumGetName(obj_name)),
+						   keyName, createObject);
+	table_close(catalog, AccessShareLock);
+}
+
+/*
+ * A helper routine to insert key:value where value is array of qualname to
+ * the output parse state.
+ */
+static void
+new_jsonbArray_for_qualname_id(JsonbInState *state, char *keyname, List *array)
+{
+	ListCell   *lc;
+
+	/* Push the key first */
+	insert_jsonb_key(state, keyname);
+
+	begin_jsonb_array(state);
+
+	/* Push the array elements now */
+	foreach(lc, array)
+		new_jsonb_for_qualname_id(state, RelationRelationId, lfirst_oid(lc),
+								  NULL, true);
+
+	end_jsonb_array(state);
+}
+
+/*
+ * Return the string representation of the given RELPERSISTENCE value, or
+ * NULL for a permanent relation.
+ */
+static char *
+get_persistence_str(char persistence)
+{
+	switch (persistence)
+	{
+		case RELPERSISTENCE_TEMP:
+			return "TEMPORARY";
+		case RELPERSISTENCE_UNLOGGED:
+			return "UNLOGGED";
+		case RELPERSISTENCE_PERMANENT:
+			return NULL;
+		default:
+			elog(ERROR, "unexpected persistence marking %c", persistence);
+			return NULL;		/* keep compiler quiet */
+	}
+}
+
+/*
+ * If the given (raw) TypeName is one of the serial pseudo-types, return its
+ * spelling as the user typed it; otherwise return NULL.
+ *
+ * This mirrors the serial detection in transformColumnDefinition().
+ */
+static const char *
+get_serial_typename(const TypeName *typeName)
+{
+	const char *name;
+
+	if (list_length(typeName->names) != 1 || typeName->pct_type)
+		return NULL;
+
+	name = strVal(linitial(typeName->names));
+
+	if (strcmp(name, "smallserial") == 0 ||
+		strcmp(name, "serial2") == 0 ||
+		strcmp(name, "serial") == 0 ||
+		strcmp(name, "serial4") == 0 ||
+		strcmp(name, "bigserial") == 0 ||
+		strcmp(name, "serial8") == 0)
+		return name;
+
+	return NULL;
+}
+
+/*
+ * Return the first constraint of the given type attached to the column
+ * definition, or NULL if there is none. If more than one is present,
+ * prefer a named one: for redundant constraints such as "NOT NULL
+ * CONSTRAINT foo NOT NULL", the explicitly given name is the one that ends
+ * up in the catalogs.
+ */
+static Constraint *
+find_column_constraint(const ColumnDef *coldef, ConstrType contype)
+{
+	Constraint *result = NULL;
+
+	foreach_node(Constraint, constraint, coldef->constraints)
+	{
+		if (constraint->contype != contype)
+			continue;
+
+		if (result == NULL ||
+			(result->conname == NULL && constraint->conname != NULL))
+			result = constraint;
+	}
+
+	return result;
+}
+
+/*
+ * Obtain the deparsed form of the stored (cooked) expression in pg_attrdef
+ * for the given column; used for DEFAULT and GENERATED columns.
+ *
+ * A DEFAULT clause whose cooked expression is a plain NULL constant is
+ * deliberately not stored in pg_attrdef (see AddRelationNewConstraints), so
+ * a raw DEFAULT constraint may legitimately have no stored expression;
+ * missing_ok callers get a NULL return for that case.
+ *
+ * Note we don't use build_column_default() here: it substitutes the type's
+ * default when the column has none, returns a NextValueExpr for identity
+ * columns, and may wrap the stored expression in another coercion; we want
+ * exactly the expression that this command stored.
+ */
+static char *
+get_stored_default_expr(Oid relid, AttrNumber attnum, List *dpcontext,
+						bool missing_ok)
+{
+	Relation	attrdefrel;
+	ScanKeyData keys[2];
+	SysScanDesc scan;
+	HeapTuple	tuple;
+	Datum		adbin;
+	bool		isnull;
+	Node	   *expr;
+	char	   *defstr;
+
+	attrdefrel = table_open(AttrDefaultRelationId, AccessShareLock);
+	ScanKeyInit(&keys[0],
+				Anum_pg_attrdef_adrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	ScanKeyInit(&keys[1],
+				Anum_pg_attrdef_adnum,
+				BTEqualStrategyNumber, F_INT2EQ,
+				Int16GetDatum(attnum));
+	scan = systable_beginscan(attrdefrel, AttrDefaultIndexId, true,
+							  NULL, 2, keys);
+	tuple = systable_getnext(scan);
+	if (!HeapTupleIsValid(tuple))
+	{
+		if (missing_ok)
+		{
+			systable_endscan(scan);
+			table_close(attrdefrel, AccessShareLock);
+			return NULL;
+		}
+
+		elog(ERROR, "could not find default value for attribute %d of relation %u",
+			 attnum, relid);
+	}
+
+	adbin = heap_getattr(tuple, Anum_pg_attrdef_adbin,
+						 RelationGetDescr(attrdefrel), &isnull);
+	if (isnull)
+		elog(ERROR, "null adbin for attribute %d of relation %u",
+			 attnum, relid);
+
+	expr = stringToNode(TextDatumGetCString(adbin));
+
+	systable_endscan(scan);
+	table_close(attrdefrel, AccessShareLock);
+
+	defstr = deparse_expression(expr, dpcontext, false, false);
+
+	return defstr;
+}
+
+/*
+ * Return the list of inheritance parent relations of the given relation, in
+ * inhseqno order (which is the order in which they were specified in the
+ * command).
+ */
+static List *
+get_inheritance_parents(Oid relid)
+{
+	List	   *parents = NIL;
+	Relation	inhrel;
+	ScanKeyData key;
+	SysScanDesc scan;
+	HeapTuple	tuple;
+
+	inhrel = table_open(InheritsRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_inherits_inhrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(inhrel, InheritsRelidSeqnoIndexId, true,
+							  NULL, 1, &key);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(scan)))
+	{
+		Form_pg_inherits inhForm = (Form_pg_inherits) GETSTRUCT(tuple);
+
+		parents = lappend_oid(parents, inhForm->inhparent);
+	}
+
+	systable_endscan(scan);
+	table_close(inhrel, AccessShareLock);
+
+	return parents;
+}
+
+/*
+ * Return the deparsed partition bound specification of the given relation,
+ * from pg_class.relpartbound. We cannot use the parse tree's partbound as
+ * it's the untransformed expression.
+ */
+static char *
+get_partbound_spec_string(Oid relid)
+{
+	char	   *result;
+	Datum		bound;
+	Node	   *node;
+	HeapTuple	tuple;
+	bool		isnull;
+
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation with OID %u", relid);
+
+	bound = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_relpartbound,
+							&isnull);
+	if (isnull)
+		elog(ERROR, "null relpartbound for relation with OID %u", relid);
+
+	node = (Node *) stringToNode(TextDatumGetCString(bound));
+	ReleaseSysCache(tuple);
+
+	result = deparse_expression(node, NIL, false, false);
+
+	return result;
+}
+
+/*
+ * Render one user-specified sequence option (of a GENERATED AS IDENTITY
+ * column) as a plain string. Only the options the user wrote are rendered;
+ * we do not fill in the rest from pg_sequence, so that replaying the
+ * command derives the same defaults the original did.
+ *
+ * This must handle everything gram.y's SeqOptElem production admits, since
+ * we work on the raw options list: parse analysis consumes SEQUENCE NAME
+ * (see generateSerialExtraStmts), but it is still present in the raw tree.
+ * The exception is OWNED BY, which the caller filters out.
+ */
+static char *
+deparse_SeqOptElem(DefElem *elem)
+{
+	if (strcmp(elem->defname, "as") == 0)
+		return psprintf("AS %s", TypeNameToString(defGetTypeName(elem)));
+	else if (strcmp(elem->defname, "cache") == 0)
+		return psprintf("CACHE " INT64_FORMAT, defGetInt64(elem));
+	else if (strcmp(elem->defname, "cycle") == 0)
+		return defGetBoolean(elem) ? "CYCLE" : "NO CYCLE";
+	else if (strcmp(elem->defname, "increment") == 0)
+		return psprintf("INCREMENT BY " INT64_FORMAT, defGetInt64(elem));
+	else if (strcmp(elem->defname, "maxvalue") == 0)
+		return elem->arg ?
+			psprintf("MAXVALUE " INT64_FORMAT, defGetInt64(elem)) :
+			"NO MAXVALUE";
+	else if (strcmp(elem->defname, "minvalue") == 0)
+		return elem->arg ?
+			psprintf("MINVALUE " INT64_FORMAT, defGetInt64(elem)) :
+			"NO MINVALUE";
+	else if (strcmp(elem->defname, "start") == 0)
+		return psprintf("START WITH " INT64_FORMAT, defGetInt64(elem));
+	else if (strcmp(elem->defname, "restart") == 0)
+		return elem->arg ?
+			psprintf("RESTART WITH " INT64_FORMAT, defGetInt64(elem)) :
+			"RESTART";
+	else if (strcmp(elem->defname, "logged") == 0)
+		return "LOGGED";
+	else if (strcmp(elem->defname, "unlogged") == 0)
+		return "UNLOGGED";
+	else if (strcmp(elem->defname, "sequence_name") == 0)
+		return psprintf("SEQUENCE NAME %s",
+						NameListToQuotedString(defGetQualifiedName(elem)));
+
+	elog(ERROR, "unsupported sequence option \"%s\"", elem->defname);
+	return NULL;				/* keep compiler quiet */
+}
+
+/*
+ * Deparse the definition of a column identity to Jsonb.
+ *
+ * The GENERATED { ALWAYS | BY DEFAULT } keyword comes from the raw
+ * constraint; sequence options are emitted only if the user wrote any.
+ */
+static void
+deparse_ColumnIdentity(JsonbInState *state, char *parentKey,
+					   Constraint *constraint)
+{
+	List	   *options = NIL;
+
+	insert_jsonb_key(state, parentKey);
+	begin_jsonb_object(state);
+
+	append_jsonb_pair2(state,
+					   "fmt", jbv_str(" GENERATED %{option}s AS IDENTITY%{seq_options}s"),
+					   "option", jbv_str(constraint->generated_when == ATTRIBUTE_IDENTITY_ALWAYS ?
+										 "ALWAYS" : "BY DEFAULT"));
+
+	/*
+	 * OWNED BY is accepted among identity sequence options, but execution
+	 * overrides it: the sequence always ends up owned by the identity column.
+	 * Omitting it preserves the command's effects, and is also necessary for
+	 * a faithful replay, since the name it carries need not be
+	 * schema-qualified.
+	 */
+	foreach_node(DefElem, elem, constraint->options)
+	{
+		if (strcmp(elem->defname, "owned_by") == 0)
+			continue;
+
+		options = lappend(options, elem);
+	}
+
+	if (options != NIL)
+	{
+		begin_jsonb_clause(state, "seq_options", " (%{options: }s)");
+		insert_jsonb_key(state, "options");
+		begin_jsonb_array(state);
+		foreach_node(DefElem, elem, options)
+		{
+			JsonbValue	val = jbv_str(deparse_SeqOptElem(elem));
+
+			pushJsonbValue(state, WJB_ELEM, &val);
+		}
+		end_jsonb_array(state);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "seq_options");
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Deparse the NOT NULL clause of a column definition, including an optional
+ * constraint name and NO INHERIT, all from the raw constraint.
+ */
+static void
+deparse_ColumnNotNull(JsonbInState *state, char *parentKey,
+					  Constraint *constraint)
+{
+	insert_jsonb_key(state, parentKey);
+	begin_jsonb_object(state);
+
+	append_jsonb_pair1(state,
+					   "fmt", jbv_str(" %{name}sNOT NULL%{no_inherit}s"));
+
+	if (constraint->conname != NULL)
+		new_jsonb_string_clause(state, "name", "CONSTRAINT %{conname}I ",
+								"conname", constraint->conname);
+	else
+		new_jsonb_null(state, "name");
+
+	if (constraint->is_no_inherit)
+		new_jsonb_clause(state, "no_inherit", " NO INHERIT");
+	else
+		new_jsonb_null(state, "no_inherit");
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Emit the DEFAULT clause of a column, under "default", from the stored
+ * (cooked) expression in pg_attrdef. A DEFAULT whose cooked expression is a
+ * plain NULL constant is not stored there (see get_stored_default_expr) and
+ * is emitted as written. The caller has established the column has a DEFAULT.
+ */
+static void
+deparse_column_default(JsonbInState *state, Oid relid, AttrNumber attnum,
+					   List *dpcontext)
+{
+	char	   *defstr = get_stored_default_expr(relid, attnum, dpcontext, true);
+
+	new_jsonb_string_clause(state, "default", " DEFAULT %{default_expr}s",
+							"default_expr", defstr != NULL ? defstr : "NULL");
+}
+
+/*
+ * Emit the GENERATED ... AS ( expr ) STORED/VIRTUAL clause of a generated
+ * column, under "generated_column", from the stored generation expression.
+ * The caller has established the column is generated.
+ */
+static void
+deparse_column_generated(JsonbInState *state, Oid relid, AttrNumber attnum,
+						 List *dpcontext, Constraint *constraint)
+{
+	char	   *fmt = (constraint->generated_kind == ATTRIBUTE_GENERATED_VIRTUAL) ?
+		" GENERATED ALWAYS AS (%{generation_expr}s) VIRTUAL" :
+		" GENERATED ALWAYS AS (%{generation_expr}s) STORED";
+
+	new_jsonb_string_clause(state, "generated_column", fmt, "generation_expr",
+							get_stored_default_expr(relid, attnum, dpcontext,
+													false));
+}
+
+/*
+ * Deparse a ColumnDef node within a regular (non-typed) table creation.
+ *
+ * Only clauses present in the raw column definition are emitted; clauses the
+ * user did not write are emitted as JSON null. The column's data type and
+ * the expressions are obtained from the catalogs.
+ *
+ * NOT NULL constraints in the column definition are emitted directly in the
+ * column definition by this routine; other constraints are emitted
+ * elsewhere, from pg_constraint (the info in the parse node is incomplete
+ * anyway).
+ */
+static void
+deparse_ColumnDef(JsonbInState *state, Oid relid, List *dpcontext,
+				  ColumnDef *coldef)
+{
+	HeapTuple	attrTup;
+	Form_pg_attribute attrForm;
+	const char *serial_typename;
+	Constraint *constraint;
+
+	attrTup = SearchSysCacheAttName(relid, coldef->colname);
+	if (!HeapTupleIsValid(attrTup))
+		elog(ERROR, "could not find cache entry for column \"%s\" of relation %u",
+			 coldef->colname, relid);
+	attrForm = (Form_pg_attribute) GETSTRUCT(attrTup);
+
+	/* start making column object */
+	begin_jsonb_object(state);
+
+	/* fixed format string: every clause slot is always referenced */
+	append_jsonb_pair1(state, "fmt",
+					   jbv_str("%{name}I %{coltype}T%{storage}s%{compression}s%{collation}s"
+							   "%{not_null}s%{default}s%{identity_column}s%{generated_column}s"));
+
+	/* NAME and TYPE (always present, referenced bare) */
+	append_jsonb_pair2(state,
+					   "name", jbv_str(coldef->colname),
+					   "type", jbv_str("column"));
+
+	/*
+	 * COLUMN TYPE. A serial column is emitted exactly as the user typed it;
+	 * expanding it into an integer type plus a sequence would defeat the
+	 * point of preserving the command (a replay must create its own
+	 * sequence). Other types are obtained from the catalog, which normalizes
+	 * the spelling but preserves the typmod the user gave.
+	 */
+	serial_typename = get_serial_typename(coldef->typeName);
+	if (serial_typename != NULL)
+	{
+		insert_jsonb_key(state, "coltype");
+		begin_jsonb_object(state);
+		append_jsonb_pair4(state,
+						   "schemaname", jbv_str(""),
+						   "typename", jbv_str(serial_typename),
+						   "typmod", jbv_str(""),
+						   "typarray", jbv_bool(false));
+		end_jsonb_object(state);
+	}
+	else
+		new_jsonb_for_type(state, "coltype", attrForm->atttypid,
+						   attrForm->atttypmod);
+
+	/* STORAGE: only if the user wrote it; the value as typed */
+	if (coldef->storage_name != NULL)
+		new_jsonb_string_clause(state, "storage", " STORAGE %{colstorage}s",
+								"colstorage", coldef->storage_name);
+	else
+		new_jsonb_null(state, "storage");
+
+	/*
+	 * COMPRESSION: only if the user wrote it; the value as typed. We
+	 * deliberately do not emit the effective attcompression for columns
+	 * without the clause, so that a replay adopts whatever the compression
+	 * default is there. (The value can be the keyword "default", which
+	 * survives being quoted by %I.)
+	 */
+	if (coldef->compression != NULL)
+		new_jsonb_string_clause(state, "compression",
+								" COMPRESSION %{compression_method}I",
+								"compression_method", coldef->compression);
+	else
+		new_jsonb_null(state, "compression");
+
+	/* COLLATE: only if the user wrote it; the resolved collation */
+	if (coldef->collClause != NULL)
+	{
+		begin_jsonb_clause(state, "collation", " COLLATE %{collation_name}D");
+		new_jsonb_for_qualname_id(state, CollationRelationId,
+								  attrForm->attcollation, "collation_name",
+								  true);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "collation");
+
+	/*
+	 * NOT NULL: only if the user wrote it on this column. NOT NULL
+	 * constraints that the system derives (from a PRIMARY KEY or a serial
+	 * type) are not in the raw column definition and are correctly re-derived
+	 * on replay.
+	 */
+	constraint = find_column_constraint(coldef, CONSTR_NOTNULL);
+	if (constraint != NULL)
+		deparse_ColumnNotNull(state, "not_null", constraint);
+	else
+		new_jsonb_null(state, "not_null");
+
+	/*
+	 * DEFAULT: only if the user wrote it; the stored (cooked) expression from
+	 * pg_attrdef. A serial column has no DEFAULT in the raw tree, so the
+	 * internally-generated nextval() default does not leak out. A DEFAULT
+	 * whose cooked expression is a plain NULL constant has no stored
+	 * expression at all; emit it as written.
+	 */
+	constraint = find_column_constraint(coldef, CONSTR_DEFAULT);
+	if (constraint != NULL)
+		deparse_column_default(state, relid, attrForm->attnum, dpcontext);
+	else
+		new_jsonb_null(state, "default");
+
+	/* GENERATED AS IDENTITY */
+	constraint = find_column_constraint(coldef, CONSTR_IDENTITY);
+	if (constraint != NULL)
+		deparse_ColumnIdentity(state, "identity_column", constraint);
+	else
+		new_jsonb_null(state, "identity_column");
+
+	/* GENERATED ... AS ( expr ) STORED/VIRTUAL */
+	constraint = find_column_constraint(coldef, CONSTR_GENERATED);
+	if (constraint != NULL)
+		deparse_column_generated(state, relid, attrForm->attnum, dpcontext,
+								 constraint);
+	else
+		new_jsonb_null(state, "generated_column");
+
+	ReleaseSysCache(attrTup);
+
+	/* mark the end of one column object */
+	end_jsonb_object(state);
+}
+
+/*
+ * Render an attstorage value back as the keyword the grammar accepts.
+ */
+static const char *
+storage_keyword(char storage)
+{
+	switch (storage)
+	{
+		case TYPSTORAGE_PLAIN:
+			return "PLAIN";
+		case TYPSTORAGE_EXTERNAL:
+			return "EXTERNAL";
+		case TYPSTORAGE_EXTENDED:
+			return "EXTENDED";
+		case TYPSTORAGE_MAIN:
+			return "MAIN";
+	}
+
+	elog(ERROR, "invalid attstorage value \'%c\'", storage);
+	return NULL;				/* keep compiler quiet */
+}
+
+/*
+ * Deparse a column that a LIKE clause contributed.
+ *
+ * Such a column has no definition in the raw statement -- the clause is
+ * expanded against the source table before execution -- so everything is read
+ * back from the catalogs. The user-intent rule that governs
+ * deparse_ColumnDef() has nothing to work from here and is replaced by the
+ * closest equivalent: a clause is emitted when the column's property differs
+ * from what a column of that type would get by default, which is exactly when
+ * it has to be stated for the replayed column to come out the same. Whether
+ * the property was copied because the user asked for it (INCLUDING STORAGE
+ * and friends) need not be consulted, since a property that was not copied
+ * holds its default and so emits nothing either way.
+ */
+static void
+deparse_ColumnDef_like(JsonbInState *state, Oid relid, List *dpcontext,
+					   Form_pg_attribute attrForm)
+{
+	AttrNumber	attnum = attrForm->attnum;
+
+	begin_jsonb_object(state);
+
+	/* fixed format string: every clause slot is always referenced */
+	append_jsonb_pair1(state, "fmt",
+					   jbv_str("%{name}I %{coltype}T%{storage}s%{compression}s%{collation}s"
+							   "%{not_null}s%{default}s%{identity_column}s%{generated_column}s"));
+
+	append_jsonb_pair2(state,
+					   "name", jbv_str(NameStr(attrForm->attname)),
+					   "type", jbv_str("column"));
+
+	new_jsonb_for_type(state, "coltype", attrForm->atttypid,
+					   attrForm->atttypmod);
+
+	/* STORAGE: only where it differs from the type's own */
+	if (attrForm->attstorage != get_typstorage(attrForm->atttypid))
+		new_jsonb_string_clause(state, "storage", " STORAGE %{colstorage}s",
+								"colstorage",
+								storage_keyword(attrForm->attstorage));
+	else
+		new_jsonb_null(state, "storage");
+
+	/* COMPRESSION: only where one was set */
+	if (CompressionMethodIsValid(attrForm->attcompression))
+		new_jsonb_string_clause(state, "compression",
+								" COMPRESSION %{compression_method}I",
+								"compression_method",
+								GetCompressionMethodName(attrForm->attcompression));
+	else
+		new_jsonb_null(state, "compression");
+
+	/* COLLATE: only where it differs from the type's own */
+	if (OidIsValid(attrForm->attcollation) &&
+		attrForm->attcollation != get_typcollation(attrForm->atttypid))
+	{
+		begin_jsonb_clause(state, "collation", " COLLATE %{collation_name}D");
+		new_jsonb_for_qualname_id(state, CollationRelationId,
+								  attrForm->attcollation, "collation_name",
+								  true);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "collation");
+
+	/*
+	 * NOT NULL, named. A LIKE clause copies the source's constraint name
+	 * verbatim, so a replay that let the name be derived afresh would not
+	 * reproduce the command; emit it as a named column constraint.
+	 */
+	if (attrForm->attnotnull)
+	{
+		Constraint *constraint = makeNode(Constraint);
+		HeapTuple	contup = findNotNullConstraintAttnum(relid, attnum);
+
+		constraint->contype = CONSTR_NOTNULL;
+		if (HeapTupleIsValid(contup))
+		{
+			Form_pg_constraint conForm;
+
+			conForm = (Form_pg_constraint) GETSTRUCT(contup);
+			constraint->conname = pstrdup(NameStr(conForm->conname));
+			constraint->is_no_inherit = conForm->connoinherit;
+			heap_freetuple(contup);
+		}
+
+		deparse_ColumnNotNull(state, "not_null", constraint);
+	}
+	else
+		new_jsonb_null(state, "not_null");
+
+	/* DEFAULT, unless the expression is a generation expression */
+	if (attrForm->atthasdef && !attrForm->attgenerated)
+		deparse_column_default(state, relid, attnum, dpcontext);
+	else
+		new_jsonb_null(state, "default");
+
+	/* GENERATED AS IDENTITY, with the sequence parameters that were copied */
+	if (attrForm->attidentity)
+	{
+		Relation	rel = relation_open(relid, AccessShareLock);
+		Constraint *constraint = makeNode(Constraint);
+
+		constraint->contype = CONSTR_IDENTITY;
+		constraint->generated_when = attrForm->attidentity;
+		constraint->options =
+			sequence_options(getIdentitySequence(rel, attnum, false));
+		relation_close(rel, AccessShareLock);
+
+		deparse_ColumnIdentity(state, "identity_column", constraint);
+	}
+	else
+		new_jsonb_null(state, "identity_column");
+
+	/* GENERATED ... AS ( expr ) STORED/VIRTUAL */
+	if (attrForm->attgenerated)
+	{
+		Constraint *constraint = makeNode(Constraint);
+
+		constraint->contype = CONSTR_GENERATED;
+		constraint->generated_kind = attrForm->attgenerated;
+
+		deparse_column_generated(state, relid, attnum, dpcontext, constraint);
+	}
+	else
+		new_jsonb_null(state, "generated_column");
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Deparse a ColumnDef node within a typed or partition table creation.
+ * This is simpler than the regular case, because the type comes from the
+ * composite type or the parent; only the options the user listed are
+ * emitted.
+ */
+static void
+deparse_ColumnDef_typed(JsonbInState *state, Oid relid, List *dpcontext,
+						ColumnDef *coldef)
+{
+	HeapTuple	attrTup;
+	Form_pg_attribute attrForm;
+	Constraint *constraint;
+
+	attrTup = SearchSysCacheAttName(relid, coldef->colname);
+	if (!HeapTupleIsValid(attrTup))
+		elog(ERROR, "could not find cache entry for column \"%s\" of relation %u",
+			 coldef->colname, relid);
+	attrForm = (Form_pg_attribute) GETSTRUCT(attrTup);
+
+	/* Identity columns cannot be specified this way */
+	if (find_column_constraint(coldef, CONSTR_IDENTITY) != NULL)
+		elog(ERROR, "unexpected constraint in typed or partition table column");
+
+	/* start making column object */
+	begin_jsonb_object(state);
+
+	append_jsonb_pair1(state, "fmt",
+					   jbv_str("%{name}I WITH OPTIONS%{not_null}s%{default}s%{generated_column}s"));
+
+	/* NAME and TYPE */
+	append_jsonb_pair2(state,
+					   "name", jbv_str(coldef->colname),
+					   "type", jbv_str("column"));
+
+	/* NOT NULL */
+	constraint = find_column_constraint(coldef, CONSTR_NOTNULL);
+	if (constraint != NULL)
+		deparse_ColumnNotNull(state, "not_null", constraint);
+	else
+		new_jsonb_null(state, "not_null");
+
+	/* DEFAULT; as in deparse_ColumnDef, DEFAULT NULL has no stored expr */
+	constraint = find_column_constraint(coldef, CONSTR_DEFAULT);
+	if (constraint != NULL)
+		deparse_column_default(state, relid, attrForm->attnum, dpcontext);
+	else
+		new_jsonb_null(state, "default");
+
+	/* GENERATED ... AS ( expr ), e.g. overridden in a partition */
+	constraint = find_column_constraint(coldef, CONSTR_GENERATED);
+	if (constraint != NULL)
+		deparse_column_generated(state, relid, attrForm->attnum, dpcontext,
+								 constraint);
+	else
+		new_jsonb_null(state, "generated_column");
+
+	ReleaseSysCache(attrTup);
+
+	/* mark the end of column object */
+	end_jsonb_object(state);
+}
+
+/*
+ * Deparse a raw table-level NOT NULL constraint (NOT NULL column_name).
+ *
+ * These are not obtained from pg_constraint like the other table
+ * constraints; the raw tree tells us exactly which ones the user wrote.
+ */
+static void
+deparse_TableNotNull(JsonbInState *state, Constraint *constraint)
+{
+	begin_jsonb_object(state);
+
+	append_jsonb_pair3(state,
+					   "fmt", jbv_str("%{name}sNOT NULL %{column}I%{no_inherit}s"),
+					   "type", jbv_str("constraint"),
+					   "contype", jbv_str("not null"));
+
+	if (constraint->conname != NULL)
+		new_jsonb_string_clause(state, "name", "CONSTRAINT %{conname}I ",
+								"conname", constraint->conname);
+	else
+		new_jsonb_null(state, "name");
+
+	append_jsonb_pair1(state, "column",
+					   jbv_str(strVal(linitial(constraint->keys))));
+
+	if (constraint->is_no_inherit)
+		new_jsonb_clause(state, "no_inherit", " NO INHERIT");
+	else
+		new_jsonb_null(state, "no_inherit");
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Verify that "suffix" is an exact trailing substring of "def" and, if so,
+ * truncate it off "def" in place and return true; otherwise leave def
+ * unchanged and return false. Used to peel catalog-flag-determined trailing
+ * clauses off a pg_get_constraintdef result; the caller reports a mismatch,
+ * which would mean the regenerated text has fallen out of sync with
+ * ruleutils.
+ */
+static bool
+strip_suffix(char *def, const char *suffix)
+{
+	size_t		deflen = strlen(def);
+	size_t		suffixlen = strlen(suffix);
+
+	if (deflen < suffixlen ||
+		strcmp(def + deflen - suffixlen, suffix) != 0)
+		return false;
+	def[deflen - suffixlen] = '\0';
+	return true;
+}
+
+/*
+ * Split the constraint-attribute trailer (DEFERRABLE etc.) off a
+ * pg_get_constraintdef result, truncating def in place. The trailer text
+ * is reconstructed from the pg_constraint flags exactly as
+ * pg_get_constraintdef_worker appends it, so a suffix mismatch means the
+ * two have fallen out of sync and is reported as an internal error rather
+ * than risking wrong output. Returns the trailer, or NULL if there is
+ * none.
+ */
+static char *
+split_constraint_attributes(char *def, Form_pg_constraint constrForm)
+{
+	StringInfoData trailer;
+
+	initStringInfo(&trailer);
+
+	if (constrForm->condeferrable)
+		appendStringInfoString(&trailer, " DEFERRABLE");
+
+	if (constrForm->condeferred)
+		appendStringInfoString(&trailer, " INITIALLY DEFERRED");
+
+	if (!constrForm->conenforced)
+		appendStringInfoString(&trailer, " NOT ENFORCED");
+	else if (!constrForm->convalidated)
+		appendStringInfoString(&trailer, " NOT VALID");
+
+	if (trailer.len == 0)
+		return NULL;
+
+	if (!strip_suffix(def, trailer.data))
+		elog(ERROR, "unexpected format of constraint definition \"%s\"", def);
+
+	return trailer.data;
+}
+
+/*
+ * Split the predicate (" WHERE (...)") off an exclusion constraint
+ * definition whose attribute trailer has already been removed, truncating
+ * def in place. As above, the predicate text is regenerated exactly as
+ * pg_get_constraintdef produced it -- through pg_get_expr, which uses the
+ * same deparse context and the same pretty flags (PRETTYFLAG_INDENT, which
+ * can render constructs such as CASE across multiple lines) -- and any
+ * mismatch is an internal error. Returns the predicate clause, or NULL if
+ * the index has none.
+ */
+static char *
+split_exclusion_where(char *def, Oid indexId, Oid relationId)
+{
+	HeapTuple	indtup;
+	Datum		pred;
+	bool		isnull;
+	char	   *predstr;
+	char	   *wheretext;
+
+	indtup = SearchSysCache1(INDEXRELID, ObjectIdGetDatum(indexId));
+	if (!HeapTupleIsValid(indtup))
+		elog(ERROR, "cache lookup failed for index %u", indexId);
+	pred = SysCacheGetAttr(INDEXRELID, indtup, Anum_pg_index_indpred,
+						   &isnull);
+	if (isnull)
+	{
+		ReleaseSysCache(indtup);
+		return NULL;
+	}
+
+	predstr = TextDatumGetCString(DirectFunctionCall2(pg_get_expr, pred,
+													  ObjectIdGetDatum(relationId)));
+	ReleaseSysCache(indtup);
+
+	wheretext = psprintf(" WHERE (%s)", predstr);
+
+	if (!strip_suffix(def, wheretext))
+		elog(ERROR, "unexpected predicate in constraint definition \"%s\"", def);
+
+	return wheretext;
+}
+
+/*
+ * Emit one constraint (given by OID) as a JSON constraint object into the
+ * current container. Shared by CREATE TABLE (table-element constraints) and
+ * ALTER TABLE ADD CONSTRAINT.
+ *
+ * The bulk of the definition comes from pg_get_constraintdef, but for an
+ * index-backed constraint (PRIMARY KEY / UNIQUE / EXCLUDE) the clauses that
+ * pg_get_constraintdef either omits (index storage parameters) or renders in
+ * the wrong grammatical position (USING INDEX TABLESPACE relative to the
+ * attribute trailer and the exclusion predicate) are emitted through their
+ * own slots, sourced from the collected CREATE INDEX subcommand (cmds).
+ *
+ * The constraint name is emitted only if the user specified one -- i.e. its
+ * catalog name appears in typed_names, the list of names the user wrote --
+ * so that an unnamed constraint gets the same automatically-derived name on
+ * replay.
+ */
+static void
+deparse_one_constraint(JsonbInState *state, Oid conoid, List *cmds,
+					   List *typed_names)
+{
+	HeapTuple	tuple;
+	Form_pg_constraint constrForm;
+	char	   *contype;
+	char	   *conname;
+	char	   *definition;
+	char	   *attributes = NULL;
+	char	   *wheretext = NULL;
+	bool		user_named = false;
+	IndexStmt  *idxstmt = NULL;
+	ListCell   *lc;
+
+	tuple = SearchSysCache1(CONSTROID, ObjectIdGetDatum(conoid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for constraint %u", conoid);
+
+	constrForm = (Form_pg_constraint) GETSTRUCT(tuple);
+
+	switch (constrForm->contype)
+	{
+		case CONSTRAINT_CHECK:
+			contype = "check";
+			break;
+		case CONSTRAINT_FOREIGN:
+			contype = "foreign key";
+			break;
+		case CONSTRAINT_PRIMARY:
+			contype = "primary key";
+			break;
+		case CONSTRAINT_UNIQUE:
+			contype = "unique";
+			break;
+		case CONSTRAINT_EXCLUSION:
+			contype = "exclusion";
+			break;
+		default:
+			elog(ERROR, "unrecognized constraint type");
+	}
+
+	conname = NameStr(constrForm->conname);
+
+	/* Did the user name this constraint? */
+	foreach(lc, typed_names)
+	{
+		if (strcmp(conname, (const char *) lfirst(lc)) == 0)
+		{
+			user_named = true;
+			break;
+		}
+	}
+
+	/*
+	 * For an index-backed constraint, find the collected CREATE INDEX
+	 * subcommand that created its index; it records the clauses that
+	 * execution honored for it.
+	 */
+	if (constrForm->contype == CONSTRAINT_PRIMARY ||
+		constrForm->contype == CONSTRAINT_UNIQUE ||
+		constrForm->contype == CONSTRAINT_EXCLUSION)
+	{
+		idxstmt = find_collected_index_stmt(cmds, constrForm->conindid);
+
+		if (idxstmt == NULL)
+			elog(ERROR, "could not find collected CREATE INDEX command for index %u",
+				 constrForm->conindid);
+	}
+
+	definition = TextDatumGetCString(DirectFunctionCall1(pg_get_constraintdef,
+														 ObjectIdGetDatum(conoid)));
+
+	/*
+	 * For index-backed constraints, split the attribute trailer (and the
+	 * exclusion predicate) off the definition so that the tablespace clause
+	 * can be emitted in its grammatical position before them. CHECK and
+	 * FOREIGN KEY constraints have no such clause, so their definitions can
+	 * be kept whole.
+	 */
+	if (idxstmt != NULL)
+	{
+		attributes = split_constraint_attributes(definition, constrForm);
+
+		if (constrForm->contype == CONSTRAINT_EXCLUSION)
+			wheretext = split_exclusion_where(definition,
+											  constrForm->conindid,
+											  constrForm->conrelid);
+	}
+
+	begin_jsonb_object(state);
+
+	/* fixed format string always references the optional slots */
+	append_jsonb_pair4(state,
+					   "fmt", jbv_str("%{name}s%{definition}s%{with}s"
+									  "%{using_index_tablespace}s"
+									  "%{exclusion_where}s%{attributes}s"),
+					   "type", jbv_str("constraint"),
+					   "contype", jbv_str(contype),
+					   "definition", jbv_str(definition));
+
+	if (user_named)
+		new_jsonb_string_clause(state, "name", "CONSTRAINT %{conname}I ",
+								"conname", conname);
+	else
+		new_jsonb_null(state, "name");
+
+	/*
+	 * WITH (index storage parameters), only if the user wrote them. Not for
+	 * exclusion constraints: pg_get_constraintdef renders those via
+	 * pg_get_indexdef_worker, whose output already carries the WITH clause
+	 * (in its grammatical position), unlike the PRIMARY KEY and UNIQUE
+	 * output, which omits it.
+	 */
+	if (idxstmt != NULL && idxstmt->options != NIL &&
+		constrForm->contype != CONSTRAINT_EXCLUSION)
+	{
+		ListCell   *optcell;
+
+		begin_jsonb_clause(state, "with", " WITH (%{options:, }s)");
+		insert_jsonb_key(state, "options");
+		begin_jsonb_array(state);
+
+		foreach(optcell, idxstmt->options)
+			deparse_DefElem(state, (DefElem *) lfirst(optcell), false);
+
+		end_jsonb_array(state);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "with");
+
+	/* USING INDEX TABLESPACE, only if the user wrote it */
+	if (idxstmt != NULL && idxstmt->tableSpace != NULL)
+		new_jsonb_string_clause(state, "using_index_tablespace",
+								" USING INDEX TABLESPACE %{tblspc}I",
+								"tblspc", idxstmt->tableSpace);
+	else
+		new_jsonb_null(state, "using_index_tablespace");
+
+	/*
+	 * The split-off texts are emitted through value slots, not as format
+	 * strings: the exclusion predicate is user-derived text that must not be
+	 * scanned for conversion specifiers.
+	 */
+	if (wheretext != NULL)
+		new_jsonb_string_clause(state, "exclusion_where", "%{where}s",
+								"where", wheretext);
+	else
+		new_jsonb_null(state, "exclusion_where");
+
+	if (attributes != NULL)
+		new_jsonb_string_clause(state, "attributes", "%{attrs}s",
+								"attrs", attributes);
+	else
+		new_jsonb_null(state, "attributes");
+
+	end_jsonb_object(state);
+
+	ReleaseSysCache(tuple);
+}
+
+/*
+ * Subroutine for CREATE TABLE deparsing.
+ *
+ * Given a table OID, obtain its constraints from pg_constraint and append
+ * them to the given JsonbInState, as table-level constraints.
+ *
+ * All the local constraints of the table were created by this command (LIKE
+ * clauses are not supported), so emitting all of them preserves what the
+ * user wrote; constraints given with a column definition are normalized to
+ * table constraints, which has the same effects. The constraint name is
+ * emitted only if the user specified one (i.e. the name appears in
+ * typed_names, collected from the raw tree); an unnamed constraint gets the
+ * same automatically-derived name on replay. NOT NULL constraints are
+ * handled from the raw tree instead (see deparse_ColumnNotNull and
+ * deparse_TableNotNull).
+ *
+ * cmds is the list of commands collected for the query; the CREATE INDEX
+ * subcommands in it tell us whether a USING INDEX TABLESPACE clause was in
+ * effect for each index-backed constraint, so the clause is only emitted if
+ * the user wrote it (an index can end up in a non-default tablespace via
+ * the default_tablespace GUC as well, and that decision is not ours to
+ * freeze).
+ *
+ * Returns the number of constraint elements emitted, which the caller adds to
+ * its own count of the elements in the list.
+ */
+static int
+deparse_Constraints(JsonbInState *state, Oid relationId, List *typed_names,
+					List *cmds)
+{
+	Relation	conRel;
+	ScanKeyData key;
+	SysScanDesc scan;
+	HeapTuple	tuple;
+	Oid		   *conoids;
+	int			nconstraints = 0;
+	int			maxconstraints = 8;
+
+	Assert(OidIsValid(relationId));
+
+	/*
+	 * Scan pg_constraint to fetch all constraints linked to the given
+	 * relation. The index scan returns them in name order; sort them by OID
+	 * instead, which for constraints created by a single command is their
+	 * creation order, so that replaying the deparsed command creates them in
+	 * the same order as the original did.
+	 */
+	conoids = palloc_array(Oid, maxconstraints);
+	conRel = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key, Anum_pg_constraint_conrelid, BTEqualStrategyNumber,
+				F_OIDEQ, ObjectIdGetDatum(relationId));
+	scan = systable_beginscan(conRel, ConstraintRelidTypidNameIndexId, true,
+							  NULL, 1, &key);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(scan)))
+	{
+		Form_pg_constraint constrForm;
+
+		constrForm = (Form_pg_constraint) GETSTRUCT(tuple);
+
+		/* No need to deparse constraints inherited from parent table. */
+		if (!constrForm->conislocal)
+			continue;
+
+		/* NOT NULL constraints are emitted from the raw tree instead */
+		if (constrForm->contype == CONSTRAINT_NOTNULL)
+			continue;
+
+		if (nconstraints >= maxconstraints)
+		{
+			maxconstraints *= 2;
+			conoids = repalloc_array(conoids, Oid, maxconstraints);
+		}
+		conoids[nconstraints++] = constrForm->oid;
+	}
+
+	systable_endscan(scan);
+	table_close(conRel, AccessShareLock);
+
+	qsort(conoids, nconstraints, sizeof(Oid), oid_cmp);
+
+	/* Emit each constraint, in creation (OID) order. */
+	for (int i = 0; i < nconstraints; i++)
+		deparse_one_constraint(state, conoids[i], cmds, typed_names);
+
+	pfree(conoids);
+
+	return nconstraints;
+}
+
+/*
+ * Every constraint name on the relation, as a list of plain strings in the
+ * form deparse_one_constraint() expects for its "user named it" test.
+ */
+static List *
+all_constraint_names(Oid relationId)
+{
+	Relation	conRel;
+	ScanKeyData key;
+	SysScanDesc scan;
+	HeapTuple	tuple;
+	List	   *names = NIL;
+
+	conRel = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key, Anum_pg_constraint_conrelid, BTEqualStrategyNumber,
+				F_OIDEQ, ObjectIdGetDatum(relationId));
+	scan = systable_beginscan(conRel, ConstraintRelidTypidNameIndexId, true,
+							  NULL, 1, &key);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(scan)))
+	{
+		Form_pg_constraint constrForm = (Form_pg_constraint) GETSTRUCT(tuple);
+
+		names = lappend(names, pstrdup(NameStr(constrForm->conname)));
+	}
+
+	systable_endscan(scan);
+	table_close(conRel, AccessShareLock);
+
+	return names;
+}
+
+/*
+ * Does this raw table element list contain a LIKE clause?
+ */
+static bool
+has_like_clause(List *tableElts)
+{
+	ListCell   *lc;
+
+	foreach(lc, tableElts)
+	{
+		if (IsA(lfirst(lc), TableLikeClause))
+			return true;
+	}
+	return false;
+}
+
+/*
+ * Subroutine for CREATE TABLE deparsing.
+ *
+ * Insert columns and constraints elements (if any) in output JsonbInState.
+ */
+static void
+add_table_elems(JsonbInState *state, Oid objectId, CreateStmt *stmt,
+				List *dpcontext, List *typed_names,
+				List *cmds, bool typed)
+{
+	bool		parenthesize;
+	int			nelems = 0;
+	ListCell   *lc;
+
+	insert_jsonb_key(state, "table_elements");
+	begin_jsonb_object(state);
+
+	/*
+	 * Process table elements: column definitions and table-level NOT NULL
+	 * constraints come from the raw parse tree; the other constraints come
+	 * from pg_constraint, because the parse node lacks things such as the
+	 * definitive constraint definition.
+	 */
+	insert_jsonb_key(state, "elements");
+	begin_jsonb_array(state);
+
+	if (has_like_clause(stmt->tableElts))
+	{
+		Relation	rel;
+		TupleDesc	tupdesc;
+
+		/*
+		 * A LIKE clause contributed columns that are not in the raw
+		 * statement, so the column list has to be driven by the catalog to
+		 * pick them up. Walking it in attribute order also keeps the columns
+		 * in the order the original command produced them, which the raw
+		 * element list no longer describes once a LIKE clause is interleaved
+		 * with written-out columns.
+		 *
+		 * A column that does appear in the raw statement is still deparsed
+		 * from there, so that what the user wrote about it is preserved as
+		 * usual; only the ones the clause brought in fall back to the
+		 * catalog.
+		 */
+		rel = relation_open(objectId, AccessShareLock);
+		tupdesc = RelationGetDescr(rel);
+
+		for (int i = 0; i < tupdesc->natts; i++)
+		{
+			Form_pg_attribute att = TupleDescAttr(tupdesc, i);
+			ColumnDef  *raw = NULL;
+
+			if (att->attisdropped)
+				continue;
+
+			/* an inherited column is re-derived by the INHERITS clause */
+			if (!att->attislocal)
+				continue;
+
+			/* Find the raw definition of the named column */
+			foreach(lc, stmt->tableElts)
+			{
+				Node	   *elt = (Node *) lfirst(lc);
+
+				if (IsA(elt, ColumnDef) &&
+					strcmp(((ColumnDef *) elt)->colname, NameStr(att->attname)) == 0)
+				{
+					raw = (ColumnDef *) elt;
+					break;
+				}
+			}
+
+			if (raw != NULL)
+				deparse_ColumnDef(state, objectId, dpcontext, raw);
+			else
+				deparse_ColumnDef_like(state, objectId, dpcontext, att);
+
+			nelems++;
+		}
+
+		relation_close(rel, AccessShareLock);
+
+		/*
+		 * A LIKE clause copies constraint names verbatim, so the names in the
+		 * catalog are the ones the command produced and every one of them has
+		 * to be emitted. Treating them all as user-named does that; for a
+		 * constraint whose name was in fact derived, emitting it is harmless,
+		 * since the name derived on replay is the same one.
+		 */
+		typed_names = all_constraint_names(objectId);
+
+		/* table-level NOT NULL constraints, as in the plain case below */
+		foreach(lc, stmt->tableElts)
+		{
+			Node	   *elt = (Node *) lfirst(lc);
+
+			if (IsA(elt, Constraint) &&
+				((Constraint *) elt)->contype == CONSTR_NOTNULL)
+			{
+				deparse_TableNotNull(state, (Constraint *) elt);
+				nelems++;
+			}
+		}
+	}
+	else
+	{
+		foreach(lc, stmt->tableElts)
+		{
+			Node	   *elt = (Node *) lfirst(lc);
+
+			switch (nodeTag(elt))
+			{
+				case T_ColumnDef:
+					if (typed)
+						deparse_ColumnDef_typed(state, objectId, dpcontext,
+												(ColumnDef *) elt);
+					else
+						deparse_ColumnDef(state, objectId, dpcontext,
+										  (ColumnDef *) elt);
+					nelems++;
+
+					break;
+				case T_Constraint:
+					{
+						Constraint *constraint = (Constraint *) elt;
+
+						/*
+						 * Raw table-level NOT NULL constraints are emitted
+						 * from the parse tree; the others from pg_constraint
+						 * below.
+						 */
+						if (constraint->contype == CONSTR_NOTNULL)
+						{
+							deparse_TableNotNull(state, constraint);
+							nelems++;
+						}
+					}
+
+					break;
+				default:
+					elog(ERROR, "invalid node type %d", nodeTag(elt));
+			}
+		}
+	}
+
+	nelems += deparse_Constraints(state, objectId, typed_names, cmds);
+
+	end_jsonb_array(state);
+
+	/*
+	 * Decide whether to put '()' around the element list. Parens are needed
+	 * when actual elements are present (eg: create table t1 (a int)), or in
+	 * the inherit case even with no local elements (eg: create table t1 ()
+	 * inherits (t2)); they are not needed for the 'partition of' or 'of type'
+	 * cases with no local elements, where the list must expand to nothing.
+	 */
+	parenthesize = (stmt->partbound == NULL && !typed) || nelems > 0;
+
+	/* leading space lives here; root references a bare %{table_elements}s */
+	append_jsonb_pair1(state,
+					   "fmt", jbv_str(parenthesize ? " (%{elements:, }s)" : "%{elements:, }s"));
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Deparse DefElems, as used by Create Table
+ */
+static void
+deparse_DefElem(JsonbInState *state, DefElem *elem, bool is_reset)
+{
+	begin_jsonb_object(state);
+
+	/* fixed format string: the optional value slot is always referenced */
+	append_jsonb_pair1(state, "fmt", jbv_str("%{label}s%{value}s"));
+
+	/* LABEL: a possibly schema-qualified identifier */
+	insert_jsonb_key(state, "label");
+	begin_jsonb_object(state);
+	append_jsonb_pair2(state,
+					   "fmt", jbv_str("%{schema}s%{label}I"),
+					   "label", jbv_str(elem->defname));
+
+	if (elem->defnamespace != NULL)
+		new_jsonb_string_clause(state, "schema", "%{schema_name}I.",
+								"schema_name", elem->defnamespace);
+	else
+		new_jsonb_null(state, "schema");
+	end_jsonb_object(state);
+
+	/* VALUE (omitted for RESET) */
+	if (!is_reset)
+		new_jsonb_string_clause(state, "value", " = %{value_literal}L",
+								"value_literal",
+								elem->arg ? defGetString(elem) :
+								defGetBoolean(elem) ? "true" : "false");
+	else
+		new_jsonb_null(state, "value");
+
+	end_jsonb_object(state);
+}
+
+/*
+ * Deparse WITH clause, as used by Create Table.
+ */
+static void
+deparse_withObj(JsonbInState *state, CreateStmt *stmt)
+{
+	/* WITH */
+	insert_jsonb_key(state, "with");
+	begin_jsonb_array(state);
+
+	/* add elements to array */
+	foreach_node(DefElem, opt, stmt->options)
+	{
+		deparse_DefElem(state, opt, false);
+	}
+
+	/* with's array end */
+	end_jsonb_array(state);
+}
+
+/*
+ * Collect, from the raw parse tree, the set of constraint names the user
+ * specified.
+ */
+static void
+collect_raw_constraint_names(CreateStmt *stmt, List **typed_names)
+{
+	ListCell   *lc;
+
+	*typed_names = NIL;
+
+	foreach(lc, stmt->tableElts)
+	{
+		Node	   *elt = (Node *) lfirst(lc);
+		List	   *constraints = NIL;
+
+		if (IsA(elt, ColumnDef))
+			constraints = ((ColumnDef *) elt)->constraints;
+		else if (IsA(elt, Constraint))
+			constraints = list_make1(elt);
+
+		foreach_node(Constraint, constraint, constraints)
+		{
+			if (constraint->conname != NULL)
+				*typed_names = lappend(*typed_names, constraint->conname);
+		}
+	}
+}
+
+/*
+ * Find, in the list of commands collected for this query, the CREATE INDEX
+ * subcommand that created the index with the given OID, and return its
+ * IndexStmt.
+ *
+ * For an index-backed constraint of CREATE TABLE, this is the (merged)
+ * IndexStmt that transformCreateStmt generated for it, so it records
+ * exactly the clauses that execution honored -- notably whether a USING
+ * INDEX TABLESPACE clause was in effect. Matching by index OID (through
+ * pg_constraint.conindid) is immune to transformIndexConstraints having
+ * merged redundant raw constraints, which any pairing based on the raw
+ * parse tree is not.
+ */
+static IndexStmt *
+find_collected_index_stmt(List *cmds, Oid indexId)
+{
+	ListCell   *lc;
+
+	foreach(lc, cmds)
+	{
+		CollectedCommand *cmd = (CollectedCommand *) lfirst(lc);
+
+		if (cmd->type == SCT_Simple &&
+			cmd->parsetree != NULL &&
+			IsA(cmd->parsetree, IndexStmt) &&
+			cmd->d.simple.address.objectId == indexId)
+			return (IndexStmt *) cmd->parsetree;
+	}
+
+	return NULL;
+}
+
+/*
+ * Deparse a CreateStmt (CREATE TABLE).
+ *
+ * Given a table OID and the raw parse tree that created it, append the
+ * JSONB object representing the creation command to the given state.
+ *
+ * Each clause that the user wrote is emitted as a sub-object carrying its
+ * own "fmt"; clauses that the user did not write are emitted as JSON null.
+ * The leading space of each clause lives in its own "fmt", so the fixed
+ * top-level format string references bare %{x}s slots.
+ */
+static void
+deparse_CreateStmt(JsonbInState *state, Oid objectId, CreateStmt *stmt,
+				   List *cmds)
+{
+	Relation	relation = relation_open(objectId, AccessShareLock);
+	Oid			nspid = relation->rd_rel->relnamespace;
+	char	   *relname = RelationGetRelationName(relation);
+	List	   *dpcontext;
+	List	   *typed_names;
+	char	   *perstr;
+	bool		typed = stmt->ofTypename != NULL || stmt->partbound != NULL;
+
+	collect_raw_constraint_names(stmt, &typed_names);
+
+	/* mark the begin of the command object and start adding elements */
+	begin_jsonb_object(state);
+
+	/* fixed format string: every clause slot is always referenced */
+	append_jsonb_pair1(state, "fmt",
+					   jbv_str("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"));
+
+	/* PERSISTENCE (TEMPORARY/UNLOGGED only; permanent has no keyword) */
+	perstr = get_persistence_str(stmt->relation->relpersistence);
+	if (perstr != NULL)
+		new_jsonb_string_clause(state, "persistence", " %{persistence_type}s",
+								"persistence_type", perstr);
+	else
+		new_jsonb_null(state, "persistence");
+
+	/* IF NOT EXISTS */
+	if (stmt->if_not_exists)
+		new_jsonb_clause(state, "if_not_exists", " IF NOT EXISTS");
+	else
+		new_jsonb_null(state, "if_not_exists");
+
+	/* IDENTITY (table name) -- required, referenced bare as %{identity}D */
+	new_jsonb_for_qualname(state, nspid, relname, "identity", true);
+
+	dpcontext = deparse_context_for(relname, objectId);
+
+	/* OF type / PARTITION OF parent (mutually exclusive) */
+
+	if (stmt->ofTypename)
+	{
+		Assert(!stmt->partbound);
+
+		begin_jsonb_clause(state, "of_type", " OF %{type}T");
+		new_jsonb_for_type(state, "type", relation->rd_rel->reloftype, -1);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "of_type");
+
+	if (stmt->partbound)
+	{
+		List	   *parents = get_inheritance_parents(objectId);
+
+		Assert(list_length(parents) == 1);
+		Assert(!stmt->ofTypename);
+
+		begin_jsonb_clause(state, "partition_of", " PARTITION OF %{parent}D");
+		new_jsonb_for_qualname_id(state, RelationRelationId,
+								  linitial_oid(parents), "parent", true);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "partition_of");
+
+	/*
+	 * TABLE ELEMENTS (structural; always emitted, parenthesization decided
+	 * inside).
+	 */
+	add_table_elems(state, objectId, stmt, dpcontext, typed_names,
+					cmds, typed);
+
+	/*
+	 * INHERITS. We cannot simply use the list of parents from the parse stmt,
+	 * because that may lack the qualified names of the parent relations, and
+	 * we deparse under a restricted search_path. Grab the resolved parents
+	 * from pg_inherits instead.
+	 */
+	if (stmt->inhRelations != NIL && stmt->partbound == NULL)
+	{
+		begin_jsonb_clause(state, "inherits", " INHERITS (%{parents:, }D)");
+		new_jsonbArray_for_qualname_id(state, "parents",
+									   get_inheritance_parents(objectId));
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "inherits");
+
+	/*
+	 * FOR VALUES (partition bound). Get pg_class.relpartbound; we cannot use
+	 * partbound in the parse tree directly as it's the untransformed
+	 * expression.
+	 */
+	if (stmt->partbound != NULL)
+		new_jsonb_string_clause(state, "partition_bound", " %{spec}s",
+								"spec", get_partbound_spec_string(objectId));
+	else
+		new_jsonb_null(state, "partition_bound");
+
+	/* PARTITION BY */
+	if (stmt->partspec != NULL)
+		new_jsonb_string_clause(state, "partition_by",
+								" PARTITION BY %{definition}s", "definition",
+								TextDatumGetCString(DirectFunctionCall1(pg_get_partkeydef,
+																		ObjectIdGetDatum(objectId))));
+	else
+		new_jsonb_null(state, "partition_by");
+
+	/* USING access method: only if the user wrote it; the value as typed */
+	if (stmt->accessMethod != NULL)
+		new_jsonb_string_clause(state, "access_method",
+								" USING %{access_method_name}I",
+								"access_method_name", stmt->accessMethod);
+	else
+		new_jsonb_null(state, "access_method");
+
+	/* WITH (...): only the options the user wrote, as typed */
+	if (stmt->options)
+	{
+		begin_jsonb_clause(state, "with_clause", " WITH (%{with:, }s)");
+		deparse_withObj(state, stmt);
+		end_jsonb_clause(state);
+	}
+	else
+		new_jsonb_null(state, "with_clause");
+
+	/* ON COMMIT */
+	if (stmt->oncommit != ONCOMMIT_NOOP)
+	{
+		char	   *oncommit_value;
+
+		switch (stmt->oncommit)
+		{
+			case ONCOMMIT_PRESERVE_ROWS:
+				oncommit_value = "PRESERVE ROWS";
+				break;
+			case ONCOMMIT_DELETE_ROWS:
+				oncommit_value = "DELETE ROWS";
+				break;
+			case ONCOMMIT_DROP:
+				oncommit_value = "DROP";
+				break;
+			default:
+				elog(ERROR, "unexpected oncommit value %d", stmt->oncommit);
+		}
+
+		new_jsonb_string_clause(state, "on_commit",
+								" ON COMMIT %{on_commit_value}s",
+								"on_commit_value", oncommit_value);
+	}
+	else
+		new_jsonb_null(state, "on_commit");
+
+	/* TABLESPACE */
+	if (stmt->tablespacename != NULL)
+		new_jsonb_string_clause(state, "tablespace",
+								" TABLESPACE %{tablespace_name}I",
+								"tablespace_name", stmt->tablespacename);
+	else
+		new_jsonb_null(state, "tablespace");
+
+	relation_close(relation, AccessShareLock);
+
+	/* Mark the end of the command object */
+	end_jsonb_object(state);
+}
+
+/*
+ * Can the given raw statement be deparsed by deparse_ddl_command()?
+ *
+ * This is a pure parse-tree check that performs no catalog access, so it
+ * can be used before the statement is executed.
+ *
+ * Every caller of deparse_ddl_command() must ask this first and decide for
+ * itself what to do with a command that cannot be reproduced -- execute it
+ * unchanged, warn, or refuse it. That policy differs between consumers and
+ * so does not belong here; the deparser only answers whether it can.
+ *
+ * The answer must err towards false: a command reported as supported and
+ * then deparsed into something that does not reproduce it is a silent
+ * corruption of whatever the consumer does with the result, while a command
+ * needlessly reported as unsupported merely goes unhandled.
+ */
+bool
+ddl_deparse_command_supported(const Node *parsetree)
+{
+	ListCell   *lc;
+
+	if (parsetree == NULL || nodeTag(parsetree) != T_CreateStmt)
+		return false;
+
+	/*
+	 * A LIKE clause is expanded against the source table before the statement
+	 * executes. What it contributes to the table itself is recovered from the
+	 * catalogs (see deparse_ColumnDef_like), but the options below each copy
+	 * an object that needs a statement of its own -- CREATE INDEX, COMMENT
+	 * ON, CREATE STATISTICS -- and none of those is deparsable yet.
+	 */
+	foreach(lc, ((const CreateStmt *) parsetree)->tableElts)
+	{
+		if (IsA(lfirst(lc), TableLikeClause) &&
+			(((TableLikeClause *) lfirst(lc))->options &
+			 (CREATE_TABLE_LIKE_INDEXES |
+			  CREATE_TABLE_LIKE_COMMENTS |
+			  CREATE_TABLE_LIKE_STATISTICS)))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * Deparse a DDL command into a JSON blob.
+ *
+ * original_parsetree is the raw (untransformed) parse tree of the command;
+ * cmds is the list of CollectedCommand its execution produced (see
+ * ddl_collect.c), which supplies the OID of the created object. The result
+ * is a JSON envelope of the form
+ *
+ *		{ "tag": <command tag>, "command": { <fmt object> } }
+ *
+ * that deparse_ddl_json_to_string() can expand back into a (single) plain
+ * command. Sibling elements of "command" may be added in the future (e.g.
+ * the user that ran the command). Information provided by the transport
+ * layer of an eventual consumer (such as the transaction ID or origin, for
+ * logical replication) deliberately has no place here.
+ *
+ * Returns NULL when the command left nothing to reproduce: an IF [NOT]
+ * EXISTS guard that did nothing, or a command run by an extension script.
+ * That is an ordinary outcome, not a refusal, and the caller has nothing to
+ * do about it.
+ *
+ * A command the deparser cannot reproduce is a different matter, and is
+ * reported as an error rather than as a NULL that a caller might mistake for
+ * the case above. Callers are expected to have asked
+ * ddl_deparse_command_supported() beforehand and to have applied their own
+ * policy to the answer, so reaching that error means the caller did not.
+ *
+ * The result is allocated in the caller's memory context.
+ */
+char *
+deparse_ddl_command(const Node *original_parsetree, List *cmds)
+{
+	MemoryContext oldcxt;
+	MemoryContext tmpcxt;
+	Oid			objectId = InvalidOid;
+	char	   *command = NULL;
+	StringInfoData str;
+	Jsonb	   *jsonb;
+	ListCell   *lc;
+	int			save_nestlevel;
+	JsonbInState state = {0};
+
+	/*
+	 * Asking for a command we cannot reproduce is a caller error: the answer
+	 * would otherwise have to be indistinguishable from "there was nothing to
+	 * reproduce" below, and a consumer that cannot tell the two apart drops
+	 * the command silently.
+	 */
+	if (!ddl_deparse_command_supported(original_parsetree))
+		elog(ERROR, "cannot deparse unsupported command of node type %d",
+			 (int) nodeTag(original_parsetree));
+
+	/*
+	 * Find the object created by this command. The transformed parse trees in
+	 * the collected commands are used only for this; all deparsing works on
+	 * the raw tree and the catalogs.
+	 */
+	foreach(lc, cmds)
+	{
+		CollectedCommand *cmd = (CollectedCommand *) lfirst(lc);
+
+		if (cmd->type != SCT_Simple || cmd->parsetree == NULL ||
+			!IsA(cmd->parsetree, CreateStmt))
+			continue;
+
+		/* Commands run by extension scripts are not to be reproduced */
+		if (cmd->in_extension)
+			return NULL;
+
+		objectId = cmd->d.simple.address.objectId;
+		break;
+	}
+
+	/* Nothing was created (e.g. IF NOT EXISTS on an existing table) */
+	if (!OidIsValid(objectId))
+		return NULL;
+
+	/*
+	 * Allocate everything done by the deparsing routines into a temp context,
+	 * to avoid having to sprinkle them with memory handling code, but
+	 * allocate the output StringInfo before switching.
+	 */
+	initStringInfo(&str);
+	tmpcxt = AllocSetContextCreate(CurrentMemoryContext,
+								   "deparse ctx",
+								   ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(tmpcxt);
+
+	/*
+	 * Many routines underlying this one invoke ruleutils.c to deparse
+	 * expressions, and we want all object names in those results to be
+	 * schema-qualified so the output is portable across search_path settings.
+	 * Restrict the search path centrally here rather than at each call site.
+	 */
+	save_nestlevel = NewGUCNestLevel();
+	RestrictSearchPath();
+
+	/* Build the envelope */
+	begin_jsonb_object(&state);
+	append_jsonb_pair1(&state, "tag",
+					   jbv_str(CreateCommandName((Node *) original_parsetree)));
+	insert_jsonb_key(&state, "command");
+	deparse_CreateStmt(&state, objectId, (CreateStmt *) original_parsetree,
+					   cmds);
+	end_jsonb_object(&state);
+
+	jsonb = JsonbValueToJsonb(state.result);
+
+	AtEOXact_GUC(true, save_nestlevel);
+
+	command = JsonbToCString(&str, &jsonb->root, JSONB_ESTIMATED_LEN);
+
+	/*
+	 * Clean up. Note that since we created the StringInfo in the caller's
+	 * context, the output string is not deleted here.
+	 */
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(tmpcxt);
+
+	return command;
+}
diff --git a/src/backend/commands/ddl_json.c b/src/backend/commands/ddl_json.c
new file mode 100644
index 00000000000..5fc4d91a8e2
--- /dev/null
+++ b/src/backend/commands/ddl_json.c
@@ -0,0 +1,747 @@
+/*-------------------------------------------------------------------------
+ *
+ * ddl_json.c
+ *	  JSON code related to DDL command deparsing
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * NOTES
+ *
+ * A deparsed DDL command is wrapped in a top-level envelope object whose
+ * "command" element carries the command itself; sibling elements (such as
+ * "tag") carry additional information about the command.  The command
+ * element (and each nested object within it) has a "fmt" element which
+ * tells the expansion routines how the object is to be expanded into the
+ * DDL command text.  One example snippet from the JSONB envelope for
+ * 'ALTER TABLE sales ADD col1 int':
+ *
+ * {
+ *	 "tag": "ALTER TABLE",
+ *	 "command": { *1-level*
+ *		"fmt": "ALTER %{objtype}s %{only}s %{identity}D %{subcmds:, }s",
+ *		"only": "",
+ *		"objtype": "TABLE",
+ *		"identity": {"objname": "sales", "schemaname": "public"}
+ *		"subcmds": [
+ *			{ *2-level*
+ *				"fmt": "ADD %{objtype}s %{if_not_exists}s %{definition}s",
+ *				"type": "add column",
+ *				"objtype": "COLUMN",
+ *				"definition": {}
+ *				 ...
+ *			}
+ *			...
+ *	 }
+ * }
+ *
+ * From above, we can see different key-value pairs.
+ * level-1 represents the command object with 'fmt', 'only', 'objtype',
+ * 'identity', 'subcmds' as the keys with the values appended after ":" with
+ * each key.  Value can be string, bool, numeric, array or any nested object.
+ * As an example, "objtype" has string value while "subcmds" has nested-object
+ * as its value which can further have multiple key-value pairs.
+ *
+ * The value of "fmt" tells us how the expansion will be carried on. The
+ * value of "fmt"  may contain zero or more %-escapes, which consist of key
+ * name enclosed in { }, followed by a conversion specifier which tells us
+ * how the value for that particular key should be expanded.
+ * Possible conversion specifiers are:
+ * %			expand to a literal %
+ * I			expand as a single, non-qualified identifier
+ * D			expand as a possibly-qualified identifier
+ * T			expand as a type name
+ * L			expand as a string literal (quote using single quotes)
+ * s			expand as a simple string (no quoting)
+ * n			expand as a simple number (no quoting)
+ *
+ * In order to build a DDL command, it will first extract "fmt" node in
+ * jsonb string and will read each key name enclosed in { } in fmt-string
+ * and will replace it with its value. For each name mentioned in { } in
+ * fmt string, there must be a key-value pair, in absence of which, the
+ * expansion will error out. While doing this expansion, it will consider
+ * the conversion-specifier maintained with each key in fmt string to figure
+ * out how value should actually be represented. This is how DDL command can
+ * be constructed back from the jsonb-string.
+ *
+ * IDENTIFICATION
+ *	  src/backend/commands/ddl_json.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "commands/ddl_deparse.h"
+#include "miscadmin.h"
+#include "utils/builtins.h"
+#include "utils/jsonb.h"
+
+#define ADVANCE_PARSE_POINTER(ptr,end_ptr) \
+	do { \
+		if (++(ptr) >= (end_ptr)) \
+			ereport(ERROR, \
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE), \
+					errmsg("unterminated format specifier")); \
+	} while (0)
+
+/*
+ * Conversion specifier which determines how to expand the JSON element
+ * into a string.
+ */
+typedef enum
+{
+	SpecDottedName,
+	SpecIdentifier,
+	SpecNumber,
+	SpecString,
+	SpecStringLiteral,
+	SpecTypeName,
+} convSpecifier;
+
+/*
+ * A ternary value that represents a boolean type JsonbValue.
+ */
+typedef enum
+{
+	tv_absent,
+	tv_true,
+	tv_false
+} json_trivalue;
+
+static bool expand_one_jsonb_element(StringInfo buf, char *param,
+									 JsonbValue *jsonval, convSpecifier specifier,
+									 const char *fmt);
+static void expand_jsonb_array(StringInfo buf, char *param,
+							   JsonbValue *jsonarr, char *arraysep,
+							   convSpecifier specifier, const char *fmt);
+static void fmtstr_error_callback(void *arg);
+
+/*
+ * Given a JsonbContainer, find the JsonbValue with the given key name in it.
+ * If it's of a type other than jbvBool, an error is raised. If it doesn't
+ * exist, tv_absent is returned; otherwise return the actual json_trivalue.
+ */
+static json_trivalue
+find_bool_in_jsonbcontainer(JsonbContainer *container, char *keyname)
+{
+	JsonbValue	key;
+	JsonbValue *value;
+	json_trivalue result;
+
+	key.type = jbvString;
+	key.val.string.val = keyname;
+	key.val.string.len = strlen(keyname);
+	value = findJsonbValueFromContainer(container,
+										JB_FOBJECT, &key);
+	if (value == NULL)
+		return tv_absent;
+	if (value->type != jbvBool)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" is not of type boolean", keyname));
+	result = value->val.boolean ? tv_true : tv_false;
+	pfree(value);
+
+	return result;
+}
+
+/*
+ * Given a JsonbContainer, find the JsonbValue with the given key name in it.
+ * If it's of a type other than jbvString, an error is raised.	If it doesn't
+ * exist, an error is raised unless missing_ok; otherwise return NULL.
+ *
+ * If it exists and is a string, a freshly palloc'ed copy is returned.
+ *
+ * If *length is not NULL, it is set to the length of the string.
+ */
+static char *
+find_string_in_jsonbcontainer(JsonbContainer *container, char *keyname,
+							  bool missing_ok, int *length)
+{
+	JsonbValue	key;
+	JsonbValue *value;
+	char	   *str;
+
+	/* XXX verify that this is an object, not an array */
+
+	key.type = jbvString;
+	key.val.string.val = keyname;
+	key.val.string.len = strlen(keyname);
+	value = findJsonbValueFromContainer(container,
+										JB_FOBJECT, &key);
+	if (value == NULL)
+	{
+		if (missing_ok)
+			return NULL;
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("missing element \"%s\" in JSON object", keyname));
+	}
+
+	if (value->type != jbvString)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" is not of type string", keyname));
+
+	str = pnstrdup(value->val.string.val, value->val.string.len);
+	if (length)
+		*length = value->val.string.len;
+	pfree(value);
+	return str;
+}
+
+/*
+ * Recursive helper for deparse_ddl_json_to_string.
+ *
+ * Find the "fmt" element in the given container, and expand it into the
+ * provided StringInfo.
+ */
+static void
+expand_fmt_recursive(StringInfo buf, JsonbContainer *container)
+{
+	JsonbValue	key;
+	JsonbValue *value;
+	const char *cp;
+	const char *start_ptr;
+	const char *end_ptr;
+	int			len;
+
+	/*
+	 * The input JSON may come from outside (e.g. a remote node), so its
+	 * nesting depth is not under our control; the mutual recursion with
+	 * expand_jsonval_string must protect the stack itself.
+	 */
+	check_stack_depth();
+
+	start_ptr = find_string_in_jsonbcontainer(container, "fmt", false, &len);
+	end_ptr = start_ptr + len;
+
+	for (cp = start_ptr; cp < end_ptr; cp++)
+	{
+		convSpecifier specifier;
+		bool		is_array = false;
+		char	   *param = NULL;
+		char	   *arraysep = NULL;
+
+		if (*cp != '%')
+		{
+			appendStringInfoCharMacro(buf, *cp);
+			continue;
+		}
+
+		ADVANCE_PARSE_POINTER(cp, end_ptr);
+
+		/* Easy case: %% outputs a single % */
+		if (*cp == '%')
+		{
+			appendStringInfoCharMacro(buf, *cp);
+			continue;
+		}
+
+		/*
+		 * Scan the mandatory element name.	 Allow for an array separator
+		 * (which may be the empty string) to be specified after a colon.
+		 */
+		if (*cp == '{')
+		{
+			StringInfoData parbuf;
+			StringInfoData arraysepbuf;
+			StringInfo	appendTo;
+
+			initStringInfo(&parbuf);
+			appendTo = &parbuf;
+
+			ADVANCE_PARSE_POINTER(cp, end_ptr);
+			while (cp < end_ptr)
+			{
+				if (*cp == ':')
+				{
+					/*
+					 * Found array separator delimiter; element name is now
+					 * complete, start filling the separator.
+					 */
+					initStringInfo(&arraysepbuf);
+					appendTo = &arraysepbuf;
+					is_array = true;
+					ADVANCE_PARSE_POINTER(cp, end_ptr);
+					continue;
+				}
+
+				if (*cp == '}')
+				{
+					ADVANCE_PARSE_POINTER(cp, end_ptr);
+					break;
+				}
+				appendStringInfoCharMacro(appendTo, *cp);
+				ADVANCE_PARSE_POINTER(cp, end_ptr);
+			}
+			param = parbuf.data;
+			if (is_array)
+				arraysep = arraysepbuf.data;
+		}
+		if (param == NULL)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("missing conversion name in conversion specifier"));
+
+		switch (*cp)
+		{
+			case 'I':
+				specifier = SpecIdentifier;
+				break;
+			case 'D':
+				specifier = SpecDottedName;
+				break;
+			case 's':
+				specifier = SpecString;
+				break;
+			case 'L':
+				specifier = SpecStringLiteral;
+				break;
+			case 'T':
+				specifier = SpecTypeName;
+				break;
+			case 'n':
+				specifier = SpecNumber;
+				break;
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("invalid conversion specifier \"%c\"", *cp));
+		}
+
+		/*
+		 * Obtain the element to be expanded; expand_jsonb_array and
+		 * expand_one_jsonb_element report the error if it is missing.  (No
+		 * Assert on the lookup result: a fmt string referencing an absent
+		 * element is a malformed-input condition, not an internal invariant.)
+		 */
+		key.type = jbvString;
+		key.val.string.val = param;
+		key.val.string.len = strlen(param);
+
+		value = findJsonbValueFromContainer(container, JB_FOBJECT, &key);
+
+		/*
+		 * Expand the data (possibly an array) into the output StringInfo.
+		 */
+		if (is_array)
+			expand_jsonb_array(buf, param, value, arraysep, specifier, start_ptr);
+		else
+			expand_one_jsonb_element(buf, param, value, specifier, start_ptr);
+
+		pfree(value);
+	}
+}
+
+/*
+ * Expand a json value as a quoted identifier.	The value must be of type string.
+ */
+static void
+expand_jsonval_identifier(StringInfo buf, JsonbValue *jsonval)
+{
+	char	   *str;
+
+	Assert(jsonval->type == jbvString);
+
+	str = pnstrdup(jsonval->val.string.val, jsonval->val.string.len);
+	appendStringInfoString(buf, quote_identifier(str));
+	pfree(str);
+}
+
+/*
+ * Expand a json value as a dot-separated-name.	 The value must be of type
+ * binary and may contain elements "schemaname" (optional), "objname"
+ * (mandatory), "attrname" (optional).	Double quotes are added to each element
+ * as necessary, and dot separators where needed.
+ *
+ * One day we might need a "catalog" element as well, but no current use case
+ * needs that.
+ */
+static void
+expand_jsonval_dottedname(StringInfo buf, JsonbValue *jsonval)
+{
+	char	   *str;
+	JsonbContainer *data = jsonval->val.binary.data;
+
+	Assert(jsonval->type == jbvBinary);
+
+	str = find_string_in_jsonbcontainer(data, "schemaname", true, NULL);
+	if (str)
+	{
+		appendStringInfo(buf, "%s.", quote_identifier(str));
+		pfree(str);
+	}
+
+	str = find_string_in_jsonbcontainer(data, "objname", false, NULL);
+	appendStringInfo(buf, "%s", quote_identifier(str));
+	pfree(str);
+
+	str = find_string_in_jsonbcontainer(data, "attrname", true, NULL);
+	if (str)
+	{
+		appendStringInfo(buf, ".%s", quote_identifier(str));
+		pfree(str);
+	}
+}
+
+/*
+ * Expand a JSON value as a type name.
+ */
+static void
+expand_jsonval_typename(StringInfo buf, JsonbValue *jsonval)
+{
+	char	   *schema = NULL;
+	char	   *typename = NULL;
+	char	   *typmodstr = NULL;
+	json_trivalue is_array;
+	char	   *array_decor;
+	JsonbContainer *data = jsonval->val.binary.data;
+
+	/*
+	 * We omit schema-qualifying the output name if the schema element is
+	 * either the empty string or NULL; the difference between those two cases
+	 * is that in the latter we quote the type name, in the former we don't.
+	 * This allows for types with special typmod needs, such as interval and
+	 * timestamp (see format_type_detailed), while at the same time allowing
+	 * for the schema name to be omitted from type names that require quotes
+	 * but are to be obtained from a user schema.
+	 */
+
+	schema = find_string_in_jsonbcontainer(data, "schemaname", true, NULL);
+	typename = find_string_in_jsonbcontainer(data, "typename", false, NULL);
+	typmodstr = find_string_in_jsonbcontainer(data, "typmod", true, NULL);
+	is_array = find_bool_in_jsonbcontainer(data, "typarray");
+	switch (is_array)
+	{
+		case tv_true:
+			array_decor = "[]";
+			break;
+
+		case tv_false:
+			array_decor = "";
+			break;
+
+		case tv_absent:
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("missing typarray element"));
+	}
+
+	if (schema == NULL)
+		appendStringInfo(buf, "%s", quote_identifier(typename));
+	else if (schema[0] == '\0')
+		appendStringInfo(buf, "%s", typename);	/* Special typmod needs */
+	else
+		appendStringInfo(buf, "%s.%s", quote_identifier(schema),
+						 quote_identifier(typename));
+
+	appendStringInfo(buf, "%s%s", typmodstr ? typmodstr : "", array_decor);
+
+	if (schema)
+		pfree(schema);
+	if (typename)
+		pfree(typename);
+	if (typmodstr)
+		pfree(typmodstr);
+}
+
+/*
+ * Expand a JSON value as a string.	 The value must be of type string or of
+ * type Binary.	 In the latter case, it must contain a "fmt" element which will
+ * be recursively expanded; also, if the object contains an element "present"
+ * and it is set to false, the expansion is the empty string.
+ *
+ * Returns false if no actual expansion was made due to the "present" flag
+ * being set to "false".
+ *
+ * The caller is responsible to check jsonval is of type jbvString or jbvBinary.
+ */
+static bool
+expand_jsonval_string(StringInfo buf, JsonbValue *jsonval)
+{
+	bool		expanded = false;
+
+	Assert((jsonval->type == jbvString) || (jsonval->type == jbvBinary));
+
+	if (jsonval->type == jbvString)
+	{
+		appendBinaryStringInfo(buf, jsonval->val.string.val,
+							   jsonval->val.string.len);
+		expanded = true;
+	}
+	else if (jsonval->type == jbvBinary)
+	{
+		json_trivalue present;
+
+		present = find_bool_in_jsonbcontainer(jsonval->val.binary.data,
+											  "present");
+
+		/*
+		 * If "present" is set to false, this element expands to empty;
+		 * otherwise (either true or absent), expand "fmt".
+		 */
+		if (present != tv_false)
+		{
+			expand_fmt_recursive(buf, jsonval->val.binary.data);
+			expanded = true;
+		}
+	}
+
+	return expanded;
+}
+
+/*
+ * Expand a JSON value as a string literal.
+ */
+static void
+expand_jsonval_strlit(StringInfo buf, JsonbValue *jsonval)
+{
+	char	   *str;
+	StringInfoData dqdelim;
+	static const char dqsuffixes[] = "_XYZZYX_";
+	int			dqnextchar = 0;
+
+	Assert(jsonval->type == jbvString);
+
+	str = pnstrdup(jsonval->val.string.val, jsonval->val.string.len);
+
+	/* Easy case: if there are no ' and no \, just use a single quote */
+	if (strpbrk(str, "\'\\") == NULL)
+	{
+		appendStringInfo(buf, "'%s'", str);
+		pfree(str);
+		return;
+	}
+
+	/* Otherwise need to find a useful dollar-quote delimiter */
+	initStringInfo(&dqdelim);
+	appendStringInfoString(&dqdelim, "$");
+	while (strstr(str, dqdelim.data) != NULL)
+	{
+		appendStringInfoChar(&dqdelim, dqsuffixes[dqnextchar++]);
+		dqnextchar = dqnextchar % (sizeof(dqsuffixes) - 1);
+	}
+	/* Add trailing $ */
+	appendStringInfoChar(&dqdelim, '$');
+
+	/* And finally produce the quoted literal into the output StringInfo */
+	appendStringInfo(buf, "%s%s%s", dqdelim.data, str, dqdelim.data);
+	pfree(dqdelim.data);
+	pfree(str);
+}
+
+/*
+ * Expand a JSON value as an integer quantity.
+ */
+static void
+expand_jsonval_number(StringInfo buf, JsonbValue *jsonval)
+{
+	char	   *strdatum;
+
+	Assert(jsonval->type == jbvNumeric);
+
+	strdatum = DatumGetCString(DirectFunctionCall1(numeric_out,
+												   NumericGetDatum(jsonval->val.numeric)));
+	appendStringInfoString(buf, strdatum);
+	pfree(strdatum);
+}
+
+/*
+ * Expand one JSON element into the output StringInfo according to the
+ * conversion specifier.  The element type is validated, and an error is raised
+ * if it doesn't match what we expect for the conversion specifier.
+ *
+ * Returns true, except for the formatted string case if no actual expansion
+ * was made (due to the "present" flag being set to "false").
+ */
+static bool
+expand_one_jsonb_element(StringInfo buf, char *param, JsonbValue *jsonval,
+						 convSpecifier specifier, const char *fmt)
+{
+	bool		string_expanded = true;
+	ErrorContextCallback errcallback;
+
+	errcallback.callback = fmtstr_error_callback;
+	errcallback.arg = (void *) fmt;
+	errcallback.previous = error_context_stack;
+	error_context_stack = &errcallback;
+
+	if (!jsonval)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" not found", param));
+
+	switch (specifier)
+	{
+		case SpecIdentifier:
+			if (jsonval->type != jbvString)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON string for %%I element \"%s\", got %d",
+							   param, jsonval->type));
+			expand_jsonval_identifier(buf, jsonval);
+			break;
+
+		case SpecDottedName:
+			if (jsonval->type != jbvBinary)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON struct for %%D element \"%s\", got %d",
+							   param, jsonval->type));
+			expand_jsonval_dottedname(buf, jsonval);
+			break;
+
+		case SpecString:
+			if (jsonval->type != jbvString &&
+				jsonval->type != jbvBinary &&
+				jsonval->type != jbvNull)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON string, struct, or null for %%s element \"%s\", got %d",
+							   param, jsonval->type));
+			if (jsonval->type != jbvNull)
+				string_expanded = expand_jsonval_string(buf, jsonval);
+			break;
+
+		case SpecStringLiteral:
+			if (jsonval->type != jbvString)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON string for %%L element \"%s\", got %d",
+							   param, jsonval->type));
+			expand_jsonval_strlit(buf, jsonval);
+			break;
+
+		case SpecTypeName:
+			if (jsonval->type != jbvBinary)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON struct for %%T element \"%s\", got %d",
+							   param, jsonval->type));
+			expand_jsonval_typename(buf, jsonval);
+			break;
+
+		case SpecNumber:
+			if (jsonval->type != jbvNumeric)
+				ereport(ERROR,
+						errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						errmsg("expected JSON numeric for %%n element \"%s\", got %d",
+							   param, jsonval->type));
+			expand_jsonval_number(buf, jsonval);
+			break;
+	}
+
+	error_context_stack = errcallback.previous;
+
+	return string_expanded;
+}
+
+/*
+ * Iterate on the elements of a JSON array, expanding each one into the output
+ * StringInfo per the given conversion specifier, separated by the given
+ * separator.
+ */
+static void
+expand_jsonb_array(StringInfo buf, char *param,
+				   JsonbValue *jsonarr, char *arraysep, convSpecifier specifier,
+				   const char *fmt)
+{
+	ErrorContextCallback errcallback;
+	JsonbContainer *container;
+	JsonbIterator *it;
+	JsonbValue	v;
+	int			type;
+	bool		first = true;
+	StringInfoData arrayelem;
+
+	errcallback.callback = fmtstr_error_callback;
+	errcallback.arg = (void *) fmt;
+	errcallback.previous = error_context_stack;
+	error_context_stack = &errcallback;
+
+	if (!jsonarr)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" not found", param));
+
+	if (jsonarr->type != jbvBinary)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" is not a JSON array", param));
+
+	container = jsonarr->val.binary.data;
+	if (!JsonContainerIsArray(container))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("element \"%s\" is not a JSON array", param));
+
+	initStringInfo(&arrayelem);
+
+	it = JsonbIteratorInit(container);
+	while ((type = JsonbIteratorNext(&it, &v, true)) != WJB_DONE)
+	{
+		if (type == WJB_ELEM)
+		{
+			resetStringInfo(&arrayelem);
+
+			if (expand_one_jsonb_element(&arrayelem, param, &v, specifier, NULL))
+			{
+				if (!first)
+					appendStringInfoString(buf, arraysep);
+
+				appendBinaryStringInfo(buf, arrayelem.data, arrayelem.len);
+				first = false;
+			}
+		}
+	}
+
+	error_context_stack = errcallback.previous;
+}
+
+/*
+ * Expand a JSON envelope, as generated by deparse_ddl_command(), back into
+ * a plain DDL command string.
+ *
+ * The envelope's "command" element carries the command; find it and expand
+ * its format string recursively.
+ */
+char *
+deparse_ddl_json_to_string(char *json_str)
+{
+	Datum		d;
+	Jsonb	   *jsonb;
+	JsonbValue	key;
+	JsonbValue *command;
+	StringInfo	buf = (StringInfo) palloc0(sizeof(StringInfoData));
+
+	initStringInfo(buf);
+
+	d = DirectFunctionCall1(jsonb_in, PointerGetDatum(json_str));
+	jsonb = (Jsonb *) DatumGetPointer(d);
+
+	key.type = jbvString;
+	key.val.string.val = "command";
+	key.val.string.len = strlen(key.val.string.val);
+
+	command = findJsonbValueFromContainer(&jsonb->root, JB_FOBJECT, &key);
+	if (command == NULL || command->type != jbvBinary)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("missing \"command\" element in JSON envelope"));
+
+	expand_fmt_recursive(buf, command->val.binary.data);
+
+	return buf->data;
+}
+
+/*
+ * Error context callback for JSON format string expansion.
+ */
+static void
+fmtstr_error_callback(void *arg)
+{
+	if (arg)
+		errcontext("while expanding format string \"%s\"", (char *) arg);
+}
diff --git a/src/backend/commands/meson.build b/src/backend/commands/meson.build
index 9f258d566eb..abd5c1f3dcf 100644
--- a/src/backend/commands/meson.build
+++ b/src/backend/commands/meson.build
@@ -16,6 +16,8 @@ backend_sources += files(
   'copyto.c',
   'createas.c',
   'dbcommands.c',
+  'ddl_deparse.c',
+  'ddl_json.c',
   'define.c',
   'discard.c',
   'dropcmds.c',
diff --git a/src/backend/utils/adt/format_type.c b/src/backend/utils/adt/format_type.c
index 7a5695c6245..989193a959b 100644
--- a/src/backend/utils/adt/format_type.c
+++ b/src/backend/utils/adt/format_type.c
@@ -333,6 +333,116 @@ format_type_extended(Oid type_oid, int32 typemod, uint16 flags)
 	return buf;
 }
 
+/*
+ * format_type_detailed
+ *		Returns detailed information about a type: separate namespace, name,
+ *		typemod string and array-ness.
+ *
+ * Arguments:
+ *	type_oid, typemod: same as format_type_extended
+ *
+ * Return values:
+ * - nspid is the schema OID.  For certain SQL-standard types which have weird
+ *	 typmod rules, we return InvalidOid; the caller is expected to not schema-
+ *	 qualify the name nor add quotes to the type name in this case.
+ *
+ * - typname is set to the type name, without quotes
+ *
+ * - typemodstr is set to the typemod, if any, as a string with parentheses
+ *
+ * - typearray indicates whether []s must be added
+ *
+ * We don't try to decode type names to their standard-mandated names, except
+ * in the cases of types with unusual typmod rules.
+ */
+void
+format_type_detailed(Oid type_oid, int32 typemod,
+					 Oid *nspid, char **typname, char **typemodstr,
+					 bool *typearray)
+{
+	HeapTuple	tuple;
+	Form_pg_type typeform;
+	Oid			array_base_type;
+
+	tuple = SearchSysCache1(TYPEOID, ObjectIdGetDatum(type_oid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for type with OID %u", type_oid);
+
+	typeform = (Form_pg_type) GETSTRUCT(tuple);
+
+	/*
+	 * We switch our attention to the array element type for certain cases.
+	 * Check if it's a "true" array type.  Pseudo-array types such as "name"
+	 * shouldn't get deconstructed.  Also check the toast property, and don't
+	 * deconstruct "plain storage" array types --- this is because we don't
+	 * want to show oidvector as oid[].
+	 */
+	array_base_type = typeform->typelem;
+
+	*typearray = (IsTrueArrayType(typeform) &&
+				  typeform->typstorage != TYPSTORAGE_PLAIN);
+
+	if (*typearray)
+	{
+		ReleaseSysCache(tuple);
+		tuple = SearchSysCache1(TYPEOID, ObjectIdGetDatum(array_base_type));
+		if (!HeapTupleIsValid(tuple))
+			elog(ERROR, "cache lookup failed for type with OID %u", type_oid);
+
+		typeform = (Form_pg_type) GETSTRUCT(tuple);
+		type_oid = array_base_type;
+	}
+
+	/*
+	 * Special-case crock for types with strange typmod rules where we put
+	 * typemod at the middle of name (e.g. TIME(6) with time zone). We cannot
+	 * schema-qualify nor add quotes to the type name in these cases.
+	 */
+	*nspid = InvalidOid;
+
+	switch (type_oid)
+	{
+		case TIMEOID:
+			*typname = pstrdup("TIME");
+			break;
+		case TIMESTAMPOID:
+			*typname = pstrdup("TIMESTAMP");
+			break;
+		case TIMESTAMPTZOID:
+			if (typemod < 0)
+				*typname = pstrdup("TIMESTAMP WITH TIME ZONE");
+			else
+				/* otherwise, WITH TZ is added by typmod. */
+				*typname = pstrdup("TIMESTAMP");
+			break;
+		case INTERVALOID:
+			*typname = pstrdup("INTERVAL");
+			break;
+		case TIMETZOID:
+			if (typemod < 0)
+				*typname = pstrdup("TIME WITH TIME ZONE");
+			else
+				/* otherwise, WITH TZ is added by typmod. */
+				*typname = pstrdup("TIME");
+			break;
+		default:
+
+			/*
+			 * No additional processing is required for other types, so get
+			 * the type name and schema directly from the catalog.
+			 */
+			*nspid = typeform->typnamespace;
+			*typname = pstrdup(NameStr(typeform->typname));
+	}
+
+	if (typemod >= 0)
+		*typemodstr = printTypmod("", typemod, typeform->typmodout);
+	else
+		*typemodstr = pstrdup("");
+
+	ReleaseSysCache(tuple);
+}
+
 /*
  * This version is for use within the backend in error messages, etc.
  * One difference is that it will fail for an invalid type.
diff --git a/src/include/commands/ddl_deparse.h b/src/include/commands/ddl_deparse.h
new file mode 100644
index 00000000000..3d0f87b8b01
--- /dev/null
+++ b/src/include/commands/ddl_deparse.h
@@ -0,0 +1,25 @@
+/*-------------------------------------------------------------------------
+ *
+ * ddl_deparse.h
+ *	  Declarations for DDL command deparsing.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/commands/ddl_deparse.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef DDL_DEPARSE_H
+#define DDL_DEPARSE_H
+
+#include "nodes/pg_list.h"
+
+/* ddl_deparse.c */
+extern bool ddl_deparse_command_supported(const Node *parsetree);
+extern char *deparse_ddl_command(const Node *original_parsetree, List *cmds);
+
+/* ddl_json.c */
+extern char *deparse_ddl_json_to_string(char *json_str);
+
+#endif							/* DDL_DEPARSE_H */
diff --git a/src/include/utils/builtins.h b/src/include/utils/builtins.h
index b6a11bfa288..87485445f94 100644
--- a/src/include/utils/builtins.h
+++ b/src/include/utils/builtins.h
@@ -127,6 +127,9 @@ extern Datum numeric_float8_no_overflow(PG_FUNCTION_ARGS);
 #define FORMAT_TYPE_FORCE_QUALIFY	0x04	/* force qualification of type */
 #define FORMAT_TYPE_INVALID_AS_NULL	0x08	/* NULL if undefined */
 extern char *format_type_extended(Oid type_oid, int32 typemod, uint16 flags);
+extern void format_type_detailed(Oid type_oid, int32 typemod,
+								 Oid *nspid, char **typname,
+								 char **typemodstr, bool *typearray);
 
 extern char *format_type_be(Oid type_oid);
 extern char *format_type_be_qualified(Oid type_oid);
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index eb3dd5481e6..a03df297fa1 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2422,6 +2422,7 @@ PrepareStmt
 PreparedStatement
 PresortedKeyData
 PrewarmType
+PrintDeparsedDDL
 PrintExtraTocPtrType
 PrintTocDataPtrType
 PrintfArgType
@@ -3183,6 +3184,7 @@ Tcl_Obj
 Tcl_Size
 Tcl_Time
 TempNamespaceStatus
+TestDDLDeparseErrorInfo
 TestDSMRegistryHashEntry
 TestDSMRegistryStruct
 TestDecodingData
@@ -3697,6 +3699,7 @@ config_handle
 config_var_value
 contain_aggs_of_level_context
 contain_placeholder_references_context
+convSpecifier
 convert_testexpr_context
 copy_data_dest_cb
 copy_data_source_cb
@@ -3929,6 +3932,7 @@ json_manifest_version_callback
 json_ofield_action
 json_scalar_action
 json_struct_action
+json_trivalue
 keepwal_entry
 keepwal_hash
 key_t
-- 
2.55.0

