From 25e8adf022d7064d5861dba38b6120c05f0d0d43 Mon Sep 17 00:00:00 2001
From: Alexander Kukushkin <cyberdemn@gmail.com>
Date: Fri, 2 Oct 2026 09:19:20 +0200
Subject: [PATCH v4] pg_dump: sort casts, transforms, and RLS policies
 independent of OIDs

DOTypeNameCompare() sorts dumpable objects by (priority, namespace, name,
objType) and then an object-type-specific natural-key tiebreaker.  Three
object types reach that tiebreaker without a complete key and can tie,
falling through to the Assert(false) added in commit 0decd5e89db (aborting
assert-enabled pg_dump) or, on non-assert builds, to oidcmp() -- which
reintroduces exactly the schema-diff instability that commit and its
follow-ups (b61a5c4bed7, 4921a5972a3, d49936f3028) have been eliminating.

Casts and transforms build their sort "name" from the *unqualified* type
(and language) names, so two casts tie whenever their source and target
type names match while the types live in different schemas -- for example a
cast to pg_catalog.json and a cast to someext.json from the same source
type both get the sort name "sourcetype json".  Transforms tie the same way
("typname langname").  Break those ties using the referenced types' full
natural keys via the existing pgTypeNameCompare() (nspname, then typname),
the same helper already used for function arguments and operator operands.
For transforms, comparing trftype alone suffices: a name tie already
implies the same unqualified typname and the same language name, so only
the type's schema can differ.

getPolicies() represents "row level security is enabled on this table" as a
PolicyInfo with polname == NULL that borrows the table's name, so it ties
with a real policy whose name equals that table's own name.  The DO_POLICY
tiebreaker compared only the table name and then fell through.  Break the
remaining tie on whether polname is NULL -- the one natural-key column the
pseudo-object lacks -- sorting the RLS-enable marker first.

Add regression coverage to 002_pg_dump.pl for all three cases: casts that
tie on the target type's schema and on the source type's schema (exercising
both new type comparisons), two transforms sharing a typname across
schemas, and a policy named after its own RLS-enabled table.  These abort an
unpatched assert-enabled run and pass with the fix.
---
 src/bin/pg_dump/pg_dump_sort.c   | 41 ++++++++++++++++
 src/bin/pg_dump/t/002_pg_dump.pl | 81 ++++++++++++++++++++++++++++++++
 2 files changed, 122 insertions(+)

diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index 4f3469d4396..86721dacde2 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -341,6 +341,38 @@ DOTypeNameCompare(const void *p1, const void *p2)
 		if (cmpval != 0)
 			return cmpval;
 	}
+	else if (obj1->objType == DO_CAST)
+	{
+		CastInfo   *cobj1 = *(CastInfo *const *) p1;
+		CastInfo   *cobj2 = *(CastInfo *const *) p2;
+
+		/*
+		 * The "name" is only the source and target type names, unqualified,
+		 * so two casts tie whenever their types share typnames across
+		 * different schemas.  Break the tie by the source then target types'
+		 * full natural keys.
+		 */
+		cmpval = pgTypeNameCompare(cobj1->castsource, cobj2->castsource);
+		if (cmpval != 0)
+			return cmpval;
+		cmpval = pgTypeNameCompare(cobj1->casttarget, cobj2->casttarget);
+		if (cmpval != 0)
+			return cmpval;
+	}
+	else if (obj1->objType == DO_TRANSFORM)
+	{
+		TransformInfo *tobj1 = *(TransformInfo *const *) p1;
+		TransformInfo *tobj2 = *(TransformInfo *const *) p2;
+
+		/*
+		 * Same unqualified-typname ambiguity as casts.  The language name
+		 * was already compared as part of dobj.name, so trftype is the only
+		 * remaining natural-key field that can break the tie.
+		 */
+		cmpval = pgTypeNameCompare(tobj1->trftype, tobj2->trftype);
+		if (cmpval != 0)
+			return cmpval;
+	}
 	else if (obj1->objType == DO_ATTRDEF)
 	{
 		AttrDefInfo *adobj1 = *(AttrDefInfo *const *) p1;
@@ -361,6 +393,15 @@ DOTypeNameCompare(const void *p1, const void *p2)
 						pobj2->poltable->dobj.name);
 		if (cmpval != 0)
 			return cmpval;
