This is an automated email from the ASF dual-hosted git repository.

jrgemignani pushed a commit to branch PG19
in repository https://gitbox.apache.org/repos/asf/age.git

commit e5431fcbc63b4f87cf2defd67c2ecae5e7662f1e
Author: Muhammad Taha Naveed <[email protected]>
AuthorDate: Fri Jul 3 16:20:59 2026 +0500

    Propagate null through unnest and single() three-valued logic (#2406)
    
    Fix age_unnest to emit SQL NULL for AGTV_NULL elements and rewrite single() 
to proper three-valued logic, fixing UNWIND [null] count, list-comprehension 
null filters, and single() semantics per openCypher.
    
    Closes #2383
    Closes #2393
---
 regress/expected/list_comprehension.out  |  33 +++++
 regress/expected/predicate_functions.out | 116 +++++++++++++++---
 regress/sql/list_comprehension.sql       |   8 ++
 regress/sql/predicate_functions.sql      |  75 ++++++++++--
 src/backend/parser/cypher_clause.c       | 199 +++++++++++++++++++------------
 src/backend/parser/cypher_gram.y         |  37 ++----
 src/backend/utils/adt/agtype.c           |  20 +++-
 7 files changed, 355 insertions(+), 133 deletions(-)

diff --git a/regress/expected/list_comprehension.out 
b/regress/expected/list_comprehension.out
index e47b2d56..63b24647 100644
--- a/regress/expected/list_comprehension.out
+++ b/regress/expected/list_comprehension.out
@@ -809,6 +809,39 @@ SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN 
[1, null, 2] | -x] $$
  [-1, null, -2]
 (1 row)
 
+-- Issue 2393 - WHERE filter over null elements should use openCypher's
+-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them.
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x 
IS NULL] $$) AS (result agtype);
+ result 
+--------
+ [null]
+(1 row)
+
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] 
WHERE x IS NULL] $$) AS (result agtype);
+    result    
+--------------
+ [null, null]
+(1 row)
+
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x 
IS NOT NULL] $$) AS (result agtype);
+ result 
+--------
+ [1]
+(1 row)
+
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x 
IS NULL] $$) AS (result agtype);
+ result 
+--------
+ []
+(1 row)
+
+SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, 
x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype);
+ x |   a   |   b   
+---+-------+-------
+   | true  | false
+ 1 | false | true
+(2 rows)
+
 -- Clean up
 SELECT * FROM drop_graph('list_comprehension', true);
 NOTICE:  drop cascades to 4 other objects
diff --git a/regress/expected/predicate_functions.out 
b/regress/expected/predicate_functions.out
index 9ee673a4..f6957c49 100644
--- a/regress/expected/predicate_functions.out
+++ b/regress/expected/predicate_functions.out
@@ -194,20 +194,20 @@ $$) AS (result agtype);
 --
 -- NULL predicate results: three-valued logic
 --
--- Note: In AGE's agtype, null is a first-class value.  The comparison
--- agtype_null > agtype_integer evaluates to true (not SQL NULL).
--- Three-valued logic only applies when the predicate itself is a
--- literal null constant, which becomes SQL NULL after coercion.
--- agtype null in list: null > 0 = true in AGE, so any() = true
+-- Null list elements arrive at the predicate as SQL NULL, so the usual
+-- strict-operator short-circuit applies: `null > 0` yields NULL, and the
+-- predicate functions combine NULLs with the openCypher three-valued
+-- logic (true trumps null in any(), false trumps null in all(), etc.).
+-- [null]: only null predicate, no true -> any() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN any(x IN [null] WHERE x > 0)
 $$) AS (result agtype);
  result 
 --------
- true
+ 
 (1 row)
 
--- agtype null + real values: all comparisons are true
+-- one true (1 > 0) is enough: any() = true
 SELECT * FROM cypher('predicate_functions', $$
     RETURN any(x IN [null, 1, 2] WHERE x > 0)
 $$) AS (result agtype);
@@ -226,16 +226,16 @@ $$) AS (result agtype);
  
 (1 row)
 
--- agtype null in list: null > 0 = true in AGE, so all() = true
+-- no false, but one null -> all() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN all(x IN [1, null, 2] WHERE x > 0)
 $$) AS (result agtype);
  result 
 --------
- true
+ 
 (1 row)
 
