From f7b7d515f87ffcc8034a953648701d5775530fc7 Mon Sep 17 00:00:00 2001
From: satyanarayana narlapuram <satyanarlapuram@gmail.com>
Date: Tue, 21 Apr 2026 16:56:18 +0000
Subject: [PATCH] Allow SJE to recognize GiST-backed temporal primary keys

relation_has_unique_index_for() checks whether join clause equality
operators belong to the index's opfamily via mergeopfamilies.  Since
mergeopfamilies only lists btree opfamilies, this check always fails
for GiST-backed unique indexes such as those created by PRIMARY KEY
with WITHOUT OVERLAPS, preventing self-join elimination.

Fix by falling back to op_in_opfamily() when the mergeopfamilies check
fails.  The clause is already known to be a mergejoinable equality, so
confirming the operator is registered in the index's opfamily is
sufficient to prove that the index's uniqueness guarantee applies.
---
 src/backend/optimizer/path/indxpath.c | 11 +++++++++-
 src/test/regress/expected/join.out    | 30 +++++++++++++++++++++++++++
 src/test/regress/sql/join.sql         | 21 +++++++++++++++++++
 3 files changed, 61 insertions(+), 1 deletion(-)

diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 430e06dc..990ada34 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -4227,8 +4227,17 @@ relation_has_unique_index_for(PlannerInfo *root, RelOptInfo *rel,
 				 * index opfamily, else it is not asserting the right kind of
 				 * equality behavior for this index.  We check this first
 				 * since it's probably cheaper than match_index_to_operand().
+				 *
+				 * For non-btree unique indexes (e.g., GiST-backed temporal
+				 * primary keys created with WITHOUT OVERLAPS), the index
+				 * opfamily won't appear in mergeopfamilies, which only lists
+				 * btree opfamilies.  Fall back to checking whether the
+				 * clause's equality operator is directly a member of the
+				 * index's opfamily.
 				 */
-				if (!list_member_oid(rinfo->mergeopfamilies, ind->opfamily[c]))
+				if (!list_member_oid(rinfo->mergeopfamilies, ind->opfamily[c]) &&
+					!op_in_opfamily(castNode(OpExpr, rinfo->clause)->opno,
+									ind->opfamily[c]))
 					continue;
 
 				/*
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 78bf022f..cc72eb92 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -8202,6 +8202,36 @@ ON sj_t1.id = _t2t3t4.id;
    ->  Seq Scan on sj_t1
 (24 rows)
 
+-- SJE with GiST-backed temporal primary key (WITHOUT OVERLAPS).
+-- Use int4range for the scalar key since btree_gist may not be available.
+CREATE TABLE sj_temporal (
+    id int4range,
+    valid_at daterange NOT NULL,
+    val text,
+    PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+-- Remove SJ: join covers the full temporal PK
+EXPLAIN (COSTS OFF)
+SELECT t1.val FROM sj_temporal t1
+    JOIN sj_temporal t2 ON t1.id = t2.id AND t1.valid_at = t2.valid_at;
+         QUERY PLAN         
+----------------------------
+ Seq Scan on sj_temporal t2
+(1 row)
+
+-- Don't remove SJ: join only covers part of the temporal PK
+EXPLAIN (COSTS OFF)
+SELECT t1.val FROM sj_temporal t1
+    JOIN sj_temporal t2 ON t1.id = t2.id;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Nested Loop
+   ->  Seq Scan on sj_temporal t1
+   ->  Index Only Scan using sj_temporal_pkey on sj_temporal t2
+         Index Cond: (id = t1.id)
+(4 rows)
+
+DROP TABLE sj_temporal;
 --
 -- Test RowMarks-related code
 --
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index fae19113..bbc7c15d 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -3215,6 +3215,27 @@ JOIN (
 	) _t2t3t4
 ON sj_t1.id = _t2t3t4.id;
 
+-- SJE with GiST-backed temporal primary key (WITHOUT OVERLAPS).
+-- Use int4range for the scalar key since btree_gist may not be available.
+CREATE TABLE sj_temporal (
+    id int4range,
+    valid_at daterange NOT NULL,
+    val text,
+    PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+
+-- Remove SJ: join covers the full temporal PK
+EXPLAIN (COSTS OFF)
+SELECT t1.val FROM sj_temporal t1
+    JOIN sj_temporal t2 ON t1.id = t2.id AND t1.valid_at = t2.valid_at;
+
+-- Don't remove SJ: join only covers part of the temporal PK
+EXPLAIN (COSTS OFF)
+SELECT t1.val FROM sj_temporal t1
+    JOIN sj_temporal t2 ON t1.id = t2.id;
+
+DROP TABLE sj_temporal;
+
 --
 -- Test RowMarks-related code
 --
-- 
2.43.0

