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

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


The following commit(s) were added to refs/heads/master by this push:
     new 4f9db377 Implement predicate functions: all(), any(), none(), single() 
(#2359)
4f9db377 is described below

commit 4f9db377cc5576403e891ba67f2c7d103aaebcd7
Author: Greg Felice <[email protected]>
AuthorDate: Sun Apr 19 09:26:47 2026 -0400

    Implement predicate functions: all(), any(), none(), single() (#2359)
    
    * Implement predicate functions: all(), any(), none(), single()
    
    Implement the four openCypher predicate functions (issues #552, #553,
    #555, #556) that test list elements against a predicate:
    
      all(x IN list WHERE predicate)    -- true if all elements match
      any(x IN list WHERE predicate)    -- true if at least one matches
      none(x IN list WHERE predicate)   -- true if no elements match
      single(x IN list WHERE predicate) -- true if exactly one matches
    
    Implementation approach:
    - Add cypher_predicate_function node type with CPFK_ALL/ANY/NONE/SINGLE
      kind enum, reusing the list comprehension's unnest-based transformation
    - Grammar rules in expr_func_subexpr (alongside EXISTS, COALESCE, COUNT)
    - Transform to efficient SQL sublinks:
      all()   -> NOT EXISTS (SELECT 1 FROM unnest WHERE NOT pred)
      any()   -> EXISTS (SELECT 1 FROM unnest WHERE pred)
      none()  -> NOT EXISTS (SELECT 1 FROM unnest WHERE pred)
      single() -> (SELECT count(*) FROM unnest WHERE pred) = 1
    - Three new keywords (ANY_P, NONE, SINGLE) added to safe_keywords for
      backward compatibility as property keys and label names
    - Shared extract_iter_variable_name() helper for variable validation
    
    All 32 regression tests pass. New predicate_functions test covers basic
    semantics, empty lists, graph data integration, boolean combinations,
    nested predicates, and keyword backward compatibility.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address Copilot review: NULL semantics, iterator validation, single() 
perf, tests
    
    - Rewrite predicate functions from EXISTS_SUBLINK to EXPR_SUBLINK with
      aggregate-based CASE expressions (bool_or + IS TRUE/FALSE/NULL) to
      preserve three-valued Cypher NULL semantics
    - Add list_length check in extract_iter_variable_name() to reject
      qualified names like x.y as iterator variables
    - Add copy/read support for cypher_predicate_function ExtensibleNode
      to prevent query rewriter crashes
    - Use IS TRUE filtering in single() count (LIMIT 2 optimization
      breaks correlated variable refs in graph contexts -- documented)
    - Add 13 NULL regression tests: null list input, null elements,
      null predicates for all four functions
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address Copilot round 2: NULL-list guard, single() comment, pg_aggregate.h
    
    1. Add NULL-list guard for all predicate functions (all/any/none/single).
       Wraps the result with CASE WHEN list IS NULL THEN NULL ELSE <result>
       END in the grammar layer.  This fixes single(x IN null WHERE ...)
       returning false instead of NULL.  The expr pointer is safely shared
       between the NullTest and the predicate function node because AGE's
       expression transformer creates new nodes without modifying the
       parse tree in-place.
    
    2. Fix single() block comment in transform_cypher_predicate_function:
       described LIMIT 2 optimization but implementation uses plain
       count(*).  Updated comment to match actual implementation.
    
    3. Keep #include "catalog/pg_aggregate.h" -- Copilot suggested removal
       but AGGKIND_NORMAL macro requires it (build fails without it).
    
    Regression test: predicate_functions OK.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address Copilot round 3: reuse extract_iter_variable_name for list 
comprehensions
    
    - Refactor build_list_comprehension_node() to reuse the shared
      extract_iter_variable_name() helper, so `var IN list` validation
      is consistent between list comprehensions and predicate functions
      (all/any/none/single). Qualified ColumnRefs like `x.y IN list`
      are now rejected in list comprehensions the same way they are
      in predicate functions.
    - Update list_comprehension expected output for the normalized
      lowercase "syntax error at or near IN" message.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
 Makefile                                 |   1 +
 regress/expected/list_comprehension.out  |   4 +-
 regress/expected/predicate_functions.out | 409 +++++++++++++++++++++++++++++++
 regress/sql/predicate_functions.sql      | 250 +++++++++++++++++++
 src/backend/nodes/ag_nodes.c             |   6 +-
 src/backend/nodes/cypher_copyfuncs.c     |  12 +
 src/backend/nodes/cypher_outfuncs.c      |  11 +
 src/backend/nodes/cypher_readfuncs.c     |  14 ++
 src/backend/parser/cypher_analyze.c      |  16 ++
 src/backend/parser/cypher_clause.c       | 334 +++++++++++++++++++++++++
 src/backend/parser/cypher_gram.y         | 197 ++++++++++++++-
 src/include/nodes/ag_nodes.h             |   4 +-
 src/include/nodes/cypher_copyfuncs.h     |   4 +
 src/include/nodes/cypher_nodes.h         |  21 ++
 src/include/nodes/cypher_outfuncs.h      |   1 +
 src/include/nodes/cypher_readfuncs.h     |   3 +
 src/include/parser/cypher_kwlist.h       |   3 +
 17 files changed, 1272 insertions(+), 18 deletions(-)

diff --git a/Makefile b/Makefile
index e358acf3..76f63c24 100644
--- a/Makefile
+++ b/Makefile
@@ -174,6 +174,7 @@ REGRESS = scan \
           name_validation \
           jsonb_operators \
           list_comprehension \
+          predicate_functions \
           map_projection \
           direct_field_access \
           security
diff --git a/regress/expected/list_comprehension.out 
b/regress/expected/list_comprehension.out
index 5a375642..e12ad621 100644
--- a/regress/expected/list_comprehension.out
+++ b/regress/expected/list_comprehension.out
@@ -577,11 +577,11 @@ LINE 1: ...$$ RETURN [i IN range(0, 10, 2) WHERE i>5 | 
i^2], i $$) AS (...
 HINT:  variable i does not exist within scope of usage
 -- Invalid list comprehension
 SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN range(0, 10, 2) 
WHERE 2>5] $$) AS (result agtype);
-ERROR:  Syntax error at or near IN
+ERROR:  syntax error at or near IN
 LINE 1: SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN r...
                                                     ^
 SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN range(0, 10, 2) | 
1] $$) AS (result agtype);
-ERROR:  Syntax error at or near IN
+ERROR:  syntax error at or near IN
 LINE 1: SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN r...
                                                     ^
 -- Issue - error using list comprehension with WITH *