+
+		/*
+		 * The RLS-enabled pseudo-object has null polname but borrows its
+		 * table's name, so it ties with a real policy of that name.  Break by
+		 * polname, sorting the pseudo-object first.
+		 */
+		cmpval = (pobj1->polname != NULL) - (pobj2->polname != NULL);
+		if (cmpval != 0)
+			return cmpval;
 	}
 	else if (obj1->objType == DO_RULE)
 	{
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 1299c837063..0a9561d99f8 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -2204,6 +2204,42 @@ my %tests = (
 		like => { %full_runs, section_pre_data => 1, },
 	},
 
+	'CREATE CAST to public target type sharing a typname' => {
+		create_order => 51,
+		create_sql => '
+			CREATE SCHEMA dump_cast_schema;
+			CREATE TYPE public.dump_cast_src_for_target_test AS ENUM (\'a\');
+			CREATE TYPE public.dump_cast_tgt AS ENUM (\'a\');
+			CREATE TYPE dump_cast_schema.dump_cast_tgt AS ENUM (\'a\');
+			CREATE TYPE public.dump_cast_src_for_source_test AS ENUM (\'a\');
+			CREATE TYPE dump_cast_schema.dump_cast_src_for_source_test AS ENUM (\'a\');
+			CREATE CAST (public.dump_cast_src_for_target_test AS public.dump_cast_tgt) WITH INOUT;
+			CREATE CAST (public.dump_cast_src_for_target_test AS dump_cast_schema.dump_cast_tgt) WITH INOUT;
+			CREATE CAST (public.dump_cast_src_for_source_test AS public.dump_cast_tgt) WITH INOUT;
+			CREATE CAST (dump_cast_schema.dump_cast_src_for_source_test AS public.dump_cast_tgt) WITH INOUT;',
+		regexp =>
+		  qr/CREATE CAST \(public\.dump_cast_src_for_target_test AS public\.dump_cast_tgt\) WITH INOUT;/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE CAST to schema-qualified target type sharing a typname' => {
+		regexp =>
+		  qr/CREATE CAST \(public\.dump_cast_src_for_target_test AS dump_cast_schema\.dump_cast_tgt\) WITH INOUT;/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE CAST from public source type sharing a typname' => {
+		regexp =>
+		  qr/CREATE CAST \(public\.dump_cast_src_for_source_test AS public\.dump_cast_tgt\) WITH INOUT;/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE CAST from schema-qualified source type sharing a typname' => {
+		regexp =>
+		  qr/CREATE CAST \(dump_cast_schema\.dump_cast_src_for_source_test AS public\.dump_cast_tgt\) WITH INOUT;/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
 	'CREATE DATABASE postgres' => {
 		regexp => qr/^
 			\QCREATE DATABASE postgres WITH TEMPLATE = template0 \E
@@ -2927,6 +2963,25 @@ my %tests = (
 		like => { %full_runs, section_pre_data => 1, },
 	},
 
+	'CREATE TRANSFORM with typname shared across schemas' => {
+		create_order => 34,
+		create_sql => '
+			CREATE SCHEMA dump_trf_schema;
+			CREATE TYPE public.dump_trf_type AS ENUM (\'a\');
+			CREATE TYPE dump_trf_schema.dump_trf_type AS ENUM (\'a\');
+			CREATE TRANSFORM FOR public.dump_trf_type LANGUAGE sql (FROM SQL WITH FUNCTION prsd_lextype(internal));
+			CREATE TRANSFORM FOR dump_trf_schema.dump_trf_type LANGUAGE sql (FROM SQL WITH FUNCTION prsd_lextype(internal));',
+		regexp =>
+		  qr/CREATE TRANSFORM FOR public\.dump_trf_type LANGUAGE sql \(FROM SQL WITH FUNCTION pg_catalog\.prsd_lextype\(internal\)\);/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE TRANSFORM for schema-qualified type sharing a typname' => {
+		regexp =>
+		  qr/CREATE TRANSFORM FOR dump_trf_schema\.dump_trf_type LANGUAGE sql \(FROM SQL WITH FUNCTION pg_catalog\.prsd_lextype\(internal\)\);/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
 	'CREATE LANGUAGE pltestlang' => {
 		create_order => 18,
 		create_sql => 'CREATE LANGUAGE pltestlang
@@ -3172,6 +3227,32 @@ my %tests = (
 		},
 	},
 
+	# The "RLS is enabled" pseudo-object borrows its table's relname, so it
+	# ties in the sort with a policy of that same name on that same table.
+	# Check that the marker still dumps ahead of the policy.
+	'CREATE POLICY test_table ON test_table' => {
+		create_order => 28,
+		create_sql => 'CREATE POLICY test_table ON dump_test.test_table
+						   USING (true);',
+		regexp => qr/^
+			\QALTER TABLE dump_test.test_table ENABLE ROW LEVEL SECURITY;\E\n.+
+			\QCREATE POLICY test_table ON dump_test.test_table USING (true);\E
+			/xms,
+		like => {
+			%full_runs,
+			%dump_test_schema_runs,
+			only_dump_test_table => 1,
+			section_post_data => 1,
+		},
+		unlike => {
+			exclude_dump_test_schema => 1,
+			exclude_test_table => 1,
+			no_policies => 1,
+			no_policies_restore => 1,
+			only_dump_measurement => 1,
+		},
+	},
+
 	'CREATE PUBLICATION pub1' => {
 		create_order => 50,
 		create_sql => 'CREATE PUBLICATION pub1;',
-- 
2.34.1

