From 5c968dd818f0e3d3efde41919a0612963c7a2262 Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Tue, 25 Aug 2026 19:33:44 -0700
Subject: [PATCH 4/4] semijoin-v1-patch-d

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 src/backend/optimizer/README                  |   4 +
 src/backend/optimizer/plan/analyzejoins.c     |  13 ++
 .../regress/expected/semijoin_conversion.out  | 130 ++++++++++++++++++
 src/test/regress/sql/semijoin_conversion.sql  |  60 ++++++++
 4 files changed, 207 insertions(+)

diff --git a/src/backend/optimizer/README b/src/backend/optimizer/README
index 4efc4414419..579a4fdceeb 100644
--- a/src/backend/optimizer/README
+++ b/src/backend/optimizer/README
@@ -313,6 +313,10 @@ as one righthand side, but with a selective lefthand side and no index, in a
 handful of adversarial cases, the transformation could cost more than
 preventing the fanout would save.
 
+A table, a subquery, a function scan and a VALUES list can all be lifted.  The
+last two build their own rows from expressions, so a volatile expression rules
+one out: unique-ification changes how often the rows are built.
+
 enable_semijoin_conversion controls the transformation.  Only queries whose
 joins are all plain inner joins are considered, and lateral references and
 PlaceHolderVars rule a query out, since either may need a column from a
diff --git a/src/backend/optimizer/plan/analyzejoins.c b/src/backend/optimizer/plan/analyzejoins.c
index 1517a2d68ff..0f353ce4359 100644
--- a/src/backend/optimizer/plan/analyzejoins.c
+++ b/src/backend/optimizer/plan/analyzejoins.c
@@ -1176,6 +1176,19 @@ convert_joins_to_semijoins(PlannerInfo *root, List *joinlist)
 			case RTE_SUBQUERY:
 				break;
 
+				/*
+				 * These build their own rows, and unique-ification changes
+				 * how often that happens.
+				 */
+			case RTE_FUNCTION:
+				if (contain_volatile_functions((Node *) rte->functions))
+					continue;
+				break;
+			case RTE_VALUES:
+				if (contain_volatile_functions((Node *) rte->values_lists))
+					continue;
+				break;
+
 			default:
 				continue;
 		}
diff --git a/src/test/regress/expected/semijoin_conversion.out b/src/test/regress/expected/semijoin_conversion.out
index 29875be96de..31f08e52b31 100644
--- a/src/test/regress/expected/semijoin_conversion.out
+++ b/src/test/regress/expected/semijoin_conversion.out
@@ -393,6 +393,98 @@ SELECT DISTINCT d.id, d.payload
                ->  Seq Scan on sjc_driver d
 (12 rows)
 
+--
+-- Relation kinds other than tables and subqueries
+--
+-- A VALUES list restricting which drivers survive is no different from a table
+-- restricting them, and duplicates in the list fan the driver out the same way.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp;
+                   QUERY PLAN                    
+-------------------------------------------------
+ HashAggregate
+   Group Key: d.id, d.payload
+   ->  Hash Semi Join
+         Hash Cond: (d.grp = "*VALUES*".column1)
+         ->  Seq Scan on sjc_driver d
+         ->  Hash
+               ->  Values Scan on "*VALUES*"
+(7 rows)
+
+-- Same for a set-returning function, which is how a client-supplied key set
+-- often arrives.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d JOIN generate_series(1, 4) g(id) ON g.id = d.grp;
+                      QUERY PLAN                      
+------------------------------------------------------
+ HashAggregate
+   Group Key: d.id, d.payload
+   ->  Hash Semi Join
+         Hash Cond: (d.grp = g.id)
+         ->  Seq Scan on sjc_driver d
+         ->  Hash
+               ->  Function Scan on generate_series g
+(7 rows)
+
+-- The function scan and the table join to each other, so the two form one
+-- group and the group is declined.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN generate_series(1, 4) g(id) ON true
+       JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+ WHERE f.flag;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Unique
+   ->  Sort
+         Sort Key: d.id, d.payload
+         ->  Nested Loop
+               ->  Hash Join
+                     Hash Cond: ((f.id % 4) = g.id)
+                     ->  Seq Scan on sjc_filter f
+                           Filter: flag
+                     ->  Hash
+                           ->  Function Scan on generate_series g
+               ->  Index Scan using sjc_driver_pkey on sjc_driver d
+                     Index Cond: (id = f.driver_id)
+(12 rows)
+
+-- A volatile function builds fresh rows on every read, and unique-ification
+-- would change how often the read happens.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN generate_series(1, (random() * 4)::int) g(id) ON g.id = d.grp;
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: d.id, d.payload
+   ->  Hash Join
+         Hash Cond: (g.id = d.grp)
+         ->  Function Scan on generate_series g
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
+-- Likewise a VALUES list whose entries are volatile.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN (VALUES ((random() * 4)::int), (2)) v(id) ON v.id = d.grp;
+                   QUERY PLAN                    
+-------------------------------------------------
+ HashAggregate
+   Group Key: d.id, d.payload
+   ->  Hash Join
+         Hash Cond: (d.grp = "*VALUES*".column1)
+         ->  Seq Scan on sjc_driver d
+         ->  Hash
+               ->  Values Scan on "*VALUES*"
+(7 rows)
+
 --
 -- A key set filtering a chain of to-many joins
 --
