From af54d0b5ad4ef567d9eb30f52ec88219b1901294 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Thu, 12 Nov 2026 13:29:13 +0100
Subject: [PATCH v3-15] Fix FULL JOIN constant propagation with non-strict
 operands

Deriving input restrictions from COALESCE(left, right) = constant can
discard unmatched rows when either operand produces a non-NULL value
for a null-extended input.

Require both operands to be provably null-preserving before deriving
either equality, so that the value remains NULL for null-extended input.
An expression is null-preserving if it contains only strict functions,
and at least one level-zero variable (Var or PlaceHolderVar). COALESCE
clauses and PlaceHolderVar expressions are handled explicitly.

COALESCE is null-preserving iff all it's arguments are.  So for example

    COALESCE(left.a, COALESCE(right.a, right.b)) = K

is null-preserving, while

    COALESCE(left.a, COALESCE(right.a, K)) = K

is not, because the nested COALESCE discards NULL values.

PlaceHolderVars at level zero are automatically null-preserving. Those
represent expressions calculated below the join, so the result will be
nulled no matter the expression.

Add regression coverage for both safe and unsafe operands, and safe
nested FULL JOIN USING cases.

Backpatch to all supported branches.  The issue was likely introduced in
8.1, by commit cc5e80b8d1c4.

Author: Tomas Vondra <tomas@vondra.me>
Reviewed-by: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Reviewed-by: Kirill Reshke <reshkekirill@gmail.com>
Discussion: https://postgr.es/m/0074fbf7-a92c-417a-937a-6b1fc27aed82@vondra.me
Backpatch-through: 15
---
 src/backend/optimizer/path/equivclass.c | 108 ++++++++++++++++++++++
 src/test/regress/expected/join.out      | 115 ++++++++++++++++++++++++
 src/test/regress/sql/join.sql           |  49 ++++++++++
 3 files changed, 272 insertions(+)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index b15f6603dd6..1ebe48fc927 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -64,6 +64,8 @@ static bool reconsider_outer_join_clause(PlannerInfo *root,
 										 bool outer_on_left);
 static bool reconsider_full_join_clause(PlannerInfo *root,
 										RestrictInfo *rinfo);
+static bool is_full_join_expr_null_preserving(Node *node);
+static bool full_join_expr_nonstrict_walker(Node *node, bool *found_nullable);
 static Bitmapset *get_eclass_indexes_for_relids(PlannerInfo *root,
 												Relids relids);
 static Bitmapset *get_common_eclass_indexes(PlannerInfo *root, Relids relids1,
@@ -2258,6 +2260,15 @@ reconsider_full_join_clause(PlannerInfo *root, RestrictInfo *rinfo)
 		  bms_is_subset(right_relids, sjinfo->syn_lefthand)))
 		return false;
 
+	/*
+	 * For an unmatched row, the other input's expression must become NULL.
+	 * Otherwise COALESCE could supply the constant even when this input's
+	 * expression is different, making the derived restriction invalid.
+	 */
+	if (!is_full_join_expr_null_preserving((Node *) leftvar) ||
+		!is_full_join_expr_null_preserving((Node *) rightvar))
+		return false;
+
 	foreach(lc1, root->eq_classes)
 	{
 		EquivalenceClass *cur_ec = (EquivalenceClass *) lfirst(lc1);
@@ -2394,6 +2405,103 @@ reconsider_full_join_clause(PlannerInfo *root, RestrictInfo *rinfo)
 }
 
 