diff --git a/regress/expected/predicate_functions.out 
b/regress/expected/predicate_functions.out
new file mode 100644
index 00000000..47226453
--- /dev/null
+++ b/regress/expected/predicate_functions.out
@@ -0,0 +1,409 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+LOAD 'age';
+SET search_path TO ag_catalog;
+SELECT create_graph('predicate_functions');
+NOTICE:  graph "predicate_functions" has been created
+ create_graph 
+--------------
+ 
+(1 row)
+
+--
+-- all() predicate function
+--
+-- all elements satisfy predicate -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, 2, 3] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+-- not all elements satisfy predicate -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, 2, 3] WHERE x > 1)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- empty list -> true (vacuous truth)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+--
+-- any() predicate function
+--
+-- at least one element satisfies -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+-- no element satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- empty list -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+--
+-- none() predicate function
+--
+-- no element satisfies predicate -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+-- at least one satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- empty list -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+--
+-- single() predicate function
+--
+-- exactly one element satisfies -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+-- more than one satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 1)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- none satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- empty list -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+--
+-- NULL list input: all four return null
+-- (NULL-list guard in the grammar produces CASE WHEN expr IS NULL
+-- THEN NULL ELSE <subquery> END)
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN null WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN null WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN null WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN null WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+--
+-- 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
+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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [null, 1, 2] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+-- literal null predicate: pred = SQL NULL -> three-valued logic
+-- all([1] WHERE null) = null (unknown)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1] WHERE null)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+-- agtype null in list: null > 0 = true in AGE, so all() = true
+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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, null, -1] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- agtype null > 0 = true in AGE, so none() = false
+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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [null, 5] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [null, 5] WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ false
+(1 row)
+
+-- single() with null list: NULL (same as other predicate functions)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN null WHERE x > 0)
+$$) AS (result agtype);
+ result 
+--------
+ 
+(1 row)
+
+--
+-- Integration with graph data
+--
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'even', vals: [2, 4, 6, 8]})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'mixed', vals: [1, 2, 3, 4]})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'odd', vals: [1, 3, 5, 7]})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+-- all() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE all(x IN u.vals WHERE x % 2 = 0)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+ result 
+--------
+ "even"
+(1 row)
+
+-- any() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE any(x IN u.vals WHERE x > 6)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+ result 
+--------
+ "even"
+ "odd"
+(2 rows)
+
+-- none() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE none(x IN u.vals WHERE x < 0)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+ result  
+---------
+ "even"
+ "mixed"
+ "odd"
+(3 rows)
+
+-- single() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE single(x IN u.vals WHERE x = 8)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+ result 
+--------
+ "even"
+(1 row)
+
+--
+-- Predicate functions in boolean expressions
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 2)
+           AND all(y IN [4, 5, 6] WHERE y > 0)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 5)
+           OR single(y IN [1, 2, 3] WHERE y = 2)
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+--
+-- Nested predicate functions
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE all(y IN [1, 2] WHERE y < x))
+$$) AS (result agtype);
+ result 
+--------
+ true
+(1 row)
+
+--
+-- Keywords as property key names (safe_keywords backward compatibility)
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN {any: 1, none: 2, single: 3}
+$$) AS (result agtype);
+               result               
+------------------------------------
+ {"any": 1, "none": 2, "single": 3}
+(1 row)
+
+--
+-- Cleanup
+--
+SELECT * FROM drop_graph('predicate_functions', true);
+NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to table predicate_functions._ag_label_vertex
+drop cascades to table predicate_functions._ag_label_edge
+NOTICE:  graph "predicate_functions" has been dropped
+ drop_graph 
+------------
+ 
+(1 row)
+
diff --git a/regress/sql/predicate_functions.sql 
b/regress/sql/predicate_functions.sql
new file mode 100644
index 00000000..7466cc2a
--- /dev/null
+++ b/regress/sql/predicate_functions.sql
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+LOAD 'age';
+SET search_path TO ag_catalog;
+
+SELECT create_graph('predicate_functions');
+
+--
+-- all() predicate function
+--
+-- all elements satisfy predicate -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, 2, 3] WHERE x > 0)
+$$) AS (result agtype);
+
+-- not all elements satisfy predicate -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, 2, 3] WHERE x > 1)
+$$) AS (result agtype);
+
+-- empty list -> true (vacuous truth)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- any() predicate function
+--
+-- at least one element satisfies -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+
+-- no element satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+
+-- empty list -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- none() predicate function
+--
+-- no element satisfies predicate -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+
+-- at least one satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+
+-- empty list -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- single() predicate function
+--
+-- exactly one element satisfies -> true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 2)
+$$) AS (result agtype);
+
+-- more than one satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 1)
+$$) AS (result agtype);
+
+-- none satisfies -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [1, 2, 3] WHERE x > 5)
+$$) AS (result agtype);
+
+-- empty list -> false
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN [] WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- NULL list input: all four return null
+-- (NULL-list guard in the grammar produces CASE WHEN expr IS NULL
+-- THEN NULL ELSE <subquery> END)
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN null WHERE x > 0)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN null WHERE x > 0)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN null WHERE x > 0)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN null WHERE x > 0)
+$$) 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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [null] WHERE x > 0)
+$$) AS (result agtype);
+
+-- agtype null + real values: all comparisons are true
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [null, 1, 2] WHERE x > 0)
+$$) AS (result agtype);
+
+-- literal null predicate: pred = SQL NULL -> three-valued logic
+-- all([1] WHERE null) = null (unknown)
+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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN all(x IN [1, null, 2] WHERE x > 0)
+$$) AS (result agtype);
+
+-- -1 > 0 = false, so 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
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [null] WHERE x > 0)
+$$) AS (result agtype);
+
+-- 5 > 0 = true, so 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
+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)
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN single(x IN null WHERE x > 0)
+$$) AS (result agtype);
+
+--
+-- Integration with graph data
+--
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'even', vals: [2, 4, 6, 8]})
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'mixed', vals: [1, 2, 3, 4]})
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    CREATE ({name: 'odd', vals: [1, 3, 5, 7]})
+$$) AS (result agtype);
+
+-- all() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE all(x IN u.vals WHERE x % 2 = 0)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+
+-- any() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE any(x IN u.vals WHERE x > 6)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+
+-- none() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE none(x IN u.vals WHERE x < 0)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+
+-- single() with graph properties
+SELECT * FROM cypher('predicate_functions', $$
+    MATCH (u) WHERE single(x IN u.vals WHERE x = 8)
+    RETURN u.name
+    ORDER BY u.name
+$$) AS (result agtype);
+
+--
+-- Predicate functions in boolean expressions
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE x > 2)
+           AND all(y IN [4, 5, 6] WHERE y > 0)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN none(x IN [1, 2, 3] WHERE x > 5)
+           OR single(y IN [1, 2, 3] WHERE y = 2)
+$$) AS (result agtype);
+
+--
+-- Nested predicate functions
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN any(x IN [1, 2, 3] WHERE all(y IN [1, 2] WHERE y < x))
+$$) AS (result agtype);
+
+--
+-- Keywords as property key names (safe_keywords backward compatibility)
+--
+SELECT * FROM cypher('predicate_functions', $$
+    RETURN {any: 1, none: 2, single: 3}
+$$) AS (result agtype);
+
+--
+-- Cleanup
+--
+SELECT * FROM drop_graph('predicate_functions', true);
diff --git a/src/backend/nodes/ag_nodes.c b/src/backend/nodes/ag_nodes.c
index 7aaaecaa..bd78549c 100644
--- a/src/backend/nodes/ag_nodes.c
+++ b/src/backend/nodes/ag_nodes.c
@@ -64,7 +64,8 @@ const char *node_names[] = {
     "cypher_update_item",
     "cypher_delete_information",
     "cypher_delete_item",
-    "cypher_merge_information"
+    "cypher_merge_information",
+    "cypher_predicate_function"
 };
 
 /*
@@ -132,7 +133,8 @@ const ExtensibleNodeMethods node_methods[] = {
     DEFINE_NODE_METHODS_EXTENDED(cypher_update_item),
     DEFINE_NODE_METHODS_EXTENDED(cypher_delete_information),
     DEFINE_NODE_METHODS_EXTENDED(cypher_delete_item),
-    DEFINE_NODE_METHODS_EXTENDED(cypher_merge_information)
+    DEFINE_NODE_METHODS_EXTENDED(cypher_merge_information),
+    DEFINE_NODE_METHODS_EXTENDED(cypher_predicate_function)
 };
 
 static bool equal_ag_node(const ExtensibleNode *a, const ExtensibleNode *b)
diff --git a/src/backend/nodes/cypher_copyfuncs.c 
b/src/backend/nodes/cypher_copyfuncs.c
index 56895ee0..420ab1d2 100644
--- a/src/backend/nodes/cypher_copyfuncs.c
+++ b/src/backend/nodes/cypher_copyfuncs.c
@@ -169,3 +169,15 @@ void copy_cypher_merge_information(ExtensibleNode 
*newnode, const ExtensibleNode
     COPY_SCALAR_FIELD(merge_function_attr);
     COPY_NODE_FIELD(path);
 }
+
+/* copy function for cypher_predicate_function */
+void copy_cypher_predicate_function(ExtensibleNode *newnode,
+                                    const ExtensibleNode *from)
+{
+    COPY_LOCALS(cypher_predicate_function);
+
+    COPY_SCALAR_FIELD(kind);
+    COPY_STRING_FIELD(varname);
+    COPY_NODE_FIELD(expr);
+    COPY_NODE_FIELD(where);
+}
diff --git a/src/backend/nodes/cypher_outfuncs.c 
b/src/backend/nodes/cypher_outfuncs.c
index 4772621c..cf8a400f 100644
--- a/src/backend/nodes/cypher_outfuncs.c
+++ b/src/backend/nodes/cypher_outfuncs.c
@@ -189,6 +189,17 @@ void out_cypher_list_comprehension(StringInfo str, const 
ExtensibleNode *node)
 }
 
 
