From 8bce7e3bc75b9c3576f589fad6c6294042ca5ed5 Mon Sep 17 00:00:00 2001
From: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Date: Mon, 27 Jul 2026 17:35:49 +0530
Subject: [PATCH] Fix SAOP nullability analysis below top level

find_nonnullable_rels() and find_nonnullable_vars() use
is_strict_saop() to determine whether a ScalarArrayOpExpr rejects NULL
input.  At top level, it is enough for NULL input to make the clause
false or NULL.  After descending below NOT, a BooleanTest, or a strict
function, however, the ScalarArrayOpExpr itself must return NULL.

Both walkers passed falseOK = true unconditionally.  Hence an ANY
expression over an empty array was treated as strict even in the latter
context, although NULL = ANY(empty_array) is false.  This could
incorrectly prove a subquery output non-null and produce wrong answers
after converting NOT IN to an anti-join.  It could also incorrectly
reduce an outer join.

Pass the walkers' current top_level state as falseOK, and add regression
tests for both cases.
---
 src/backend/optimizer/util/clauses.c    |  4 ++--
 src/test/regress/expected/subselect.out | 24 ++++++++++++++++++++++++
 src/test/regress/sql/subselect.sql      | 18 ++++++++++++++++++
 3 files changed, 44 insertions(+), 2 deletions(-)

diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index aa8886ec210..ef1c5e4102b 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -1546,7 +1546,7 @@ find_nonnullable_rels_walker(Node *node, bool top_level)
 	{
 		ScalarArrayOpExpr *expr = (ScalarArrayOpExpr *) node;
 
-		if (is_strict_saop(expr, true))
+		if (is_strict_saop(expr, top_level))
 			result = find_nonnullable_rels_walker((Node *) expr->args, false);
 	}
 	else if (IsA(node, BoolExpr))
@@ -1799,7 +1799,7 @@ find_nonnullable_vars_walker(Node *node, bool top_level)
 	{
 		ScalarArrayOpExpr *expr = (ScalarArrayOpExpr *) node;
 
-		if (is_strict_saop(expr, true))
+		if (is_strict_saop(expr, top_level))
 			result = find_nonnullable_vars_walker((Node *) expr->args, false);
 	}
 	else if (IsA(node, BoolExpr))
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index e7ff7191082..7c647326c62 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -3998,4 +3998,28 @@ WHERE id NOT IN (SELECT id FROM notnull_notvalid_tab);
 ----+-----
 (0 rows)
 
+-- A ScalarArrayOpExpr below NOT must return NULL, not just false, to prove
+-- that its input is nonnullable
+CREATE TEMP TABLE saop_outer (x int NOT NULL);
+CREATE TEMP TABLE saop_inner (y int);
+INSERT INTO saop_outer VALUES (1);
+INSERT INTO saop_inner VALUES (NULL);
+SELECT * FROM saop_outer
+WHERE x NOT IN (
+  SELECT y FROM saop_inner
+  WHERE NOT (y = ANY ('{}'::int[]))
+);
+ x 
+---
+(0 rows)
+
+-- Likewise, do not reduce this LEFT JOIN to an inner join
+SELECT saop_outer.*
+FROM saop_outer LEFT JOIN saop_inner ON x = y
+WHERE (y = ANY ('{}'::int[])) IS FALSE;
+ x 
+---
+ 1
+(1 row)
+
 ROLLBACK;
diff --git a/src/test/regress/sql/subselect.sql b/src/test/regress/sql/subselect.sql
index 76bce5cdba5..5c35cba4372 100644
--- a/src/test/regress/sql/subselect.sql
+++ b/src/test/regress/sql/subselect.sql
@@ -1751,4 +1751,22 @@ WHERE id NOT IN (SELECT id FROM notnull_notvalid_tab);
 SELECT * FROM not_null_tab
 WHERE id NOT IN (SELECT id FROM notnull_notvalid_tab);
 
+-- A ScalarArrayOpExpr below NOT must return NULL, not just false, to prove
+-- that its input is nonnullable
+CREATE TEMP TABLE saop_outer (x int NOT NULL);
+CREATE TEMP TABLE saop_inner (y int);
+INSERT INTO saop_outer VALUES (1);
+INSERT INTO saop_inner VALUES (NULL);
+
+SELECT * FROM saop_outer
+WHERE x NOT IN (
+  SELECT y FROM saop_inner
+  WHERE NOT (y = ANY ('{}'::int[]))
+);
+
+-- Likewise, do not reduce this LEFT JOIN to an inner join
+SELECT saop_outer.*
+FROM saop_outer LEFT JOIN saop_inner ON x = y
+WHERE (y = ANY ('{}'::int[])) IS FALSE;
+
 ROLLBACK;
-- 
2.34.1

