]> git.ipfire.org Git - thirdparty/postgresql.git/commitdiff
Add pg_get_database_ddl() function
authorAndrew Dunstan <andrew@dunslane.net>
Thu, 19 Mar 2026 13:57:35 +0000 (09:57 -0400)
committerAndrew Dunstan <andrew@dunslane.net>
Sun, 5 Apr 2026 14:54:54 +0000 (10:54 -0400)
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
src/backend/utils/adt/ddlutils.c
src/include/catalog/pg_proc.dat
src/test/regress/expected/database_ddl.out [new file with mode: 0644]
src/test/regress/parallel_schedule
src/test/regress/sql/database_ddl.sql [new file with mode: 0644]

index e14c209bf14c72ea0e30563f72ac2d0d57f1e281..80cf11083d6690f8b9010cca5ba18e2e63ea5f6e 100644 (file)
@@ -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>
index d953963a71222bbbdaca768ffe57ce9b9e646abd..5ff15bc2cf1f0b4d16bae3a18abed38d45540283 100644 (file)
 #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);
+       }
+}
index 8eca4cf98a19fa81e994722b20842709f77fcb37..cbf85d6a5be0f1df5201df9853a71890cbfb8d6c 100644 (file)
   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 (file)
index 0000000..5081c1a
--- /dev/null
@@ -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;
index fabaebf2c78395f36bb67b3546fcab39f25109b7..cc365393bb7dad65ac21ee6a35e86e4b61d51124 100644 (file)
@@ -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 (file)
index 0000000..093ccc0
--- /dev/null
@@ -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;