From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Thu, 20 Aug 2026 19:15:00 +0000
Subject: [PATCH] Fix semijoin RHS unique-ification to use join collation

When unique-ifying a semijoin RHS for JOIN_UNIQUE_*, pathkeys and Unique
take collation from the RHS expression (SortGroupClause does not carry
one).  That can be a different (deterministic) collation than the join's
inputcollid.  Unique then leaves values that are still equal under the
join, and the subsequent INNER join duplicates outer rows.

Force each semi_rhs_expr to expose the join operator's input collation
via canonicalize_ec_expression (RelabelType).

BUG #19633
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Suyang Zhong <syzhong16@gmail.com>
Discussion: https://www.postgresql.org/message-id/19633-647cd4c73a84b085@postgresql.org
---
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index f08a918146c..fa98e7ab162 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -2628,7 +2628,17 @@ 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));
+
+		/*
+		 * Unique-ification takes collation from the expression, so label
+		 * the RHS with the join's input collation (RelabelType if needed).
+		 * Otherwise Unique may keep values that are still equal under the
+		 * join, and JOIN_UNIQUE_* (as INNER) will duplicate outer rows.
+		 */
+		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 fcfcc658bea..a7ca794a0a2 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -1988,6 +1988,35 @@ ORDER BY 1;
  ghi
 (4 rows)
 
+-- Unique-ification of an IN/semijoin RHS must use the join collation.
+CREATE TABLE t_semi_ci (c1 text COLLATE case_insensitive);
+CREATE TABLE t_semi_cs (c0 text);
+INSERT INTO t_semi_ci VALUES ('a'), ('x'), ('y');
+INSERT INTO t_semi_cs VALUES ('a'), ('a');
+ANALYZE t_semi_ci, t_semi_cs;
+INSERT INTO t_semi_cs VALUES ('A');
+SELECT count(*) FROM t_semi_ci WHERE c1 IN (SELECT c0 FROM t_semi_cs);
+ count 
+-------
+     1
+(1 row)
+
+SET enable_hashagg TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM t_semi_ci WHERE c1 IN (SELECT c0 FROM t_semi_cs);
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Aggregate
+   ->  Nested Loop
+         Join Filter: (t_semi_ci.c1 = ((t_semi_cs.c0)::text))
+         ->  Unique
+               ->  Sort
+                     Sort Key: t_semi_cs.c0 COLLATE case_insensitive
+                     ->  Seq Scan on t_semi_cs
+         ->  Seq Scan on t_semi_ci
+(8 rows)
+
+RESET enable_hashagg;
 CREATE TABLE test1ci (x text COLLATE case_insensitive);
 CREATE TABLE test2ci (x text COLLATE case_insensitive);
 CREATE TABLE test3ci (x text COLLATE case_insensitive);
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index ce4e2bb3ffd..b4994c64262 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -721,6 +721,19 @@ SELECT * FROM test3cs t1
                 WHERE t1.x = t2.x COLLATE case_insensitive)
 ORDER BY 1;
 
+-- Unique-ification of an IN/semijoin RHS must use the join collation.
+CREATE TABLE t_semi_ci (c1 text COLLATE case_insensitive);
+CREATE TABLE t_semi_cs (c0 text);
+INSERT INTO t_semi_ci VALUES ('a'), ('x'), ('y');
+INSERT INTO t_semi_cs VALUES ('a'), ('a');
+ANALYZE t_semi_ci, t_semi_cs;
+INSERT INTO t_semi_cs VALUES ('A');
+SELECT count(*) FROM t_semi_ci WHERE c1 IN (SELECT c0 FROM t_semi_cs);
+SET enable_hashagg TO off;
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM t_semi_ci WHERE c1 IN (SELECT c0 FROM t_semi_cs);
+RESET enable_hashagg;
+
 CREATE TABLE test1ci (x text COLLATE case_insensitive);
 CREATE TABLE test2ci (x text COLLATE case_insensitive);
 CREATE TABLE test3ci (x text COLLATE case_insensitive);
