From 0f08a5a1bb0ffa493e18a7cf461e2be172e9768d Mon Sep 17 00:00:00 2001
From: reshke <reshke@double.cloud>
Date: Sat, 3 Oct 2026 14:36:26 +0300
Subject: [PATCH v1] Check for foreign key with non-deterministic collations in
 pg_upgrade

Since PG 18/9321d2fdf808, a foreign key with different collations on the key
columns is rejected if either collation is nondeterministic.
Upgrading such cluster would fail.

Add a per-database pre-check of the old cluster reporting exactly the
shapes the new cluster would reject
---
 src/bin/pg_upgrade/check.c                    | 132 ++++++++++++++++++
 src/bin/pg_upgrade/meson.build                |   1 +
 .../pg_upgrade/t/009_fk_collation_mismatch.pl |  94 +++++++++++++
 3 files changed, 227 insertions(+)
 create mode 100644 src/bin/pg_upgrade/t/009_fk_collation_mismatch.pl

diff --git a/src/bin/pg_upgrade/check.c b/src/bin/pg_upgrade/check.c
index 150c4fc34b9..b5526d9d1cf 100644
--- a/src/bin/pg_upgrade/check.c
+++ b/src/bin/pg_upgrade/check.c
@@ -29,6 +29,7 @@ static void check_for_user_defined_postfix_ops(ClusterInfo *cluster);
 static void check_for_incompatible_polymorphics(ClusterInfo *cluster);
 static void check_for_tables_with_oids(ClusterInfo *cluster);
 static void check_for_not_null_inheritance(ClusterInfo *cluster);
+static void check_for_fk_collation_mismatch(ClusterInfo *cluster);
 static void check_for_gist_inet_ops(ClusterInfo *cluster);
 static void check_for_new_tablespace_dir(void);
 static void check_for_user_defined_encoding_conversions(ClusterInfo *cluster);
@@ -673,6 +674,19 @@ check_and_dump_old_cluster(void)
 	if (GET_MAJOR_VERSION(old_cluster.major_version) <= 1800)
 		check_for_not_null_inheritance(&old_cluster);
 
