From 42ca91a5b69381860678a9bda66782fad30ae184 Mon Sep 17 00:00:00 2001
From: Alexander Kukushkin <cyberdemn@gmail.com>
Date: Thu, 20 Aug 2026 12:06:17 +0200
Subject: [PATCH] pg_dump: sort casts and transforms independent of OIDs

DOTypeNameCompare() sorts dumpable objects by (priority, namespace, name,
objType) and then an object-type-specific natural-key tiebreaker.  Casts
and transforms have no namespace of their own, and getCasts() /
getTransforms() build their sort "name" from the *unqualified* type (and
language) names.  Two casts therefore 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").

With no DO_CAST or DO_TRANSFORM tiebreaker, such pairs reach the
Assert(false) fall-through added in commit 0decd5e89db (aborting
assert-enabled pg_dump), or on non-assert builds sort by OID, reintroducing
exactly the schema-diff instability that commit and its follow-ups
(b61a5c4bed7, 4921a5972a3, d49936f3028) have been eliminating.

Break the tie 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.

Add regression coverage to 002_pg_dump.pl: two casts and two transforms
whose types share a typname across schemas, which abort an unpatched
assert-enabled run and pass with the fix.
---
 src/bin/pg_dump/pg_dump_sort.c   | 28 +++++++++++++++++++++++
 src/bin/pg_dump/t/002_pg_dump.pl | 39 ++++++++++++++++++++++++++++++++
 2 files changed, 67 insertions(+)

diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index 03e5c1c1116..6ff106d8ac3 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -342,6 +342,34 @@ 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; break by type. */
+		cmpval = pgTypeNameCompare(tobj1->trftype, tobj2->trftype);
+		if (cmpval != 0)
+			return cmpval;
+	}
 	else if (obj1->objType == DO_ATTRDEF)
 	{
 		AttrDefInfo *adobj1 = *(AttrDefInfo *const *) p1;
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 9258948b583..4307cca8525 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -2204,6 +2204,26 @@ my %tests = (
 		like => { %full_runs, section_pre_data => 1, },
 	},
 
+	'CREATE CAST with typname shared across schemas' => {
+		create_order => 51,
+		create_sql => '
+			CREATE SCHEMA dump_cast_schema;
+			CREATE TYPE public.dump_cast_src AS ENUM (\'a\');
+			CREATE TYPE public.dump_cast_tgt AS ENUM (\'a\');
+			CREATE TYPE dump_cast_schema.dump_cast_tgt AS ENUM (\'a\');
+			CREATE CAST (public.dump_cast_src AS public.dump_cast_tgt) WITH INOUT;
+			CREATE CAST (public.dump_cast_src AS dump_cast_schema.dump_cast_tgt) WITH INOUT;',
+		regexp =>
+		  qr/CREATE CAST \(public\.dump_cast_src AS public\.dump_cast_tgt\) WITH INOUT;/m,
+		like => { %full_runs, section_pre_data => 1, },
+	},
+
+	'CREATE CAST to schema-qualified type sharing a typname' => {
+		regexp =>
+		  qr/CREATE CAST \(public\.dump_cast_src AS dump_cast_schema\.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 +2947,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
-- 
2.34.1

