From dc7b94ad319e4f42918b4f27b5643e9ef0d341f8 Mon Sep 17 00:00:00 2001
From: Evdokimov Ilia <ilya.evdokimov@tantorlabs.com>
Date: Thu, 23 Jul 2026 13:51:22 +0300
Subject: [PATCH v2 8/9] Check EC collation before trusting a unique index's
 uniqueness proof

find_ec_position_matching_expr() matched a candidate EquivalenceClass
against a unique index's columns by opfamily alone, without checking
collation. A btree opfamily like text_ops is shared across every
collation, but a unique index built under one collation says nothing
about duplicates under a coarser one -- e.g. a case-sensitive unique
index does not rule out two rows that differ only in case, which a
case-insensitive join or DISTINCT clause would still consider equal.

Without this check, a unique index's guarantee could get attached to
an EC that was actually built under a different collation override
(the override typically survives only as a RelabelType around the EC
member's expression, which find_ec_member_matching_expr() strips
before comparing) -- making a case-insensitive join look "inner
unique" and silently drop matching rows:

    SELECT * FROM test1cs t1, test3cs t2
    WHERE t1.x = t2.x COLLATE case_insensitive
    ORDER BY 1, 2;

(test1cs.x is declared COLLATE case_sensitive) returned 4 rows
instead of 6, dropping the case-insensitive duplicate matches, and
EXPLAIN showed a spurious "Inner Unique: true" on the join.

Require ec->ec_collation to match before accepting an EC as proof of
uniqueness for the given expression.
---
 src/backend/optimizer/path/uniquekey.c | 22 +++++++++++++++++++++-
 1 file changed, 21 insertions(+), 1 deletion(-)

diff --git a/src/backend/optimizer/path/uniquekey.c b/src/backend/optimizer/path/uniquekey.c
index ccbcf8258a2..adebe43c889 100644
--- a/src/backend/optimizer/path/uniquekey.c
+++ b/src/backend/optimizer/path/uniquekey.c
@@ -249,6 +249,7 @@ find_ec_position_matching_expr(PlannerInfo *root, RelOptInfo *baserel,
 	int		ec_index;
 	JoinDomain *jdomain;
 	ListCell	*lc;
+	Oid			collation = exprCollation((Node *) expr);
 
 	/*
 	 * XXX Currently the function is only used to build unique keys, so we
@@ -272,6 +273,25 @@ find_ec_position_matching_expr(PlannerInfo *root, RelOptInfo *baserel,
 
 		ec = list_nth(root->eq_classes, i);
 
+		/*
+		 * The EC must compare values the same way the unique index does, or
+		 * its uniqueness guarantee does not apply to it. That requires both
+		 * the same operator family *and* the same collation: a btree
+		 * opfamily like text_ops is shared by every collation, but a unique
+		 * index built under one collation (e.g. case-sensitive) says
+		 * nothing about duplicates under a coarser collation (e.g.
+		 * case-insensitive) that some join/DISTINCT clause elsewhere in the
+		 * query might have used to build this EC. Without this check we can
+		 * attach a unique index's guarantee to an EC that was built with a
+		 * different collation override (the override typically survives
+		 * only as a RelabelType around the member expr, which
+		 * find_ec_member_matching_expr() strips before comparing), which
+		 * makes a case-insensitive join look "inner unique" and drops
+		 * matching rows.
+		 */
+		if (ec->ec_collation != collation)
+			continue;
+
 		/*
 		 * The EC must understand equality in the same way as the unique index
 		 * that guarantees the uniqueness. That is, ec_opfamilies must contain
@@ -292,7 +312,7 @@ find_ec_position_matching_expr(PlannerInfo *root, RelOptInfo *baserel,
 	ec = get_eclass_for_sort_expr(root, expr,
 								  opfamilies,
 								  exprType((Node *) expr),
-								  exprCollation((Node *) expr),
+								  collation,
 								  0,
 								  NULL,
 								  jdomain,
-- 
2.34.1

