From 90a81db1e0b7880c6874ec6e26aff33403e1f131 Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Wed, 16 Sep 2026 18:13:02 +0900 Subject: [PATCH v2] Fix mismatched PHVs for join aliases in LATERAL UNION ALL subqueries When a LATERAL UNION ALL subquery is pulled up as an appendrel, its lateral references live on in two places: in the parent RTE's subquery, which find_lateral_references examines on the assumption that the children are exact copies of parts of it, and in the child rels, which are what actually get planned. Both copies had their join alias Vars expanded later, by separate flatten_join_alias_vars calls in subquery_planner. If an expansion needs a PlaceHolderVar to carry nullingrels, each call made its own PHV with a different phid, so the PHVs demanded by the children had no PlaceHolderInfo, leading to "too late to create a new PlaceHolderInfo" errors when creating the plan. To fix, keep the PHVs made by add_nullingrels_if_needed in a new PlannerInfo field, and reuse one whenever the same expression is expanded again for the same PlannerInfo, as pullup_replace_vars_callback does with its rv_cache. This makes all expansions of the same join alias share one phid, however the copies came about. --- src/backend/optimizer/plan/planner.c | 1 + src/backend/optimizer/prep/prepjointree.c | 1 + src/backend/optimizer/util/var.c | 29 +++- src/include/nodes/pathnodes.h | 3 + src/test/regress/expected/join.out | 185 ++++++++++++++++++++++ src/test/regress/sql/join.sql | 34 ++++ 6 files changed, 250 insertions(+), 3 deletions(-) diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 8d30131855a..5831959cd72 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -818,6 +818,7 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name, root->append_rel_list = NIL; root->row_identity_vars = NIL; root->rowMarks = NIL; + root->join_alias_phvs = NIL; memset(root->upper_rels, 0, sizeof(root->upper_rels)); memset(root->upper_targets, 0, sizeof(root->upper_targets)); root->processed_groupClause = NIL; diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c index dfe320beccd..7028cca15c9 100644 --- a/src/backend/optimizer/prep/prepjointree.c +++ b/src/backend/optimizer/prep/prepjointree.c @@ -1465,6 +1465,7 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte, subroot->append_rel_list = NIL; subroot->row_identity_vars = NIL; subroot->rowMarks = NIL; + subroot->join_alias_phvs = NIL; memset(subroot->upper_rels, 0, sizeof(subroot->upper_rels)); memset(subroot->upper_targets, 0, sizeof(subroot->upper_targets)); subroot->processed_groupClause = NIL; diff --git a/src/backend/optimizer/util/var.c b/src/backend/optimizer/util/var.c index aca7399cd8c..467efa7851f 100644 --- a/src/backend/optimizer/util/var.c +++ b/src/backend/optimizer/util/var.c @@ -1222,6 +1222,10 @@ mark_nullable_by_grouping(PlannerInfo *root, Node *newnode, Var *oldvar) /* * Add oldvar's varnullingrels, if any, to a flattened join alias expression. * The newnode has been copied, so we can modify it freely. + * + * If we need a PlaceHolderVar, we reuse any PHV already made for the same + * expression with this root, so that all expansions of the same alias get + * the same phid, much as pullup_replace_vars_callback does with its rv_cache. */ static Node * add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) @@ -1240,7 +1244,7 @@ add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) * expression; but if that expression is variable-free, fall back to * evaluating it at the join that the oldvar is an alias Var for. */ - PlaceHolderVar *newphv; + PlaceHolderVar *newphv = NULL; Index levelsup = oldvar->varlevelsup; Relids phrels = pull_varnos_of_level(root, newnode, levelsup); @@ -1253,9 +1257,28 @@ add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) phrels = bms_del_member(phrels, oldvar->varno); Assert(!bms_is_empty(phrels)); } - newphv = make_placeholder_expr(root, (Expr *) newnode, phrels); + + /* Look for an existing PHV, comparing at level zero */ + if (levelsup != 0) + IncrementVarSublevelsUp(newnode, -((int) levelsup), 0); + foreach_node(PlaceHolderVar, phv, root->join_alias_phvs) + { + if (bms_equal(phv->phrels, phrels) && + equal(phv->phexpr, newnode)) + { + newphv = copyObject(phv); + break; + } + } + if (newphv == NULL) + { + newphv = make_placeholder_expr(root, (Expr *) newnode, phrels); + root->join_alias_phvs = lappend(root->join_alias_phvs, + copyObject(newphv)); + } /* newphv has zero phlevelsup and NULL phnullingrels; fix it */ - newphv->phlevelsup = levelsup; + if (levelsup != 0) + IncrementVarSublevelsUp((Node *) newphv, levelsup, 0); newphv->phnullingrels = bms_copy(oldvar->varnullingrels); newnode = (Node *) newphv; } diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 1c6d1fe3d04..6200193430e 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -495,6 +495,9 @@ struct PlannerInfo /* list of PlaceHolderInfos */ List *placeholder_list; + /* PHVs made by flatten_join_alias_vars, with phlevelsup = 0 */ + List *join_alias_phvs; + /* list of AggClauseInfos */ List *agg_clause_list; diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 75544fe6aa3..451294d042b 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -9788,6 +9788,191 @@ select * from generate_series(100,200) g, 123 | 4567890123456789 | 123 (3 rows) +-- lateral UNION ALL referencing a join alias Var that needs a PHV; the +-- appendrel parent and children must share the PHV +-- Here the Var is a whole-row reference to a nullable join. +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral ((select j offset 0) union all select null) ss; + QUERY PLAN +------------------------------------------------------------- + Nested Loop + Output: ((ROW(b.f1, c.f1))) + -> Nested Loop Left Join + Output: (ROW(b.f1, c.f1)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (ROW(b.f1, c.f1)) + -> Nested Loop + Output: ROW(b.f1, c.f1) + -> Seq Scan on public.int4_tbl b + Output: b.f1 + -> Materialize + Output: c.f1 + -> Seq Scan on public.int4_tbl c + Output: c.f1 + -> Append + -> Result + Output: (ROW(b.f1, c.f1)) + -> Result + Output: NULL::record +(21 rows) + +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select j union all select null) ss; + QUERY PLAN +------------------------------------------------------------- + Nested Loop + Output: ((ROW(b.f1, c.f1))) + -> Nested Loop Left Join + Output: (ROW(b.f1, c.f1)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (ROW(b.f1, c.f1)) + -> Nested Loop + Output: ROW(b.f1, c.f1) + -> Seq Scan on public.int4_tbl b + Output: b.f1 + -> Materialize + Output: c.f1 + -> Seq Scan on public.int4_tbl c + Output: c.f1 + -> Append + -> Result + Output: (ROW(b.f1, c.f1)) + -> Result + Output: NULL::record +(21 rows) + +-- Here the Var is a merged column of a full join with a non-Var input +explain (verbose, costs off) +select ss.c from + int4_tbl a left join + ((select f1 + 0 as c from int4_tbl) s full join int4_tbl b(c) using (c)) j + on true, + lateral ((select j.c offset 0) union all select 1) ss; + QUERY PLAN +-------------------------------------------------------------- + Nested Loop + Output: ((COALESCE((int4_tbl.f1 + 0), b.c))) + -> Nested Loop Left Join + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Hash Full Join + Output: COALESCE((int4_tbl.f1 + 0), b.c) + Hash Cond: ((int4_tbl.f1 + 0) = b.c) + -> Seq Scan on public.int4_tbl + Output: int4_tbl.f1 + -> Hash + Output: b.c + -> Seq Scan on public.int4_tbl b + Output: b.c + -> Append + -> Result + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Result + Output: 1 +(22 rows) + +explain (verbose, costs off) +select ss.c from + int4_tbl a left join + ((select f1 + 0 as c from int4_tbl) s full join int4_tbl b(c) using (c)) j + on true, + lateral (select j.c union all select 1) ss; + QUERY PLAN +-------------------------------------------------------------- + Nested Loop + Output: ((COALESCE((int4_tbl.f1 + 0), b.c))) + -> Nested Loop Left Join + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Hash Full Join + Output: COALESCE((int4_tbl.f1 + 0), b.c) + Hash Cond: ((int4_tbl.f1 + 0) = b.c) + -> Seq Scan on public.int4_tbl + Output: int4_tbl.f1 + -> Hash + Output: b.c + -> Seq Scan on public.int4_tbl b + Output: b.c + -> Append + -> Result + Output: (COALESCE((int4_tbl.f1 + 0), b.c)) + -> Result + Output: 1 +(22 rows) + +-- Here the UNION ALL is nested in a pulled-up lateral subquery +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select * from ((select j offset 0) union all select null) s) ss; + QUERY PLAN +------------------------------------------------------------- + Nested Loop + Output: ((ROW(b.f1, c.f1))) + -> Nested Loop Left Join + Output: (ROW(b.f1, c.f1)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (ROW(b.f1, c.f1)) + -> Nested Loop + Output: ROW(b.f1, c.f1) + -> Seq Scan on public.int4_tbl b + Output: b.f1 + -> Materialize + Output: c.f1 + -> Seq Scan on public.int4_tbl c + Output: c.f1 + -> Append + -> Result + Output: (ROW(b.f1, c.f1)) + -> Result + Output: NULL::record +(21 rows) + +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select * from (select j union all select null) s) ss; + QUERY PLAN +------------------------------------------------------------- + Nested Loop + Output: ((ROW(b.f1, c.f1))) + -> Nested Loop Left Join + Output: (ROW(b.f1, c.f1)) + -> Seq Scan on public.int4_tbl a + Output: a.f1 + -> Materialize + Output: (ROW(b.f1, c.f1)) + -> Nested Loop + Output: ROW(b.f1, c.f1) + -> Seq Scan on public.int4_tbl b + Output: b.f1 + -> Materialize + Output: c.f1 + -> Seq Scan on public.int4_tbl c + Output: c.f1 + -> Append + -> Result + Output: (ROW(b.f1, c.f1)) + -> Result + Output: NULL::record +(21 rows) + -- lateral with VALUES explain (costs off) select count(*) from tenk1 a, diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index fb83a96e939..8a81b6b2181 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -3847,6 +3847,40 @@ select * from generate_series(100,200) g, lateral (select * from int8_tbl a where g = q1 union all select * from int8_tbl b where g = q2) ss; +-- lateral UNION ALL referencing a join alias Var that needs a PHV; the +-- appendrel parent and children must share the PHV +-- Here the Var is a whole-row reference to a nullable join. +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral ((select j offset 0) union all select null) ss; +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select j union all select null) ss; +-- Here the Var is a merged column of a full join with a non-Var input +explain (verbose, costs off) +select ss.c from + int4_tbl a left join + ((select f1 + 0 as c from int4_tbl) s full join int4_tbl b(c) using (c)) j + on true, + lateral ((select j.c offset 0) union all select 1) ss; +explain (verbose, costs off) +select ss.c from + int4_tbl a left join + ((select f1 + 0 as c from int4_tbl) s full join int4_tbl b(c) using (c)) j + on true, + lateral (select j.c union all select 1) ss; +-- Here the UNION ALL is nested in a pulled-up lateral subquery +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select * from ((select j offset 0) union all select null) s) ss; +explain (verbose, costs off) +select ss.j from + int4_tbl a left join (int4_tbl b cross join int4_tbl c) j on true, + lateral (select * from (select j union all select null) s) ss; + -- lateral with VALUES explain (costs off) select count(*) from tenk1 a, -- 2.37.1 (Apple Git-137.1)