--- -1 > 0 = false, so all() = false
+-- -1 > 0 = false dominates the null -> all() = false
 SELECT * FROM cypher('predicate_functions', $$
     RETURN all(x IN [1, null, -1] WHERE x > 0)
 $$) AS (result agtype);
@@ -244,16 +244,16 @@ $$) AS (result agtype);
  false
 (1 row)
 
--- agtype null > 0 = true in AGE, so none() = false
+-- [null]: only null predicate, no true -> none() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN none(x IN [null] WHERE x > 0)
 $$) AS (result agtype);
  result 
 --------
- false
+ 
 (1 row)
 
--- 5 > 0 = true, so none() = false
+-- one true (5 > 0) dominates: none() = false
 SELECT * FROM cypher('predicate_functions', $$
     RETURN none(x IN [null, 5] WHERE x > 0)
 $$) AS (result agtype);
@@ -262,24 +262,108 @@ $$) AS (result agtype);
  false
 (1 row)
 
--- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
+-- one definite true (5 > 0) and one null predicate: the null could also
+-- be a match, so we cannot conclude exactly-one -> single() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN single(x IN [null, 5] WHERE x > 0)
 $$) AS (result agtype);
  result 
 --------
+ 
+(1 row)
+
+-- two definite trues dominate any null -> single() = false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [null, 5, 6] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
  false
 (1 row)
 
--- single() with null list: NULL (same as other predicate functions)
+-- only null predicates -> single() = NULL
 SELECT * FROM cypher('predicate_functions', $$
-    RETURN single(x IN null WHERE x > 0)
+    RETURN single(x IN [null, null] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+-- one true + one false + one null: the null could be the second true
+-- so we cannot conclude exactly-one -> single() = NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, null, -1] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+--
+-- Additional null/three-valued coverage for any()/all()/none()
+--
+-- any() with no true and one null -> NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [null, -1] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+-- all() with one true and one null -> NULL (the null might be false)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, null] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+-- all() with one definite false dominates any null -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [null, -1] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- none() with no true and one null -> NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [null, -1] WHERE x > 0)
 $$) AS (result agtype);
  result 
 --------
  
 (1 row)
 
+-- IS NULL predicate now sees unwound null elements (issue #2393)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, null] WHERE x IS NULL)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, null] WHERE x IS NULL)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [null, null] WHERE x IS NULL)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
 --
 -- Integration with graph data
 --
diff --git a/regress/sql/list_comprehension.sql 
b/regress/sql/list_comprehension.sql
index 57b43221..e493da6d 100644
--- a/regress/sql/list_comprehension.sql
+++ b/regress/sql/list_comprehension.sql
@@ -194,5 +194,13 @@ SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN 
[1, null, 2] | x % 2]
 SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x ^ 
2] $$) AS (result agtype);
 SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | -x] 
$$) AS (result agtype);
 
+-- Issue 2393 - WHERE filter over null elements should use openCypher's
+-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them.
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x 
IS NULL] $$) AS (result agtype);
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] 
WHERE x IS NULL] $$) AS (result agtype);
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x 
IS NOT NULL] $$) AS (result agtype);
+SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x 
IS NULL] $$) AS (result agtype);
+SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, 
x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype);
+
 -- Clean up
 SELECT * FROM drop_graph('list_comprehension', true);
\ No newline at end of file
diff --git a/regress/sql/predicate_functions.sql 
b/regress/sql/predicate_functions.sql
index 82933f87..ad99a582 100644
--- a/regress/sql/predicate_functions.sql
+++ b/regress/sql/predicate_functions.sql
@@ -123,17 +123,17 @@ $$) AS (result agtype);
 --
 -- NULL predicate results: three-valued logic
 --
--- Note: In AGE's agtype, null is a first-class value.  The comparison
--- agtype_null > agtype_integer evaluates to true (not SQL NULL).
--- Three-valued logic only applies when the predicate itself is a
--- literal null constant, which becomes SQL NULL after coercion.
+-- Null list elements arrive at the predicate as SQL NULL, so the usual
+-- strict-operator short-circuit applies: `null > 0` yields NULL, and the
+-- predicate functions combine NULLs with the openCypher three-valued
+-- logic (true trumps null in any(), false trumps null in all(), etc.).
 
--- agtype null in list: null > 0 = true in AGE, so any() = true
+-- [null]: only null predicate, no true -> any() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN any(x IN [null] WHERE x > 0)
 $$) AS (result agtype);
 
