From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Thu, 10 Sep 2026 09:00:00 +0500
Subject: [PATCH v4] Use the join collation when unique-ifying a semijoin's RHS

A semijoin whose RHS is unique-ified groups the RHS on the expressions in
SpecialJoinInfo.semi_rhs_exprs.  Those were recorded with whatever collation
the RHS expression itself exposes, which need not be the collation the join
compares with.  Neither SortGroupClause nor the pathkey machinery carries a
collation of its own, so both Unique-over-Sort and HashAggregate then grouped
by the wrong equality: values the join considers equal survived, and the
following inner join emitted the outer row once per survivor.

With a non-deterministic collation on one side, "SELECT count(*) FROM t WHERE
c IN (SELECT c0 FROM t2)" therefore counted more rows than the same predicate
reports for the rows of t.

Label each RHS expression with the operator's input collation, the same
treatment process_equivalence() gives to equivalence class members.  Every
consumer of semi_rhs_exprs reads the collation off the expression, so this
fixes the sort-based and hash-based paths together; in the branches where
create_unique_path() also passes these expressions to
relation_has_unique_index_for(), it likewise stops a unique index built with a
different collation from being taken as proof that unique-ification can be
skipped.

Reported-by: Suyang Zhong <syzhong16@gmail.com>
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reviewed-by: Tender Wang <tndrwang@gmail.com>
Reviewed-by: Alexander Korotkov <aekorotkov@gmail.com>
Discussion: https://postgr.es/m/19633-647cd4c73a84b085%40postgresql.org
Backpatch-through: 14
---
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index fb6f81453ea..7e519e9b4a7 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -2524,7 +2524,18 @@ compute_semijoin_info(PlannerInfo *root, SpecialJoinInfo *sjinfo, List *clause)
 
 		/* so far so good, keep building lists */
 		semi_operators = lappend_oid(semi_operators, opno);
-		semi_rhs_exprs = lappend(semi_rhs_exprs, copyObject(right_expr));
+
+		/*
+		 * Ensure that the RHS expression exposes the join operator's input
+		 * collation.  Unique-ification groups on this expression and takes the
+		 * collation from it, since SortGroupClause carries none, so leaving
+		 * the expression's own collation here would group by different
+		 * equality semantics than the semijoin compares with.
+		 */
+		semi_rhs_exprs = lappend(semi_rhs_exprs,
+								 canonicalize_ec_expression((Expr *) copyObject(right_expr),
+															exprType(right_expr),
+															op->inputcollid));
 	}
 
 	/* Punt if we didn't find at least one column to unique-ify */
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index eb483b9015e..dd5ef982476 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -2114,6 +2114,121 @@ SELECT string_to_array('ABCDEFGHI' COLLATE case_insensitive, NULL, 'b');
  {A,NULL,C,D,E,F,G,H,I}
 (1 row)
 
