From 9eaf74ae7919b3ccee051e99a3661301e4eac9c1 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Thu, 10 Sep 2026 11:15:28 -0400
Subject: [PATCH v6 5/5] pg_get_database_ddl, pg_get_role_ddl: require catalog
 access matching what's actually public

Everything these three functions emit is public by default -- pg_authid
is revoked from PUBLIC at initdb, but pg_roles, pg_auth_members,
pg_db_role_setting, pg_database, and pg_tablespace keep their default
grants.  An object-specific privilege like CONNECT on a database
doesn't protect anything in the common case, and doesn't handle the
case that matters: a site that has hardened catalog access.  Alvaro
Herrera raised this for pg_get_database_ddl(), where a narrow CONNECT
grant let a role get full DDL even after an admin revoked catalog-wide
SELECT on pg_database.

pg_get_database_ddl(): replace the CONNECT check with SELECT on both
pg_database and pg_db_role_setting (the ALTER DATABASE ... SET rows
come from the latter).

pg_get_role_ddl(): add an unconditional check requiring SELECT on
pg_roles, pg_auth_members, and pg_db_role_setting together -- pg_roles
alone misses membership and IN DATABASE settings, which it doesn't
expose.  The existing pg_authid check for the password clause is
unchanged.

pg_get_tablespace_ddl() already had the right check
(pg_class_aclcheck(..., ACL_SELECT)); it was just undocumented.

Extend this to catalogs reached only through a name-resolution helper:
GetUserNameFromId(), get_tablespace_name(), and get_database_name()
all read their catalog directly with no ACL check, so resolving a
foreign object's name for display (an owner; a referenced tablespace
or database) can disclose that catalog's content even after the checks
above pass.  Add the matching check before each such lookup: pg_roles
before an OWNER clause (twice), pg_database before an IN DATABASE
setting's database name, and pg_tablespace before a TABLESPACE clause.
No equivalent check is needed for the membership grantor/grantee names
in pg_get_role_ddl(), since the pg_roles check above already covers
them.

Add a "Requires ... privilege" paragraph to the docs for all three
functions.

Convert test_misc/012_ddlutils.pl's TAP assertions to a pg_regress
script; add exact-row-shape checks, IS_TEMPLATE/ALLOW_CONNECTIONS
coverage, and permission-check coverage for all of the above, each
catalog revoked individually to prove the checks are a real AND.  Add
t/016_ddlutils_pg_dump_crosscheck.pl, comparing this output against
pg_dump/pg_dumpall for the same objects.

Discussion: https://postgr.es/m/20260827015242.54.noahmisch@microsoft.com
Discussion: https://postgr.es/m/ia2gifmcdsiunj3j6i4yumnnzru45nvet4mxtydb6nodwrvkhe@qkd7lzlxsrht
---
 doc/src/sgml/func/func-info.sgml              |  70 ++-
 src/backend/utils/adt/ddlutils.c              | 124 +++-
 src/test/modules/test_misc/Makefile           |   2 +
 .../modules/test_misc/expected/ddlutils.out   | 579 ++++++++++++++++++
 src/test/modules/test_misc/meson.build        |  11 +-
 src/test/modules/test_misc/sql/ddlutils.sql   | 382 ++++++++++++
 src/test/modules/test_misc/t/012_ddlutils.pl  | 341 -----------
 .../t/016_ddlutils_pg_dump_crosscheck.pl      | 115 ++++
 8 files changed, 1248 insertions(+), 376 deletions(-)
 create mode 100644 src/test/modules/test_misc/expected/ddlutils.out
 create mode 100644 src/test/modules/test_misc/sql/ddlutils.sql
 delete mode 100644 src/test/modules/test_misc/t/012_ddlutils.pl
 create mode 100644 src/test/modules/test_misc/t/016_ddlutils_pg_dump_crosscheck.pl

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index e56c9a22c42..c7ac3188ddc 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -3895,6 +3895,20 @@ acl      | {postgres=arwdDxtm/postgres,foo=r/postgres}
         <literal>OWNER</literal> clause is omitted.  When
         <parameter>tablespace</parameter> is false, the
         <literal>TABLESPACE</literal> clause is omitted.
+       </para>
+       <para>
+        Requires <literal>SELECT</literal> privilege on both the
+        <link linkend="catalog-pg-database"><structname>pg_database</structname></link>
+        and <link linkend="catalog-pg-db-role-setting"><structname>pg_db_role_setting</structname></link>
+        catalogs, both of which are readable by all roles by default.  If
+        <parameter>owner</parameter> is true (the default), also requires
+        <literal>SELECT</literal> privilege on
+        <link linkend="view-pg-roles"><structname>pg_roles</structname></link>,
+        to name the database's owner.  If <parameter>tablespace</parameter>
+        is true (the default) and the database is not in the default
+        tablespace, also requires <literal>SELECT</literal> privilege on
+        <link linkend="catalog-pg-tablespace"><structname>pg_tablespace</structname></link>,
+        to name the tablespace.
        </para></entry>
       </row>
       <row>
@@ -3907,19 +3921,56 @@ acl      | {postgres=arwdDxtm/postgres,foo=r/postgres}
         <optional>, <parameter>pretty</parameter> <type>boolean</type>
         <literal>DEFAULT</literal> false</optional>
         <optional>, <parameter>memberships</parameter> <type>boolean</type>
+        <literal>DEFAULT</literal> true</optional>
+        <optional>, <parameter>password</parameter> <type>boolean</type>
+        <literal>DEFAULT</literal> true</optional>
+        <optional>, <parameter>in_database_settings</parameter> <type>boolean</type>
         <literal>DEFAULT</literal> true</optional> )
         <returnvalue>setof text</returnvalue>
        </para>
        <para>
         Reconstructs the <link linkend="sql-createrole"><command>CREATE ROLE
-        </command></link> statement and any <link linkend="sql-alterrole">
-        <command>ALTER ROLE ... SET</command></link> statements for the given
-        role.  Each statement is returned as a separate row.
-        Password information is never included in the output.
+        </command></link> statement for the given role, an <link
+        linkend="sql-alterrole"><command>ALTER ROLE</command></link> statement
+        carrying its attributes, and any role-wide <command>ALTER ROLE ...
+        SET</command> statements.  Each statement is returned as a separate
+        row. If the role has a password, it is included in encrypted form,
+        exactly as stored in
+        <link linkend="catalog-pg-authid"><structname>pg_authid</structname></link>.
         When <parameter>pretty</parameter> is true, the output is
         pretty-printed.  When <parameter>memberships</parameter> is false,
         <link linkend="sql-grant"><command>GRANT</command></link> statements
-        for role memberships are omitted.
+        for role memberships are omitted.  When
+        <parameter>password</parameter> is false, the
+        <literal>PASSWORD</literal> clause is omitted and the function can be
+        used by any role.
+       </para>
+       <para>
+        Role-wide <command>ALTER ROLE ... SET</command> statements are
+        always emitted.  <link linkend="sql-alterrole"><command>ALTER
+        ROLE ... IN DATABASE ... SET</command></link> statements, which
+        depend on the named database already existing, are also emitted
+        unless <parameter>in_database_settings</parameter> is false --
+        useful when assembling this role's DDL before any database has
+        been created.
+       </para>
+       <para>
+        Requires <literal>SELECT</literal> privilege on the
+        <link linkend="view-pg-roles"><structname>pg_roles</structname></link>,
+        <link linkend="catalog-pg-auth-members"><structname>pg_auth_members</structname></link>,
+        and <link linkend="catalog-pg-db-role-setting"><structname>pg_db_role_setting</structname></link>
+        catalogs, all of which are readable by all roles by default. If
+        <parameter>password</parameter> is true (the default), also
+        requires <literal>SELECT</literal> privilege on
+        <link linkend="catalog-pg-authid"><structname>pg_authid</structname></link>,
+        which is not readable by ordinary roles by default -- pass
+        <parameter>password</parameter> as false to call this function
+        without that privilege.  If <parameter>in_database_settings</parameter>
+        is true (the default) and the role has any <command>ALTER ROLE ...
+        IN DATABASE ... SET</command> settings, also requires
+        <literal>SELECT</literal> privilege on
+        <link linkend="catalog-pg-database"><structname>pg_database</structname></link>,
+        to name the database each such setting applies to.
        </para></entry>
       </row>
       <row>
@@ -3953,6 +4004,15 @@ acl      | {postgres=arwdDxtm/postgres,foo=r/postgres}
         returned as a separate row.  When <parameter>pretty</parameter> is
         true, the output is pretty-printed.  When <parameter>owner</parameter>
         is false, the <literal>OWNER</literal> clause is omitted.
+       </para>
+       <para>
+        Requires <literal>SELECT</literal> privilege on the
+        <link linkend="catalog-pg-tablespace"><structname>pg_tablespace</structname></link>
+        catalog, which is readable by all roles by default.  If
+        <parameter>owner</parameter> is true (the default), also requires
+        <literal>SELECT</literal> privilege on
+        <link linkend="view-pg-roles"><structname>pg_roles</structname></link>,
+        to name the tablespace's owner.
        </para></entry>
       </row>
      </tbody>
diff --git a/src/backend/utils/adt/ddlutils.c b/src/backend/utils/adt/ddlutils.c
index cb457380fab..d8e0ade142b 100644
--- a/src/backend/utils/adt/ddlutils.c
+++ b/src/backend/utils/adt/ddlutils.c
@@ -27,6 +27,7 @@
 #include "catalog/pg_collation.h"
 #include "catalog/pg_database.h"
 #include "catalog/pg_db_role_setting.h"
+#include "catalog/pg_namespace_d.h"
 #include "catalog/pg_tablespace.h"
 #include "commands/tablespace.h"
 #include "common/relpath.h"
@@ -179,6 +180,7 @@ pg_get_role_ddl_internal(Oid roleid, bool pretty, bool memberships,
 	ScanKeyData scankey;
 	SysScanDesc scan;
 	List	   *statements = NIL;
+	Oid			pgroles_relid;
 
 	tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid));
 	if (!HeapTupleIsValid(tuple))