+/* serialization function for the cypher_predicate_function ExtensibleNode. */
+void out_cypher_predicate_function(StringInfo str, const ExtensibleNode *node)
+{
+    DEFINE_AG_NODE(cypher_predicate_function);
+
+    WRITE_ENUM_FIELD(kind, cypher_predicate_function_kind);
+    WRITE_STRING_FIELD(varname);
+    WRITE_NODE_FIELD(expr);
+    WRITE_NODE_FIELD(where);
+}
+
 /* serialization function for the cypher_delete ExtensibleNode. */
 void out_cypher_merge(StringInfo str, const ExtensibleNode *node)
 {
diff --git a/src/backend/nodes/cypher_readfuncs.c 
b/src/backend/nodes/cypher_readfuncs.c
index a58b90cb..14b553db 100644
--- a/src/backend/nodes/cypher_readfuncs.c
+++ b/src/backend/nodes/cypher_readfuncs.c
@@ -311,3 +311,17 @@ void read_cypher_merge_information(struct ExtensibleNode 
*node)
     READ_INT_FIELD(merge_function_attr);
     READ_NODE_FIELD(path);
 }
+
+/*
+ * Deserialize a string representing the cypher_predicate_function
+ * data structure.
+ */
+void read_cypher_predicate_function(struct ExtensibleNode *node)
+{
+    READ_LOCALS(cypher_predicate_function);
+
+    READ_ENUM_FIELD(kind, cypher_predicate_function_kind);
+    READ_STRING_FIELD(varname);
+    READ_NODE_FIELD(expr);
+    READ_NODE_FIELD(where);
+}
diff --git a/src/backend/parser/cypher_analyze.c 
b/src/backend/parser/cypher_analyze.c
index a408eea6..7844af2f 100644
--- a/src/backend/parser/cypher_analyze.c
+++ b/src/backend/parser/cypher_analyze.c
@@ -843,6 +843,22 @@ bool cypher_raw_expr_tree_walker_impl(Node *node,
                 return true;
             }
         }
