From 89b88becf88ef4ea91aca161d26649396fa8dac5 Mon Sep 17 00:00:00 2001
From: David Rowley <dgrowley@gmail.com>
Date: Thu, 2 Oct 2025 23:38:46 +1300
Subject: [PATCH v3 2/2] Teach planner to short-circuit EXCEPT/INTERSECT with
 dummy inputs

When either inputs of an INTERSECT operator are proven not to return any
results (a dummy rel), then mark the entire INTERSECT operation as dummy.

Likewise, if an EXCEPT operation's left input is proven empty, then mark
the entire operation as dummy.

With EXCEPT ALL, we can easily handle the right input being dummy as
we can return the left input without any processing.  The same isn't
true for EXCEPT (without ALL), as that would require deduplication of
the left input.  Wiring up those Paths is likely more complex than it's
worth, so let's leave that one alone.

Author: David Rowley <dgrowleyml@gmail.com>
Discussion: https://postgr.es/m/CAApHDvri53PPF76c3M94_QNWbJfXjyCnjXuj_2=LYM-0m8WZtw@mail.gmail.com
---
 src/backend/optimizer/prep/prepunion.c | 64 ++++++++++++++++++++++++++
 src/test/regress/expected/union.out    | 57 ++++++++++++++++++++++-
 src/test/regress/sql/union.sql         | 28 ++++++++++-
 3 files changed, 147 insertions(+), 2 deletions(-)

diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c
index bf10988cd2d..675f7d60d13 100644
--- a/src/backend/optimizer/prep/prepunion.c
+++ b/src/backend/optimizer/prep/prepunion.c
@@ -1186,6 +1186,70 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root,
 		result_rel->reltarget = create_setop_pathtarget(root, tlist,
 														list_make2(lpath, rpath));
 
