From 3a0f3c76f1c35ffa56295bf1ea9a2d2c8efce928 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Thu, 19 Mar 2026 09:57:35 -0400
Subject: [PATCH v4 4/4] Add pg_get_database_ddl() function
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add a new SQL-callable function that returns the DDL statements needed
to recreate a database. It takes a regdatabase argument and an optional
VARIADIC text argument for options that are specified as alternating
name/value pairs. The following options are supported: pretty (boolean)
for formatted output, owner (boolean) to include OWNER and tablespace
(boolean) to include TABLESPACE. The return is one or multiple rows
where the first row is a CREATE DATABASE statement and subsequent rows are
ALTER DATABASE statements to set some database properties.

The caller must have CONNECT privilege on the target database.

Author: Akshay Joshi <akshay.joshi@enterprisedb.com>
Co-authored-by: Andrew Dunstan <andrew@dunslane.net>
Co-authored-by: Euler Taveira <euler@eulerto.com>
Reviewed-by: Japin Li <japinli@hotmail.com>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Reviewed-by: Álvaro Herrera <alvherre@kurilemu.de>
Reviewed-by: Quan Zongliang <quanzongliang@yeah.net>
Discussion: https://postgr.es/m/CANxoLDc6FHBYJvcgOnZyS+jF0NUo3Lq_83-rttBuJgs9id_UDg@mail.gmail.com
Discussion: https://postgr.es/m/e247c261-e3fb-4810-81e0-a65893170e94@dunslane.net
---
 doc/src/sgml/func/func-info.sgml           |  23 ++
 src/backend/utils/adt/ddlutils.c           | 330 +++++++++++++++++++++
 src/include/catalog/pg_proc.dat            |   8 +
 src/test/regress/expected/database_ddl.out |  88 ++++++
 src/test/regress/parallel_schedule         |   2 +-
 src/test/regress/sql/database_ddl.sql      |  66 +++++
 6 files changed, 516 insertions(+), 1 deletion(-)
 create mode 100644 src/test/regress/expected/database_ddl.out
 create mode 100644 src/test/regress/sql/database_ddl.sql

diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml
index e14c209bf14..80cf11083d6 100644
--- a/doc/src/sgml/func/func-info.sgml
+++ b/doc/src/sgml/func/func-info.sgml
@@ -3938,6 +3938,29 @@ acl      | {postgres=arwdDxtm/postgres,foo=r/postgres}
         <literal>OWNER</literal>.
        </para></entry>
       </row>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_get_database_ddl</primary>
+        </indexterm>
+        <function>pg_get_database_ddl</function>
+        ( <parameter>database</parameter> <type>regdatabase</type>
+        <optional>, <literal>VARIADIC</literal> <parameter>options</parameter>
+        <type>text</type> </optional> )
+        <returnvalue>setof text</returnvalue>
+       </para>
+       <para>
+        Reconstructs the <command>CREATE DATABASE</command> statement for the
+        specified database, followed by <command>ALTER DATABASE</command>
+        statements for connection limit, template status, and configuration
+        settings.  Each statement is returned as a separate row.
+        The following options are supported:
+        <literal>pretty</literal> (boolean) for formatted output,
+        <literal>owner</literal> (boolean) to include <literal>OWNER</literal>,
+        and <literal>tablespace</literal> (boolean) to include
+        <literal>TABLESPACE</literal>.
+       </para></entry>
+      </row>
      </tbody>
     </tgroup>
    </table>
diff --git a/src/backend/utils/adt/ddlutils.c b/src/backend/utils/adt/ddlutils.c
index d953963a712..5ff15bc2cf1 100644
--- a/src/backend/utils/adt/ddlutils.c
+++ b/src/backend/utils/adt/ddlutils.c
@@ -23,11 +23,14 @@
 #include "access/table.h"
 #include "catalog/pg_auth_members.h"
 #include "catalog/pg_authid.h"
+#include "catalog/pg_collation.h"
+#include "catalog/pg_database.h"
 #include "catalog/pg_db_role_setting.h"
 #include "catalog/pg_tablespace.h"
 #include "commands/tablespace.h"
 #include "common/relpath.h"
 #include "funcapi.h"
+#include "mb/pg_wchar.h"
 #include "miscadmin.h"
 #include "utils/acl.h"
 #include "utils/array.h"
@@ -36,6 +39,7 @@
 #include "utils/fmgroids.h"
 #include "utils/guc.h"
 #include "utils/lsyscache.h"