--- agtype null + real values: all comparisons are true
+-- one true (1 > 0) is enough: any() = true
 SELECT * FROM cypher('predicate_functions', $$
     RETURN any(x IN [null, 1, 2] WHERE x > 0)
 $$) AS (result agtype);
@@ -144,34 +144,83 @@ SELECT * FROM cypher('predicate_functions', $$
     RETURN all(x IN [1] WHERE null)
 $$) AS (result agtype);
 
--- agtype null in list: null > 0 = true in AGE, so all() = true
+-- no false, but one null -> all() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN all(x IN [1, null, 2] WHERE x > 0)
 $$) AS (result agtype);
 
--- -1 > 0 = false, so all() = false
+-- -1 > 0 = false dominates the null -> all() = false
 SELECT * FROM cypher('predicate_functions', $$
     RETURN all(x IN [1, null, -1] WHERE x > 0)
 $$) AS (result agtype);
 
--- agtype null > 0 = true in AGE, so none() = false
+-- [null]: only null predicate, no true -> none() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN none(x IN [null] WHERE x > 0)
 $$) AS (result agtype);
 
--- 5 > 0 = true, so none() = false
+-- one true (5 > 0) dominates: none() = false
 SELECT * FROM cypher('predicate_functions', $$
     RETURN none(x IN [null, 5] WHERE x > 0)
 $$) AS (result agtype);
 
--- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
+-- one definite true (5 > 0) and one null predicate: the null could also
+-- be a match, so we cannot conclude exactly-one -> single() = NULL
 SELECT * FROM cypher('predicate_functions', $$
     RETURN single(x IN [null, 5] WHERE x > 0)
 $$) AS (result agtype);
 
--- single() with null list: NULL (same as other predicate functions)
+-- two definite trues dominate any null -> single() = false
 SELECT * FROM cypher('predicate_functions', $$
-    RETURN single(x IN null WHERE x > 0)
+    RETURN single(x IN [null, 5, 6] WHERE x > 0)
+$$) AS (result agtype);
+
+-- only null predicates -> single() = NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [null, null] WHERE x > 0)
+$$) AS (result agtype);
+
+-- one true + one false + one null: the null could be the second true
+-- so we cannot conclude exactly-one -> single() = NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, null, -1] WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- Additional null/three-valued coverage for any()/all()/none()
+--
+
+-- any() with no true and one null -> NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [null, -1] WHERE x > 0)
+$$) AS (result agtype);
+
+-- all() with one true and one null -> NULL (the null might be false)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, null] WHERE x > 0)
+$$) AS (result agtype);
+
+-- all() with one definite false dominates any null -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [null, -1] WHERE x > 0)
+$$) AS (result agtype);
+
+-- none() with no true and one null -> NULL
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [null, -1] WHERE x > 0)
+$$) AS (result agtype);
+
+-- IS NULL predicate now sees unwound null elements (issue #2393)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, null] WHERE x IS NULL)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, null] WHERE x IS NULL)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [null, null] WHERE x IS NULL)
 $$) AS (result agtype);
 
 --
diff --git a/src/backend/parser/cypher_clause.c 
b/src/backend/parser/cypher_clause.c
index 72370ba0..147e3e74 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -56,6 +56,10 @@
 #include "utils/ag_func.h"
 #include "utils/ag_guc.h"
 
