From 924a587d8524dea432b6b3435bbad72aea9ffd05 Mon Sep 17 00:00:00 2001
From: zhenglong li <smartkeyerror@gmail.com>
Date: Mon, 24 Aug 2026 17:53:36 +0800
Subject: [PATCH v1] Fix planner's strictness check for JsonExpr

contain_nonstrict_functions_walker() had no case for JsonExpr, so the
SQL/JSON query functions JSON_EXISTS, JSON_QUERY and JSON_VALUE were
implicitly treated as strict.  They are not: while a NULL context item
or path specification does yield a NULL result, a NULL PASSING argument
merely becomes a jsonpath variable containing a JSON null, so the
result can still be non-NULL.  Likewise, inputs appearing only within
the ON EMPTY / ON ERROR expressions need not affect the result at all.

This could lead to wrong results in at least two ways.  First,
inline_function() could inline a STRICT SQL function whose body isn't
actually strict in the function's parameters, so that calls with NULL
arguments returned non-NULL instead of NULL.  Second, when pulling up
a subquery underneath an outer join, pullup_replace_vars() could
decide that such an expression needs no PlaceHolderVar, allowing it to
be evaluated above the join and return non-NULL for unmatched rows,
where the correct result is NULL.

Fix by treating JsonExpr as non-strict, as we already do for CASE,
COALESCE and similar constructs.  This is conservative -- a JsonExpr
with no PASSING arguments and constant ON EMPTY / ON ERROR behaviors
is in fact strict -- but distinguishing those cases hardly seems worth
the trouble.
---
 src/backend/optimizer/util/clauses.c             | 13 +++++++++++++
 src/test/regress/expected/sqljson_queryfuncs.out | 12 ++++++++++++
 src/test/regress/sql/sqljson_queryfuncs.sql      |  9 +++++++++
 3 files changed, 34 insertions(+)

diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 8da4ed617b5..a87f4037780 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -1147,6 +1147,19 @@ contain_nonstrict_functions_walker(Node *node, void *context)
 		return true;
 	else if (IsA(node, JsonConstructorExpr))
 		return true;
+	else if (IsA(node, JsonExpr))
+	{
+		/*
+		 * JSON_EXISTS, JSON_QUERY, and JSON_VALUE are strict with respect to
+		 * their context item, but not their PASSING arguments: a NULL
+		 * PASSING value merely becomes a jsonpath variable containing a JSON
+		 * null, so the result can still be non-NULL.  Likewise, inputs
+		 * appearing only within the ON EMPTY/ON ERROR expressions need not
+		 * affect the result at all.  So we must treat the whole construct as
+		 * non-strict.
+		 */
+		return true;
+	}
 	else
 	{
 		/* Check other function-containing nodes */
diff --git a/src/test/regress/expected/sqljson_queryfuncs.out b/src/test/regress/expected/sqljson_queryfuncs.out
index ff64dce0c59..74e3f2aff0e 100644
--- a/src/test/regress/expected/sqljson_queryfuncs.out
+++ b/src/test/regress/expected/sqljson_queryfuncs.out
@@ -1557,3 +1557,15 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 ERROR:  bit string length 4 does not match type bit(3)
 DROP DOMAIN queryfuncs_d_varbit3;
+-- Test that the planner treats JsonExpr as non-strict: JSON_VALUE() etc. can
+-- return non-NULL even when their PASSING arguments are NULL.
+-- When pulling up a subquery underneath an outer join, its JsonExpr output
+-- must be wrapped in a PlaceHolderVar; this must return NULL, not '1'.
+SELECT v FROM (VALUES (1)) a
+LEFT JOIN (SELECT JSON_VALUE('1', '$' PASSING y AS p) v
+           FROM (VALUES (1), (2)) b(y)) ss ON false;
+ v 
+---
+ 
+(1 row)
+
diff --git a/src/test/regress/sql/sqljson_queryfuncs.sql b/src/test/regress/sql/sqljson_queryfuncs.sql
index a69ef253f66..f1d092bbf33 100644
--- a/src/test/regress/sql/sqljson_queryfuncs.sql
+++ b/src/test/regress/sql/sqljson_queryfuncs.sql
@@ -519,3 +519,12 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1 ON ERROR);
 SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR);
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 DROP DOMAIN queryfuncs_d_varbit3;
+
+-- Test that the planner treats JsonExpr as non-strict: JSON_VALUE() etc. can
+-- return non-NULL even when their PASSING arguments are NULL.
+
+-- When pulling up a subquery underneath an outer join, its JsonExpr output
+-- must be wrapped in a PlaceHolderVar; this must return NULL, not '1'.
+SELECT v FROM (VALUES (1)) a
+LEFT JOIN (SELECT JSON_VALUE('1', '$' PASSING y AS p) v
+           FROM (VALUES (1), (2)) b(y)) ss ON false;
-- 
2.50.1 (Apple Git-155)