+	/* Check for provably empty setop inputs and add short-circuit paths. */
+	if (op->op == SETOP_EXCEPT)
+	{
+		/*
+		 * For EXCEPTs, if the left side is dummy then there's no need to
+		 * inspect the right-hand side as scanning the right to find tuples to
+		 * remove won't make the left-hand input any more empty.
+		 */
+		if (is_dummy_rel(lrel))
+		{
+			result_rel->reltarget = create_pathtarget(root, lpath_tlist);
+			mark_dummy_rel(result_rel);
+
+			return result_rel;
+		}
+
+		/* Handle EXCEPTs with dummy right input */
+		if (is_dummy_rel(rrel))
+		{
+			if (op->all)
+			{
+				Path	   *apath;
+
+				/*
+				 * EXCEPT ALL: If the right-hand input is dummy then we can
+				 * simply scan the left-hand input.  To keep createplan.c
+				 * happy, use a single child Append to handle the translation
+				 * between the set op targetlist and the targetlist of the
+				 * left input.  The Append will be removed in setrefs.c.
+				 */
+				apath = (Path *) create_append_path(root, result_rel, list_make1(lpath),
+													NIL, NIL, NULL, 0, false, -1);
+
+				add_path(result_rel, apath);
+
+				return result_rel;
+			}
+			else
+			{
+				/*
+				 * To make EXCEPT with a dummy RHS work means having to
+				 * deduplicate the left input.  That could be done with
+				 * AggPaths, but doesn't seem worth the effort.  Let the
+				 * normal path generation code below handle this one.
+				 */
+			}
+		}
+	}
+	else
+	{
+		/*
+		 * For INTERSECT, if either input is a dummy rel then we can mark the
+		 * result_rel as dummy since intersecting with an empty relation can
+		 * never yield any results.  This is true regardless of INTERSECT or
+		 * INTERSECT ALL.
+		 */
+		if (is_dummy_rel(lrel) || is_dummy_rel(rrel))
+		{
+			result_rel->reltarget = create_pathtarget(root, lpath_tlist);
+			mark_dummy_rel(result_rel);
+			return result_rel;
+		}
+	}
+
 	/*
 	 * Estimate number of distinct groups that we'll need hashtable entries
 	 * for; this is the size of the left-hand input for EXCEPT, or the smaller
diff --git a/src/test/regress/expected/union.out b/src/test/regress/expected/union.out
index 7c089e0d598..6856583c165 100644
--- a/src/test/regress/expected/union.out
+++ b/src/test/regress/expected/union.out
@@ -1217,7 +1217,7 @@ select event_id
 drop table events_child, events, other_events;
 reset enable_indexonlyscan;
 --
--- Test handling of UNION with provably empty inputs
+-- Test handling of UNION / EXCEPT / INTERSECT with provably empty inputs
 --
 -- Ensure the empty UNION input is pruned and de-duplication is done for the
 -- remaining relation.
@@ -1267,6 +1267,61 @@ SELECT ten FROM tenk1 WHERE 1=2;
    One-Time Filter: false
 (4 rows)
 
+-- Ensure the planner provides a const-false Result node
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1 WHERE 1=2
+INTERSECT
+SELECT four FROM tenk1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Result
+   Output: unnamed_subquery.two
+   Replaces: Aggregate on unnamed_subquery, unnamed_subquery_1
+   One-Time Filter: false
+(4 rows)
+
+-- As above, with the inputs swapped
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT four FROM tenk1
+INTERSECT
+SELECT two FROM tenk1 WHERE 1=2;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Result
+   Output: unnamed_subquery_1.two
+   Replaces: Aggregate on unnamed_subquery, unnamed_subquery_1
+   One-Time Filter: false
+(4 rows)
+
+-- Ensure the planner provides a const-false Result node when the left input
+-- is empty
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1 WHERE 1=2
+EXCEPT
+SELECT four FROM tenk1;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ Result
+   Output: unnamed_subquery.two
+   Replaces: Aggregate on unnamed_subquery, unnamed_subquery_1
+   One-Time Filter: false
+(4 rows)
+
+-- Ensure the planner only scans the left input
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1
+EXCEPT ALL
+SELECT four FROM tenk1 WHERE 1=2
+ORDER BY 1;
+           QUERY PLAN           
+--------------------------------
+ Sort
+   Output: tenk1.two
+   Sort Key: tenk1.two
+   ->  Seq Scan on public.tenk1
+         Output: tenk1.two
+(5 rows)
+
 -- Test constraint exclusion of UNION ALL subqueries
 explain (costs off)
  SELECT * FROM
diff --git a/src/test/regress/sql/union.sql b/src/test/regress/sql/union.sql
index 56bd20e741c..e66239931cd 100644
--- a/src/test/regress/sql/union.sql
+++ b/src/test/regress/sql/union.sql
@@ -460,7 +460,7 @@ drop table events_child, events, other_events;
 reset enable_indexonlyscan;
 
 --
--- Test handling of UNION with provably empty inputs
+-- Test handling of UNION / EXCEPT / INTERSECT with provably empty inputs
 --
 
 -- Ensure the empty UNION input is pruned and de-duplication is done for the
@@ -486,6 +486,32 @@ SELECT four FROM tenk1 WHERE 1=2
 UNION
 SELECT ten FROM tenk1 WHERE 1=2;
 
+-- Ensure the planner provides a const-false Result node
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1 WHERE 1=2
+INTERSECT
+SELECT four FROM tenk1;
+
+-- As above, with the inputs swapped
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT four FROM tenk1
+INTERSECT
+SELECT two FROM tenk1 WHERE 1=2;
+
+-- Ensure the planner provides a const-false Result node when the left input
+-- is empty
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1 WHERE 1=2
+EXCEPT
+SELECT four FROM tenk1;
+
+-- Ensure the planner only scans the left input
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT two FROM tenk1
+EXCEPT ALL
+SELECT four FROM tenk1 WHERE 1=2
+ORDER BY 1;
+
 -- Test constraint exclusion of UNION ALL subqueries
 explain (costs off)
  SELECT * FROM
-- 
2.43.0