+#ifndef INT8PASSBYVAL
+#define INT8PASSBYVAL FLOAT8PASSBYVAL
+#endif
+
 /*
  * Variable string names for makeTargetEntry. As they are going to be variable
  * names that will be hidden from the user, we need to do our best to make sure
@@ -1764,25 +1768,71 @@ static Node *make_bool_or_agg(ParseState *pstate, Node 
*arg)
     return (Node *) agg;
 }
 
+/*
+ * Helper: build a fully-transformed `count(*) FILTER (WHERE filter)` Aggref.
+ *
+ * The filter must already be a transformed boolean expression.
+ */
+static Node *make_count_star_filter_agg(ParseState *pstate, Node *filter)
+{
+    Aggref *agg;
+    Oid count_oid;
+
+    /* count() -- the zero-argument count-star form */
+    count_oid = LookupFuncName(list_make1(makeString("count")),
+                               0, NULL, false);
+
+    agg = makeNode(Aggref);
+    agg->aggfnoid = count_oid;
+    agg->aggtype = INT8OID;
+    agg->aggcollid = InvalidOid;
+    agg->inputcollid = InvalidOid;
+    agg->aggtranstype = InvalidOid;  /* filled by planner */
+    agg->aggargtypes = NIL;
+    agg->aggdirectargs = NIL;
+    agg->args = NIL;
+    agg->aggorder = NIL;
+    agg->aggdistinct = NIL;
+    agg->aggfilter = (Expr *) filter;
+    agg->aggstar = true;
+    agg->aggvariadic = false;
+    agg->aggkind = AGGKIND_NORMAL;
+    agg->aggpresorted = false;
+    agg->agglevelsup = 0;
+    agg->aggsplit = AGGSPLIT_SIMPLE;
+    agg->aggno = -1;
+    agg->aggtransno = -1;
+    agg->location = -1;
+
+    pstate->p_hasAggs = true;
+
+    return (Node *) agg;
+}
+
 /*
  * Helper: build a transformed CASE expression implementing three-valued
- * predicate logic for all(), any(), and none().
+ * predicate logic for all(), any(), none(), and single().
+ *
+ * any():    CASE WHEN bool_or(pred IS TRUE)  THEN true
+ *                WHEN bool_or(pred IS NULL)  THEN NULL
+ *                ELSE false END
  *
- * any():   CASE WHEN bool_or(pred IS TRUE) THEN true
- *               WHEN bool_or(pred IS NULL) THEN NULL
- *               ELSE false END
+ * all():    CASE WHEN bool_or(pred IS FALSE) THEN false
+ *                WHEN bool_or(pred IS NULL)  THEN NULL
+ *                ELSE true END
  *
- * all():   CASE WHEN bool_or(pred IS FALSE) THEN false
- *               WHEN bool_or(pred IS NULL) THEN NULL
- *               ELSE true END
+ * none():   CASE WHEN bool_or(pred IS TRUE)  THEN false
+ *                WHEN bool_or(pred IS NULL)  THEN NULL
+ *                ELSE true END
  *
- * none():  CASE WHEN bool_or(pred IS TRUE) THEN false
- *               WHEN bool_or(pred IS NULL) THEN NULL
- *               ELSE true END
+ * single(): CASE WHEN count(*) FILTER (WHERE pred IS TRUE) >= 2 THEN false
+ *                WHEN bool_or(pred IS NULL)                     THEN NULL
+ *                WHEN count(*) FILTER (WHERE pred IS TRUE) =  1 THEN true
+ *                ELSE false END
  *
- * Empty list: both bool_or calls return NULL (no rows), so the CASE
- * falls through to the default: false for any(), true for all()/none().
- * This matches Cypher's vacuous truth semantics.
+ * Empty list: bool_or returns NULL on zero rows and count(*) returns 0, so
+ * the CASE falls through to the default: false for any()/single(), true for
+ * all()/none(). This matches Cypher's vacuous truth semantics.
  */
 static Node *make_predicate_case_expr(ParseState *pstate, Node *pred,
                                       cypher_predicate_function_kind kind)
@@ -1840,7 +1890,7 @@ static Node *make_predicate_case_expr(ParseState *pstate, 
Node *pred,
         cexpr->defresult = (Expr *) false_const;
         cexpr->location = -1;
     }
