From 2e51a24910406d77d69b592cbd8dba9a2841950b Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Thu, 17 Sep 2026 15:26:08 +0900
Subject: [PATCH v2 2/2] 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.
---
 src/backend/optimizer/util/var.c   |  26 +--
 src/test/regress/expected/join.out | 256 +++++++++++++++++++++++++++++
 src/test/regress/sql/join.sql      |  80 +++++++++
 3 files changed, 351 insertions(+), 11 deletions(-)

diff --git a/src/backend/optimizer/util/var.c b/src/backend/optimizer/util/var.c
index 7390e8982e4..5e9c525fd3b 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;
@@ -1248,21 +1250,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);
-
-		if (bms_is_empty(phrels))	/* variable-free? */
+		Relids		joinrelids;
+		Relids		phrels;
+
+		/* 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 eb88fb2f616..bd3436c730b 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -4451,6 +4451,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: (ROW())
+               ->  Nested Loop
+                     Output: ROW()
+                     ->  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: ((ROW()))::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 29eca8eda2e..9f9378d7edb 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -1397,6 +1397,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
 --
-- 
2.37.1 (Apple Git-137.1)

