From b0bb15a58bf537c3ac3ba29fdc1f833042885957 Mon Sep 17 00:00:00 2001
From: Andrey Kazarinov <a.kazarinov@postgrespro.ru>
Date: Wed, 13 May 2026 14:27:30 +0300
Subject: [PATCH] Allow subquery pull-up past inlineable CTEs

Extract CTE inlineability checks into is_cte_inlineable() as the single
source of truth, and use it to permit pull-up when all CTEs are inlineable.
CTE inlining happens on the copy inside pull_up_simple_subquery so the
original RTE is preserved on fallback

---
 src/backend/optimizer/plan/subselect.c    | 142 ++++++++++++++++------
 src/backend/optimizer/prep/prepjointree.c |  17 ++-
 src/include/optimizer/subselect.h         |   2 +
 src/test/regress/expected/with.out        |  87 +++++++++++--
 src/test/regress/sql/with.sql             |  40 ++++++
 5 files changed, 243 insertions(+), 45 deletions(-)

diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index 2cf5c15a309..9f34da92018 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -91,6 +91,7 @@ static bool contain_outer_selfref(Node *node);
 static bool contain_outer_selfref_walker(Node *node, Index *depth);
 static void inline_cte(PlannerInfo *root, CommonTableExpr *cte);
 static bool inline_cte_walker(Node *node, inline_cte_walker_context *context);