+#include "utils/pg_locale.h"
 #include "utils/rel.h"
 #include "utils/ruleutils.h"
 #include "utils/syscache.h"
@@ -80,6 +84,8 @@ static List *pg_get_role_ddl_internal(Oid roleid, bool pretty,
 									  bool memberships);
 static List *pg_get_tablespace_ddl_internal(Oid tsid, bool pretty, bool no_owner);
 static Datum pg_get_tablespace_ddl_srf(FunctionCallInfo fcinfo, Oid tsid, bool isnull);
+static List *pg_get_database_ddl_internal(Oid dbid, bool pretty,
+										  bool no_owner, bool no_tablespace);
 
 
 /*
@@ -838,3 +844,327 @@ pg_get_tablespace_ddl_name(PG_FUNCTION_ARGS)
 
 	return pg_get_tablespace_ddl_srf(fcinfo, tsid, isnull);
 }
+
+/*
+ * pg_get_database_ddl_internal
+ *		Generate DDL statements to recreate a database.
+ *
+ * Returns a List of palloc'd strings.  The first element is the
+ * CREATE DATABASE statement; subsequent elements are ALTER DATABASE
+ * statements for properties and configuration settings.
+ */
+static List *
+pg_get_database_ddl_internal(Oid dbid, bool pretty,
+							 bool no_owner, bool no_tablespace)
+{
+	HeapTuple	tuple;
+	Form_pg_database dbform;
+	StringInfoData buf;
+	bool		isnull;
+	Datum		datum;
+	const char *encoding;
+	char	   *dbname;
+	char	   *collate;
+	char	   *ctype;
+	Relation	rel;
+	ScanKeyData scankey[2];
+	SysScanDesc scan;
+	List	   *statements = NIL;
+	AclResult	aclresult;
+
+	tuple = SearchSysCache1(DATABASEOID, ObjectIdGetDatum(dbid));
+	if (!HeapTupleIsValid(tuple))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_OBJECT),
+				 errmsg("database with OID %u does not exist", dbid)));
+
+	/* User must have connect privilege for target database. */
+	aclresult = object_aclcheck(DatabaseRelationId, dbid, GetUserId(), ACL_CONNECT);
+	if (aclresult != ACLCHECK_OK)
+		aclcheck_error(aclresult, OBJECT_DATABASE,
+					   get_database_name(dbid));
+
+	dbform = (Form_pg_database) GETSTRUCT(tuple);
+	dbname = pstrdup(NameStr(dbform->datname));
+
+	/*
+	 * We don't support generating DDL for system databases.  The primary
+	 * reason for this is that users shouldn't be recreating them.
+	 */
+	if (strcmp(dbname, "template0") == 0 || strcmp(dbname, "template1") == 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_RESERVED_NAME),
+				 errmsg("database \"%s\" is a system database", dbname),
+			 errdetail("DDL generation is not supported for template0 and template1.")));
+
+	initStringInfo(&buf);
+
+	/* --- Build CREATE DATABASE statement --- */
+	appendStringInfo(&buf, "CREATE DATABASE %s", quote_identifier(dbname));
+
+	/*
+	 * Always use template0: the target database already contains the catalog
+	 * data from whatever template was used originally, so we must start from
+	 * the pristine template to avoid duplication.
+	 */
+	append_ddl_option(&buf, pretty, 4, "WITH TEMPLATE = template0");
+
+	/* ENCODING */
+	encoding = pg_encoding_to_char(dbform->encoding);
+	if (strlen(encoding) > 0)
+		append_ddl_option(&buf, pretty, 4, "ENCODING = %s",
+						  quote_literal_cstr(encoding));
+
+	/* LOCALE_PROVIDER */
+	if (dbform->datlocprovider == COLLPROVIDER_BUILTIN ||
+		dbform->datlocprovider == COLLPROVIDER_ICU ||
+		dbform->datlocprovider == COLLPROVIDER_LIBC)
+		append_ddl_option(&buf, pretty, 4, "LOCALE_PROVIDER = %s",
+						  collprovider_name(dbform->datlocprovider));
+	else
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("unrecognized locale provider: %c",
+						dbform->datlocprovider)));
+
+	/* LOCALE, LC_COLLATE, LC_CTYPE */
+	datum = SysCacheGetAttr(DATABASEOID, tuple,
+							Anum_pg_database_datcollate, &isnull);
+	collate = isnull ? NULL : TextDatumGetCString(datum);
+	datum = SysCacheGetAttr(DATABASEOID, tuple,
+							Anum_pg_database_datctype, &isnull);
+	ctype = isnull ? NULL : TextDatumGetCString(datum);
+	if (collate != NULL && ctype != NULL && strcmp(collate, ctype) == 0)
+	{
+		append_ddl_option(&buf, pretty, 4, "LOCALE = %s",
+						  quote_literal_cstr(collate));
+	}
+	else
+	{
+		if (collate != NULL)
+			append_ddl_option(&buf, pretty, 4, "LC_COLLATE = %s",
+							  quote_literal_cstr(collate));
+		if (ctype != NULL)
+			append_ddl_option(&buf, pretty, 4, "LC_CTYPE = %s",
+							  quote_literal_cstr(ctype));
+	}
+
+	/* LOCALE (provider-specific) */
+	datum = SysCacheGetAttr(DATABASEOID, tuple,
+							Anum_pg_database_datlocale, &isnull);
+	if (!isnull)
+	{
+		const char *locale = TextDatumGetCString(datum);
+
+		if (dbform->datlocprovider == COLLPROVIDER_BUILTIN)
+			append_ddl_option(&buf, pretty, 4, "BUILTIN_LOCALE = %s",
+							  quote_literal_cstr(locale));
+		else if (dbform->datlocprovider == COLLPROVIDER_ICU)
+			append_ddl_option(&buf, pretty, 4, "ICU_LOCALE = %s",
+							  quote_literal_cstr(locale));
+	}
+
+	/* ICU_RULES */
+	datum = SysCacheGetAttr(DATABASEOID, tuple,
+							Anum_pg_database_daticurules, &isnull);
+	if (!isnull && dbform->datlocprovider == COLLPROVIDER_ICU)
+		append_ddl_option(&buf, pretty, 4, "ICU_RULES = %s",
+						  quote_literal_cstr(TextDatumGetCString(datum)));
+
+	/* TABLESPACE */
+	if (!no_tablespace && OidIsValid(dbform->dattablespace))
+	{
+		char	   *spcname = get_tablespace_name(dbform->dattablespace);
+
+		if (pg_strcasecmp(spcname, "pg_default") != 0)
+			append_ddl_option(&buf, pretty, 4, "TABLESPACE = %s",
+							  quote_identifier(spcname));
+	}
+
+	appendStringInfoChar(&buf, ';');
+	statements = lappend(statements, pstrdup(buf.data));
+
+	/* OWNER */
+	if (!no_owner && OidIsValid(dbform->datdba))
+	{
+		char	   *owner = GetUserNameFromId(dbform->datdba, false);
+
+		resetStringInfo(&buf);
+		appendStringInfo(&buf, "ALTER DATABASE %s OWNER TO %s;",
+						 quote_identifier(dbname), quote_identifier(owner));
+		pfree(owner);
+		statements = lappend(statements, pstrdup(buf.data));
+	}
+
+	/* CONNECTION LIMIT */
+	if (dbform->datconnlimit != -1)
+	{
+		resetStringInfo(&buf);
+		appendStringInfo(&buf, "ALTER DATABASE %s CONNECTION LIMIT = %d;",
+						 quote_identifier(dbname), dbform->datconnlimit);
+		statements = lappend(statements, pstrdup(buf.data));
+	}
+
+	/* IS_TEMPLATE */
+	if (dbform->datistemplate)
+	{
+		resetStringInfo(&buf);
+		appendStringInfo(&buf, "ALTER DATABASE %s IS_TEMPLATE = true;",
+						 quote_identifier(dbname));
+		statements = lappend(statements, pstrdup(buf.data));
+	}
+
+	/* ALLOW_CONNECTIONS */
+	if (!dbform->datallowconn)
+	{
+		resetStringInfo(&buf);
+		appendStringInfo(&buf, "ALTER DATABASE %s ALLOW_CONNECTIONS = false;",
+						 quote_identifier(dbname));
+		statements = lappend(statements, pstrdup(buf.data));
+	}
+
+	ReleaseSysCache(tuple);
+
+	/*
+	 * Now scan pg_db_role_setting for ALTER DATABASE SET configurations.
+	 *
+	 * It is only database-wide (setrole = 0). It generates one ALTER
+	 * statement per setting.
+	 */
+	rel = table_open(DbRoleSettingRelationId, AccessShareLock);
+	ScanKeyInit(&scankey[0],
+				Anum_pg_db_role_setting_setdatabase,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(dbid));
+	ScanKeyInit(&scankey[1],
+				Anum_pg_db_role_setting_setrole,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(InvalidOid));
+
+	scan = systable_beginscan(rel, DbRoleSettingDatidRolidIndexId, true,
+							  NULL, 2, scankey);
+
+	while (HeapTupleIsValid(tuple = systable_getnext(scan)))
+	{
+		ArrayType  *dbconfig;
+		Datum	   *settings;
+		bool	   *nulls;
+		int			nsettings;
+
+		/*
+		 * The setconfig column is a text array in "name=value" format. It
+		 * should never be null for a valid row, but be defensive.
+		 */
+		datum = heap_getattr(tuple, Anum_pg_db_role_setting_setconfig,
+							 RelationGetDescr(rel), &isnull);
+		if (isnull)
+			continue;
+
+		dbconfig = DatumGetArrayTypeP(datum);
+
+		deconstruct_array_builtin(dbconfig, TEXTOID, &settings, &nulls, &nsettings);
+
+		for (int i = 0; i < nsettings; i++)
+		{
+			char	   *s,
+					   *p;
+
+			if (nulls[i])
+				continue;
+
+			s = TextDatumGetCString(settings[i]);
+			p = strchr(s, '=');
+			if (p == NULL)
+			{
+				pfree(s);
+				continue;
+			}
+			*p++ = '\0';
+
+			resetStringInfo(&buf);
+			appendStringInfo(&buf, "ALTER DATABASE %s SET %s TO ",
+							 quote_identifier(dbname),
+							 quote_identifier(s));
+
+			append_guc_value(&buf, s, p);
+
+			appendStringInfoChar(&buf, ';');
+
+			statements = lappend(statements, pstrdup(buf.data));
+
+			pfree(s);
+		}
+
+		pfree(settings);
+		pfree(nulls);
+		pfree(dbconfig);
+	}
+
+	systable_endscan(scan);
+	table_close(rel, AccessShareLock);
+
+	pfree(buf.data);
+	pfree(dbname);
+
+	return statements;
+}
+
+/*
+ * pg_get_database_ddl
+ *		Return DDL to recreate a database as a set of text rows.
+ */
+Datum
+pg_get_database_ddl(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *funcctx;
+	List	   *statements;
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		MemoryContext oldcontext;
+		Oid			dbid;
+		DdlOption	opts[] = {
+			{"pretty", DDL_OPT_BOOL},
+			{"owner", DDL_OPT_BOOL},
+			{"tablespace", DDL_OPT_BOOL},
+		};
+
+		funcctx = SRF_FIRSTCALL_INIT();
+		oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+		if (PG_ARGISNULL(0))
+		{
+			MemoryContextSwitchTo(oldcontext);
+			SRF_RETURN_DONE(funcctx);
+		}
+
+		dbid = PG_GETARG_OID(0);
+		parse_ddl_options(fcinfo, 1, opts, lengthof(opts));
+
+		statements = pg_get_database_ddl_internal(dbid,
+												  opts[0].isset && opts[0].boolval,
+												  opts[1].isset && !opts[1].boolval,
+												  opts[2].isset && !opts[2].boolval);
+		funcctx->user_fctx = statements;
+		funcctx->max_calls = list_length(statements);
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	funcctx = SRF_PERCALL_SETUP();
+	statements = (List *) funcctx->user_fctx;
+
+	if (funcctx->call_cntr < funcctx->max_calls)
+	{
+		char	   *stmt;
+
+		stmt = list_nth(statements, funcctx->call_cntr);
+
+		SRF_RETURN_NEXT(funcctx, CStringGetTextDatum(stmt));
+	}
+	else
+	{
+		list_free_deep(statements);
+		SRF_RETURN_DONE(funcctx);
+	}
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 984cf620bc8..787deb82a3a 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -8627,6 +8627,14 @@
   proallargtypes => '{name,text}',
   pronargdefaults => '1', proargdefaults => '{NULL}',
   prosrc => 'pg_get_tablespace_ddl_name' },