+-- Unique-ification of an IN/semijoin RHS must group under the join's collation
+-- rather than the one exposed by the RHS expression.  Otherwise values the join
+-- considers equal survive unique-ification and duplicate the outer rows.
+-- 'abc' collides with the existing 'ABC' only under case_insensitive.
+INSERT INTO test2cs VALUES ('abc');
+-- As-is the reused tables prefer a plain semi join.  Pin n_distinct so
+-- unique-ification looks worthwhile and the plans below stay the ones we mean.
+ALTER TABLE test2cs ALTER COLUMN x SET (n_distinct = 1);
+ANALYZE test1ci, test2cs;
+-- The predicate holds for exactly one row, so filtering on it must return one.
+SELECT x, x COLLATE case_insensitive IN
+       (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC')) AS p
+FROM test1ci ORDER BY 1;
+  x  | p 
+-----+---
+ abc | t
+ def | f
+ ghi | f
+(3 rows)
+
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+ count 
+-------
+     1
+(1 row)
+
+-- Both unique-ification strategies must use the join collation.
+SET enable_hashagg TO off;
+SET enable_hashjoin TO off;
+SET enable_mergejoin TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Aggregate
+   ->  Nested Loop
+         Join Filter: (test1ci.x = ((test2cs.x)::text))
+         ->  Unique
+               ->  Sort
+                     Sort Key: test2cs.x COLLATE case_insensitive
+                     ->  Seq Scan on test2cs
+                           Filter: (x = ANY ('{abc,ABC}'::text[]))
+         ->  Seq Scan on test1ci
+(9 rows)
+
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_hashagg;
+RESET enable_hashjoin;
+RESET enable_mergejoin;
+SET enable_sort TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Aggregate
+   ->  Nested Loop
+         Join Filter: (test1ci.x = (test2cs.x)::text)
+         ->  HashAggregate
+               Group Key: (test2cs.x)::text
+               ->  Seq Scan on test2cs
+                     Filter: (x = ANY ('{abc,ABC}'::text[]))
+         ->  Seq Scan on test1ci
+(8 rows)
+
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_sort;
+ALTER TABLE test2cs ALTER COLUMN x RESET (n_distinct);
+-- A unique index on the RHS proves uniqueness under its own collation only, so
+-- it must not be taken as a reason to skip unique-ification for a join that
+-- compares under a different one.  The filler rows are what make unique-ifying
+-- the RHS look worthwhile, and so put that decision on the table at all; the
+-- plan is checked too, so that a costing change cannot quietly turn this into
+-- a test of nothing.
+INSERT INTO test1cs VALUES ('v1'), ('v2');
+ANALYZE test1cs;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN (SELECT x FROM test1cs);
+                     QUERY PLAN                     
+----------------------------------------------------
+ Aggregate
+   ->  Hash Right Semi Join
+         Hash Cond: ((test1cs.x)::text = test1ci.x)
+         ->  Seq Scan on test1cs
+         ->  Hash
+               ->  Seq Scan on test1ci
+(6 rows)
+
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN (SELECT x FROM test1cs);
+ count 
+-------
+     3
+(1 row)
+
 -- These queries should be able to use the index on test1ci.x:
 SET enable_seqscan = off;
 SET enable_indexonlyscan = off;
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index df57ebc8bc8..fc45127f83e 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -751,6 +751,63 @@ CREATE UNIQUE INDEX ON test3ci (x);  -- error
 SELECT string_to_array('ABC,DEF,GHI' COLLATE case_insensitive, ',', 'abc');
 SELECT string_to_array('ABCDEFGHI' COLLATE case_insensitive, NULL, 'b');
 
+-- Unique-ification of an IN/semijoin RHS must group under the join's collation
+-- rather than the one exposed by the RHS expression.  Otherwise values the join
+-- considers equal survive unique-ification and duplicate the outer rows.
+-- 'abc' collides with the existing 'ABC' only under case_insensitive.
+INSERT INTO test2cs VALUES ('abc');
+-- As-is the reused tables prefer a plain semi join.  Pin n_distinct so
+-- unique-ification looks worthwhile and the plans below stay the ones we mean.
+ALTER TABLE test2cs ALTER COLUMN x SET (n_distinct = 1);
+ANALYZE test1ci, test2cs;
+-- The predicate holds for exactly one row, so filtering on it must return one.
+SELECT x, x COLLATE case_insensitive IN
+       (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC')) AS p
+FROM test1ci ORDER BY 1;
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+
+-- Both unique-ification strategies must use the join collation.
+SET enable_hashagg TO off;
+SET enable_hashjoin TO off;
+SET enable_mergejoin TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+RESET enable_hashagg;
+RESET enable_hashjoin;
+RESET enable_mergejoin;
+
+SET enable_sort TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN
+      (SELECT x FROM test2cs WHERE x IN ('abc', 'ABC'));
+RESET enable_sort;
+ALTER TABLE test2cs ALTER COLUMN x RESET (n_distinct);
+
+-- A unique index on the RHS proves uniqueness under its own collation only, so
+-- it must not be taken as a reason to skip unique-ification for a join that
+-- compares under a different one.  The filler rows are what make unique-ifying
+-- the RHS look worthwhile, and so put that decision on the table at all; the
+-- plan is checked too, so that a costing change cannot quietly turn this into
+-- a test of nothing.
+INSERT INTO test1cs VALUES ('v1'), ('v2');
+ANALYZE test1cs;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN (SELECT x FROM test1cs);
+SELECT count(*) FROM test1ci
+WHERE x COLLATE case_insensitive IN (SELECT x FROM test1cs);
+
 -- These queries should be able to use the index on test1ci.x:
 SET enable_seqscan = off;
 SET enable_indexonlyscan = off;
--
2.55.0