+static bool is_cte_inlineable(CommonTableExpr *cte);
 static bool sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink);
 static bool simplify_EXISTS_query(PlannerInfo *root, Query *query);
 static Query *convert_EXISTS_to_ANY(PlannerInfo *root, Query *subselect,
@@ -918,43 +919,8 @@ SS_process_ctes(PlannerInfo *root)
 		/*
 		 * Consider inlining the CTE (creating RTE_SUBQUERY RTE(s)) instead of
 		 * implementing it as a separately-planned CTE.
-		 *
-		 * We cannot inline if any of these conditions hold:
-		 *
-		 * 1. The user said not to (the CTEMaterializeAlways option).
-		 *
-		 * 2. The CTE is recursive.
-		 *
-		 * 3. The CTE has side-effects; this includes either not being a plain
-		 * SELECT, or containing volatile functions.  Inlining might change
-		 * the side-effects, which would be bad.
-		 *
-		 * 4. The CTE is multiply-referenced and contains a self-reference to
-		 * a recursive CTE outside itself.  Inlining would result in multiple
-		 * recursive self-references, which we don't support.
-		 *
-		 * Otherwise, we have an option whether to inline or not.  That should
-		 * always be a win if there's just a single reference, but if the CTE
-		 * is multiply-referenced then it's unclear: inlining adds duplicate
-		 * computations, but the ability to absorb restrictions from the outer
-		 * query level could outweigh that.  We do not have nearly enough
-		 * information at this point to tell whether that's true, so we let
-		 * the user express a preference.  Our default behavior is to inline
-		 * only singly-referenced CTEs, but a CTE marked CTEMaterializeNever
-		 * will be inlined even if multiply referenced.
-		 *
-		 * Note: we check for volatile functions last, because that's more
-		 * expensive than the other tests needed.
 		 */
-		if ((cte->ctematerialized == CTEMaterializeNever ||
-			 (cte->ctematerialized == CTEMaterializeDefault &&
-			  cte->cterefcount == 1)) &&
-			!cte->cterecursive &&
-			cmdType == CMD_SELECT &&
-			!contain_dml(cte->ctequery) &&
-			(cte->cterefcount <= 1 ||
-			 !contain_outer_selfref(cte->ctequery)) &&
-			!contain_volatile_functions(cte->ctequery))
+		if (is_cte_inlineable(cte))
 		{
 			inline_cte(root, cte);
 			/* Make a dummy entry in cte_plan_ids */
@@ -1223,6 +1189,110 @@ inline_cte_walker(Node *node, inline_cte_walker_context *context)
 	return expression_tree_walker(node, inline_cte_walker, context);
 }
 
+/*
+ * We cannot inline if any of these conditions hold:
+ *
+ * 1. The user said not to (the CTEMaterializeAlways option).
+ *
+ * 2. The CTE is recursive.
+ *
+ * 3. The CTE has side-effects; this includes either not being a plain
+ * SELECT, or containing volatile functions.  Inlining might change
+ * the side-effects, which would be bad.
+ *
+ * 4. The CTE is multiply-referenced and contains a self-reference to
+ * a recursive CTE outside itself.  Inlining would result in multiple
+ * recursive self-references, which we don't support.
+ *
+ * Otherwise, we have an option whether to inline or not.  That should
+ * always be a win if there's just a single reference, but if the CTE
+ * is multiply-referenced then it's unclear: inlining adds duplicate
+ * computations, but the ability to absorb restrictions from the outer
+ * query level could outweigh that.  We do not have nearly enough
+ * information at this point to tell whether that's true, so we let
+ * the user express a preference.  Our default behavior is to inline
+ * only singly-referenced CTEs, but a CTE marked CTEMaterializeNever
+ * will be inlined even if multiply referenced.
+ *
+ * Note: we check for volatile functions last, because that's more
+ * expensive than the other tests needed.
+ */
+static bool
+is_cte_inlineable(CommonTableExpr *cte)
+{
+	CmdType		cmdType = ((Query *) cte->ctequery)->commandType;
+
+	return (cte->ctematerialized == CTEMaterializeNever ||
+			(cte->ctematerialized == CTEMaterializeDefault &&
+			 cte->cterefcount == 1)) &&
+			!cte->cterecursive &&
+			cmdType == CMD_SELECT &&
+			!contain_dml(cte->ctequery) &&
+			(cte->cterefcount <= 1 ||
+			 !contain_outer_selfref(cte->ctequery)) &&
+			 !contain_volatile_functions((Node *) cte->ctequery);
+}
+
+/*
+ * SS_all_ctes_inlineable: are all CTEs in the query inlineable?
+ *
+ * Returns true if every CTE in cteList is either unreferenced (SELECT)
+ * or passes the inlineability checks of is_cte_inlineable().  This is used
+ * by is_simple_subquery to determine whether a subquery with CTEs can still
+ * be pulled up.
+ */
+bool
+SS_all_ctes_inlineable(Query *subquery)
+{
+	ListCell   *lc;
+
+	foreach(lc, subquery->cteList)
+	{
+		CommonTableExpr *cte = (CommonTableExpr *) lfirst(lc);
+		CmdType		cmdType = ((Query *) cte->ctequery)->commandType;
+
+		/*
+		 * Handle the case of unreferenced SELECT CTEs,
+		 * which are neither inlined nor materialized.
+		 */
+		if (cte->cterefcount == 0 && cmdType == CMD_SELECT)
+			return false;
+
+		if (!is_cte_inlineable(cte))
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * SS_inline_ctes: inline all inlineable CTEs in the given query.
+ *
+ * Inlineable CTEs are replaced with RTE_SUBQUERY references via
+ * inline_cte_walker, and removed from cteList.
+ *
+ * Inlineability conditions are determined by SS_all_ctes_inlineable().
+ */
+void
+SS_inline_ctes(PlannerInfo *root)
+{
+	ListCell   *lc;
+
+	foreach(lc, root->parse->cteList)
+	{
+		CommonTableExpr *cte = (CommonTableExpr *) lfirst(lc);
+
+		Assert(((Query *) cte->ctequery)->commandType == CMD_SELECT &&
+			   is_cte_inlineable(cte));
+
+		inline_cte(root, cte);
+	}
+
+	/* CTEs have inlined, so we can clean this list */
+	root->parse->cteList = NIL;
+	return;
+}
+
 /*
  * Attempt to transform 'testexpr' over the VALUES subquery into
  * a ScalarArrayOpExpr.  We currently support the transformation only when
diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index 18f05caac3b..26222474c57 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -1481,6 +1481,17 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte,
 	subroot->non_recursive_path = NULL;
 	/* We don't currently need a top JoinDomain for the subroot */
 
+	/*
+	 * If the subquery has inlineable CTEs, inline them now so that the
+	 * Assert below is satisfied.  is_simple_subquery should have already
+	 * verified that all CTEs are inlineable, so SS_inline_ctes is expected
+	 * to succeed.
+	 */
+	if (subquery->cteList)
+	{
+		SS_inline_ctes(subroot);
+	}
+
 	/* No CTEs to worry about */
 	Assert(subquery->cteList == NIL);
 
@@ -1980,7 +1991,9 @@ is_simple_subquery(PlannerInfo *root, Query *subquery, RangeTblEntry *rte,
 
 	/*
 	 * Can't pull up a subquery involving grouping, aggregation, SRFs,
-	 * sorting, limiting, or WITH.  (XXX WITH could possibly be allowed later)
+	 * sorting, limiting, or non-inlineable CTEs.  CTEs that are inlineable
+	 * will be inlined by pull_up_simple_subquery before the
+	 * Assert(cteList==NIL).
 	 *
 	 * We also don't pull up a subquery that has explicit FOR UPDATE/SHARE
 	 * clauses, because pullup would cause the locking to occur semantically
@@ -1999,7 +2012,7 @@ is_simple_subquery(PlannerInfo *root, Query *subquery, RangeTblEntry *rte,
 		subquery->limitOffset ||
 		subquery->limitCount ||
 		subquery->hasForUpdate ||
-		subquery->cteList)
+		(subquery->cteList && !SS_all_ctes_inlineable(subquery)))
 		return false;
 
 	/*
diff --git a/src/include/optimizer/subselect.h b/src/include/optimizer/subselect.h
index 4ecccf46bd3..b7c91865260 100644
--- a/src/include/optimizer/subselect.h
+++ b/src/include/optimizer/subselect.h
@@ -17,6 +17,8 @@
 #include "nodes/plannodes.h"
 
 extern void SS_process_ctes(PlannerInfo *root);
+extern bool SS_all_ctes_inlineable(Query *subquery);
+extern void SS_inline_ctes(PlannerInfo *root);
 extern ScalarArrayOpExpr *convert_VALUES_to_ANY(PlannerInfo *root,
 												Node *testexpr,
 												Query *values);
diff --git a/src/test/regress/expected/with.out b/src/test/regress/expected/with.out
index addb24896be..1c81ca713a1 100644
--- a/src/test/regress/expected/with.out
+++ b/src/test/regress/expected/with.out
@@ -2921,13 +2921,11 @@ SELECT q1 FROM
   SELECT q1, (SELECT q2 FROM t_cte WHERE t_cte.q1 = i8.q1) AS t_sub
   FROM int8_tbl i8
 ) ss;
-              QUERY PLAN              
---------------------------------------
- Subquery Scan on ss
-   Output: ss.q1
-   ->  Seq Scan on public.int8_tbl i8
-         Output: i8.q1, NULL::bigint
-(4 rows)
+           QUERY PLAN           
+--------------------------------
+ Seq Scan on public.int8_tbl i8
+   Output: i8.q1
+(2 rows)
 
 SELECT q1 FROM
 (
@@ -3854,3 +3852,78 @@ WHERE t1.two = 0 AND t2.two = 0 AND t1.thousand = t2.thousand;
                Filter: (two = 0)
 (11 rows)
 
+-- Test CTE inlining during subquery pull-up
+CREATE TABLE cte_pullup_t (id INT PRIMARY KEY, val TEXT);
+INSERT INTO cte_pullup_t SELECT g, 'val' || g FROM generate_series(1,100) g;
+CREATE TABLE cte_pullup_s (id INT, tid INT);
+INSERT INTO cte_pullup_s SELECT g, (g % 10) + 1 FROM generate_series(1,50) g;
+ANALYZE cte_pullup_t, cte_pullup_s;
+-- NOT MATERIALIZED CTE in subquery: should be inlined and pulled up
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS NOT materialized (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Merge Right Join
+   Merge Cond: (cte_pullup_t.id = s.tid)
+   ->  Index Scan using cte_pullup_t_pkey on cte_pullup_t
+   ->  Sort
+         Sort Key: s.tid
+         ->  Seq Scan on cte_pullup_s s
+               Filter: (id < 5)
+(7 rows)
+
+-- Default (singly-referenced) CTE in subquery: same, should be inlined and pulled up
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Merge Right Join
+   Merge Cond: (cte_pullup_t.id = s.tid)
+   ->  Index Scan using cte_pullup_t_pkey on cte_pullup_t
+   ->  Sort
+         Sort Key: s.tid
+         ->  Seq Scan on cte_pullup_s s
+               Filter: (id < 5)
+(7 rows)
+
+-- MATERIALIZED CTE in subquery: should NOT be inlined
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS materialized (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+               QUERY PLAN               
+----------------------------------------
+ Hash Right Join
+   Hash Cond: (cte.id = s.tid)
+   ->  CTE Scan on cte
+         CTE cte
+           ->  Seq Scan on cte_pullup_t
+   ->  Hash
+         ->  Seq Scan on cte_pullup_s s
+               Filter: (id < 5)
+(8 rows)
+
+-- CTE WITH volatile function: should NOT be inlined
+EXPLAIN (COSTS OFF)
+WITH cte AS NOT materialized (SELECT id, random() AS r FROM cte_pullup_t)
+SELECT * FROM (SELECT id FROM cte) sub WHERE id = 1;
+            QUERY PLAN            
+----------------------------------
+ CTE Scan on cte
+   Filter: (id = 1)
+   CTE cte
+     ->  Seq Scan on cte_pullup_t
+(4 rows)
+
+DROP TABLE cte_pullup_s;
+DROP TABLE cte_pullup_t;
diff --git a/src/test/regress/sql/with.sql b/src/test/regress/sql/with.sql
index 0cb26312e21..bfcb56a77b4 100644
--- a/src/test/regress/sql/with.sql
+++ b/src/test/regress/sql/with.sql
@@ -1800,3 +1800,43 @@ EXPLAIN (COSTS OFF)
 WITH cte AS (SELECT DISTINCT two, thousand FROM tenk1)
 SELECT * FROM cte t1, cte t2
 WHERE t1.two = 0 AND t2.two = 0 AND t1.thousand = t2.thousand;
+
+-- Test CTE inlining during subquery pull-up
+CREATE TABLE cte_pullup_t (id INT PRIMARY KEY, val TEXT);
+INSERT INTO cte_pullup_t SELECT g, 'val' || g FROM generate_series(1,100) g;
+CREATE TABLE cte_pullup_s (id INT, tid INT);
+INSERT INTO cte_pullup_s SELECT g, (g % 10) + 1 FROM generate_series(1,50) g;
+
+ANALYZE cte_pullup_t, cte_pullup_s;
+
+-- NOT MATERIALIZED CTE in subquery: should be inlined and pulled up
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS NOT materialized (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+
+-- Default (singly-referenced) CTE in subquery: same, should be inlined and pulled up
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+
+-- MATERIALIZED CTE in subquery: should NOT be inlined
+EXPLAIN (COSTS OFF)
+SELECT * FROM cte_pullup_s s LEFT JOIN (
+  WITH cte AS materialized (SELECT id, val FROM cte_pullup_t)
+  SELECT * FROM (SELECT id, val FROM cte) sub
+) t on t.id = s.tid
+WHERE s.id < 5;
+
+-- CTE WITH volatile function: should NOT be inlined
+EXPLAIN (COSTS OFF)
+WITH cte AS NOT materialized (SELECT id, random() AS r FROM cte_pullup_t)
+SELECT * FROM (SELECT id FROM cte) sub WHERE id = 1;
+
+DROP TABLE cte_pullup_s;
+DROP TABLE cte_pullup_t;
-- 
2.53.0