+{ oid => '8762', descr => 'get DDL to recreate a database',
+  proname => 'pg_get_database_ddl', provariadic => 'text', proisstrict => 'f',
+  provolatile => 's', proretset => 't', prorows => '10', prorettype => 'text',
+  proargtypes => 'regdatabase text',
+  proargmodes => '{i,v}',
+  proallargtypes => '{regdatabase,text}',
+  pronargdefaults => '1', proargdefaults => '{NULL}',
+  prosrc => 'pg_get_database_ddl' },
 { oid => '2509',
   descr => 'deparse an encoded expression with pretty-print option',
   proname => 'pg_get_expr', provolatile => 's', prorettype => 'text',
diff --git a/src/test/regress/expected/database_ddl.out b/src/test/regress/expected/database_ddl.out
new file mode 100644
index 00000000000..5081c1a2b53
--- /dev/null
+++ b/src/test/regress/expected/database_ddl.out
@@ -0,0 +1,88 @@
+--
+-- Tests for pg_get_database_ddl()
+--
+-- To produce stable regression test output, strip locale/collation details
+-- from the DDL output.  Uses a plain SQL function to avoid a PL/pgSQL
+-- dependency.
+CREATE OR REPLACE FUNCTION ddl_filter(ddl_input TEXT)
+RETURNS TEXT LANGUAGE sql AS $$
+SELECT regexp_replace(
+  regexp_replace(
+    regexp_replace(
+      regexp_replace(
+        regexp_replace(
+          ddl_input,
+          '\s*\mLOCALE_PROVIDER\M\s*=\s*([''"]?[^''"\s]+[''"]?)', '', 'gi'),
+        '\s*LC_COLLATE\s*=\s*([''"])[^''"]*\1', '', 'gi'),
+      '\s*LC_CTYPE\s*=\s*([''"])[^''"]*\1', '', 'gi'),
+    '\s*\S*LOCALE\S*\s*=?\s*([''"])[^''"]*\1', '', 'gi'),
+  '\s*\S*COLLATION\S*\s*=?\s*([''"])[^''"]*\1', '', 'gi')
+$$;
+CREATE ROLE regress_datdba;
+CREATE DATABASE regress_database_ddl
+    ENCODING utf8 LC_COLLATE "C" LC_CTYPE "C" TEMPLATE template0
+    OWNER regress_datdba;
+ALTER DATABASE regress_database_ddl CONNECTION_LIMIT 123;
+ALTER DATABASE regress_database_ddl SET random_page_cost = 2.0;
+ALTER ROLE regress_datdba IN DATABASE regress_database_ddl SET random_page_cost = 1.1;
+-- Database doesn't exist
+SELECT * FROM pg_get_database_ddl('regression_database');
+ERROR:  database "regression_database" does not exist
+LINE 1: SELECT * FROM pg_get_database_ddl('regression_database');
+                                          ^
+-- NULL value
+SELECT * FROM pg_get_database_ddl(NULL);
+ pg_get_database_ddl 
+---------------------
+(0 rows)
+
+-- Invalid option value (should error)
+SELECT * FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'invalid');
+ERROR:  invalid value for boolean option "owner": invalid
+-- Duplicate option (should error)
+SELECT * FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'false', 'owner', 'true');
+ERROR:  option "owner" is specified more than once
+-- Without options
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl');
+                                    ddl_filter                                     
+-----------------------------------------------------------------------------------
+ CREATE DATABASE regress_database_ddl WITH TEMPLATE = template0 ENCODING = 'UTF8';
+ ALTER DATABASE regress_database_ddl OWNER TO regress_datdba;
+ ALTER DATABASE regress_database_ddl CONNECTION LIMIT = 123;
+ ALTER DATABASE regress_database_ddl SET random_page_cost TO '2.0';
+(4 rows)
+
+-- With owner
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'true');
+                                    ddl_filter                                     
+-----------------------------------------------------------------------------------
+ CREATE DATABASE regress_database_ddl WITH TEMPLATE = template0 ENCODING = 'UTF8';
+ ALTER DATABASE regress_database_ddl OWNER TO regress_datdba;
+ ALTER DATABASE regress_database_ddl CONNECTION LIMIT = 123;
+ ALTER DATABASE regress_database_ddl SET random_page_cost TO '2.0';
+(4 rows)
+
+-- Pretty-printed output
+\pset format unaligned
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl', 'pretty', 'true', 'tablespace', 'false');
+ddl_filter
+CREATE DATABASE regress_database_ddl
+    WITH TEMPLATE = template0
+    ENCODING = 'UTF8';
+ALTER DATABASE regress_database_ddl OWNER TO regress_datdba;
+ALTER DATABASE regress_database_ddl CONNECTION LIMIT = 123;
+ALTER DATABASE regress_database_ddl SET random_page_cost TO '2.0';
+(4 rows)
+\pset format aligned
+-- Permission check: revoke CONNECT on database
+CREATE ROLE regress_db_ddl_noaccess;
+REVOKE CONNECT ON DATABASE regress_database_ddl FROM PUBLIC;
+SET ROLE regress_db_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_database_ddl');  -- should fail
+ERROR:  permission denied for database regress_database_ddl
+RESET ROLE;
+GRANT CONNECT ON DATABASE regress_database_ddl TO PUBLIC;
+DROP ROLE regress_db_ddl_noaccess;
+DROP DATABASE regress_database_ddl;
+DROP FUNCTION ddl_filter(text);
+DROP ROLE regress_datdba;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index fabaebf2c78..cc365393bb7 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -130,7 +130,7 @@ test: partition_merge partition_split partition_join partition_prune reloptions
 # oidjoins is read-only, though, and should run late for best coverage
 test: oidjoins event_trigger
 