+/*
+ * Check whether an expression becomes NULL when its FULL JOIN input is
+ * null-extended.  The caller has verified that all referenced rels belong
+ * to one input.
+ *
+ * We conservatively require that the expression contains no non-strict
+ * constructs, and at least one level-zero Var or PlaceHolderVar.  The latter
+ * test rules out non-NULL constants and pseudoconstants appearing as COALESCE
+ * arguments.
+ *
+ * A level-zero PlaceHolderVar is evaluated below the join, so its result is
+ * nulled regardless of the contained expression, and we don't look inside
+ * it.  COALESCE is not strict in any one argument, but preserves NULL if all
+ * its arguments do.
+ */
+static bool
+is_full_join_expr_null_preserving(Node *node)
+{
+	bool		found_nullable = false;
+
+	if (full_join_expr_nonstrict_walker(node, &found_nullable))
+		return false;
+
+	return found_nullable;
+}
+
+/*
+ * Walker for is_full_join_expr_null_preserving.  Returns true if it finds a
+ * construct that might produce non-NULL output for a null-extended input,
+ * and sets *found_nullable if it finds an input nulled by the join.
+ */
+static bool
+full_join_expr_nonstrict_walker(Node *node, bool *found_nullable)
+{
+	if (node == NULL)
+		return false;
+
+	switch (nodeTag(node))
+	{
+		case T_Var:
+			if (((Var *) node)->varlevelsup == 0)
+				*found_nullable = true;
+			return false;
+
+		case T_PlaceHolderVar:
+			if (((PlaceHolderVar *) node)->phlevelsup == 0)
+			{
+				/* don't descend into the contained expression */
+				*found_nullable = true;
+				return false;
+			}
+			break;
+
+		case T_CoalesceExpr:
+			{
+				CoalesceExpr *cexpr = (CoalesceExpr *) node;
+				ListCell   *lc;
+
+				foreach(lc, cexpr->args)
+				{
+					if (!is_full_join_expr_null_preserving(lfirst(lc)))
+						return true;
+				}
+				*found_nullable = true;
+				return false;
+			}
+
+		case T_FuncExpr:
+			if (!func_strict(((FuncExpr *) node)->funcid))
+				return true;
+			break;
+
+		case T_OpExpr:
+			if (!op_strict(((OpExpr *) node)->opno))
+				return true;
+			break;
+
+		case T_RelabelType:
+		case T_CoerceViaIO:
+			/* treated as strict, as in contain_nonstrict_functions() */
+			break;
+
+		default:
+
+			/*
+			 * For anything else, rely on the generic tests.  Those look
+			 * inside PlaceHolderVars, so they may give up unnecessarily.
+			 */
+			if (contain_vars_of_level(node, 0))
+				*found_nullable = true;
+			return contain_nonstrict_functions(node);
+	}
+
+	return expression_tree_walker(node, full_join_expr_nonstrict_walker,
+								  found_nullable);
+}
+
 /*
  * exprs_known_equal
  *	  Detect whether two expressions are known equal due to equivalence
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 7c68e4bd2b6..cee1fe1638a 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -6653,6 +6653,121 @@ ERROR:  invalid reference to FROM-clause entry for table "xx1"
 LINE 1: ...xx1 using lateral (select * from int4_tbl where f1 = x1) ss;
                                                                 ^
 HINT:  There is an entry for table "xx1", but it cannot be referenced from this part of the query.
+-- non-strict operands must not cause unmatched rows to be filtered out
+explain (costs off)
+select * from (values (null::int), (1)) a(x)
+  full join (values (0), (1)) b(x) on a.x = coalesce(b.x, 0)
+  where coalesce(a.x, coalesce(b.x, 0)) = 0
+  order by a.x, b.x;
+                                      QUERY PLAN                                       
+---------------------------------------------------------------------------------------
+ Sort
+   Sort Key: "*VALUES*".column1, "*VALUES*_1".column1
+   ->  Hash Full Join
+         Hash Cond: ("*VALUES*".column1 = COALESCE("*VALUES*_1".column1, 0))
+         Filter: (COALESCE("*VALUES*".column1, COALESCE("*VALUES*_1".column1, 0)) = 0)
+         ->  Values Scan on "*VALUES*"
+         ->  Hash
+               ->  Values Scan on "*VALUES*_1"
+(8 rows)
+
+select * from (values (null::int), (1)) a(x)
+  full join (values (0), (1)) b(x) on a.x = coalesce(b.x, 0)
+  where coalesce(a.x, coalesce(b.x, 0)) = 0
+  order by a.x, b.x;
+ x | x 
+---+---
+   | 0
+   |  
+(2 rows)
+
+explain (costs off)
+select * from (values (0), (1)) a(x)
+  full join (values (null::int), (1)) b(x)
+    on (case when a.x is null then 0 else a.x end) = b.x
+  where coalesce(case when a.x is null then 0 else a.x end, b.x) = 0
+  order by a.x, b.x;
+                                                           QUERY PLAN                                                            
+---------------------------------------------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: "*VALUES*".column1, "*VALUES*_1".column1
+   ->  Hash Full Join
+         Hash Cond: (CASE WHEN ("*VALUES*".column1 IS NULL) THEN 0 ELSE "*VALUES*".column1 END = "*VALUES*_1".column1)
+         Filter: (COALESCE(CASE WHEN ("*VALUES*".column1 IS NULL) THEN 0 ELSE "*VALUES*".column1 END, "*VALUES*_1".column1) = 0)
+         ->  Values Scan on "*VALUES*"
+         ->  Hash
+               ->  Values Scan on "*VALUES*_1"
+(8 rows)
+
+select * from (values (0), (1)) a(x)
+  full join (values (null::int), (1)) b(x)
+    on (case when a.x is null then 0 else a.x end) = b.x
+  where coalesce(case when a.x is null then 0 else a.x end, b.x) = 0
+  order by a.x, b.x;
+ x | x 
+---+---
+ 0 |  
+   |  
+(2 rows)
+
+-- nested merged columns and precomputed expressions remain null-preserving
+explain (costs off)
+select * from (select coalesce(f1, 0) as f1 from int4_tbl) a
+  full join int4_tbl b using (f1)
+  full join int4_tbl c using (f1)
+  where f1 = 0;
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
+ Hash Full Join
+   Hash Cond: (COALESCE((COALESCE(int4_tbl.f1, 0)), b.f1) = c.f1)
+   Filter: (COALESCE(COALESCE((COALESCE(int4_tbl.f1, 0)), b.f1), c.f1) = 0)
+   ->  Hash Full Join
+         Hash Cond: ((COALESCE(int4_tbl.f1, 0)) = b.f1)
+         ->  Seq Scan on int4_tbl
+         ->  Hash
+               ->  Seq Scan on int4_tbl b
+   ->  Hash
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(11 rows)
+
+select * from (select coalesce(f1, 0) as f1 from int4_tbl) a
+  full join int4_tbl b using (f1)
+  full join int4_tbl c using (f1)
+  where f1 = 0;
+ f1 
+----
+  0
+(1 row)
+
+-- strict expressions of precomputed values remain null-preserving
+explain (costs off)
+select * from (select coalesce(x, 0) as x from (values (null::int), (1)) v(x)) a
+  full join (values (1), (2)) b(x) on a.x + 0 = b.x
+  where coalesce(a.x + 0, b.x) = 0
+  order by a.x, b.x;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: (COALESCE("*VALUES*".column1, 0)), "*VALUES*_1".column1
+   ->  Hash Full Join
+         Hash Cond: (((COALESCE("*VALUES*".column1, 0)) + 0) = "*VALUES*_1".column1)
+         Filter: (COALESCE(((COALESCE("*VALUES*".column1, 0)) + 0), "*VALUES*_1".column1) = 0)
+         ->  Values Scan on "*VALUES*"
+         ->  Hash
+               ->  Values Scan on "*VALUES*_1"
+                     Filter: (column1 = 0)
+(9 rows)
+
+select * from (select coalesce(x, 0) as x from (values (null::int), (1)) v(x)) a
+  full join (values (1), (2)) b(x) on a.x + 0 = b.x
+  where coalesce(a.x + 0, b.x) = 0
+  order by a.x, b.x;
+ x | x 
+---+---
+ 0 |  
+(1 row)
+
 --
 -- test LATERAL reference propagation down a multi-level inheritance hierarchy
 -- produced for a multi-level partitioned table hierarchy.
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index affe1c4cbaf..196379bc073 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -2313,6 +2313,55 @@ delete from xx1 using (select * from int4_tbl where f1 = x1) ss;
 delete from xx1 using (select * from int4_tbl where f1 = xx1.x1) ss;
 delete from xx1 using lateral (select * from int4_tbl where f1 = x1) ss;
 
+-- non-strict operands must not cause unmatched rows to be filtered out
+explain (costs off)
+select * from (values (null::int), (1)) a(x)
+  full join (values (0), (1)) b(x) on a.x = coalesce(b.x, 0)
+  where coalesce(a.x, coalesce(b.x, 0)) = 0
+  order by a.x, b.x;
+
+select * from (values (null::int), (1)) a(x)
+  full join (values (0), (1)) b(x) on a.x = coalesce(b.x, 0)
+  where coalesce(a.x, coalesce(b.x, 0)) = 0
+  order by a.x, b.x;
+
+explain (costs off)
+select * from (values (0), (1)) a(x)
+  full join (values (null::int), (1)) b(x)
+    on (case when a.x is null then 0 else a.x end) = b.x
+  where coalesce(case when a.x is null then 0 else a.x end, b.x) = 0
+  order by a.x, b.x;
+
+select * from (values (0), (1)) a(x)
+  full join (values (null::int), (1)) b(x)
+    on (case when a.x is null then 0 else a.x end) = b.x
+  where coalesce(case when a.x is null then 0 else a.x end, b.x) = 0
+  order by a.x, b.x;
+
+-- nested merged columns and precomputed expressions remain null-preserving
+explain (costs off)
+select * from (select coalesce(f1, 0) as f1 from int4_tbl) a
+  full join int4_tbl b using (f1)
+  full join int4_tbl c using (f1)
+  where f1 = 0;
+
+select * from (select coalesce(f1, 0) as f1 from int4_tbl) a
+  full join int4_tbl b using (f1)
+  full join int4_tbl c using (f1)
+  where f1 = 0;
+
+-- strict expressions of precomputed values remain null-preserving
+explain (costs off)
+select * from (select coalesce(x, 0) as x from (values (null::int), (1)) v(x)) a
+  full join (values (1), (2)) b(x) on a.x + 0 = b.x
+  where coalesce(a.x + 0, b.x) = 0
+  order by a.x, b.x;
+
+select * from (select coalesce(x, 0) as x from (values (null::int), (1)) v(x)) a
+  full join (values (1), (2)) b(x) on a.x + 0 = b.x
+  where coalesce(a.x + 0, b.x) = 0
+  order by a.x, b.x;
+
 --
 -- test LATERAL reference propagation down a multi-level inheritance hierarchy
 -- produced for a multi-level partitioned table hierarchy.
-- 
2.56.0