@@ -190,8 +192,28 @@ pg_get_role_ddl_internal(Oid roleid, bool pretty, bool memberships,
 	rolname = pstrdup(NameStr(roleform->rolname));
 
 	/*
-	 * rolpassword needs SELECT on pg_authid; nothing else here is
-	 * sensitive, so only check it when the PASSWORD clause is wanted.
+	 * Everything below except the password is public by default -- pg_authid
+	 * is revoked from PUBLIC at initdb, but pg_roles, pg_auth_members, and
+	 * pg_db_role_setting keep their default grants -- so the only thing worth
+	 * checking is whether a site has hardened one of these catalogs. Check
+	 * all three: pg_roles alone misses pg_auth_members (memberships) and
+	 * pg_db_role_setting (settings), neither of which it exposes.
+	 */
+	pgroles_relid = get_relname_relid("pg_roles", PG_CATALOG_NAMESPACE);
+	if (!OidIsValid(pgroles_relid) ||
+		pg_class_aclcheck(pgroles_relid, GetUserId(), ACL_SELECT) != ACLCHECK_OK ||
+		pg_class_aclcheck(AuthMemRelationId, GetUserId(), ACL_SELECT) != ACLCHECK_OK ||
+		pg_class_aclcheck(DbRoleSettingRelationId, GetUserId(), ACL_SELECT) != ACLCHECK_OK)
+	{
+		ReleaseSysCache(tuple);
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied for role %s", rolname)));
+	}
+
+	/*
+	 * rolpassword needs SELECT on pg_authid; nothing else here is sensitive,
+	 * so only check it when the PASSWORD clause is wanted.
 	 */
 	if (password &&
 		pg_class_aclcheck(AuthIdRelationId, GetUserId(),
@@ -204,8 +226,8 @@ pg_get_role_ddl_internal(Oid roleid, bool pretty, bool memberships,
 	}
 
 	/*
-	 * Lock and re-verify existence, closing the window for a concurrent
-	 * DROP ROLE before the scans below.
+	 * Lock and re-verify existence, closing the window for a concurrent DROP
+	 * ROLE before the scans below.
 	 */
 	shdepLockAndCheckObject(AuthIdRelationId, roleid);
 
@@ -326,16 +348,22 @@ pg_get_role_ddl_internal(Oid roleid, bool pretty, bool memberships,
 
 		/*
 		 * If setdatabase is valid, this is a role-in-database setting;
-		 * otherwise it's a role-wide setting.  In-database settings depend on
-		 * that database already existing, so they're optional; role-wide ones
-		 * are not.  Look up the database name once for all settings in this
-		 * row.
+		 * otherwise a role-wide one.  In-database settings are optional since
+		 * they depend on that database existing; role-wide ones aren't.
+		 * get_database_name() bypasses ACL checks, so verify pg_database
+		 * access before resolving the name it would disclose.
 		 */
 		if (OidIsValid(datid))
 		{
 			if (!in_database_settings)
 				continue;
 
+			if (pg_class_aclcheck(DatabaseRelationId, GetUserId(),
+								  ACL_SELECT) != ACLCHECK_OK)
+				ereport(ERROR,
+						(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+						 errmsg("permission denied for role %s", rolname)));
+
 			datname = get_database_name(datid);
 			/* Database has been dropped; skip all settings in this row. */
 			if (datname == NULL)
@@ -407,9 +435,9 @@ pg_get_role_ddl_internal(Oid roleid, bool pretty, bool memberships,
 	 * member = roleid, meaning this role has been granted membership in other
 	 * roles.
 	 *
-	 * A grantor needs ADMIN OPTION on the granted role already, and the
-	 * only grant of ours that can supply that is a self-grant, so
-	 * self-granted rows must sort last.
+	 * A grantor needs ADMIN OPTION on the granted role already, and the only
+	 * grant of ours that can supply that is a self-grant, so self-granted
+	 * rows must sort last.
 	 */
 	if (memberships)
 	{
@@ -575,8 +603,8 @@ pg_get_tablespace_ddl_internal(Oid tsid, bool pretty, bool no_owner)
 	spcname = pstrdup(NameStr(tspForm->spcname));
 
 	/*
-	 * Everything this emits is public by default, so what matters is
-	 * catalog SELECT on pg_tablespace, not any object-specific privilege.
+	 * Everything this emits is public by default, so what matters is catalog
+	 * SELECT on pg_tablespace, not any object-specific privilege.
 	 */
 	if (pg_class_aclcheck(TableSpaceRelationId, GetUserId(),
 						  ACL_SELECT) != ACLCHECK_OK)
@@ -603,9 +631,21 @@ pg_get_tablespace_ddl_internal(Oid tsid, bool pretty, bool no_owner)
 	/* Start building the CREATE TABLESPACE statement */
 	appendStringInfo(&buf, "CREATE TABLESPACE %s", quote_identifier(spcname));
 
-	/* Add OWNER clause */
+	/*
+	 * GetUserNameFromId() bypasses ACL checks, so verify pg_roles access
+	 * before resolving the owner's name.
+	 */
 	if (!no_owner)
 	{
+		Oid			pgroles_relid = get_relname_relid("pg_roles", PG_CATALOG_NAMESPACE);
+
+		if (!OidIsValid(pgroles_relid) ||
+			pg_class_aclcheck(pgroles_relid, GetUserId(), ACL_SELECT) != ACLCHECK_OK)
+		{
+			ReleaseSysCache(tuple);
+			aclcheck_error(ACLCHECK_NO_PRIV, OBJECT_TABLESPACE, spcname);
+		}
+
 		spcowner = GetUserNameFromId(tspForm->spcowner, false);
 		append_ddl_option(&buf, pretty, 4, "OWNER %s",
 						  quote_identifier(spcowner));
@@ -647,8 +687,8 @@ pg_get_tablespace_ddl_internal(Oid tsid, bool pretty, bool no_owner)
 						 quote_identifier(spcname));
 
 		/*
-		 * Elements are already "name=value", and every option is numeric,
-		 * so emit verbatim, matching pg_dumpall.
+		 * Elements are already "name=value", and every option is numeric, so
+		 * emit verbatim, matching pg_dumpall.
 		 */
 		deconstruct_array_builtin(DatumGetArrayTypeP(datum), TEXTOID,
 								  &options, NULL, &noptions);
@@ -756,7 +796,6 @@ pg_get_database_ddl_internal(Oid dbid, bool pretty,
 	ScanKeyData scankey[2];
 	SysScanDesc scan;
 	List	   *statements = NIL;
-	AclResult	aclresult;
 
 	tuple = SearchSysCache1(DATABASEOID, ObjectIdGetDatum(dbid));
 	if (!HeapTupleIsValid(tuple))
@@ -765,21 +804,25 @@ pg_get_database_ddl_internal(Oid dbid, bool pretty,
 				 errmsg("database with OID %u does not exist", dbid)));
 
 	/*
-	 * User must have connect privilege for target database.  Check this
-	 * before taking any lock below: a caller who can't connect to the
-	 * database has no business holding a lock on it either.
+	 * Everything this emits is public by default, so check catalog SELECT on
+	 * pg_database and pg_db_role_setting (the SET rows come from the latter).
 	 */
-	aclresult = object_aclcheck(DatabaseRelationId, dbid, GetUserId(), ACL_CONNECT);
-	if (aclresult != ACLCHECK_OK)
-		aclcheck_error(aclresult, OBJECT_DATABASE,
+	if (pg_class_aclcheck(DatabaseRelationId, GetUserId(),
+						  ACL_SELECT) != ACLCHECK_OK ||
+		pg_class_aclcheck(DbRoleSettingRelationId, GetUserId(),
+						  ACL_SELECT) != ACLCHECK_OK)
+	{
+		ReleaseSysCache(tuple);
+		aclcheck_error(ACLCHECK_NO_PRIV, OBJECT_DATABASE,
 					   get_database_name(dbid));
+	}
 
 	dbform = (Form_pg_database) GETSTRUCT(tuple);
 	dbname = pstrdup(NameStr(dbform->datname));
 
 	/*
-	 * Lock and re-verify existence, closing the window for a concurrent
-	 * DROP DATABASE before the scan below.
+	 * Lock and re-verify existence, closing the window for a concurrent DROP
+	 * DATABASE before the scan below.
 	 */
 	shdepLockAndCheckObject(DatabaseRelationId, dbid);
 
@@ -880,12 +923,22 @@ pg_get_database_ddl_internal(Oid dbid, bool pretty,
 	/*
 	 * TABLESPACE.  Skip the default tablespace.  Compare by OID: tablespace
 	 * names are case-sensitive, so a user-defined "PG_DEFAULT" is a
-	 * different, valid tablespace.
+	 * different, valid tablespace.  get_tablespace_name() bypasses ACL
+	 * checks, so verify pg_tablespace access first.
 	 */
 	if (!no_tablespace && OidIsValid(dbform->dattablespace) &&
 		dbform->dattablespace != DEFAULTTABLESPACE_OID)
 	{
-		char	   *spcname = get_tablespace_name(dbform->dattablespace);
+		char	   *spcname;
+
+		if (pg_class_aclcheck(TableSpaceRelationId, GetUserId(),
+							  ACL_SELECT) != ACLCHECK_OK)
+		{
+			ReleaseSysCache(tuple);
+			aclcheck_error(ACLCHECK_NO_PRIV, OBJECT_DATABASE, dbname);
+		}
+
+		spcname = get_tablespace_name(dbform->dattablespace);
 
 		if (spcname == NULL)
 			ereport(ERROR,
@@ -901,10 +954,23 @@ pg_get_database_ddl_internal(Oid dbid, bool pretty,
 	appendStringInfoChar(&buf, ';');
 	push_statement(&statements, &buf);
 
-	/* OWNER */
+	/*
+	 * OWNER.  GetUserNameFromId() bypasses ACL checks, so verify pg_roles
+	 * access before resolving the owner's name.
+	 */
 	if (!no_owner && OidIsValid(dbform->datdba))
 	{
-		char	   *owner = GetUserNameFromId(dbform->datdba, false);
+		char	   *owner;
+		Oid			pgroles_relid = get_relname_relid("pg_roles", PG_CATALOG_NAMESPACE);
+
+		if (!OidIsValid(pgroles_relid) ||
+			pg_class_aclcheck(pgroles_relid, GetUserId(), ACL_SELECT) != ACLCHECK_OK)
+		{
+			ReleaseSysCache(tuple);
+			aclcheck_error(ACLCHECK_NO_PRIV, OBJECT_DATABASE, dbname);
+		}
+
+		owner = GetUserNameFromId(dbform->datdba, false);
 
 		appendStringInfo(&buf, "ALTER DATABASE %s OWNER TO %s;",
 						 quote_identifier(dbname), quote_identifier(owner));
diff --git a/src/test/modules/test_misc/Makefile b/src/test/modules/test_misc/Makefile
index fedbef071ef..927ffb83224 100644
--- a/src/test/modules/test_misc/Makefile
+++ b/src/test/modules/test_misc/Makefile
@@ -1,5 +1,7 @@
 # src/test/modules/test_misc/Makefile
 
+REGRESS = ddlutils
+
 TAP_TESTS = 1
 
 EXTRA_INSTALL=src/test/modules/injection_points \
diff --git a/src/test/modules/test_misc/expected/ddlutils.out b/src/test/modules/test_misc/expected/ddlutils.out
new file mode 100644
index 00000000000..9a5418b35eb
--- /dev/null
+++ b/src/test/modules/test_misc/expected/ddlutils.out
@@ -0,0 +1,579 @@
+--
+-- pg_get_role_ddl, pg_get_tablespace_ddl, pg_get_database_ddl
+--
+-- Converted from t/012_ddlutils.pl: none of its checks actually needed a
+-- fresh backend per assertion.
+--
+-- Two things need pinning for a deterministic flat expected-output file:
+-- SCRAM verifiers (compared as booleans against the live pg_authid value,
+-- not printed) and LOCALE_PROVIDER/timezone (pinned explicitly rather than
+-- filtered post hoc, as the original TAP test did).
+SET timezone = 'UTC';
+SET allow_in_place_tablespaces = true;
+--
+-- pg_get_role_ddl
+--
+-- Basic role: CREATE ROLE carries just the name; attributes are a
+-- separate ALTER ROLE.
+CREATE ROLE regress_role_ddl_test1;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1');
+                                                    pg_get_role_ddl                                                    
+-----------------------------------------------------------------------------------------------------------------------
+ CREATE ROLE regress_role_ddl_test1;
+ ALTER ROLE regress_role_ddl_test1 WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;
+(2 rows)
+
+-- Replaying over an existing role: CREATE fails but the ALTER still
+-- applies.  Captured into a temp table so each statement replays
+-- separately -- a single multi-statement string would abort on the
+-- failed CREATE and never reach the ALTER.
+CREATE ROLE regress_role_ddl_exists NOLOGIN;
+CREATE TEMP TABLE exists_ddl_capture AS
+  SELECT stmt, ord FROM pg_get_role_ddl('regress_role_ddl_exists')
+    WITH ORDINALITY AS t(stmt, ord);
+ALTER ROLE regress_role_ddl_exists LOGIN CREATEDB;
+SELECT stmt FROM exists_ddl_capture ORDER BY ord \gexec
+CREATE ROLE regress_role_ddl_exists;
+ERROR:  role "regress_role_ddl_exists" already exists
+ALTER ROLE regress_role_ddl_exists WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;
+SELECT rolcanlogin, rolcreatedb FROM pg_roles
+  WHERE rolname = 'regress_role_ddl_exists';
+ rolcanlogin | rolcreatedb 
+-------------+-------------
+ f           | f
+(1 row)
+
+DROP TABLE exists_ddl_capture;
+-- Role with privileges, password, and VALID UNTIL set directly in
+-- CREATE ROLE.
+CREATE ROLE regress_role_ddl_test2
+  LOGIN SUPERUSER CREATEDB CREATEROLE
+  CONNECTION LIMIT 5
+  PASSWORD 'secret'
+  VALID UNTIL '2030-12-31 23:59:59+00';
+-- Boolean checks, not a printed row, since it also carries PASSWORD.
+SELECT stmt ~ 'SUPERUSER' AS has_superuser,
+       stmt ~ 'CREATEDB' AS has_createdb,
+       stmt ~ 'CONNECTION LIMIT 5' AS has_connection_limit,
+       stmt ~ 'VALID UNTIL ''2030-12-31' AS has_valid_until
+  FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+  WHERE stmt LIKE 'ALTER ROLE%WITH%';
+ has_superuser | has_createdb | has_connection_limit | has_valid_until 
+---------------+--------------+----------------------+-----------------
+ t             | t            | t                    | t
+(1 row)
+
+-- Role without a password emits no PASSWORD clause.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test1') AS t(stmt)
+    WHERE stmt LIKE '%PASSWORD%') AS role_without_password_has_no_password_clause;
+ role_without_password_has_no_password_clause 
+----------------------------------------------
+ t
+(1 row)
+
+-- PASSWORD clause carries the stored verifier, compared as a boolean
+-- since it's randomly salted.
+SELECT EXISTS (
+  SELECT 1
+    FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+    JOIN pg_authid a ON a.rolname = 'regress_role_ddl_test2'
+    WHERE stmt LIKE '%PASSWORD ' || quote_literal(a.rolpassword) || '%'
+) AS password_clause_matches_stored_verifier;
+ password_clause_matches_stored_verifier 
+-----------------------------------------
+ t
+(1 row)
+
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+    WHERE stmt LIKE '%secret%') AS plaintext_password_not_exposed;
+ plaintext_password_not_exposed 
+--------------------------------
+ t
+(1 row)
+
+-- password => false suppresses the PASSWORD clause.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', password => false) AS t(stmt)
+    WHERE stmt LIKE '%PASSWORD%') AS password_suppressed;
+ password_suppressed 
+---------------------
+ t
+(1 row)
+
+-- Role-wide settings (test1 has no password, safe to print directly).
+ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB';
+ALTER ROLE regress_role_ddl_test1 SET search_path TO myschema, public;
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test1') AS t(stmt)
+  WHERE stmt LIKE '%work_mem%' OR stmt LIKE '%search_path%'
+  ORDER BY stmt;
+                                    stmt                                    
+----------------------------------------------------------------------------
+ ALTER ROLE regress_role_ddl_test1 SET search_path TO 'myschema', 'public';
+ ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB';
+(2 rows)
+
+-- Role with a database-specific setting; LOCALE_PROVIDER pinned for
+-- later determinism.
+CREATE DATABASE regression_ddlutils_test
+  TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc
+  LC_COLLATE 'C' LC_CTYPE 'C';
+ALTER ROLE regress_role_ddl_test2
+  IN DATABASE regression_ddlutils_test SET work_mem TO '128MB';
+-- No PASSWORD clause on this row, safe to print directly.
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+  WHERE stmt LIKE '%IN DATABASE%';
+                                              stmt                                               
+-------------------------------------------------------------------------------------------------
+ ALTER ROLE regress_role_ddl_test2 IN DATABASE regression_ddlutils_test SET work_mem TO '128MB';
+(1 row)
+
+-- Exact-shape check: each statement must be its own row, not
+-- concatenated -- this would catch a buffer-reuse bug in
+-- push_statement().
+SELECT count(*) AS role_ddl_test2_row_count,
+       count(*) FILTER (WHERE stmt ~ ';\s*\S') AS role_ddl_test2_rows_with_embedded_statement
+  FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt);
+ role_ddl_test2_row_count | role_ddl_test2_rows_with_embedded_statement 
+--------------------------+---------------------------------------------
+                        3 |                                           0
+(1 row)
+
+-- in_database_settings => false drops IN DATABASE settings, keeps
+-- role-wide ones.
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test1', in_database_settings => false) AS t(stmt)
+  WHERE stmt LIKE '%work_mem%';
+                            stmt                            
+------------------------------------------------------------
+ ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB';
+(1 row)
+
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', in_database_settings => false) AS t(stmt)
+    WHERE stmt LIKE '%IN DATABASE%') AS in_database_settings_false_suppresses_in_database;
+ in_database_settings_false_suppresses_in_database 
+---------------------------------------------------
+ t
+(1 row)
+
+-- Role with special characters (requires quoting).
+CREATE ROLE "regress_role-with-dash";
+SELECT * FROM pg_get_role_ddl('regress_role-with-dash');
+                                                     pg_get_role_ddl                                                     
+-------------------------------------------------------------------------------------------------------------------------
+ CREATE ROLE "regress_role-with-dash";
+ ALTER ROLE "regress_role-with-dash" WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;
+(2 rows)
+
+-- Pretty-printed; boolean check again since the row carries PASSWORD.
+SELECT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', pretty => true) AS t(stmt)
+    WHERE stmt ~ '\n\s+SUPERUSER') AS pretty_print_indents_attributes;
+ pretty_print_indents_attributes 
+---------------------------------
+ t
+(1 row)
+
+-- Role with memberships.
+CREATE ROLE regress_role_ddl_grantor CREATEROLE;
+CREATE ROLE regress_role_ddl_group1;
+CREATE ROLE regress_role_ddl_group2;
+CREATE ROLE regress_role_ddl_member;
+GRANT regress_role_ddl_group1 TO regress_role_ddl_grantor WITH ADMIN TRUE;
+GRANT regress_role_ddl_group2 TO regress_role_ddl_grantor WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_grantor;
+GRANT regress_role_ddl_group1 TO regress_role_ddl_member
+  WITH INHERIT TRUE, SET FALSE;
+GRANT regress_role_ddl_group2 TO regress_role_ddl_member
+  WITH ADMIN TRUE;
+RESET ROLE;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_member');
+                                                             pg_get_role_ddl                                                             
+-----------------------------------------------------------------------------------------------------------------------------------------
+ CREATE ROLE regress_role_ddl_member;
+ ALTER ROLE regress_role_ddl_member WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;
+ GRANT regress_role_ddl_group1 TO regress_role_ddl_member WITH ADMIN FALSE, INHERIT TRUE, SET FALSE GRANTED BY regress_role_ddl_grantor;
+ GRANT regress_role_ddl_group2 TO regress_role_ddl_member WITH ADMIN TRUE, INHERIT TRUE, SET TRUE GRANTED BY regress_role_ddl_grantor;
+(4 rows)
+
+-- Membership ordering: a self-grant must come after the grant that
+-- gave ADMIN OPTION; the catalog scan's OID order doesn't guarantee
+-- that.
+CREATE ROLE regress_role_ddl_group3;
+CREATE ROLE regress_role_ddl_self;
+CREATE ROLE regress_role_ddl_admin CREATEROLE;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_admin WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_admin;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_self;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH INHERIT FALSE;
+RESET ROLE;
+SELECT (ord_admin IS NOT NULL AND ord_self IS NOT NULL) AS both_membership_grants_emitted,
+       (ord_admin < ord_self) AS admin_grant_precedes_self_grant
+  FROM (SELECT min(ord) FILTER (WHERE stmt LIKE '%GRANTED BY regress_role_ddl_admin%') AS ord_admin,
+               min(ord) FILTER (WHERE stmt LIKE '%GRANTED BY regress_role_ddl_self%') AS ord_self
+          FROM pg_get_role_ddl('regress_role_ddl_self') WITH ORDINALITY AS t(stmt, ord)) s;
+ both_membership_grants_emitted | admin_grant_precedes_self_grant 
+--------------------------------+---------------------------------
+ t                              | t
+(1 row)
+
+-- That order makes the payload replayable; captured for per-statement
+-- replay as above.
+CREATE TEMP TABLE self_ddl_capture AS
+  SELECT stmt, ord FROM pg_get_role_ddl('regress_role_ddl_self')
+    WITH ORDINALITY AS t(stmt, ord);
+DROP ROLE regress_role_ddl_self;
+SELECT stmt FROM self_ddl_capture ORDER BY ord \gexec
+CREATE ROLE regress_role_ddl_self;
+ALTER ROLE regress_role_ddl_self WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH ADMIN TRUE, INHERIT TRUE, SET TRUE GRANTED BY regress_role_ddl_admin;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH ADMIN FALSE, INHERIT FALSE, SET TRUE GRANTED BY regress_role_ddl_self;
+DROP TABLE self_ddl_capture;
+-- memberships => false suppresses GRANT statements.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_member', memberships => false) AS t(stmt)
+    WHERE stmt LIKE '%GRANT%') AS memberships_suppressed;
+ memberships_suppressed 
+------------------------
+ t
+(1 row)
+
+-- Non-existent role errors.
+SELECT * FROM pg_get_role_ddl(9999999::oid);
+ERROR:  role with OID 9999999 does not exist
+-- NULL input returns no rows.
+SELECT count(*) FROM pg_get_role_ddl(NULL);
+ count 
+-------
+     0
+(1 row)
+
+-- Needs SELECT on pg_authid even for a passwordless role, to determine
+-- password status.
+CREATE ROLE regress_role_ddl_noaccess;
+REVOKE SELECT ON pg_authid FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1');
+ERROR:  permission denied for role regress_role_ddl_test1
+RESET ROLE;
+-- password => false needs no pg_authid access; everything else is
+-- public via pg_roles/pg_auth_members/pg_db_role_setting.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+                                                                            pg_get_role_ddl                                                                            
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE ROLE regress_role_ddl_test2;
+ ALTER ROLE regress_role_ddl_test2 WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 5 VALID UNTIL '2030-12-31 23:59:59+00';
+ ALTER ROLE regress_role_ddl_test2 IN DATABASE regression_ddlutils_test SET work_mem TO '128MB';
+(3 rows)
+
+RESET ROLE;
+-- Also requires pg_roles, pg_auth_members, and pg_db_role_setting,
+-- unconditionally.  Revoke each individually to prove the check is a
+-- real AND across all three, not satisfiable by a subset.
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+ERROR:  permission denied for role regress_role_ddl_test2
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+REVOKE SELECT ON pg_auth_members FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+ERROR:  permission denied for role regress_role_ddl_test2
+RESET ROLE;
+GRANT SELECT ON pg_auth_members TO PUBLIC;
+REVOKE SELECT ON pg_db_role_setting FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+ERROR:  permission denied for role regress_role_ddl_test2
+RESET ROLE;
+GRANT SELECT ON pg_db_role_setting TO PUBLIC;
+-- Also requires pg_database before an IN DATABASE clause
+-- (get_database_name() bypasses ACL checks otherwise).
+-- regress_role_ddl_test2 already has an IN DATABASE setting, so this
+-- exercises the real path.
+REVOKE SELECT ON pg_database FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+ERROR:  permission denied for role regress_role_ddl_test2
+RESET ROLE;
+GRANT SELECT ON pg_database TO PUBLIC;
+-- All catalogs granted: allowed.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+                                                                            pg_get_role_ddl                                                                            
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE ROLE regress_role_ddl_test2;
+ ALTER ROLE regress_role_ddl_test2 WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 5 VALID UNTIL '2030-12-31 23:59:59+00';
+ ALTER ROLE regress_role_ddl_test2 IN DATABASE regression_ddlutils_test SET work_mem TO '128MB';
+(3 rows)
+
+RESET ROLE;
+--
+-- pg_get_database_ddl
+--
+ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT 123;
+ALTER DATABASE regression_ddlutils_test SET random_page_cost = 2.0;
+ALTER ROLE regress_role_ddl_test2
+  IN DATABASE regression_ddlutils_test SET random_page_cost = 1.1;
+-- Non-existent database errors.
+SELECT * FROM pg_get_database_ddl('regression_no_such_db');
+ERROR:  database "regression_no_such_db" does not exist
+LINE 1: SELECT * FROM pg_get_database_ddl('regression_no_such_db');
+                                          ^
+-- NULL input returns no rows.
+SELECT count(*) FROM pg_get_database_ddl(NULL);
+ count 
+-------
+     0
+(1 row)
+
+-- Invalid option (bad boolean cast) errors.
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', owner => 'invalid');
+ERROR:  invalid input syntax for type boolean: "invalid"
+LINE 1: ...database_ddl('regression_ddlutils_test', owner => 'invalid')...
+                                                             ^
+-- Duplicate named argument errors.
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', owner => false, owner => true);
+ERROR:  argument name "owner" used more than once
+LINE 1: ...e_ddl('regression_ddlutils_test', owner => false, owner => t...
+                                                             ^
+-- IS_TEMPLATE/ALLOW_CONNECTIONS coverage; reset immediately so later
+-- checks aren't affected.
+ALTER DATABASE regression_ddlutils_test IS_TEMPLATE true;
+ALTER DATABASE regression_ddlutils_test ALLOW_CONNECTIONS false;
+SELECT pg_get_database_ddl FROM pg_get_database_ddl('regression_ddlutils_test');
+                                                    pg_get_database_ddl                                                    
+---------------------------------------------------------------------------------------------------------------------------
+ CREATE DATABASE regression_ddlutils_test WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'C';
+ ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT = 123;
+ ALTER DATABASE regression_ddlutils_test IS_TEMPLATE = true;
+ ALTER DATABASE regression_ddlutils_test ALLOW_CONNECTIONS = false;
+ ALTER DATABASE regression_ddlutils_test SET random_page_cost TO '2.0';
+(6 rows)
+
+ALTER DATABASE regression_ddlutils_test ALLOW_CONNECTIONS true;
+ALTER DATABASE regression_ddlutils_test IS_TEMPLATE false;
+-- Basic output; LOCALE_PROVIDER above keeps this deterministic.
+SELECT pg_get_database_ddl FROM pg_get_database_ddl('regression_ddlutils_test');
+                                                    pg_get_database_ddl                                                    
+---------------------------------------------------------------------------------------------------------------------------
+ CREATE DATABASE regression_ddlutils_test WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'C';
+ ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT = 123;
+ ALTER DATABASE regression_ddlutils_test SET random_page_cost TO '2.0';
+(4 rows)
+
+-- Exact-shape check: same buffer-reuse concern as pg_get_role_ddl()
+-- above.
+SELECT count(*) AS database_ddl_row_count,
+       count(*) FILTER (WHERE pg_get_database_ddl ~ ';\s*\S') AS database_ddl_rows_with_embedded_statement
+  FROM pg_get_database_ddl('regression_ddlutils_test');
+ database_ddl_row_count | database_ddl_rows_with_embedded_statement 
+------------------------+-------------------------------------------
+                      4 |                                         0
+(1 row)
+
+-- Pretty-printed output, without the default tablespace clause.
+SELECT pg_get_database_ddl
+  FROM pg_get_database_ddl('regression_ddlutils_test', pretty => true, tablespace => false);
+                           pg_get_database_ddl                            
+--------------------------------------------------------------------------
+ CREATE DATABASE regression_ddlutils_test                                +
+     WITH TEMPLATE = template0                                           +
+     ENCODING = 'UTF8'                                                   +
+     LOCALE_PROVIDER = libc                                              +
+     LOCALE = 'C';
+ ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT = 123;
+ ALTER DATABASE regression_ddlutils_test SET random_page_cost TO '2.0';
+(4 rows)
+
+-- Owner suppressed.
+SELECT pg_get_database_ddl
+  FROM pg_get_database_ddl('regression_ddlutils_test', owner => false);
+                                                    pg_get_database_ddl                                                    
+---------------------------------------------------------------------------------------------------------------------------
+ CREATE DATABASE regression_ddlutils_test WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'C';
+ ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT = 123;
+ ALTER DATABASE regression_ddlutils_test SET random_page_cost TO '2.0';
+(3 rows)
+
+-- Requires SELECT on both pg_database and pg_db_role_setting (the SET
+-- rows come from the latter).
+REVOKE SELECT ON pg_database FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+ERROR:  permission denied for database regression_ddlutils_test
+RESET ROLE;
+GRANT SELECT ON pg_database TO PUBLIC;
+-- Reverse case: proves the check is an AND, not an OR either catalog
+-- alone would satisfy.
+REVOKE SELECT ON pg_db_role_setting FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+ERROR:  permission denied for database regression_ddlutils_test
+RESET ROLE;
+GRANT SELECT ON pg_db_role_setting TO PUBLIC;
+-- Both catalogs readable: allowed.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+                                                    pg_get_database_ddl                                                    
+---------------------------------------------------------------------------------------------------------------------------
+ CREATE DATABASE regression_ddlutils_test WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'C';
+ ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT = 123;
+ ALTER DATABASE regression_ddlutils_test SET random_page_cost TO '2.0';
+(4 rows)
+
+RESET ROLE;
+-- Also requires pg_roles before the OWNER clause (GetUserNameFromId()
+-- bypasses ACL checks otherwise).
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+ERROR:  permission denied for database regression_ddlutils_test
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+-- Also requires pg_tablespace before a non-default TABLESPACE clause.
+-- Uses a throwaway database/tablespace since regression_ddlutils_test
+-- is in the default tablespace.
+CREATE TABLESPACE regress_ddl_tblsp_check LOCATION '';
+CREATE DATABASE regress_ddl_tblsp_check_db
+  TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc
+  LC_COLLATE 'C' LC_CTYPE 'C' TABLESPACE regress_ddl_tblsp_check;
+-- Owner would otherwise default to whoever runs this script; pin it
+-- for determinism.
+ALTER DATABASE regress_ddl_tblsp_check_db OWNER TO regress_role_ddl_test1;
+REVOKE SELECT ON pg_tablespace FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_ddl_tblsp_check_db');
+ERROR:  permission denied for database regress_ddl_tblsp_check_db
+RESET ROLE;
+GRANT SELECT ON pg_tablespace TO PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_ddl_tblsp_check_db');
+                                                                       pg_get_database_ddl                                                                        
+------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE DATABASE regress_ddl_tblsp_check_db WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = libc LOCALE = 'C' TABLESPACE = regress_ddl_tblsp_check;
+ ALTER DATABASE regress_ddl_tblsp_check_db OWNER TO regress_role_ddl_test1;
+(2 rows)
+
+RESET ROLE;
+DROP DATABASE regress_ddl_tblsp_check_db;
+DROP TABLESPACE regress_ddl_tblsp_check;
+--
+-- pg_get_tablespace_ddl
+--
+-- Non-existent tablespace by name and by OID both error.
+SELECT * FROM pg_get_tablespace_ddl('regress_nonexistent_tblsp');
+ERROR:  tablespace "regress_nonexistent_tblsp" does not exist
+SELECT * FROM pg_get_tablespace_ddl(0::oid);
+ERROR:  tablespace with OID 0 does not exist
+-- NULL input (name and OID variants) returns no rows.
+SELECT count(*) FROM pg_get_tablespace_ddl(NULL::name);
+ count 
+-------
+     0
+(1 row)
+
+SELECT count(*) FROM pg_get_tablespace_ddl(NULL::oid);
+ count 
+-------
+     0
+(1 row)
+
+-- Tablespace name requiring quoting.
+CREATE TABLESPACE "regress_ tblsp" OWNER regress_role_ddl_test1
+  LOCATION '';
+SELECT * FROM pg_get_tablespace_ddl('regress_ tblsp');
+                            pg_get_tablespace_ddl                             
+------------------------------------------------------------------------------
+ CREATE TABLESPACE "regress_ tblsp" OWNER regress_role_ddl_test1 LOCATION '';
+(1 row)
+
+-- Rename and add options; reuse for remaining tests.
+ALTER TABLESPACE "regress_ tblsp" RENAME TO regress_allopt_tblsp;
+ALTER TABLESPACE regress_allopt_tblsp
+  SET (seq_page_cost = '1.5', random_page_cost = '1.1234567890',
+       effective_io_concurrency = '17', maintenance_io_concurrency = '18');
+-- Tablespace with multiple options.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+                                                                   pg_get_tablespace_ddl                                                                   
+-----------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TABLESPACE regress_allopt_tblsp OWNER regress_role_ddl_test1 LOCATION '';
+ ALTER TABLESPACE regress_allopt_tblsp SET (seq_page_cost=1.5, random_page_cost=1.1234567890, effective_io_concurrency=17, maintenance_io_concurrency=18);
+(2 rows)
+
+-- Exact-shape check: same buffer-reuse concern as above.
+SELECT count(*) AS tablespace_ddl_row_count,
+       count(*) FILTER (WHERE pg_get_tablespace_ddl ~ ';\s*\S') AS tablespace_ddl_rows_with_embedded_statement
+  FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+ tablespace_ddl_row_count | tablespace_ddl_rows_with_embedded_statement 
+--------------------------+---------------------------------------------
+                        2 |                                           0
+(1 row)
+
+-- Pretty-printed output.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', pretty => true);
+                                                                   pg_get_tablespace_ddl                                                                   
+-----------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TABLESPACE regress_allopt_tblsp                                                                                                                   +
+     OWNER regress_role_ddl_test1                                                                                                                         +
+     LOCATION '';
+ ALTER TABLESPACE regress_allopt_tblsp SET (seq_page_cost=1.5, random_page_cost=1.1234567890, effective_io_concurrency=17, maintenance_io_concurrency=18);
+(2 rows)
+
+-- Owner suppressed.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', owner => false);
+                                                                   pg_get_tablespace_ddl                                                                   
+-----------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TABLESPACE regress_allopt_tblsp LOCATION '';
+ ALTER TABLESPACE regress_allopt_tblsp SET (seq_page_cost=1.5, random_page_cost=1.1234567890, effective_io_concurrency=17, maintenance_io_concurrency=18);
+(2 rows)
+
+-- Lookup by OID.
+SELECT pg_get_tablespace_ddl
+  FROM pg_get_tablespace_ddl(
+    (SELECT oid FROM pg_tablespace WHERE spcname = 'regress_allopt_tblsp'));
+                                                                   pg_get_tablespace_ddl                                                                   
+-----------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TABLESPACE regress_allopt_tblsp OWNER regress_role_ddl_test1 LOCATION '';
+ ALTER TABLESPACE regress_allopt_tblsp SET (seq_page_cost=1.5, random_page_cost=1.1234567890, effective_io_concurrency=17, maintenance_io_concurrency=18);
+(2 rows)
+
+-- Requires catalog-wide SELECT on pg_tablespace.
+REVOKE SELECT ON pg_tablespace FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+ERROR:  permission denied for tablespace regress_allopt_tblsp
+RESET ROLE;
+GRANT SELECT ON pg_tablespace TO PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+                                                                   pg_get_tablespace_ddl                                                                   
+-----------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TABLESPACE regress_allopt_tblsp OWNER regress_role_ddl_test1 LOCATION '';
+ ALTER TABLESPACE regress_allopt_tblsp SET (seq_page_cost=1.5, random_page_cost=1.1234567890, effective_io_concurrency=17, maintenance_io_concurrency=18);
+(2 rows)
+
+RESET ROLE;
+-- Also requires pg_roles before the OWNER clause.
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+ERROR:  permission denied for tablespace regress_allopt_tblsp
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+-- Cleanup.
+DROP TABLESPACE regress_allopt_tblsp;
+DROP DATABASE regression_ddlutils_test;
+DROP ROLE regress_role_ddl_test1, regress_role_ddl_test2, regress_role_ddl_exists,
+          regress_role_ddl_noaccess, regress_role_ddl_grantor, regress_role_ddl_group1,
+          regress_role_ddl_group2, regress_role_ddl_member, regress_role_ddl_group3,
+          regress_role_ddl_self, regress_role_ddl_admin, "regress_role-with-dash";
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 5d81f5b13be..38c7ea1599c 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -4,6 +4,15 @@ tests += {
   'name': 'test_misc',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'ddlutils',
+    ],
+    # The script grants/revokes on shared catalogs (pg_authid) and creates
+    # objects it doesn't clean up incrementally between statements, so it
+    # isn't safe to run against a live, persistent, possibly-shared cluster.
+    'runningcheck': false,
+  },
   'tap': {
     'env': {
        'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
@@ -20,10 +29,10 @@ tests += {
       't/009_log_temp_files.pl',
       't/010_index_concurrently_upsert.pl',
       't/011_lock_stats.pl',
-      't/012_ddlutils.pl',
       't/013_temp_obj_multisession.pl',
       't/014_log_statement_max_length.pl',
       't/015_temp_schema_exit_deferrable.pl',
+      't/016_ddlutils_pg_dump_crosscheck.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/sql/ddlutils.sql b/src/test/modules/test_misc/sql/ddlutils.sql
new file mode 100644
index 00000000000..dde3f50cdb6
--- /dev/null
+++ b/src/test/modules/test_misc/sql/ddlutils.sql
@@ -0,0 +1,382 @@
+--
+-- pg_get_role_ddl, pg_get_tablespace_ddl, pg_get_database_ddl
+--
+-- Converted from t/012_ddlutils.pl: none of its checks actually needed a
+-- fresh backend per assertion.
+--
+-- Two things need pinning for a deterministic flat expected-output file:
+-- SCRAM verifiers (compared as booleans against the live pg_authid value,
+-- not printed) and LOCALE_PROVIDER/timezone (pinned explicitly rather than
+-- filtered post hoc, as the original TAP test did).
+SET timezone = 'UTC';
+SET allow_in_place_tablespaces = true;
+
+--
+-- pg_get_role_ddl
+--
+
+-- Basic role: CREATE ROLE carries just the name; attributes are a
+-- separate ALTER ROLE.
+CREATE ROLE regress_role_ddl_test1;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1');
+
+-- Replaying over an existing role: CREATE fails but the ALTER still
+-- applies.  Captured into a temp table so each statement replays
+-- separately -- a single multi-statement string would abort on the
+-- failed CREATE and never reach the ALTER.
+CREATE ROLE regress_role_ddl_exists NOLOGIN;
+CREATE TEMP TABLE exists_ddl_capture AS
+  SELECT stmt, ord FROM pg_get_role_ddl('regress_role_ddl_exists')
+    WITH ORDINALITY AS t(stmt, ord);
+ALTER ROLE regress_role_ddl_exists LOGIN CREATEDB;
+SELECT stmt FROM exists_ddl_capture ORDER BY ord \gexec
+SELECT rolcanlogin, rolcreatedb FROM pg_roles
+  WHERE rolname = 'regress_role_ddl_exists';
+DROP TABLE exists_ddl_capture;
+
+-- Role with privileges, password, and VALID UNTIL set directly in
+-- CREATE ROLE.
+CREATE ROLE regress_role_ddl_test2
+  LOGIN SUPERUSER CREATEDB CREATEROLE
+  CONNECTION LIMIT 5
+  PASSWORD 'secret'
+  VALID UNTIL '2030-12-31 23:59:59+00';
+
+-- Boolean checks, not a printed row, since it also carries PASSWORD.
+SELECT stmt ~ 'SUPERUSER' AS has_superuser,
+       stmt ~ 'CREATEDB' AS has_createdb,
+       stmt ~ 'CONNECTION LIMIT 5' AS has_connection_limit,
+       stmt ~ 'VALID UNTIL ''2030-12-31' AS has_valid_until
+  FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+  WHERE stmt LIKE 'ALTER ROLE%WITH%';
+
+-- Role without a password emits no PASSWORD clause.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test1') AS t(stmt)
+    WHERE stmt LIKE '%PASSWORD%') AS role_without_password_has_no_password_clause;
+
+-- PASSWORD clause carries the stored verifier, compared as a boolean
+-- since it's randomly salted.
+SELECT EXISTS (
+  SELECT 1
+    FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+    JOIN pg_authid a ON a.rolname = 'regress_role_ddl_test2'
+    WHERE stmt LIKE '%PASSWORD ' || quote_literal(a.rolpassword) || '%'
+) AS password_clause_matches_stored_verifier;
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+    WHERE stmt LIKE '%secret%') AS plaintext_password_not_exposed;
+
+-- password => false suppresses the PASSWORD clause.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', password => false) AS t(stmt)
+    WHERE stmt LIKE '%PASSWORD%') AS password_suppressed;
+
+-- Role-wide settings (test1 has no password, safe to print directly).
+ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB';
+ALTER ROLE regress_role_ddl_test1 SET search_path TO myschema, public;
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test1') AS t(stmt)
+  WHERE stmt LIKE '%work_mem%' OR stmt LIKE '%search_path%'
+  ORDER BY stmt;
+
+-- Role with a database-specific setting; LOCALE_PROVIDER pinned for
+-- later determinism.
+CREATE DATABASE regression_ddlutils_test
+  TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc
+  LC_COLLATE 'C' LC_CTYPE 'C';
+ALTER ROLE regress_role_ddl_test2
+  IN DATABASE regression_ddlutils_test SET work_mem TO '128MB';
+-- No PASSWORD clause on this row, safe to print directly.
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt)
+  WHERE stmt LIKE '%IN DATABASE%';
+
+-- Exact-shape check: each statement must be its own row, not
+-- concatenated -- this would catch a buffer-reuse bug in
+-- push_statement().
+SELECT count(*) AS role_ddl_test2_row_count,
+       count(*) FILTER (WHERE stmt ~ ';\s*\S') AS role_ddl_test2_rows_with_embedded_statement
+  FROM pg_get_role_ddl('regress_role_ddl_test2') AS t(stmt);
+
+-- in_database_settings => false drops IN DATABASE settings, keeps
+-- role-wide ones.
+SELECT stmt FROM pg_get_role_ddl('regress_role_ddl_test1', in_database_settings => false) AS t(stmt)
+  WHERE stmt LIKE '%work_mem%';
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', in_database_settings => false) AS t(stmt)
+    WHERE stmt LIKE '%IN DATABASE%') AS in_database_settings_false_suppresses_in_database;
+
+-- Role with special characters (requires quoting).
+CREATE ROLE "regress_role-with-dash";
+SELECT * FROM pg_get_role_ddl('regress_role-with-dash');
+
+-- Pretty-printed; boolean check again since the row carries PASSWORD.
+SELECT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_test2', pretty => true) AS t(stmt)
+    WHERE stmt ~ '\n\s+SUPERUSER') AS pretty_print_indents_attributes;
+
+-- Role with memberships.
+CREATE ROLE regress_role_ddl_grantor CREATEROLE;
+CREATE ROLE regress_role_ddl_group1;
+CREATE ROLE regress_role_ddl_group2;
+CREATE ROLE regress_role_ddl_member;
+GRANT regress_role_ddl_group1 TO regress_role_ddl_grantor WITH ADMIN TRUE;
+GRANT regress_role_ddl_group2 TO regress_role_ddl_grantor WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_grantor;
+GRANT regress_role_ddl_group1 TO regress_role_ddl_member
+  WITH INHERIT TRUE, SET FALSE;
+GRANT regress_role_ddl_group2 TO regress_role_ddl_member
+  WITH ADMIN TRUE;
+RESET ROLE;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_member');
+
+-- Membership ordering: a self-grant must come after the grant that
+-- gave ADMIN OPTION; the catalog scan's OID order doesn't guarantee
+-- that.
+CREATE ROLE regress_role_ddl_group3;
+CREATE ROLE regress_role_ddl_self;
+CREATE ROLE regress_role_ddl_admin CREATEROLE;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_admin WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_admin;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH ADMIN TRUE;
+SET ROLE regress_role_ddl_self;
+GRANT regress_role_ddl_group3 TO regress_role_ddl_self WITH INHERIT FALSE;
+RESET ROLE;
+SELECT (ord_admin IS NOT NULL AND ord_self IS NOT NULL) AS both_membership_grants_emitted,
+       (ord_admin < ord_self) AS admin_grant_precedes_self_grant
+  FROM (SELECT min(ord) FILTER (WHERE stmt LIKE '%GRANTED BY regress_role_ddl_admin%') AS ord_admin,
+               min(ord) FILTER (WHERE stmt LIKE '%GRANTED BY regress_role_ddl_self%') AS ord_self
+          FROM pg_get_role_ddl('regress_role_ddl_self') WITH ORDINALITY AS t(stmt, ord)) s;
+
+-- That order makes the payload replayable; captured for per-statement
+-- replay as above.
+CREATE TEMP TABLE self_ddl_capture AS
+  SELECT stmt, ord FROM pg_get_role_ddl('regress_role_ddl_self')
+    WITH ORDINALITY AS t(stmt, ord);
+DROP ROLE regress_role_ddl_self;
+SELECT stmt FROM self_ddl_capture ORDER BY ord \gexec
+DROP TABLE self_ddl_capture;
+
+-- memberships => false suppresses GRANT statements.
+SELECT NOT EXISTS (
+  SELECT 1 FROM pg_get_role_ddl('regress_role_ddl_member', memberships => false) AS t(stmt)
+    WHERE stmt LIKE '%GRANT%') AS memberships_suppressed;
+
+-- Non-existent role errors.
+SELECT * FROM pg_get_role_ddl(9999999::oid);
+
+-- NULL input returns no rows.
+SELECT count(*) FROM pg_get_role_ddl(NULL);
+
+-- Needs SELECT on pg_authid even for a passwordless role, to determine
+-- password status.
+CREATE ROLE regress_role_ddl_noaccess;
+REVOKE SELECT ON pg_authid FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1');
+RESET ROLE;
+
+-- password => false needs no pg_authid access; everything else is
+-- public via pg_roles/pg_auth_members/pg_db_role_setting.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+
+-- Also requires pg_roles, pg_auth_members, and pg_db_role_setting,
+-- unconditionally.  Revoke each individually to prove the check is a
+-- real AND across all three, not satisfiable by a subset.
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+
+REVOKE SELECT ON pg_auth_members FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+GRANT SELECT ON pg_auth_members TO PUBLIC;
+
+REVOKE SELECT ON pg_db_role_setting FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+GRANT SELECT ON pg_db_role_setting TO PUBLIC;
+
+-- Also requires pg_database before an IN DATABASE clause
+-- (get_database_name() bypasses ACL checks otherwise).
+-- regress_role_ddl_test2 already has an IN DATABASE setting, so this
+-- exercises the real path.
+REVOKE SELECT ON pg_database FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+GRANT SELECT ON pg_database TO PUBLIC;
+
+-- All catalogs granted: allowed.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', password => false);
+RESET ROLE;
+
+--
+-- pg_get_database_ddl
+--
+
+ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
+ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT 123;
+ALTER DATABASE regression_ddlutils_test SET random_page_cost = 2.0;
+ALTER ROLE regress_role_ddl_test2
+  IN DATABASE regression_ddlutils_test SET random_page_cost = 1.1;
+
+-- Non-existent database errors.
+SELECT * FROM pg_get_database_ddl('regression_no_such_db');
+
+-- NULL input returns no rows.
+SELECT count(*) FROM pg_get_database_ddl(NULL);
+
+-- Invalid option (bad boolean cast) errors.
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', owner => 'invalid');
+
+-- Duplicate named argument errors.
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', owner => false, owner => true);
+
+-- IS_TEMPLATE/ALLOW_CONNECTIONS coverage; reset immediately so later
+-- checks aren't affected.
+ALTER DATABASE regression_ddlutils_test IS_TEMPLATE true;
+ALTER DATABASE regression_ddlutils_test ALLOW_CONNECTIONS false;
+SELECT pg_get_database_ddl FROM pg_get_database_ddl('regression_ddlutils_test');
+ALTER DATABASE regression_ddlutils_test ALLOW_CONNECTIONS true;
+ALTER DATABASE regression_ddlutils_test IS_TEMPLATE false;
+
+-- Basic output; LOCALE_PROVIDER above keeps this deterministic.
+SELECT pg_get_database_ddl FROM pg_get_database_ddl('regression_ddlutils_test');
+
+-- Exact-shape check: same buffer-reuse concern as pg_get_role_ddl()
+-- above.
+SELECT count(*) AS database_ddl_row_count,
+       count(*) FILTER (WHERE pg_get_database_ddl ~ ';\s*\S') AS database_ddl_rows_with_embedded_statement
+  FROM pg_get_database_ddl('regression_ddlutils_test');
+
+-- Pretty-printed output, without the default tablespace clause.
+SELECT pg_get_database_ddl
+  FROM pg_get_database_ddl('regression_ddlutils_test', pretty => true, tablespace => false);
+
+-- Owner suppressed.
+SELECT pg_get_database_ddl
+  FROM pg_get_database_ddl('regression_ddlutils_test', owner => false);
+
+-- Requires SELECT on both pg_database and pg_db_role_setting (the SET
+-- rows come from the latter).
+REVOKE SELECT ON pg_database FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+RESET ROLE;
+GRANT SELECT ON pg_database TO PUBLIC;
+
+-- Reverse case: proves the check is an AND, not an OR either catalog
+-- alone would satisfy.
+REVOKE SELECT ON pg_db_role_setting FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+RESET ROLE;
+GRANT SELECT ON pg_db_role_setting TO PUBLIC;
+
+-- Both catalogs readable: allowed.
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+RESET ROLE;
+
+-- Also requires pg_roles before the OWNER clause (GetUserNameFromId()
+-- bypasses ACL checks otherwise).
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regression_ddlutils_test');
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+
+-- Also requires pg_tablespace before a non-default TABLESPACE clause.
+-- Uses a throwaway database/tablespace since regression_ddlutils_test
+-- is in the default tablespace.
+CREATE TABLESPACE regress_ddl_tblsp_check LOCATION '';
+CREATE DATABASE regress_ddl_tblsp_check_db
+  TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc
+  LC_COLLATE 'C' LC_CTYPE 'C' TABLESPACE regress_ddl_tblsp_check;
+-- Owner would otherwise default to whoever runs this script; pin it
+-- for determinism.
+ALTER DATABASE regress_ddl_tblsp_check_db OWNER TO regress_role_ddl_test1;
+REVOKE SELECT ON pg_tablespace FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_ddl_tblsp_check_db');
+RESET ROLE;
+GRANT SELECT ON pg_tablespace TO PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_ddl_tblsp_check_db');
+RESET ROLE;
+DROP DATABASE regress_ddl_tblsp_check_db;
+DROP TABLESPACE regress_ddl_tblsp_check;
+
+--
+-- pg_get_tablespace_ddl
+--
+
+-- Non-existent tablespace by name and by OID both error.
+SELECT * FROM pg_get_tablespace_ddl('regress_nonexistent_tblsp');
+SELECT * FROM pg_get_tablespace_ddl(0::oid);
+
+-- NULL input (name and OID variants) returns no rows.
+SELECT count(*) FROM pg_get_tablespace_ddl(NULL::name);
+SELECT count(*) FROM pg_get_tablespace_ddl(NULL::oid);
+
+-- Tablespace name requiring quoting.
+CREATE TABLESPACE "regress_ tblsp" OWNER regress_role_ddl_test1
+  LOCATION '';
+SELECT * FROM pg_get_tablespace_ddl('regress_ tblsp');
+
+-- Rename and add options; reuse for remaining tests.
+ALTER TABLESPACE "regress_ tblsp" RENAME TO regress_allopt_tblsp;
+ALTER TABLESPACE regress_allopt_tblsp
+  SET (seq_page_cost = '1.5', random_page_cost = '1.1234567890',
+       effective_io_concurrency = '17', maintenance_io_concurrency = '18');
+
+-- Tablespace with multiple options.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+
+-- Exact-shape check: same buffer-reuse concern as above.
+SELECT count(*) AS tablespace_ddl_row_count,
+       count(*) FILTER (WHERE pg_get_tablespace_ddl ~ ';\s*\S') AS tablespace_ddl_rows_with_embedded_statement
+  FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+
+-- Pretty-printed output.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', pretty => true);
+
+-- Owner suppressed.
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', owner => false);
+
+-- Lookup by OID.
+SELECT pg_get_tablespace_ddl
+  FROM pg_get_tablespace_ddl(
+    (SELECT oid FROM pg_tablespace WHERE spcname = 'regress_allopt_tblsp'));
+
+-- Requires catalog-wide SELECT on pg_tablespace.
+REVOKE SELECT ON pg_tablespace FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+RESET ROLE;
+GRANT SELECT ON pg_tablespace TO PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+RESET ROLE;
+
+-- Also requires pg_roles before the OWNER clause.
+REVOKE SELECT ON pg_roles FROM PUBLIC;
+SET ROLE regress_role_ddl_noaccess;
+SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp');
+RESET ROLE;
+GRANT SELECT ON pg_roles TO PUBLIC;
+
+-- Cleanup.
+DROP TABLESPACE regress_allopt_tblsp;
+DROP DATABASE regression_ddlutils_test;
+DROP ROLE regress_role_ddl_test1, regress_role_ddl_test2, regress_role_ddl_exists,
+          regress_role_ddl_noaccess, regress_role_ddl_grantor, regress_role_ddl_group1,
+          regress_role_ddl_group2, regress_role_ddl_member, regress_role_ddl_group3,
+          regress_role_ddl_self, regress_role_ddl_admin, "regress_role-with-dash";
diff --git a/src/test/modules/test_misc/t/012_ddlutils.pl b/src/test/modules/test_misc/t/012_ddlutils.pl
deleted file mode 100644
index c4096879922..00000000000
--- a/src/test/modules/test_misc/t/012_ddlutils.pl
+++ /dev/null
@@ -1,341 +0,0 @@
-
-# Copyright (c) 2026, PostgreSQL Global Development Group
-
-# Tests for pg_get_database_ddl(), pg_get_tablespace_ddl(), and
-# pg_get_role_ddl().  These are TAP tests rather than plain regression
-# tests because they create databases and tablespaces, which are
-# heavyweight operations that should run only once rather than being
-# repeated with every invocation of the core regression suite.
-
-use strict;
-use warnings FATAL => 'all';
-use PostgreSQL::Test::Cluster;
-use PostgreSQL::Test::Utils;
-use Test::More;
-
-my $node = PostgreSQL::Test::Cluster->new('main');
-$node->init;
-# Force UTC so that timestamptz values (e.g. VALID UNTIL) render the same
-# way regardless of the host's local timezone.
-$node->append_conf('postgresql.conf', "timezone = 'UTC'\n");
-$node->start;
-
-# Perl helper that strips locale/collation details from DDL output so
-# that results are stable across platforms.
-sub ddl_filter
-{
-	my ($text) = @_;
-	$text =~ s/\s*\bLOCALE_PROVIDER\b\s*=\s*(?:'[^']*'|"[^"]*"|\S+)//gi;
-	$text =~ s/\s*LC_COLLATE\s*=\s*(['"])[^'"]*\1//gi;
-	$text =~ s/\s*LC_CTYPE\s*=\s*(['"])[^'"]*\1//gi;
-	$text =~ s/\s*\S*LOCALE\S*\s*=?\s*(['"])[^'"]*\1//gi;
-	$text =~ s/\s*\S*COLLATION\S*\s*=?\s*(['"])[^'"]*\1//gi;
-	return $text;
-}
-
-
-########################################################################
-# pg_get_role_ddl tests
-########################################################################
-
-# Basic role
-$node->safe_psql('postgres', 'CREATE ROLE regress_role_ddl_test1');
-my $result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')});
-like(
-	$result,
-	qr/CREATE ROLE regress_role_ddl_test1 .* NOLOGIN/,
-	'basic role DDL');
-
-# Role with multiple privileges
-$node->safe_psql(
-	'postgres', q{
-	CREATE ROLE regress_role_ddl_test2
-	  LOGIN SUPERUSER CREATEDB CREATEROLE
-	  CONNECTION LIMIT 5
-	  VALID UNTIL '2030-12-31 23:59:59+00'});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2')});
-like($result, qr/SUPERUSER/, 'role with SUPERUSER');
-like($result, qr/CREATEDB/, 'role with CREATEDB');
-like($result, qr/CONNECTION LIMIT 5/, 'role with CONNECTION LIMIT');
-like($result, qr/VALID UNTIL '2030-12-31/, 'role with VALID UNTIL');
-
-# Role with configuration parameters
-$node->safe_psql(
-	'postgres', q{
-	ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB';
-	ALTER ROLE regress_role_ddl_test1 SET search_path TO myschema, public});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')});
-like($result, qr/SET work_mem TO '256MB'/, 'role with work_mem setting');
-like($result, qr/SET search_path TO/, 'role with search_path setting');
-
-# Role with database-specific configuration (needs a real database)
-$node->safe_psql(
-	'postgres', q{
-	CREATE DATABASE regression_ddlutils_test
-	  TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C';
-	ALTER ROLE regress_role_ddl_test2
-	  IN DATABASE regression_ddlutils_test SET work_mem TO '128MB'});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2')});
-like(
-	$result,
-	qr/IN DATABASE regression_ddlutils_test SET work_mem TO '128MB'/,
-	'role with database-specific setting');
-
-# Role with special characters (requires quoting)
-$node->safe_psql('postgres', q{CREATE ROLE "regress_role-with-dash"});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role-with-dash')});
-like($result, qr/"regress_role-with-dash"/, 'role name requiring quoting');
-
-# Pretty-printed output
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', pretty => true)}
-);
-like($result, qr/\n\s+SUPERUSER/, 'role pretty-print indents attributes');
-
-# Role with memberships
-$node->safe_psql(
-	'postgres', q{
-	CREATE ROLE regress_role_ddl_grantor CREATEROLE;
-	CREATE ROLE regress_role_ddl_group1;
-	CREATE ROLE regress_role_ddl_group2;
-	CREATE ROLE regress_role_ddl_member;
-	GRANT regress_role_ddl_group1 TO regress_role_ddl_grantor WITH ADMIN TRUE;
-	GRANT regress_role_ddl_group2 TO regress_role_ddl_grantor WITH ADMIN TRUE;
-	SET ROLE regress_role_ddl_grantor;
-	GRANT regress_role_ddl_group1 TO regress_role_ddl_member
-	  WITH INHERIT TRUE, SET FALSE;
-	GRANT regress_role_ddl_group2 TO regress_role_ddl_member
-	  WITH ADMIN TRUE;
-	RESET ROLE});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_member')});
-like(
-	$result,
-	qr/GRANT regress_role_ddl_group1 TO regress_role_ddl_member/,
-	'role with memberships includes GRANT');
-like($result, qr/SET FALSE/, 'membership includes SET FALSE');
-like($result, qr/ADMIN TRUE/, 'membership includes ADMIN TRUE');
-
-# Memberships suppressed
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_role_ddl('regress_role_ddl_member', memberships => false)}
-);
-unlike($result, qr/GRANT/, 'memberships suppressed');
-
-# Non-existent role (should error)
-my ($ret, $stdout, $stderr) =
-  $node->psql('postgres', q{SELECT * FROM pg_get_role_ddl(9999999::oid)});
-isnt($ret, 0, 'non-existent role errors');
-like($stderr, qr/does not exist/, 'non-existent role error message');
-
-# NULL input (should return no rows)
-$result =
-  $node->safe_psql('postgres', q{SELECT count(*) FROM pg_get_role_ddl(NULL)});
-is($result, '0', 'NULL role returns no rows');
-
-# Permission check: revoke SELECT on pg_authid
-$node->safe_psql(
-	'postgres', q{
-	CREATE ROLE regress_role_ddl_noaccess;
-	REVOKE SELECT ON pg_authid FROM PUBLIC});
-($ret, $stdout, $stderr) = $node->psql(
-	'postgres',
-	q{SET ROLE regress_role_ddl_noaccess;
-	  SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')});
-isnt($ret, 0, 'role DDL denied without pg_authid access');
-$node->safe_psql(
-	'postgres', q{
-	GRANT SELECT ON pg_authid TO PUBLIC});
-
-
-########################################################################
-# pg_get_database_ddl tests
-########################################################################
-
-# Set up: the test database was already created above for role tests.
-$node->safe_psql(
-	'postgres', q{
-	ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2;
-	ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT 123;
-	ALTER DATABASE regression_ddlutils_test SET random_page_cost = 2.0;
-	ALTER ROLE regress_role_ddl_test2
-	  IN DATABASE regression_ddlutils_test SET random_page_cost = 1.1});
-
-# Non-existent database
-($ret, $stdout, $stderr) = $node->psql('postgres',
-	q{SELECT * FROM pg_get_database_ddl('regression_no_such_db')});
-isnt($ret, 0, 'non-existent database errors');
-
-# NULL input
-$result = $node->safe_psql('postgres',
-	q{SELECT count(*) FROM pg_get_database_ddl(NULL)});
-is($result, '0', 'NULL database returns no rows');
-
-# Invalid option (bad boolean cast)
-($ret, $stdout, $stderr) = $node->psql('postgres',
-	q{SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', owner => 'invalid')}
-);
-isnt($ret, 0, 'invalid boolean option errors');
-like(
-	$stderr,
-	qr/invalid input syntax for type boolean/,
-	'invalid option error message');
-
-# Duplicate named argument
-($ret, $stdout, $stderr) = $node->psql(
-	'postgres',
-	q{SELECT * FROM pg_get_database_ddl('regression_ddlutils_test',
-	  owner => false, owner => true)});
-isnt($ret, 0, 'duplicate option errors');
-
-# Basic output (without locale details)
-$result = ddl_filter(
-	$node->safe_psql(
-		'postgres',
-		q{SELECT pg_get_database_ddl
-	  FROM pg_get_database_ddl('regression_ddlutils_test')}));
-like(
-	$result,
-	qr/CREATE DATABASE regression_ddlutils_test/,
-	'database DDL includes CREATE');
-like($result, qr/TEMPLATE = template0/, 'database DDL includes TEMPLATE');
-like($result, qr/ENCODING = 'UTF8'/, 'database DDL includes ENCODING');
-like(
-	$result,
-	qr/OWNER TO regress_role_ddl_test2/,
-	'database DDL includes OWNER');
-like($result, qr/CONNECTION LIMIT = 123/, 'database DDL includes CONNLIMIT');
-like(
-	$result,
-	qr/SET random_page_cost TO '2.0'/,
-	'database DDL includes GUC setting');
-
-# Pretty-printed output
-$result = ddl_filter(
-	$node->safe_psql(
-		'postgres',
-		q{SELECT pg_get_database_ddl
-	  FROM pg_get_database_ddl('regression_ddlutils_test',
-	    pretty => true, tablespace => false)}));
-like($result, qr/\n\s+WITH TEMPLATE/, 'database DDL pretty-prints WITH');
-
-# Owner suppressed
-$result = ddl_filter(
-	$node->safe_psql(
-		'postgres',
-		q{SELECT pg_get_database_ddl
-	  FROM pg_get_database_ddl('regression_ddlutils_test', owner => false)}));
-unlike($result, qr/OWNER/, 'database DDL owner suppressed');
-
-# Permission check
-$node->safe_psql(
-	'postgres', q{
-	REVOKE CONNECT ON DATABASE regression_ddlutils_test FROM PUBLIC});
-($ret, $stdout, $stderr) = $node->psql(
-	'postgres',
-	q{SET ROLE regress_role_ddl_noaccess;
-	  SELECT * FROM pg_get_database_ddl('regression_ddlutils_test')});
-isnt($ret, 0, 'database DDL denied without CONNECT');
-$node->safe_psql(
-	'postgres', q{
-	GRANT CONNECT ON DATABASE regression_ddlutils_test TO PUBLIC});
-
-
-########################################################################
-# pg_get_tablespace_ddl tests
-########################################################################
-
-# Non-existent tablespace by name
-($ret, $stdout, $stderr) = $node->psql('postgres',
-	q{SELECT * FROM pg_get_tablespace_ddl('regress_nonexistent_tblsp')});
-isnt($ret, 0, 'non-existent tablespace errors');
-
-# Non-existent tablespace by OID
-($ret, $stdout, $stderr) =
-  $node->psql('postgres', q{SELECT * FROM pg_get_tablespace_ddl(0::oid)});
-isnt($ret, 0, 'non-existent tablespace OID errors');
-
-# NULL input (name and OID variants)
-$result = $node->safe_psql('postgres',
-	q{SELECT count(*) FROM pg_get_tablespace_ddl(NULL::name)});
-is($result, '0', 'NULL tablespace name returns no rows');
-$result = $node->safe_psql('postgres',
-	q{SELECT count(*) FROM pg_get_tablespace_ddl(NULL::oid)});
-is($result, '0', 'NULL tablespace OID returns no rows');
-
-# Tablespace name requiring quoting
-$node->safe_psql(
-	'postgres', q{
-	SET allow_in_place_tablespaces = true;
-	CREATE TABLESPACE "regress_ tblsp" OWNER regress_role_ddl_test1
-	  LOCATION ''});
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_tablespace_ddl('regress_ tblsp')});
-like($result, qr/"regress_ tblsp"/, 'tablespace name is quoted');
-
-# Rename and add options; reuse this tablespace for the remaining tests
-$node->safe_psql(
-	'postgres', q{
-	ALTER TABLESPACE "regress_ tblsp" RENAME TO regress_allopt_tblsp;
-	ALTER TABLESPACE regress_allopt_tblsp
-	  SET (seq_page_cost = '1.5', random_page_cost = '1.1234567890',
-	       effective_io_concurrency = '17', maintenance_io_concurrency = '18')});
-
-# Tablespace with multiple options
-$result = $node->safe_psql('postgres',
-	q{SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp')});
-like(
-	$result,
-	qr/CREATE TABLESPACE regress_allopt_tblsp/,
-	'tablespace DDL includes CREATE');
-like(
-	$result,
-	qr/OWNER regress_role_ddl_test1/,
-	'tablespace DDL includes OWNER');
-like($result, qr/seq_page_cost='1.5'/, 'tablespace DDL includes options');
-
-# Pretty-printed output
-$result = $node->safe_psql(
-	'postgres',
-	q{SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp',
-	  pretty => true)});
-like($result, qr/\n\s+OWNER/, 'tablespace DDL pretty-prints OWNER');
-
-# Owner suppressed
-$result = $node->safe_psql(
-	'postgres',
-	q{SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp',
-	  owner => false)});
-unlike($result, qr/OWNER/, 'tablespace DDL owner suppressed');
-
-# Lookup by OID
-$result = $node->safe_psql(
-	'postgres', q{
-	SELECT pg_get_tablespace_ddl
-	FROM pg_get_tablespace_ddl(
-	  (SELECT oid FROM pg_tablespace
-	   WHERE spcname = 'regress_allopt_tblsp'))});
-like(
-	$result,
-	qr/CREATE TABLESPACE regress_allopt_tblsp/,
-	'tablespace DDL by OID');
-
-# Permission check
-$node->safe_psql('postgres', q{REVOKE SELECT ON pg_tablespace FROM PUBLIC});
-($ret, $stdout, $stderr) = $node->psql(
-	'postgres',
-	q{SET ROLE regress_role_ddl_noaccess;
-	  SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp')});
-isnt($ret, 0, 'tablespace DDL denied without pg_tablespace access');
-$node->safe_psql(
-	'postgres', q{
-	GRANT SELECT ON pg_tablespace TO PUBLIC});
-
-$node->stop;
-
-done_testing();
diff --git a/src/test/modules/test_misc/t/016_ddlutils_pg_dump_crosscheck.pl b/src/test/modules/test_misc/t/016_ddlutils_pg_dump_crosscheck.pl
new file mode 100644
index 00000000000..99f4fd29979
--- /dev/null
+++ b/src/test/modules/test_misc/t/016_ddlutils_pg_dump_crosscheck.pl
@@ -0,0 +1,115 @@
+# Cross-check pg_get_role_ddl()/pg_get_tablespace_ddl()/pg_get_database_ddl()
+# against pg_dumpall's own output for the same objects, per Andres Freund's
+# review comment that nothing currently guards against the two diverging:
+# https://postgr.es/m/ia2gifmcdsiunj3j6i4yumnnzru45nvet4mxtydb6nodwrvkhe@qkd7lzlxsrht
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->start;
+
+$node->safe_psql(
+	'postgres', q{
+	SET allow_in_place_tablespaces = true;
+	CREATE ROLE regress_ddl_crosscheck_role LOGIN CREATEDB
+	  CONNECTION LIMIT 3 PASSWORD 'crosscheck_pw'
+	  VALID UNTIL '2035-06-15 12:00:00+00';
+	ALTER ROLE regress_ddl_crosscheck_role SET work_mem = '17MB';
+	CREATE TABLESPACE regress_ddl_crosscheck_tblspc
+	  OWNER regress_ddl_crosscheck_role LOCATION ''
+	  WITH (random_page_cost = 2.25);
+	CREATE DATABASE regress_ddl_crosscheck_db
+	  OWNER regress_ddl_crosscheck_role
+	  TABLESPACE regress_ddl_crosscheck_tblspc
+	  CONNECTION LIMIT 7;
+	ALTER DATABASE regress_ddl_crosscheck_db SET work_mem = '31MB';
+	});
+
+# Normalize: collapse whitespace, drop the trailing semicolon, and sort
+# each statement's own whitespace-split tokens so clause/token order
+# (which the two implementations aren't guaranteed to agree on) doesn't
+# cause spurious failures. Returns an arrayref, sorted, one entry per
+# input statement.
+sub normalize_statements
+{
+	my (@statements) = @_;
+	my @out;
+
+	for my $stmt (@statements)
+	{
+		$stmt =~ s/^\s+|\s+$//g;
+		$stmt =~ s/;$//;
+		$stmt =~ s/\s+/ /g;
+		next if $stmt eq '';
+		push @out, join(' ', sort split(/\s+/, $stmt));
+	}
+	return [ sort @out ];
+}
+
+# --- pg_dumpall globals (roles + tablespaces) ---
+# connstr() gives pg_dumpall the right host/port without needing PGHOST/
+# PGPORT in the environment.
+my ($globals_out, $globals_err);
+run_log(
+	[ 'pg_dumpall', '--globals-only', '-d', $node->connstr('postgres') ],
+	'>', \$globals_out, '2>', \$globals_err)
+  or die "pg_dumpall --globals-only failed: $globals_err";
+
+# Anchor on statement type, not just a substring match on the object's
+# name: a plain name match also catches pg_dumpall's "-- User Config
+# "role"" comment headers, and statements for other objects that merely
+# reference this one (e.g. the tablespace's "OWNER regress_..._role"
+# clause on its CREATE TABLESPACE line).
+my @dumpall_role_lines = grep {
+	/regress_ddl_crosscheck_role/ && /^(CREATE|ALTER) ROLE/
+} split(/\n/, $globals_out);
+my @dumpall_tblspc_lines = grep {
+	/regress_ddl_crosscheck_tblspc/ && /^(CREATE|ALTER) TABLESPACE/
+} split(/\n/, $globals_out);
+
+my $our_role_ddl = $node->safe_psql('postgres',
+	q{SELECT string_agg(pg_get_role_ddl, E'\n') FROM
+	  pg_get_role_ddl('regress_ddl_crosscheck_role')});
+my $our_tblspc_ddl = $node->safe_psql('postgres',
+	q{SELECT string_agg(pg_get_tablespace_ddl, E'\n') FROM
+	  pg_get_tablespace_ddl('regress_ddl_crosscheck_tblspc')});
+
+is_deeply(
+	normalize_statements(split(/\n/, $our_role_ddl)),
+	normalize_statements(@dumpall_role_lines),
+	'pg_get_role_ddl matches pg_dumpall --globals-only, token-for-token');
+
+is_deeply(
+	normalize_statements(split(/\n/, $our_tblspc_ddl)),
+	normalize_statements(@dumpall_tblspc_lines),
+	'pg_get_tablespace_ddl matches pg_dumpall --globals-only, token-for-token');
+
+# --- pg_dump --create (database-level CREATE DATABASE + ALTER DATABASE) ---
+my ($dbdump_out, $dbdump_err);
+run_log(
+	[
+		'pg_dump', '--create', '--schema-only',
+		'-d', $node->connstr('regress_ddl_crosscheck_db'),
+	],
+	'>', \$dbdump_out, '2>', \$dbdump_err)
+  or die "pg_dump --create failed: $dbdump_err";
+
+my @pgdump_db_lines =
+  grep { /regress_ddl_crosscheck_db/ && /^(CREATE|ALTER) DATABASE/ }
+  split(/\n/, $dbdump_out);
+
+my $our_db_ddl = $node->safe_psql('postgres',
+	q{SELECT string_agg(pg_get_database_ddl, E'\n') FROM
+	  pg_get_database_ddl('regress_ddl_crosscheck_db')});
+
+is_deeply(
+	normalize_statements(split(/\n/, $our_db_ddl)),
+	normalize_statements(@pgdump_db_lines),
+	'pg_get_database_ddl matches pg_dump --create, token-for-token');
+
+$node->stop;
+done_testing();
-- 
2.43.0

