From 5f49c6db1179be27f367fc4b6781ada83db59166 Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Wed, 7 Oct 2026 14:36:34 +0900 Subject: [PATCH] Fix PlaceHolderVar placement for join alias expressions When a join alias Var with nonempty varnullingrels expands to an expression that cannot carry the nullingrels itself, such as the RowExpr of a whole-row Var, add_nullingrels_if_needed() wraps it in a PlaceHolderVar. The choice of the PHV's phrels had several problems. First, phrels was taken from all Vars in the expression. If a LATERAL subquery within the join has been pulled up, the expression can contain lateral references to rels outside the join, so the PHV could be evaluated above the outer join that is supposed to null it. This gave wrong results, or "wrong phnullingrels" errors. Second, for a variable-free expression we fell back to the join's relids but removed the join's own outer-join relid. That leaves a set spanning both sides of the outer join without the join itself, which can make the PHV look nullable by that join and cause "wrong phnullingrels" errors. Third, that fallback raised "unsupported join alias expression" when the Var is an outer reference from a subquery. The restriction is unnecessary: every planner caller passes root->parse as the query, so the Var always belongs to root->parse regardless of its varlevelsup. To fix, restrict phrels to the rels within the join, and if nothing is left, evaluate the PHV at the join including its outer-join relid, at any varlevelsup. Add an assertion that the query is root->parse. Author: Richard Guo Discussion: https://postgr.es/m/CAMbWs49q_gvoeOoF0+KZ-DpQaDb=nGFfSgrz2V_n1_64L4ZNVQ@mail.gmail.com Backpatch-through: 16 --- src/backend/optimizer/util/var.c | 24 +-- src/test/regress/expected/join.out | 256 +++++++++++++++++++++++++++++ src/test/regress/sql/join.sql | 80 +++++++++ 3 files changed, 350 insertions(+), 10 deletions(-) diff --git a/src/backend/optimizer/util/var.c b/src/backend/optimizer/util/var.c index d14d80973e4..337e0306752 100644 --- a/src/backend/optimizer/util/var.c +++ b/src/backend/optimizer/util/var.c @@ -791,6 +791,8 @@ flatten_join_alias_vars(PlannerInfo *root, Query *query, Node *node) * it's okay to immediately increment sublevels_up. */ Assert(node != (Node *) query); + /* add_nullingrels_if_needed relies on this */ + Assert(root == NULL || query == root->parse); context.root = root; context.query = query; @@ -1251,21 +1253,23 @@ add_nullingrels_if_needed(PlannerInfo *root, Node *newnode, Var *oldvar) * We can insert a PlaceHolderVar to carry the nullingrels. However, * deciding where to evaluate the PHV is slightly tricky. We first * try to evaluate it at the natural semantic level of the new - * expression; but if that expression is variable-free, fall back to - * evaluating it at the join that the oldvar is an alias Var for. + * expression, ignoring any lateral references to rels outside the + * join; but if that leaves nothing, fall back to evaluating it at the + * join that the oldvar is an alias Var for. */ PlaceHolderVar *newphv; Index levelsup = oldvar->varlevelsup; - Relids phrels = pull_varnos_of_level(root, newnode, levelsup); + Relids joinrelids; + Relids phrels; - if (bms_is_empty(phrels)) /* variable-free? */ + /* oldvar belongs to root->parse even when levelsup > 0 */ + joinrelids = get_relids_for_join(root->parse, oldvar->varno); + phrels = pull_varnos_of_level(root, newnode, levelsup); + phrels = bms_int_members(phrels, joinrelids); + if (bms_is_empty(phrels)) { - if (levelsup != 0) /* this won't work otherwise */ - elog(ERROR, "unsupported join alias expression"); - phrels = get_relids_for_join(root->parse, oldvar->varno); - /* If it's an outer join, eval below not above the join */ - phrels = bms_del_member(phrels, oldvar->varno); - Assert(!bms_is_empty(phrels)); + /* Keep the join's own OJ relid: this set spans both its sides */ + phrels = joinrelids; } newphv = make_placeholder_expr(root, (Expr *) newnode, phrels); /* newphv has zero phlevelsup and NULL phnullingrels; fix it */ diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index a70d110769e..0d9cb75f216 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -3678,6 +3678,262 @@ group by t23 order by 1; | 2 (6 rows) +-- nulled whole-row Var of a zero-column join, referenced from a subquery +explain (verbose, costs off) +select t1.q1, t1.q2, (select t23::text) +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + cross join (select from int4_tbl where f1 = 0) t3) t23 + on (t1.q1 = 123) +order by 1, 2; + QUERY PLAN +---------------------------------------------------------------- + Sort + Output: t1.q1, t1.q2, ((SubPlan expr_1)) + Sort Key: t1.q1, t1.q2 + -> Nested Loop Left Join + Output: t1.q1, t1.q2, (SubPlan expr_1) + Join Filter: (t1.q1 = 123) + -> Seq Scan on public.int8_tbl t1 + Output: t1.q1, t1.q2 + -> Materialize + Output: ('()'::record) + -> Nested Loop + Output: '()'::record + -> Seq Scan on public.int4_tbl + Output: int4_tbl.f1 + Filter: (int4_tbl.f1 = 0) + -> Seq Scan on public.int4_tbl int4_tbl_1 + Output: int4_tbl_1.f1 + Filter: (int4_tbl_1.f1 = 0) + SubPlan expr_1 + -> Result + Output: (('()'::record))::text +(21 rows) + +select t1.q1, t1.q2, (select t23::text) +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + cross join (select from int4_tbl where f1 = 0) t3) t23 + on (t1.q1 = 123) +order by 1, 2; + q1 | q2 | t23 +------------------+-------------------+----- + 123 | 456 | () + 123 | 4567890123456789 | () + 4567890123456789 | -4567890123456789 | + 4567890123456789 | 123 | + 4567890123456789 | 4567890123456789 | +(5 rows) + +-- nulled whole-row Var of a zero-column outer join +explain (verbose, costs off) +select t1.q1, t1.q2, f +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + left join (select from int4_tbl where f1 = 0) t3 on true) t23 + on (t1.q1 = 123), + length(t23::text) f +order by 1, 2; + QUERY PLAN +---------------------------------------------------------------------- + Sort + Output: t1.q1, t1.q2, f.f + Sort Key: t1.q1, t1.q2 + -> Nested Loop + Output: t1.q1, t1.q2, f.f + -> Nested Loop Left Join + Output: t1.q1, t1.q2, ('()'::record) + Join Filter: (t1.q1 = 123) + -> Seq Scan on public.int8_tbl t1 + Output: t1.q1, t1.q2 + -> Materialize + Output: ('()'::record) + -> Nested Loop Left Join + Output: '()'::record + -> Seq Scan on public.int4_tbl + Output: int4_tbl.f1 + Filter: (int4_tbl.f1 = 0) + -> Seq Scan on public.int4_tbl int4_tbl_1 + Output: int4_tbl_1.f1 + Filter: (int4_tbl_1.f1 = 0) + -> Function Scan on pg_catalog.length f + Output: f.f + Function Call: length((('()'::record))::text) +(23 rows) + +select t1.q1, t1.q2, f +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + left join (select from int4_tbl where f1 = 0) t3 on true) t23 + on (t1.q1 = 123), + length(t23::text) f +order by 1, 2; + q1 | q2 | f +------------------+-------------------+--- + 123 | 456 | 2 + 123 | 4567890123456789 | 2 + 4567890123456789 | -4567890123456789 | + 4567890123456789 | 123 | + 4567890123456789 | 4567890123456789 | +(5 rows) + +-- nulled whole-row Var of a join with a variable-free merged column +explain (verbose, costs off) +select 1 from (unnest(array[1, (select sum(f1) from int4_tbl)]) as u(b) + right join (values (1)) as v(b) using (b) + full join int8_tbl i8 on true) as j, + length(j::text) f; + QUERY PLAN +--------------------------------------------------------------------------------------- + Nested Loop + Output: 1 + InitPlan expr_1 + -> Aggregate + Output: sum(int4_tbl.f1) + -> Seq Scan on public.int4_tbl + Output: int4_tbl.f1 + -> Merge Full Join + Output: ('1'::bigint), i8.q1, i8.q2 + -> Nested Loop Left Join + Output: '1'::bigint + -> Result + -> Function Scan on pg_catalog.unnest u + Output: u.b + Function Call: unnest(ARRAY['1'::bigint, (InitPlan expr_1).col1]) + Filter: (u.b = 1) + -> Materialize + Output: i8.q1, i8.q2 + -> Seq Scan on public.int8_tbl i8 + Output: i8.q1, i8.q2 + -> Function Scan on pg_catalog.length f + Output: f.f + Function Call: length((ROW(('1'::bigint), i8.q1, i8.q2))::text) +(23 rows) + +select 1 from (unnest(array[1, (select sum(f1) from int4_tbl)]) as u(b) + right join (values (1)) as v(b) using (b) + full join int8_tbl i8 on true) as j, + length(j::text) f; + ?column? +---------- + 1 + 1 + 1 + 1 + 1 +(5 rows) + +-- nulled whole-row Var of a join containing a lateral reference +explain (verbose, costs off) +select (j is null) from int4_tbl i4 + left join (int8_tbl i8 join lateral (select i4.f1 from (values (3)) v) ss(x) on true) as j + on false; + QUERY PLAN +------------------------------------------------ + Nested Loop Left Join + Output: ((ROW(i8.q1, i8.q2, i4.f1)) IS NULL) + Join Filter: false + -> Seq Scan on public.int4_tbl i4 + Output: i4.f1 + -> Result + Output: ROW(i8.q1, i8.q2, i4.f1) + Replaces: Scan on i8 + One-Time Filter: false +(9 rows) + +select (j is null) from int4_tbl i4 + left join (int8_tbl i8 join lateral (select i4.f1 from (values (3)) v) ss(x) on true) as j + on false; + ?column? +---------- + t + t + t + t + t +(5 rows) + +-- same, but the PHV must be passed up through another join +explain (verbose, costs off) +select j from int4_tbl i4 + left join (lateral (values (i4.f1)) as v(a) cross join int8_tbl i8) as j + on (j.q1 = 123 and j.q2 = 456), + int4_tbl i4b +where i4.f1 = 0 +order by 1; + QUERY PLAN +--------------------------------------------------------------- + Sort + Output: (ROW(i4.f1, i8.q1, i8.q2)) + Sort Key: (ROW(i4.f1, i8.q1, i8.q2)) + -> Nested Loop + Output: (ROW(i4.f1, i8.q1, i8.q2)) + -> Nested Loop Left Join + Output: (ROW(i4.f1, i8.q1, i8.q2)) + -> Seq Scan on public.int4_tbl i4 + Output: i4.f1 + Filter: (i4.f1 = 0) + -> Seq Scan on public.int8_tbl i8 + Output: ROW(i4.f1, i8.q1, i8.q2) + Filter: ((i8.q1 = 123) AND (i8.q2 = 456)) + -> Seq Scan on public.int4_tbl i4b + Output: i4b.f1 +(15 rows) + +select j from int4_tbl i4 + left join (lateral (values (i4.f1)) as v(a) cross join int8_tbl i8) as j + on (j.q1 = 123 and j.q2 = 456), + int4_tbl i4b +where i4.f1 = 0 +order by 1; + j +------------- + (0,123,456) + (0,123,456) + (0,123,456) + (0,123,456) + (0,123,456) +(5 rows) + +-- same, referenced from a subquery, with only lateral references and constants +explain (verbose, costs off) +select i4.f1, (select j::text) +from int4_tbl i4 + left join (lateral (select i4.f1) ss(x) cross join (select 1) s2(y)) j + on (i4.f1 = 0) +order by 1; + QUERY PLAN +------------------------------------------------- + Sort + Output: i4.f1, ((SubPlan expr_1)) + Sort Key: i4.f1 + -> Nested Loop Left Join + Output: i4.f1, (SubPlan expr_1) + Join Filter: (i4.f1 = 0) + -> Seq Scan on public.int4_tbl i4 + Output: i4.f1 + -> Result + Output: ROW(i4.f1, 1) + SubPlan expr_1 + -> Result + Output: ((ROW(i4.f1, 1)))::text +(13 rows) + +select i4.f1, (select j::text) +from int4_tbl i4 + left join (lateral (select i4.f1) ss(x) cross join (select 1) s2(y)) j + on (i4.f1 = 0) +order by 1; + f1 | j +-------------+------- + -2147483647 | + -123456 | + 0 | (0,1) + 123456 | + 2147483647 | +(5 rows) + -- -- test incorrect failure to NULL pulled-up subexpressions -- diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index 268bd65a770..d723003d48c 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -1108,6 +1108,86 @@ from int8_tbl t1 left join on (t1.q2 = t23.q1) group by t23 order by 1; +-- nulled whole-row Var of a zero-column join, referenced from a subquery +explain (verbose, costs off) +select t1.q1, t1.q2, (select t23::text) +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + cross join (select from int4_tbl where f1 = 0) t3) t23 + on (t1.q1 = 123) +order by 1, 2; +select t1.q1, t1.q2, (select t23::text) +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + cross join (select from int4_tbl where f1 = 0) t3) t23 + on (t1.q1 = 123) +order by 1, 2; + +-- nulled whole-row Var of a zero-column outer join +explain (verbose, costs off) +select t1.q1, t1.q2, f +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + left join (select from int4_tbl where f1 = 0) t3 on true) t23 + on (t1.q1 = 123), + length(t23::text) f +order by 1, 2; +select t1.q1, t1.q2, f +from int8_tbl t1 left join + ((select from int4_tbl where f1 = 0) t2 + left join (select from int4_tbl where f1 = 0) t3 on true) t23 + on (t1.q1 = 123), + length(t23::text) f +order by 1, 2; + +-- nulled whole-row Var of a join with a variable-free merged column +explain (verbose, costs off) +select 1 from (unnest(array[1, (select sum(f1) from int4_tbl)]) as u(b) + right join (values (1)) as v(b) using (b) + full join int8_tbl i8 on true) as j, + length(j::text) f; +select 1 from (unnest(array[1, (select sum(f1) from int4_tbl)]) as u(b) + right join (values (1)) as v(b) using (b) + full join int8_tbl i8 on true) as j, + length(j::text) f; + +-- nulled whole-row Var of a join containing a lateral reference +explain (verbose, costs off) +select (j is null) from int4_tbl i4 + left join (int8_tbl i8 join lateral (select i4.f1 from (values (3)) v) ss(x) on true) as j + on false; +select (j is null) from int4_tbl i4 + left join (int8_tbl i8 join lateral (select i4.f1 from (values (3)) v) ss(x) on true) as j + on false; + +-- same, but the PHV must be passed up through another join +explain (verbose, costs off) +select j from int4_tbl i4 + left join (lateral (values (i4.f1)) as v(a) cross join int8_tbl i8) as j + on (j.q1 = 123 and j.q2 = 456), + int4_tbl i4b +where i4.f1 = 0 +order by 1; +select j from int4_tbl i4 + left join (lateral (values (i4.f1)) as v(a) cross join int8_tbl i8) as j + on (j.q1 = 123 and j.q2 = 456), + int4_tbl i4b +where i4.f1 = 0 +order by 1; + +-- same, referenced from a subquery, with only lateral references and constants +explain (verbose, costs off) +select i4.f1, (select j::text) +from int4_tbl i4 + left join (lateral (select i4.f1) ss(x) cross join (select 1) s2(y)) j + on (i4.f1 = 0) +order by 1; +select i4.f1, (select j::text) +from int4_tbl i4 + left join (lateral (select i4.f1) ss(x) cross join (select 1) s2(y)) j + on (i4.f1 = 0) +order by 1; + -- -- test incorrect failure to NULL pulled-up subexpressions --