-test: role_ddl tablespace_ddl
+test: role_ddl tablespace_ddl database_ddl
 
 # event_trigger_login cannot run concurrently with any other tests because
 # on-login event handling could catch connection of a concurrent test.
diff --git a/src/test/regress/sql/database_ddl.sql b/src/test/regress/sql/database_ddl.sql
new file mode 100644
index 00000000000..093ccc0029e
--- /dev/null
+++ b/src/test/regress/sql/database_ddl.sql
@@ -0,0 +1,66 @@
+--
+-- Tests for pg_get_database_ddl()
+--
+
+-- To produce stable regression test output, strip locale/collation details
+-- from the DDL output.  Uses a plain SQL function to avoid a PL/pgSQL
+-- dependency.
+
+CREATE OR REPLACE FUNCTION ddl_filter(ddl_input TEXT)
+RETURNS TEXT LANGUAGE sql AS $$
+SELECT regexp_replace(
+  regexp_replace(
+    regexp_replace(
+      regexp_replace(
+        regexp_replace(
+          ddl_input,
+          '\s*\mLOCALE_PROVIDER\M\s*=\s*([''"]?[^''"\s]+[''"]?)', '', 'gi'),
+        '\s*LC_COLLATE\s*=\s*([''"])[^''"]*\1', '', 'gi'),
+      '\s*LC_CTYPE\s*=\s*([''"])[^''"]*\1', '', 'gi'),
+    '\s*\S*LOCALE\S*\s*=?\s*([''"])[^''"]*\1', '', 'gi'),
+  '\s*\S*COLLATION\S*\s*=?\s*([''"])[^''"]*\1', '', 'gi')
+$$;
+
+CREATE ROLE regress_datdba;
+CREATE DATABASE regress_database_ddl
+    ENCODING utf8 LC_COLLATE "C" LC_CTYPE "C" TEMPLATE template0
+    OWNER regress_datdba;
+ALTER DATABASE regress_database_ddl CONNECTION_LIMIT 123;
+ALTER DATABASE regress_database_ddl SET random_page_cost = 2.0;
+ALTER ROLE regress_datdba IN DATABASE regress_database_ddl SET random_page_cost = 1.1;
+
+-- Database doesn't exist
+SELECT * FROM pg_get_database_ddl('regression_database');
+
+-- NULL value
+SELECT * FROM pg_get_database_ddl(NULL);
+
+-- Invalid option value (should error)
+SELECT * FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'invalid');
+
+-- Duplicate option (should error)
+SELECT * FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'false', 'owner', 'true');
+
+-- Without options
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl');
+
+-- With owner
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl', 'owner', 'true');
+
+-- Pretty-printed output
+\pset format unaligned
+SELECT ddl_filter(pg_get_database_ddl) FROM pg_get_database_ddl('regress_database_ddl', 'pretty', 'true', 'tablespace', 'false');
+\pset format aligned
+
+-- Permission check: revoke CONNECT on database
+CREATE ROLE regress_db_ddl_noaccess;
+REVOKE CONNECT ON DATABASE regress_database_ddl FROM PUBLIC;
+SET ROLE regress_db_ddl_noaccess;
+SELECT * FROM pg_get_database_ddl('regress_database_ddl');  -- should fail
+RESET ROLE;
+GRANT CONNECT ON DATABASE regress_database_ddl TO PUBLIC;
+DROP ROLE regress_db_ddl_noaccess;
+
+DROP DATABASE regress_database_ddl;
+DROP FUNCTION ddl_filter(text);
+DROP ROLE regress_datdba;
-- 
2.43.0