+	/*
+	 * PG 18 rejects foreign keys where the referencing and referenced
+	 * columns have different collations and either of them is
+	 * nondeterministic, but older clusters allow creating them, so the
+	 * schema restore of the new cluster would fail.  Verify there are
+	 * none, iff the new cluster enforces the rule.  Versions before 12
+	 * have no nondeterministic collations, so they cannot contain the
+	 * problem.
+	 */
+	if (GET_MAJOR_VERSION(new_cluster.major_version) >= 1800 &&
+		GET_MAJOR_VERSION(old_cluster.major_version) >= 1200)
+		check_for_fk_collation_mismatch(&old_cluster);
+
 	/*
 	 * The btree_gist extension contains gist_inet_ops and gist_cidr_ops
 	 * opclasses that do not reliably give correct answers.  We want to
@@ -1738,6 +1752,124 @@ check_for_not_null_inheritance(ClusterInfo *cluster)
 		check_ok();
 }
 
+/*
+ * Callback function for processing results of query for
+ * check_for_fk_collation_mismatch()'s UpgradeTask.  If the query returned
+ * any rows (i.e., the check failed), write the details to the report file.
+ */
+static void
+process_fk_collation_mismatch(DbInfo *dbinfo, PGresult *res, void *arg)
+{
+	UpgradeTaskReport *report = (UpgradeTaskReport *) arg;
+	int			ntups = PQntuples(res);
+	int			i_fknspname = PQfnumber(res, "fknspname");
+	int			i_fkrelname = PQfnumber(res, "fkrelname");
+	int			i_conname = PQfnumber(res, "conname");
+	int			i_fkattname = PQfnumber(res, "fkattname");
+	int			i_fkcname = PQfnumber(res, "fkcname");
+	int			i_pknspname = PQfnumber(res, "pknspname");
+	int			i_pkrelname = PQfnumber(res, "pkrelname");
+	int			i_pkattname = PQfnumber(res, "pkattname");
+	int			i_pkcname = PQfnumber(res, "pkcname");
+
+	if (ntups == 0)
+		return;
+
+	if (report->file == NULL &&
+		(report->file = fopen_priv(report->path, "w")) == NULL)
+		pg_fatal("could not open file \"%s\": %m", report->path);
+
+	fprintf(report->file, "In database: %s\n", dbinfo->db_name);
+
+	for (int rowno = 0; rowno < ntups; rowno++)
+		fprintf(report->file,
+				"  constraint %s on column %s.%s.%s (collation \"%s\")\n"
+				"  references column %s.%s.%s (collation \"%s\")\n",
+				PQgetvalue(res, rowno, i_conname),
+				PQgetvalue(res, rowno, i_fknspname),
+				PQgetvalue(res, rowno, i_fkrelname),
+				PQgetvalue(res, rowno, i_fkattname),
+				PQgetvalue(res, rowno, i_fkcname),
+				PQgetvalue(res, rowno, i_pknspname),
+				PQgetvalue(res, rowno, i_pkrelname),
+				PQgetvalue(res, rowno, i_pkattname),
+				PQgetvalue(res, rowno, i_pkcname));
+}
+
+/*
+ * check_for_fk_collation_mismatch()
+ *
+ * PG 18 made it an error to declare a foreign key whose referencing and
+ * referenced columns have different collations with either of them being
+ * nondeterministic (prior versions allowed it).  Such a constraint cannot
+ * be created on the new cluster, so its schema restore would fail mid-way
+ * through the upgrade.  Check that the cluster to be upgraded doesn't
+ * contain any of those.
+ */
+static void
+check_for_fk_collation_mismatch(ClusterInfo *cluster)
+{
+	UpgradeTaskReport report;
+	UpgradeTask *task;
+	const char *query;
+
+	prep_status("Checking for foreign keys with incompatible collations");
+
+	report.file = NULL;
+	snprintf(report.path, sizeof(report.path), "%s/%s",
+			 log_opts.basedir,
+			 "fk_collation_mismatch.txt");
+
+	query = "SELECT fkn.nspname AS fknspname, fkc.relname AS fkrelname, "
+		"       con.conname, fka.attname AS fkattname, "
+		"       fkc2.collname AS fkcname, "
+		"       pkn.nspname AS pknspname, pkc.relname AS pkrelname, "
+		"       pka.attname AS pkattname, pkc2.collname AS pkcname "
+		"FROM pg_catalog.pg_constraint con, "
+		"     unnest(con.conkey, con.confkey) AS keys(fkattnum, pkattnum), "
+		"     pg_catalog.pg_attribute fka, pg_catalog.pg_collation fkc2, "
+		"     pg_catalog.pg_attribute pka, pg_catalog.pg_collation pkc2, "
+		"     pg_catalog.pg_class fkc, pg_catalog.pg_namespace fkn, "
+		"     pg_catalog.pg_class pkc, pg_catalog.pg_namespace pkn "
+		"WHERE con.contype = 'f' "
+		"      AND fka.attrelid = con.conrelid AND fka.attnum = keys.fkattnum "
+		"      AND pka.attrelid = con.confrelid AND pka.attnum = keys.pkattnum "
+		"      AND fkc2.oid = fka.attcollation "
+		"      AND pkc2.oid = pka.attcollation "
+		"      AND fka.attcollation <> pka.attcollation "
+		"      AND (NOT fkc2.collisdeterministic OR NOT pkc2.collisdeterministic) "
+		"      AND fkc.oid = con.conrelid AND fkn.oid = fkc.relnamespace "
+		"      AND pkc.oid = con.confrelid AND pkn.oid = pkc.relnamespace";
+
+	task = upgrade_task_create();
+	upgrade_task_add_step(task, query,
+						  process_fk_collation_mismatch,
+						  true, &report);
+	upgrade_task_run(task, cluster);
+	upgrade_task_free(task);
+
+	if (report.file)
+	{
+		fclose(report.file);
+		pg_log(PG_REPORT, "fatal");
+		pg_fatal("Your installation contains foreign key constraints with\n"
+				 "different collations on the referencing and referenced\n"
+				 "columns, at least one of them nondeterministic.  Such\n"
+				 "constraints are rejected by the new PostgreSQL version, so\n"
+				 "this cluster cannot currently be upgraded.  You can\n"
+				 "fix this by making the referencing and referenced columns\n"
+				 "use the same collation, e.g.:\n"
+				 "    ALTER TABLE fk_table ALTER COLUMN fk_col TYPE text\n"
+				 "        COLLATE collation;\n"
+				 "or by removing the constraint and recreating it after the\n"
+				 "upgrade.\n"
+				 "A list of the problem constraints is in the file:\n"
+				 "    %s", report.path);
+	}
+	else
+		check_ok();
+}
+
 /*
  * Callback function for processing results of query for
  * check_for_gist_inet_ops()'s UpgradeTask.  If the query returned any rows
diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build
index ffbf6ae8d75..5aeaa41be87 100644
--- a/src/bin/pg_upgrade/meson.build
+++ b/src/bin/pg_upgrade/meson.build
@@ -69,6 +69,7 @@ tests += {
       't/006_transfer_modes.pl',
       't/007_multixact_conversion.pl',
       't/008_extension_control_path.pl',
+      't/009_fk_collation_mismatch.pl',
     ],
     'deps': [test_ext],
     'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow
diff --git a/src/bin/pg_upgrade/t/009_fk_collation_mismatch.pl b/src/bin/pg_upgrade/t/009_fk_collation_mismatch.pl
new file mode 100644
index 00000000000..796b1478c2b
--- /dev/null
+++ b/src/bin/pg_upgrade/t/009_fk_collation_mismatch.pl
@@ -0,0 +1,94 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that pg_upgrade detects foreign keys with incompatible collations on
+# the key columns, where at least one of the collations is nondeterministic.
+# Those were rejected starting with PG 18, but clusters upgraded from older
+# versions could still contain them, and without the check the failure would
+# happen only in the middle of the schema restore.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Can be changed to test the other modes.
+my $mode = $ENV{PG_TEST_PG_UPGRADE_MODE} || '--copy';
+
+# Nondeterministic collations are only supported by builds with ICU.
+plan skip_all => 'ICU not supported by this build'
+  unless defined($ENV{with_icu}) && $ENV{with_icu} eq 'yes';
+
+# Initialize old and new clusters.
+my $old = PostgreSQL::Test::Cluster->new('old');
+my $new = PostgreSQL::Test::Cluster->new('new');
+$old->init();
+$new->init();
+
+$old->start;
+
+$old->safe_psql('postgres', qq{
+CREATE COLLATION ci_nd (provider = icu, locale = 'und-u-ks-level2',
+                        deterministic = false);
+CREATE TABLE fk_p (k text COLLATE ci_nd PRIMARY KEY);
+CREATE TABLE fk_c (k text COLLATE ci_nd REFERENCES fk_p (k));
+});
+
+# An FK with mismatched nondeterministic collations cannot be created with
+# regular commands anymore, so mimic a pre-18 cluster by directly pointing
+# the referencing column at a different (deterministic) collation in the
+# catalogs, in the same shape pg_upgrade would see after an upgrade from
+# an old cluster.
+$old->safe_psql('postgres', qq{
+UPDATE pg_attribute a
+SET attcollation = 'pg_catalog."default"'::regcollation
+WHERE a.attrelid = 'public.fk_c'::regclass AND a.attname = 'k';
+});
+
+$old->stop;
+
+# In a VPATH build, we'll be started in the source directory, but we want
+# to run pg_upgrade in the build directory so that any files generated
+# finish in it, like delete_old_cluster.{sh,bat}.
+chdir ${PostgreSQL::Test::Utils::tmp_check};
+
+# The check should detect the mismatch and fail.
+command_checks_all(
+	[
+		'pg_upgrade', '--no-sync', '--check',
+		'--old-datadir' => $old->data_dir,
+		'--new-datadir' => $new->data_dir,
+		'--old-bindir' => $old->config_data('--bindir'),
+		'--new-bindir' => $new->config_data('--bindir'),
+		'--socketdir' => $new->host,
+		'--old-port' => $old->port,
+		'--new-port' => $new->port,
+		$mode
+	],
+	1,
+	[qr/Checking for foreign keys with incompatible collations/,
+	 qr/foreign key constraints with.*different collations on the referencing/s],
+	[],
+	'run of pg_upgrade --check with FK collation mismatch');
+
+# After removing the offending constraint, the upgrade should succeed.
+$old->start;
+$old->safe_psql('postgres', 'ALTER TABLE fk_c DROP CONSTRAINT fk_c_k_fkey');
+$old->stop;
+
+command_ok(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $old->data_dir,
+		'--new-datadir' => $new->data_dir,
+		'--old-bindir' => $old->config_data('--bindir'),
+		'--new-bindir' => $new->config_data('--bindir'),
+		'--socketdir' => $new->host,
+		'--old-port' => $old->port,
+		'--new-port' => $new->port,
+		$mode
+	],
+	'run of pg_upgrade after dropping the offending FK');
+
+done_testing();
-- 
2.43.0