-    else /* CPFK_NONE */
+    else if (kind == CPFK_NONE)
     {
         /* bool_or(pred IS TRUE) -> false */
         bool_or_first = make_bool_or_agg(pstate,
@@ -1857,6 +1907,62 @@ static Node *make_predicate_case_expr(ParseState 
*pstate, Node *pred,
         cexpr->defresult = (Expr *) true_const;
         cexpr->location = -1;
     }
+    else /* CPFK_SINGLE */
+    {
+        /*
+         * Three WHEN arms, in order:
+         *   count(*) FILTER (pred IS TRUE) >= 2 -> false  (definitely >1)
+         *   bool_or(pred IS NULL)               -> NULL   (unknown arm)
+         *   count(*) FILTER (pred IS TRUE) =  1 -> true   (exactly one)
+         *   else                                -> false  (zero true)
+         *
+         * The >=2 arm is tested first so that 2-or-more definite trues
+         * win over any null predicates, matching Neo4j semantics.
+         */
+        CaseWhen *when0, *when3;
+        Node *count_ge_2, *count_eq_1;
+        Node *count_true_a, *count_true_b;
+        Node *int_2 = (Node *) makeConst(INT8OID, -1, InvalidOid, 
sizeof(int64),
+                                         Int64GetDatum(2), false, 
INT8PASSBYVAL);
+        Node *int_1 = (Node *) makeConst(INT8OID, -1, InvalidOid, 
sizeof(int64),
+                                         Int64GetDatum(1), false, 
INT8PASSBYVAL);
+
+        /*
+         * Two separate Aggref nodes for count(*) FILTER (pred IS TRUE);
+         * the planner will common-subexpression them back to a single
+         * aggregate evaluation.
+         */
+        count_true_a = make_count_star_filter_agg(pstate,
+                           make_boolean_test(pred, IS_TRUE));
+        count_true_b = make_count_star_filter_agg(pstate,
+                           make_boolean_test(pred, IS_TRUE));
+
+        count_ge_2 = (Node *) make_op(pstate, list_make1(makeString(">=")),
+                                      count_true_a, int_2,
+                                      pstate->p_last_srf, -1);
+        count_eq_1 = (Node *) make_op(pstate, list_make1(makeString("=")),
+                                      count_true_b, int_1,
+                                      pstate->p_last_srf, -1);
+
+        /* first arm: count >= 2 -> false */
+        when0 = makeNode(CaseWhen);
+        when0->expr = (Expr *) count_ge_2;
+        when0->result = (Expr *) false_const;
+        when0->location = -1;
+
+        /* third arm: count = 1 -> true */
+        when3 = makeNode(CaseWhen);
+        when3->expr = (Expr *) count_eq_1;
+        when3->result = (Expr *) true_const;
+        when3->location = -1;
+
+        cexpr = makeNode(CaseExpr);
+        cexpr->casetype = BOOLOID;
+        cexpr->arg = NULL;
+        cexpr->args = list_make3(when0, when2, when3);
+        cexpr->defresult = (Expr *) false_const;
+        cexpr->location = -1;
+    }
 
     return (Node *) cexpr;
 }
@@ -1933,67 +2039,12 @@ static Query 
*transform_cypher_predicate_function(cypher_parsestate *cpstate,
         pred = coerce_to_boolean(child_pstate, pred, "WHERE");
     }
 
-    if (pred_func->kind == CPFK_SINGLE)
-    {
-        /*
-         * single(): SELECT count(*) FROM unnest(list) AS x
-         *           WHERE pred IS TRUE
-         *
-         * Using IS TRUE ensures NULL predicates are not counted as
-         * matches, preserving correct semantics.  The grammar layer
-         * compares the result = 1.
-         *
-         * Note: a LIMIT 2 optimization (to short-circuit after two
-         * matches) would require a nested subquery that breaks
-         * correlated variable references.  Deferred to a future
-         * optimization pass.
-         */
-        FuncCall *count_call;
-        Node *count_expr;
-        Node *is_true_qual;
-
-        /* WHERE pred IS TRUE -- NULLs are not counted */
-        is_true_qual = make_boolean_test(pred, IS_TRUE);
-
-        count_call = makeFuncCall(list_make1(makeString("count")),
-                                  NIL, COERCE_SQL_SYNTAX, -1);
-        count_call->agg_star = true;
-
-        count_expr = transformExpr(child_pstate, (Node *) count_call,
-                                   EXPR_KIND_SELECT_TARGET);
-
-        te = makeTargetEntry((Expr *) count_expr,
-                             (AttrNumber) child_pstate->p_next_resno++,
-                             "count", false);
-
-        query->targetList = lappend(query->targetList, te);
-        query->jointree = makeFromExpr(child_pstate->p_joinlist,
-                                       is_true_qual);
-        query->rtable = child_pstate->p_rtable;
-        query->rteperminfos = child_pstate->p_rteperminfos;
-        query->hasAggs = child_pstate->p_hasAggs;
-        query->hasSubLinks = child_pstate->p_hasSubLinks;
-        query->hasTargetSRFs = child_pstate->p_hasTargetSRFs;
-
-        assign_query_collations(child_pstate, query);
-
-        if (child_pstate->p_hasAggs ||
-            query->groupClause || query->groupingSets || query->havingQual)
-        {
-            parse_check_aggregates(child_pstate, query);
-        }
-
-        free_cypher_parsestate(child_cpstate);
-
-        return query;
-    }
-    else
+    /*
+     * Build a CASE expression that preserves three-valued NULL semantics.
+     * No WHERE clause -- the logic is entirely in the SELECT list, so the
+     * CASE can see null predicates and react to them.
+     */
     {
-        /*
-         * all()/any()/none(): Build a CASE expression with bool_or()
-         * aggregates that preserves three-valued NULL semantics.
-         * No WHERE clause -- the logic is entirely in the SELECT list.
-         */
         Node *case_expr;
 
         case_expr = make_predicate_case_expr(child_pstate, pred,
diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y
index ddd62d7e..71b95bde 100644
--- a/src/backend/parser/cypher_gram.y
+++ b/src/backend/parser/cypher_gram.y
@@ -3538,12 +3538,10 @@ static Node *build_list_comprehension_node(Node *var, 
Node *expr,
  *   none(x IN list WHERE predicate)
  *   single(x IN list WHERE predicate)
  *
- * All four use EXPR_SUBLINK (scalar subquery).  The transform layer
- * generates aggregate-based queries (using bool_or + CASE) for
- * all/any/none to preserve three-valued NULL semantics, and
- * count(*) with IS TRUE filtering for single().
- *
- * For single(), the subquery result is compared = 1.
+ * All four use EXPR_SUBLINK (scalar subquery). The transform layer
+ * generates aggregate-based queries that preserve three-valued NULL
+ * semantics: bool_or + CASE for all/any/none, and a CASE built on
+ * count(*) FILTER for single().
  */
 static Node *build_predicate_function_node(cypher_predicate_function_kind kind,
                                            Node *var, Node *expr,
@@ -3583,28 +3581,11 @@ static Node 
*build_predicate_function_node(cypher_predicate_function_kind kind,
     sub->location = location;
     sub->subLinkType = EXPR_SUBLINK;
 
-    if (kind == CPFK_SINGLE)
-    {
-        /*
-         * single() -> (subquery) = 1
-         * The subquery returns count(*) with IS TRUE filtering.
-         */
-        Node *eq_expr;
-
-        eq_expr = (Node *) makeSimpleA_Expr(AEXPR_OP, "=",
-                                            (Node *) sub,
-                                            make_int_const(1, location),
-                                            location);
-        result = (Node *) node_to_agtype(eq_expr, "boolean", location);
-    }
-    else
-    {
-        /*
-         * all()/any()/none(): the subquery returns a boolean directly
-         * from the CASE+bool_or() aggregate expression.
-         */
-        result = (Node *) node_to_agtype((Node *) sub, "boolean", location);
-    }
+    /*
+     * The subquery returns a boolean directly from the CASE+bool_or()
+     * aggregate expression (count(*) FILTER for single()).
+     */
+    result = (Node *) node_to_agtype((Node *) sub, "boolean", location);
 
     /*
      * NULL-list guard: CASE WHEN expr IS NULL THEN NULL ELSE result END
diff --git a/src/backend/utils/adt/agtype.c b/src/backend/utils/adt/agtype.c
index 10d95ee4..cc9cc771 100644
--- a/src/backend/utils/adt/agtype.c
+++ b/src/backend/utils/adt/agtype.c
@@ -13103,12 +13103,28 @@ Datum age_unnest(PG_FUNCTION_ARGS)
             HeapTuple tuple;
             Datum values[1];
             bool nulls[1] = {false};
-            agtype *val = agtype_value_to_agtype(&v);
 
             /* use the tmp context so we can clean up after each tuple is done 
*/
             old_cxt = MemoryContextSwitchTo(tmp_cxt);
 
-            values[0] = PointerGetDatum(val);
+            /*
+             * Emit an agtype AGTV_NULL element as a SQL-NULL row so that
+             * `IS NULL` / `IS NOT NULL` on the unwound variable match
+             * openCypher's null semantics (issue #2393). Wrapping it as a
+             * non-SQL-NULL agtype container would leave SQL's IS NULL blind
+             * to it, dropping `WHERE x IS NULL` filters and passing through
+             * `WHERE x IS NOT NULL` filters.
+             */
+            if (v.type == AGTV_NULL)
+            {
+                values[0] = (Datum) 0;
+                nulls[0] = true;
+            }
+            else
+            {
+                agtype *val = agtype_value_to_agtype(&v);
+                values[0] = PointerGetDatum(val);
+            }
 
             tuple = heap_form_tuple(ret_tdesc, values, nulls);
 

Reply via email to