From ba8606455466827cc85af6d92bd6e3a39580c500 Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Wed, 16 Sep 2026 18:13:02 +0900 Subject: [PATCH v3] Fix mismatched PHVs for join alias Vars flattened more than once When a join alias Var with nonempty varnullingrels expands to an expression that cannot carry the nullingrels itself, add_nullingrels_if_needed wraps it in a PlaceHolderVar with a new phid. If copies of the same unexpanded alias Var are flattened by separate flatten_join_alias_vars calls, each call makes its own PHV, so the copies no longer match. Only one of the PHVs then gets a PlaceHolderInfo, and the others lead to "too late to create a new PlaceHolderInfo" errors when creating the plan. Such copies can arise in several ways. When a LATERAL UNION ALL subquery is pulled up as an appendrel, its lateral references live on both 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. A PHV made by pullup_replace_vars for a lateral reference can be copied into both the targetlist and the quals of the query, which are flattened separately. And pull_up_simple_subquery flattens the subquery's targetlist, and its jointree quals if it is LATERAL, using the subroot, while the rest of the subquery is flattened later by subquery_planner using the parent root. 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 with the same phrels, as pullup_replace_vars_callback does with its rv_cache. When pull_up_simple_subquery pulls up a subquery, adjust the subroot's list in the same way as its append_rel_list and add it to the parent's, so that the parent's flattening reuses the subroot's PHVs. This makes all expansions of the same join alias share one phid, however the copies came about. This bug dates back to v16, where add_nullingrels_if_needed was introduced. However, the fix adds a new field to PlannerInfo, which would break ABI compatibility in released branches. Given that, and the lack of field reports, back-patch only to v19, which has not been released yet. Author: Richard Guo Reviewed-by: Tom Lane Discussion: https://postgr.es/m/CAMbWs4_tg90KxNVdABpBnx2oGBQTCAHAV45mPEfCdKvo11ZEVQ@mail.gmail.com Backpatch-through: 19 --- src/backend/optimizer/plan/planner.c | 1 + src/backend/optimizer/prep/prepjointree.c | 15 +- src/backend/optimizer/util/var.c | 30 ++- src/include/nodes/pathnodes.h | 3 + src/test/regress/expected/join.out | 216 ++++++++++++++++++++++ src/test/regress/sql/join.sql | 43 +++++ 6 files changed, 303 insertions(+), 5 deletions(-) diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index edaa42d713a..233b13f4cdf 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -819,6 +819,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 28921328abc..92efaacb303 100644 --- a/src/backend/optimizer/prep/prepjointree.c +++ b/src/backend/optimizer/prep/prepjointree.c @@ -1467,6 +1467,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; @@ -1575,12 +1576,13 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte, /* * Adjust level-0 varnos in subquery so that we can append its rangetable - * to upper query's. We have to fix the subquery's append_rel_list as - * well. + * to upper query's. We have to fix the subquery's append_rel_list and + * join_alias_phvs as well. */ rtoffset = list_length(parse->rtable); OffsetVarNodes((Node *) subquery, rtoffset, 0); OffsetVarNodes((Node *) subroot->append_rel_list, rtoffset, 0); + OffsetVarNodes((Node *) subroot->join_alias_phvs, rtoffset, 0); /* * Upper-level vars in subquery are now one level closer to their parent @@ -1588,6 +1590,7 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte, */ IncrementVarSublevelsUp((Node *) subquery, -1, 1); IncrementVarSublevelsUp((Node *) subroot->append_rel_list, -1, 1); + IncrementVarSublevelsUp((Node *) subroot->join_alias_phvs, -1, 1); /* * The subquery's targetlist items are now in the appropriate form to @@ -1716,6 +1719,14 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte, root->append_rel_list = list_concat(root->append_rel_list, subroot->append_rel_list); + /* + * Also add the PHVs made while flattening the subquery's join alias Vars, + * so that the parent's flattening of the rest of the subquery reuses them + * rather than making new PHVs with different phids. + */ + root->join_alias_phvs = list_concat(root->join_alias_phvs, + subroot->join_alias_phvs); + /* * We don't have to do the equivalent bookkeeping for outer-join info, * because that hasn't been set up yet. placeholder_list likewise. diff --git a/src/backend/optimizer/util/var.c b/src/backend/optimizer/util/var.c index 337e0306752..b7474ffd542 100644 --- a/src/backend/optimizer/util/var.c +++ b/src/backend/optimizer/util/var.c @@ -1238,6 +1238,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) @@ -1257,7 +1261,7 @@ add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) * join; but if that leaves nothing, 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 joinrelids; Relids phrels; @@ -1271,9 +1275,29 @@ add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) /* Keep the join's own OJ relid: this set spans both its sides */ phrels = joinrelids; } - 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 ff46a9f7419..ce22529bec0 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 4ec9291833b..5755af344bc 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -10730,6 +10730,222 @@ explain (costs off) -> Seq Scan on int8_tbl (8 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) + +-- join alias Var needing a PHV, referenced in both the tlist and the quals of +-- a pulled-up subquery; both references must share the PHV +explain (verbose, costs off) +select * from + (select v.k from int4_tbl a + left join (int4_tbl b cross join int4_tbl c) j on true + left join lateral (select j as k from int4_tbl d) v on true + where v.k is not 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 + -> Seq Scan on public.int4_tbl d + Output: (ROW(b.f1, c.f1)) + Filter: ((ROW(b.f1, c.f1)) IS NOT NULL) +(19 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 381c57a5a88..4a9910a0b27 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -4171,6 +4171,49 @@ explain (costs off) lateral (select x.q1 from (select distinct q2 from int8_tbl) s union all select q2 from int8_tbl) ss(v); +-- 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; + +-- join alias Var needing a PHV, referenced in both the tlist and the quals of +-- a pulled-up subquery; both references must share the PHV +explain (verbose, costs off) +select * from + (select v.k from int4_tbl a + left join (int4_tbl b cross join int4_tbl c) j on true + left join lateral (select j as k from int4_tbl d) v on true + where v.k is not null) ss; + -- lateral with VALUES explain (costs off) select count(*) from tenk1 a, -- 2.37.1 (Apple Git-137.1)