+        else if (is_ag_node(node, cypher_predicate_function))
+        {
+            cypher_predicate_function *pf;
+
+            pf = (cypher_predicate_function *)node;
+
+            if (WALK(pf->expr))
+            {
+                return true;
+            }
+
+            if (WALK(pf->where))
+            {
+                return true;
+            }
+        }
         /* Add more node types here as needed */
         else
         {
diff --git a/src/backend/parser/cypher_clause.c 
b/src/backend/parser/cypher_clause.c
index 95b205c0..0ab574fe 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -25,6 +25,7 @@
 #include "postgres.h"
 
 #include "access/heapam.h"
+#include "catalog/pg_aggregate.h"
 #include "miscadmin.h"
 #include "nodes/nodeFuncs.h"
 #include "optimizer/optimizer.h"
@@ -254,6 +255,10 @@ static Query *transform_cypher_unwind(cypher_parsestate 
*cpstate,
 static Query *transform_cypher_list_comprehension(cypher_parsestate *cpstate,
                                                   cypher_clause *clause);
 
+/* predicate functions */
+static Query *transform_cypher_predicate_function(cypher_parsestate *cpstate,
+                                                  cypher_clause *clause);
+
 /* merge */
 static Query *transform_cypher_merge(cypher_parsestate *cpstate,
                                      cypher_clause *clause);
@@ -513,6 +518,10 @@ Query *transform_cypher_clause(cypher_parsestate *cpstate,
     {
         result = transform_cypher_list_comprehension(cpstate, clause);
     }
+    else if (is_ag_node(self, cypher_predicate_function))
+    {
+        result = transform_cypher_predicate_function(cpstate, clause);
+    }
     else
     {
         ereport(ERROR, (errmsg_internal("unexpected Node for cypher_clause")));
@@ -1603,6 +1612,331 @@ static Query 
*transform_cypher_list_comprehension(cypher_parsestate *cpstate,
     return query;
 }
 
+/*
+ * Helper: build a BooleanTest node (pred IS TRUE, pred IS FALSE, etc.)
+ */
+static Node *make_boolean_test(Node *arg, BoolTestType testtype)
+{
+    BooleanTest *bt = makeNode(BooleanTest);
+
+    bt->arg = (Expr *) arg;
+    bt->booltesttype = testtype;
+    bt->location = -1;
+
+    return (Node *) bt;
+}
+
+/*
+ * Helper: build a fully-transformed bool_or(expr) Aggref node.
+ *
+ * The argument must already be a transformed boolean expression.
+ * We construct the Aggref manually to avoid going through FuncCall
+ * + transformExpr, which expects raw parse tree nodes.
+ */
+static Node *make_bool_or_agg(ParseState *pstate, Node *arg)
+{
+    Aggref *agg;
+    TargetEntry *te;
+    Oid bool_or_oid;
+    Oid argtypes[1] = { BOOLOID };
+
+    /* Look up bool_or(boolean) */
+    bool_or_oid = LookupFuncName(list_make1(makeString("bool_or")),
+                                 1, argtypes, false);
+
+    /* Build the TargetEntry for the aggregate argument */
+    te = makeTargetEntry((Expr *) arg, 1, NULL, false);
+
+    /* Construct the Aggref */
+    agg = makeNode(Aggref);
+    agg->aggfnoid = bool_or_oid;
+    agg->aggtype = BOOLOID;
+    agg->aggcollid = InvalidOid;
+    agg->inputcollid = InvalidOid;
+    agg->aggtranstype = InvalidOid;  /* filled by planner */
+    agg->aggargtypes = list_make1_oid(BOOLOID);
+    agg->aggdirectargs = NIL;
+    agg->args = list_make1(te);
+    agg->aggorder = NIL;
+    agg->aggdistinct = NIL;
+    agg->aggfilter = NULL;
+    agg->aggstar = false;
+    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;
+
+    /* Register the aggregate with the parse state */
+    pstate->p_hasAggs = true;
+
+    return (Node *) agg;
+}
+
+/*
+ * Helper: build a transformed CASE expression implementing three-valued
+ * predicate logic for all(), any(), and none().
+ *
+ * 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
+ *
+ * none():  CASE WHEN bool_or(pred IS TRUE) THEN false
+ *               WHEN bool_or(pred IS NULL) THEN NULL
+ *               ELSE true 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.
+ */
+static Node *make_predicate_case_expr(ParseState *pstate, Node *pred,
+                                      cypher_predicate_function_kind kind)
+{
+    CaseExpr *cexpr;
+    CaseWhen *when1, *when2;
+    Node *bool_or_first, *bool_or_null;
+    Node *true_const, *false_const, *null_const;
+
+    /* boolean constants */
+    true_const = makeBoolConst(true, false);
+    false_const = makeBoolConst(false, false);
+    null_const = makeBoolConst(false, true);  /* isnull = true */
+
+    /* Second branch is common to all: bool_or(pred IS NULL) -> NULL */
+    bool_or_null = make_bool_or_agg(pstate,
+                                    make_boolean_test(pred, IS_UNKNOWN));
+
+    when2 = makeNode(CaseWhen);
+    when2->expr = (Expr *) bool_or_null;
+    when2->result = (Expr *) null_const;
+    when2->location = -1;
+
+    if (kind == CPFK_ALL)
+    {
+        /* bool_or(pred IS FALSE) -> false */
+        bool_or_first = make_bool_or_agg(pstate,
+                                         make_boolean_test(pred, IS_FALSE));
+        when1 = makeNode(CaseWhen);
+        when1->expr = (Expr *) bool_or_first;
+        when1->result = (Expr *) false_const;
+        when1->location = -1;
+
+        cexpr = makeNode(CaseExpr);
+        cexpr->casetype = BOOLOID;
+        cexpr->arg = NULL;
+        cexpr->args = list_make2(when1, when2);
+        cexpr->defresult = (Expr *) true_const;
+        cexpr->location = -1;
+    }
+    else if (kind == CPFK_ANY)
+    {
+        /* bool_or(pred IS TRUE) -> true */
+        bool_or_first = make_bool_or_agg(pstate,
+                                         make_boolean_test(pred, IS_TRUE));
+        when1 = makeNode(CaseWhen);
+        when1->expr = (Expr *) bool_or_first;
+        when1->result = (Expr *) true_const;
+        when1->location = -1;
+
+        cexpr = makeNode(CaseExpr);
+        cexpr->casetype = BOOLOID;
+        cexpr->arg = NULL;
+        cexpr->args = list_make2(when1, when2);
+        cexpr->defresult = (Expr *) false_const;
+        cexpr->location = -1;
+    }
+    else /* CPFK_NONE */
+    {
+        /* bool_or(pred IS TRUE) -> false */
+        bool_or_first = make_bool_or_agg(pstate,
+                                         make_boolean_test(pred, IS_TRUE));
+        when1 = makeNode(CaseWhen);
+        when1->expr = (Expr *) bool_or_first;
+        when1->result = (Expr *) false_const;
+        when1->location = -1;
+
+        cexpr = makeNode(CaseExpr);
+        cexpr->casetype = BOOLOID;
+        cexpr->arg = NULL;
+        cexpr->args = list_make2(when1, when2);
+        cexpr->defresult = (Expr *) true_const;
+        cexpr->location = -1;
+    }
+
+    return (Node *) cexpr;
+}
+
+/*
+ * Transform a cypher_predicate_function node into a query tree.
+ *
+ * Generates aggregate-based queries that preserve Cypher's three-valued
+ * NULL semantics.  The grammar layer wraps the SubLink with a
+ * CASE WHEN list IS NULL THEN NULL ELSE (subquery) END guard so all
+ * four functions return NULL when the input list is NULL.
+ *
+ * For all()/any()/none():
+ *   SELECT CASE WHEN bool_or(pred IS TRUE/FALSE) THEN ...
+ *               WHEN bool_or(pred IS NULL)       THEN NULL
+ *               ELSE ... END
+ *   FROM unnest(list) AS x
+ *
+ * For single():
+ *   SELECT count(*)
+ *   FROM unnest(list) AS x
+ *   WHERE pred IS TRUE
+ *
+ * All four use EXPR_SUBLINK so the subquery returns a scalar value.
+ */
+static Query *transform_cypher_predicate_function(cypher_parsestate *cpstate,
+                                                  cypher_clause *clause)
+{
+    Query *query;
+    RangeFunction *rf;
+    cypher_predicate_function *pred_func;
+    FuncCall *func_call;
+    Node *pred, *n;
+    RangeTblEntry *rte = NULL;
+    int rtindex;
+    List *namespace = NULL;
+    TargetEntry *te;
+    cypher_parsestate *child_cpstate = make_cypher_parsestate(cpstate);
+    ParseState *child_pstate = (ParseState *) child_cpstate;
+
+    pred_func = (cypher_predicate_function *) clause->self;
+
+    query = makeNode(Query);
+    query->commandType = CMD_SELECT;
+
+    /* FROM unnest(expr) AS varname */
+    func_call = makeFuncCall(list_make1(makeString("unnest")),
+                             list_make1(pred_func->expr),
+                             COERCE_SQL_SYNTAX, -1);
+
+    rf = makeNode(RangeFunction);
+    rf->lateral = false;
+    rf->ordinality = false;
+    rf->is_rowsfrom = false;
+    rf->functions = list_make1(list_make2((Node *) func_call, NIL));
+    rf->alias = makeAlias(pred_func->varname, NIL);
+    rf->coldeflist = NIL;
+
+    n = transform_from_clause_item(child_cpstate, (Node *) rf,
+                                   &rte, &rtindex, &namespace);
+    checkNameSpaceConflicts(child_pstate, child_pstate->p_namespace, 
namespace);
+    child_pstate->p_joinlist = lappend(child_pstate->p_joinlist, n);
+    child_pstate->p_namespace = list_concat(child_pstate->p_namespace,
+                                            namespace);
+
+    /* make all namespace items unconditionally visible */
+    setNamespaceLateralState(child_pstate->p_namespace, false, true);
+
+    /* Transform the predicate expression */
+    pred = transform_cypher_expr(child_cpstate, pred_func->where,
+                                 EXPR_KIND_WHERE);
+    if (pred)
+    {
+        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
+    {
+        /*
+         * 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,
+                                             pred_func->kind);
+
+        te = makeTargetEntry((Expr *) case_expr,
+                             (AttrNumber) child_pstate->p_next_resno++,
+                             "result", false);
+
+        query->targetList = lappend(query->targetList, te);
+        query->jointree = makeFromExpr(child_pstate->p_joinlist, NULL);
+        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;
+    }
+}
+
 /*
  * Iterate through the list of items to delete and extract the variable name.
  * Then find the resno that the variable name belongs to.
diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y
index 5ba1e635..e14c6b48 100644
--- a/src/backend/parser/cypher_gram.y
+++ b/src/backend/parser/cypher_gram.y
@@ -79,7 +79,7 @@
 %token NOT_EQ LT_EQ GT_EQ DOT_DOT TYPECAST PLUS_EQ
 
 /* keywords in alphabetical order */
-%token <keyword> ALL ANALYZE AND AS ASC ASCENDING
+%token <keyword> ALL ANALYZE AND ANY_P AS ASC ASCENDING
                  BY
                  CALL CASE COALESCE CONTAINS COUNT CREATE
                  DELETE DESC DESCENDING DETACH DISTINCT
@@ -88,10 +88,10 @@
                  IN IS
                  LIMIT
                  MATCH MERGE
-                 NOT NULL_P
+                 NONE NOT NULL_P
                  OPERATOR OPTIONAL OR ORDER
                  REMOVE RETURN
-                 SET SKIP STARTS
+                 SET SINGLE SKIP STARTS
                  THEN TRUE_P
                  UNION UNWIND
                  VERBOSE
@@ -274,11 +274,19 @@ static Node *build_comparison_expression(Node 
*left_grammar_node,
                                          Node *right_grammar_node,
                                          char *opr_name, int location);
 
+/* shared helper for list iteration constructs */
+static char *extract_iter_variable_name(Node *var);
+
 /* list_comprehension */
 static Node *build_list_comprehension_node(Node *var, Node *expr,
                                            Node *where, Node *mapping_expr,
                                            int location);
 
+/* predicate functions: all(), any(), none(), single() */
+static Node *build_predicate_function_node(cypher_predicate_function_kind kind,
+                                           Node *var, Node *expr,
+                                           Node *where, int location);
+
 /* helper functions */
 static ExplainStmt *make_explain_stmt(List *options);
 static void validate_return_item_aliases(List *items, ag_scanner_t scanner);
@@ -1853,6 +1861,22 @@ expr_func_subexpr:
                                                                          
list_make1(makeString("count")), NIL, @1);
             $$ = (Node *)n;
                }
+    | ALL '(' expr IN expr WHERE expr ')'
+        {
+            $$ = build_predicate_function_node(CPFK_ALL, $3, $5, $7, @1);
+        }
+    | ANY_P '(' expr IN expr WHERE expr ')'
+        {
+            $$ = build_predicate_function_node(CPFK_ANY, $3, $5, $7, @1);
+        }
+    | NONE '(' expr IN expr WHERE expr ')'
+        {
+            $$ = build_predicate_function_node(CPFK_NONE, $3, $5, $7, @1);
+        }
+    | SINGLE '(' expr IN expr WHERE expr ')'
+        {
+            $$ = build_predicate_function_node(CPFK_SINGLE, $3, $5, $7, @1);
+        }
     ;
 
 expr_subquery:
@@ -2372,6 +2396,7 @@ safe_keywords:
     ALL          { $$ = KEYWORD_STRDUP($1); }
     | ANALYZE    { $$ = KEYWORD_STRDUP($1); }
     | AND        { $$ = KEYWORD_STRDUP($1); }
+    | ANY_P      { $$ = KEYWORD_STRDUP($1); }
     | AS         { $$ = KEYWORD_STRDUP($1); }
     | ASC        { $$ = KEYWORD_STRDUP($1); }
     | ASCENDING  { $$ = KEYWORD_STRDUP($1); }
@@ -2396,6 +2421,7 @@ safe_keywords:
     | LIMIT      { $$ = KEYWORD_STRDUP($1); }
     | MATCH      { $$ = KEYWORD_STRDUP($1); }
     | MERGE      { $$ = KEYWORD_STRDUP($1); }
+    | NONE       { $$ = KEYWORD_STRDUP($1); }
     | NOT        { $$ = KEYWORD_STRDUP($1); }
     | OPERATOR   { $$ = KEYWORD_STRDUP($1); }
     | OPTIONAL   { $$ = KEYWORD_STRDUP($1); }
@@ -2404,6 +2430,7 @@ safe_keywords:
     | REMOVE     { $$ = KEYWORD_STRDUP($1); }
     | RETURN     { $$ = KEYWORD_STRDUP($1); }
     | SET        { $$ = KEYWORD_STRDUP($1); }
+    | SINGLE     { $$ = KEYWORD_STRDUP($1); }
     | SKIP       { $$ = KEYWORD_STRDUP($1); }
     | STARTS     { $$ = KEYWORD_STRDUP($1); }
     | THEN       { $$ = KEYWORD_STRDUP($1); }
@@ -3268,35 +3295,66 @@ static cypher_relationship *build_VLE_relation(List 
*left_arg,
     return cr;
 }
 
-/* helper function to build a list_comprehension grammar node */
-static Node *build_list_comprehension_node(Node *var, Node *expr,
-                                           Node *where, Node *mapping_expr,
-                                           int location)
+/*
+ * Extract and validate the iterator variable name from a ColumnRef node.
+ * Used by predicate functions (all/any/none/single) which share the
+ * "variable IN list" syntax with list comprehensions.
+ */
+static char *extract_iter_variable_name(Node *var)
 {
-    SubLink *sub;
+    ColumnRef *cref;
     String *val;
-    ColumnRef *cref = NULL;
-    cypher_list_comprehension *list_comp = NULL;
 
     if (!IsA(var, ColumnRef))
     {
         ereport(ERROR,
                 (errcode(ERRCODE_SYNTAX_ERROR),
-                 errmsg("Syntax error at or near IN")));
+                 errmsg("syntax error at or near IN")));
     }
 
     cref = (ColumnRef *)var;
+
+    /* The iterator must be a simple unqualified name (single field) */
+    if (list_length(cref->fields) != 1)
+    {
+        ereport(ERROR,
+                (errcode(ERRCODE_SYNTAX_ERROR),
+                 errmsg("qualified name not allowed as iterator variable")));
+    }
+
     val = linitial(cref->fields);
     if (!IsA(val, String))
     {
         ereport(ERROR,
                 (errcode(ERRCODE_SYNTAX_ERROR),
-                 errmsg("Invalid list comprehension variable name")));
+                 errmsg("invalid iterator variable name")));
     }
 
+    return val->sval;
+}
+
+/* helper function to build a list_comprehension grammar node */
+static Node *build_list_comprehension_node(Node *var, Node *expr,
+                                           Node *where, Node *mapping_expr,
+                                           int location)
+{
+    SubLink *sub;
+    ColumnRef *cref;
+    char *varname;
+    cypher_list_comprehension *list_comp = NULL;
+
+    /*
+     * Reuse the shared iterator-name helper so list comprehensions and
+     * predicate functions (all/any/none/single) validate `var IN list`
+     * the same way — in particular, qualified ColumnRefs like `x.y` are
+     * rejected rather than silently treated as iterator `x`.
+     */
+    varname = extract_iter_variable_name(var);
+    cref = (ColumnRef *) var;
+
     /* build the list comprehension node */
     list_comp = make_ag_node(cypher_list_comprehension);
-    list_comp->varname = val->sval;
+    list_comp->varname = varname;
     list_comp->expr = expr;
     list_comp->where = where;
     list_comp->mapping_expr = (mapping_expr != NULL) ? mapping_expr :
@@ -3317,6 +3375,119 @@ static Node *build_list_comprehension_node(Node *var, 
Node *expr,
     return (Node *) node_to_agtype((Node *)sub, "agtype[]", location);
 }
 
+/*
+ * Helper function to build a predicate function grammar node.
+ *
+ * Predicate functions follow the openCypher syntax:
+ *   all(x IN list WHERE predicate)
+ *   any(x IN list WHERE predicate)
+ *   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.
+ */
+static Node *build_predicate_function_node(cypher_predicate_function_kind kind,
+                                           Node *var, Node *expr,
+                                           Node *where, int location)
+{
+    SubLink *sub;
+    cypher_predicate_function *pred_func = NULL;
+    Node *result;
+
+    /* build the predicate function node */
+    pred_func = make_ag_node(cypher_predicate_function);
+    pred_func->kind = kind;
+    pred_func->varname = extract_iter_variable_name(var);
+    pred_func->expr = expr;
+    pred_func->where = where;
+
+    /*
+     * Wrap the predicate function in a SubLink. PostgreSQL's SubLink is
+     * reused here as the carrier for our custom subquery node -- the
+     * predicate function node is stored as the subselect and will be
+     * transformed into a real Query by transform_cypher_predicate_function()
+     * in cypher_clause.c.
+     *
+     * All predicate functions now use EXPR_SUBLINK: the transform layer
+     * generates aggregate-based queries that return a scalar boolean
+     * (for all/any/none) or integer (for single).
+     *
+     * The transform layer also wraps the result with a NULL-list guard
+     * (CASE WHEN list IS NULL THEN NULL ELSE <result> END) to ensure
+     * all four functions return NULL when the input list is NULL.
+     */
+    sub = makeNode(SubLink);
+    sub->subLinkId = 0;
+    sub->testexpr = NULL;
+    sub->operName = NIL;
+    sub->subselect = (Node *) pred_func;
+    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);
+    }
+
+    /*
+     * NULL-list guard: CASE WHEN expr IS NULL THEN NULL ELSE result END
+     *
+     * Without this, unnest(NULL) produces zero rows, causing all/any/none
+     * to accidentally return NULL (via bool_or over empty input) and
+     * single to return false (count(*) = 0).  Cypher semantics require
+     * all four to return NULL when the input list is NULL.
+     *
+     * The expr pointer is shared with pred_func->expr.  This is safe
+     * because AGE's expression transformer (transform_cypher_expr_recurse)
+     * creates new nodes rather than modifying the parse tree in-place,
+     * so the two references are transformed independently.
+     */
+    {
+        NullTest *null_test = makeNode(NullTest);
+        CaseWhen *case_when = makeNode(CaseWhen);
+        CaseExpr *guard = makeNode(CaseExpr);
+
+        null_test->arg = (Expr *) expr;
+        null_test->nulltesttype = IS_NULL;
+        null_test->argisrow = false;
+        null_test->location = location;
+
+        case_when->expr = (Expr *) null_test;
+        case_when->result = (Expr *) make_null_const(location);
+        case_when->location = location;
+
+        guard->arg = NULL;
+        guard->args = list_make1(case_when);
+        guard->defresult = (Expr *) result;
+        guard->location = location;
+
+        return (Node *) guard;
+    }
+}
+
 /* Helper function to create an ExplainStmt node */
 static ExplainStmt *make_explain_stmt(List *options)
 {
diff --git a/src/include/nodes/ag_nodes.h b/src/include/nodes/ag_nodes.h
index 121832c0..47c55041 100644
--- a/src/include/nodes/ag_nodes.h
+++ b/src/include/nodes/ag_nodes.h
@@ -75,7 +75,9 @@ typedef enum ag_node_tag
     /* delete data structures */
     cypher_delete_information_t,
     cypher_delete_item_t,
-    cypher_merge_information_t
+    cypher_merge_information_t,
+    /* predicate functions */
+    cypher_predicate_function_t
 } ag_node_tag;
 
 extern const char *node_names[];