@@ -639,6 +731,25 @@ SELECT count(*), sum(id) FROM (
     30 | 630
 (1 row)
 
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d
+         JOIN generate_series(1, 4) g(id) ON true
+         JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+   WHERE f.flag) s;
+ count | sum 
+-------+-----
+    30 | 630
+(1 row)
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp) s;
+ count | sum 
+-------+-----
+    16 | 304
+(1 row)
+
 SELECT count(DISTINCT c.id)
   FROM sjc_customer c
        LEFT JOIN sjc_order o ON o.customer_id = c.id
@@ -708,6 +819,25 @@ SELECT count(*), sum(id) FROM (
     30 | 630
 (1 row)
 
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d
+         JOIN generate_series(1, 4) g(id) ON true
+         JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+   WHERE f.flag) s;
+ count | sum 
+-------+-----
+    30 | 630
+(1 row)
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp) s;
+ count | sum 
+-------+-----
+    16 | 304
+(1 row)
+
 SELECT count(DISTINCT c.id)
   FROM sjc_customer c
        LEFT JOIN sjc_order o ON o.customer_id = c.id
diff --git a/src/test/regress/sql/semijoin_conversion.sql b/src/test/regress/sql/semijoin_conversion.sql
index 37ce12e776a..97d03d77cba 100644
--- a/src/test/regress/sql/semijoin_conversion.sql
+++ b/src/test/regress/sql/semijoin_conversion.sql
@@ -175,6 +175,44 @@ SELECT DISTINCT d.id, d.payload
        JOIN sjc_uniq2 u2 ON u2.unique_id = u.id
  WHERE u2.id < 30;
 
+--
+-- Relation kinds other than tables and subqueries
+--
+
+-- A VALUES list restricting which drivers survive is no different from a table
+-- restricting them, and duplicates in the list fan the driver out the same way.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp;
+
+-- Same for a set-returning function, which is how a client-supplied key set
+-- often arrives.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d JOIN generate_series(1, 4) g(id) ON g.id = d.grp;
+
+-- The function scan and the table join to each other, so the two form one
+-- group and the group is declined.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN generate_series(1, 4) g(id) ON true
+       JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+ WHERE f.flag;
+
+-- A volatile function builds fresh rows on every read, and unique-ification
+-- would change how often the read happens.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN generate_series(1, (random() * 4)::int) g(id) ON g.id = d.grp;
+
+-- Likewise a VALUES list whose entries are volatile.
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT d.id, d.payload
+  FROM sjc_driver d
+       JOIN (VALUES ((random() * 4)::int), (2)) v(id) ON v.id = d.grp;
+
 --
 -- A key set filtering a chain of to-many joins
 --
@@ -311,6 +349,17 @@ SELECT count(*), sum(id) FROM (
          JOIN sjc_deep e ON e.filter_id = f.id
    WHERE f.flag) s;
 
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d
+         JOIN generate_series(1, 4) g(id) ON true
+         JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+   WHERE f.flag) s;
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp) s;
+
 SELECT count(DISTINCT c.id)
   FROM sjc_customer c
        LEFT JOIN sjc_order o ON o.customer_id = c.id
@@ -357,6 +406,17 @@ SELECT count(*), sum(id) FROM (
          JOIN sjc_deep e ON e.filter_id = f.id
    WHERE f.flag) s;
 
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d
+         JOIN generate_series(1, 4) g(id) ON true
+         JOIN sjc_filter f ON f.driver_id = d.id AND f.id % 4 = g.id
+   WHERE f.flag) s;
+
+SELECT count(*), sum(id) FROM (
+  SELECT DISTINCT d.id
+    FROM sjc_driver d JOIN (VALUES (1), (1), (2), (2)) v(id) ON v.id = d.grp) s;
+
 SELECT count(DISTINCT c.id)
   FROM sjc_customer c
        LEFT JOIN sjc_order o ON o.customer_id = c.id