diff --git a/src/include/nodes/cypher_copyfuncs.h 
b/src/include/nodes/cypher_copyfuncs.h
index d7ed7eff..e770cebe 100644
--- a/src/include/nodes/cypher_copyfuncs.h
+++ b/src/include/nodes/cypher_copyfuncs.h
@@ -52,4 +52,8 @@ void copy_cypher_delete_item(ExtensibleNode *newnode,
 /* merge data structure */
 void copy_cypher_merge_information(ExtensibleNode *newnode,
                                    const ExtensibleNode *from);
+
+/* predicate function data structure */
+void copy_cypher_predicate_function(ExtensibleNode *newnode,
+                                    const ExtensibleNode *from);
 #endif
diff --git a/src/include/nodes/cypher_nodes.h b/src/include/nodes/cypher_nodes.h
index db47eb31..93bbe01d 100644
--- a/src/include/nodes/cypher_nodes.h
+++ b/src/include/nodes/cypher_nodes.h
@@ -224,6 +224,27 @@ typedef struct cypher_map_projection
     Node *mapping_expr;
  } cypher_list_comprehension;
 
+/*
+ * Predicate function kinds for all(), any(), none(), single().
+ * These take the form: func(variable IN list WHERE predicate)
+ */
+typedef enum cypher_predicate_function_kind
+{
+    CPFK_ALL = 0,
+    CPFK_ANY,
+    CPFK_NONE,
+    CPFK_SINGLE
+} cypher_predicate_function_kind;
+
+typedef struct cypher_predicate_function
+{
+    ExtensibleNode extensible;
+    cypher_predicate_function_kind kind;
+    char *varname;
+    Node *expr;     /* the list to iterate over */
+    Node *where;    /* the predicate to test */
+} cypher_predicate_function;
+
 typedef enum cypher_map_projection_element_type
 {
     PROPERTY_SELECTOR = 0,  /* map_var { .key } */
diff --git a/src/include/nodes/cypher_outfuncs.h 
b/src/include/nodes/cypher_outfuncs.h
index 418d35f4..55285bdb 100644
--- a/src/include/nodes/cypher_outfuncs.h
+++ b/src/include/nodes/cypher_outfuncs.h
@@ -49,6 +49,7 @@ void out_cypher_map(StringInfo str, const ExtensibleNode 
*node);
 void out_cypher_map_projection(StringInfo str, const ExtensibleNode *node);
 void out_cypher_list(StringInfo str, const ExtensibleNode *node);
 void out_cypher_list_comprehension(StringInfo str, const ExtensibleNode *node);
+void out_cypher_predicate_function(StringInfo str, const ExtensibleNode *node);
 
 /* comparison expression */
 void out_cypher_comparison_aexpr(StringInfo str, const ExtensibleNode *node);
diff --git a/src/include/nodes/cypher_readfuncs.h 
b/src/include/nodes/cypher_readfuncs.h
index 5792332a..9202ba51 100644
--- a/src/include/nodes/cypher_readfuncs.h
+++ b/src/include/nodes/cypher_readfuncs.h
@@ -51,4 +51,7 @@ void read_cypher_delete_item(struct ExtensibleNode *node);
 
 void read_cypher_merge_information(struct ExtensibleNode *node);
 
+/* predicate function data structure */
+void read_cypher_predicate_function(struct ExtensibleNode *node);
+
 #endif
diff --git a/src/include/parser/cypher_kwlist.h 
b/src/include/parser/cypher_kwlist.h
index e4c4437b..0de29497 100644
--- a/src/include/parser/cypher_kwlist.h
+++ b/src/include/parser/cypher_kwlist.h
@@ -1,6 +1,7 @@
 PG_KEYWORD("all", ALL, RESERVED_KEYWORD)
 PG_KEYWORD("analyze", ANALYZE, RESERVED_KEYWORD)
 PG_KEYWORD("and", AND, RESERVED_KEYWORD)
+PG_KEYWORD("any", ANY_P, RESERVED_KEYWORD)
 PG_KEYWORD("as", AS, RESERVED_KEYWORD)
 PG_KEYWORD("asc", ASC, RESERVED_KEYWORD)
 PG_KEYWORD("ascending", ASCENDING, RESERVED_KEYWORD)
@@ -27,6 +28,7 @@ PG_KEYWORD("is", IS, RESERVED_KEYWORD)
 PG_KEYWORD("limit", LIMIT, RESERVED_KEYWORD)
 PG_KEYWORD("match", MATCH, RESERVED_KEYWORD)
 PG_KEYWORD("merge", MERGE, RESERVED_KEYWORD)
+PG_KEYWORD("none", NONE, RESERVED_KEYWORD)
 PG_KEYWORD("not", NOT, RESERVED_KEYWORD)
 PG_KEYWORD("null", NULL_P, RESERVED_KEYWORD)
 PG_KEYWORD("operator", OPERATOR, RESERVED_KEYWORD)
@@ -36,6 +38,7 @@ PG_KEYWORD("order", ORDER, RESERVED_KEYWORD)
 PG_KEYWORD("remove", REMOVE, RESERVED_KEYWORD)
 PG_KEYWORD("return", RETURN, RESERVED_KEYWORD)
 PG_KEYWORD("set", SET, RESERVED_KEYWORD)
+PG_KEYWORD("single", SINGLE, RESERVED_KEYWORD)
 PG_KEYWORD("skip", SKIP, RESERVED_KEYWORD)
 PG_KEYWORD("starts", STARTS, RESERVED_KEYWORD)
 PG_KEYWORD("then", THEN, RESERVED_KEYWORD)

Reply via email to