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

jrgemignani 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 2bc8e95f Support pattern expressions as boolean expressions (#2360)
2bc8e95f is described below

commit 2bc8e95f00193e33a0ba8cf8839d7a97f65beeb8
Author: Greg Felice <[email protected]>
AuthorDate: Mon Jun 22 12:34:31 2026 -0400

    Support pattern expressions as boolean expressions (#2360)
    
    * Support pattern expressions in WHERE clause via GLR parser (issue #1577)
    
    Enable bare graph patterns as boolean expressions in WHERE clauses:
    
      MATCH (a:Person), (b:Person)
      WHERE (a)-[:KNOWS]->(b)        -- now valid, equivalent to EXISTS(...)
      RETURN a.name, b.name
    
    Previously, this required wrapping in EXISTS():
      WHERE EXISTS((a)-[:KNOWS]->(b))
    
    The bare pattern syntax is standard openCypher and is used extensively
    in Neo4j.  Its absence was the most frequently cited migration blocker.
    
    Implementation approach:
    - Switch the Cypher parser from LALR(1) to Bison GLR mode.  GLR handles
      the inherent ambiguity between parenthesized expressions '(' expr ')'
      and graph path nodes '(' var_name label_opt props ')' by forking the
      parse stack and discarding the failing path.
    - Add anonymous_path as an expr_atom alternative with %dprec 1 (lower
      priority than expression path at %dprec 2).  The action wraps the
      pattern in a cypher_sub_pattern + EXISTS SubLink, reusing the same
      transform_cypher_sub_pattern() machinery as explicit EXISTS().
    - Extract make_exists_pattern_sublink() helper shared by both
      EXISTS(pattern) and bare pattern rules.
    - Fix YYLLOC_DEFAULT to use YYRHSLOC() for GLR compatibility.
    - %dprec annotations on expr_var/var_name_opt resolve the reduce/reduce
      conflict between expression variables and pattern node variables.
    
    Conflict budget: 7 shift/reduce (path extension vs arithmetic on -/<),
    3 reduce/reduce (expr_var vs var_name_opt on )/}/=).  All are expected
    and handled correctly by GLR forking + %dprec disambiguation.
    
    All 32 regression tests pass (31 existing + 1 new).  New
    pattern_expression test covers: bare patterns, NOT patterns, labeled
    nodes, AND/OR combinations, left-directed patterns, anonymous nodes,
    multi-hop patterns, EXISTS() backward compatibility, and non-pattern
    expression regression checks.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address Copilot review: comment placement, %expect docs, test wording
    
    1. Move "Helper function to create an ExplainStmt node" comment from
       above make_exists_pattern_sublink() to above make_explain_stmt()
       where it belongs.
    
    2. Add block comment documenting the %expect/%expect-rr conflict
       budget: 7 S/R from path vs arithmetic on - and <, 3 R/R from
       expr_var vs var_name_opt on ) } =.
    
    3. Clarify test comment: "Regular expressions" -> "Regular (non-pattern)
       expressions" to avoid confusion with regex.
    
    Regression test: pattern_expression OK.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address Copilot round 3: broaden scope, remove %expect fragility
    
    - Pattern expressions are now accepted anywhere an expr is valid
      (RETURN, WITH, SET, CASE, boolean combinations), not only WHERE.
      This matches openCypher semantics and documents the broader surface
      area that was already implicitly enabled by adding anonymous_path
      to expr_atom.  Added regression tests for each new context:
      RETURN projection (bare and AS-aliased), mixed with other
      projections, CASE WHEN, boolean AND/OR combinators, SET to
      persist a computed boolean property, and WITH ... WHERE pipeline.
    
    - Remove the hardcoded `%expect 7` / `%expect-rr 3` conflict budget
      from cypher_gram.y.  The exact conflict counts can drift across
      Bison versions and distros, which would break builds even though
      the grammar is correct (GLR handles the conflicts at runtime via
      fork + %dprec).  Instead, pass -Wno-conflicts-sr / -Wno-conflicts-rr
      via BISONFLAGS in the Makefile so the build stays clean without
      binding us to a specific Bison release.  Kept a block comment in
      the grammar explaining why GLR conflicts are expected and how
      they resolve.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
    
    * Address jrgemignani review: keep -Werror, restore %expect budget
    
    Reverts the broad `-Werror` drop and the no-%expect approach from the
    prior round on jrgemignani's request.  The earlier framing — that
    conflict counts drift across Bison versions, so %expect is fragile —
    overcorrected: it removed the only build-time alarm bell for unintended
    new conflicts.
    
    Makefile: keep -Werror so any unexpected Bison warning (unused rules,
    undeclared types, etc.) still fails the build; downgrade only the two
    conflict categories to plain warnings via -Wno-error=conflicts-sr
    -Wno-error=conflicts-rr.  pgxs auto-adds -Wno-deprecated, so existing
    %name-prefix= / %pure-parser directives remain non-erroring.
    
    cypher_gram.y: add `%expect 7` and `%expect-rr 3` matching the
    Bison 3.8.2 totals.  Bison treats %expect as exact-match, not as a
    ceiling — any deviation fails the build and forces an audit of the new
    conflicts.  Comment updated to reflect that future Bison versions
    reporting different counts should bump the numbers explicitly with a
    version note in the commit message, rather than removing the directive.
    
    No grammar or runtime change.  Cassert installcheck 34/34 AGE tests
    green.
    
    * Add follow-up regression coverage for pattern expressions (#2360)
    
    Addresses the non-blocking test-coverage follow-ups from the review:
    pattern expressions in additional contexts opened up by allowing
    anonymous_path as an expr_atom.
    
    New cases (all verified against a PG18 build):
    - Single-node pattern on a bound variable (a:Label). Documented as an
      EXISTS existence check, NOT an openCypher label predicate: a matching
      label is always true, and a non-matching label hits AGE's pre-existing
      "multiple labels for variable" restriction (captured as expected error).
    - Pattern expressions inside list and map literals.
    - Pattern expressions as function arguments: collect() shows correct
      per-row booleans; count() counts all rows (non-null bool) -- documented
      so the value is not mistaken for a bug.
    - Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving).
    - EXISTS() and a bare pattern combined in one predicate.
    
    make installcheck: 33/33 green.
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
 Makefile                                |  17 +-
 regress/expected/pattern_expression.out | 457 ++++++++++++++++++++++++++++++++
 regress/sql/pattern_expression.sql      | 305 +++++++++++++++++++++
 src/backend/parser/cypher_gram.y        | 101 +++++--
 4 files changed, 859 insertions(+), 21 deletions(-)

diff --git a/Makefile b/Makefile
index 41208ee0..b0059213 100644
--- a/Makefile
+++ b/Makefile
@@ -215,6 +215,7 @@ REGRESS = scan \
           jsonb_operators \
           list_comprehension \
           predicate_functions \
+          pattern_expression \
           map_projection \
           direct_field_access \
           security \
@@ -282,7 +283,21 @@ src/include/parser/cypher_kwlist_d.h: 
src/include/parser/cypher_kwlist.h $(GEN_K
 
 src/include/parser/cypher_gram_def.h: src/backend/parser/cypher_gram.c
 
-src/backend/parser/cypher_gram.c: BISONFLAGS += 
--defines=src/include/parser/cypher_gram_def.h -Werror
+#
+# The Cypher grammar uses GLR mode with a number of inherent shift/reduce
+# and reduce/reduce conflicts arising from the ambiguity between
+# parenthesized expressions and graph patterns (both start with '(').
+# GLR handles these correctly at runtime by forking at the conflict
+# point; %dprec annotations resolve cases where both forks succeed.
+#
+# We keep -Werror so any unexpected Bison warning (unused rules, undeclared
+# types, etc.) still fails the build; we downgrade only the two conflict
+# categories to plain warnings via -Wno-error=.  The exact conflict totals
+# are pinned by %expect / %expect-rr in cypher_gram.y, which Bison treats
+# as exact-match: any deviation fails the build and forces an audit of
+# the new conflicts.
+#
+src/backend/parser/cypher_gram.c: BISONFLAGS += 
--defines=src/include/parser/cypher_gram_def.h -Werror -Wno-error=conflicts-sr 
-Wno-error=conflicts-rr
 
 src/backend/parser/cypher_parser.o: src/backend/parser/cypher_gram.c 
src/include/parser/cypher_gram_def.h
 src/backend/parser/cypher_parser.bc: src/backend/parser/cypher_gram.c 
src/include/parser/cypher_gram_def.h
diff --git a/regress/expected/pattern_expression.out 
b/regress/expected/pattern_expression.out
new file mode 100644
index 00000000..0494d49b
--- /dev/null
+++ b/regress/expected/pattern_expression.out
@@ -0,0 +1,457 @@
+/*
+ * 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('pattern_expr');
+NOTICE:  graph "pattern_expr" has been created
+ create_graph 
+--------------
+ 
+(1 row)
+
+--
+-- Setup test data
+--
+SELECT * FROM cypher('pattern_expr', $$
+    CREATE (alice:Person {name: 'Alice'})-[:KNOWS]->(bob:Person {name: 'Bob'}),
+           (alice)-[:WORKS_WITH]->(charlie:Person {name: 'Charlie'}),
+           (dave:Person {name: 'Dave'})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+--
+-- Basic pattern expression in WHERE
+--
+-- Bare pattern: (a)-[:REL]->(b)
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+    a    |   b   
+---------+-------
+ "Alice" | "Bob"
+(1 row)
+
+--
+-- NOT pattern expression
+--
+-- Find people who don't KNOW anyone
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE NOT (a)-[:KNOWS]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+  result   
+-----------
+ "Bob"
+ "Charlie"
+ "Dave"
+(3 rows)
+
+--
+-- Pattern with labeled first node
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a:Person)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+    a    |   b   
+---------+-------
+ "Alice" | "Bob"
+(1 row)
+
+--
+-- Pattern combined with AND
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b) AND a.name = 'Alice'
+    RETURN a.name, b.name
+$$) AS (a agtype, b agtype);
+    a    |   b   
+---------+-------
+ "Alice" | "Bob"
+(1 row)
+
+--
+-- Pattern combined with OR
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b) OR (a)-[:WORKS_WITH]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+    a    |     b     
+---------+-----------
+ "Alice" | "Bob"
+ "Alice" | "Charlie"
+(2 rows)
+
+--
+-- Left-directed pattern
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)<-[:KNOWS]-(b)
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+   a   |    b    
+-------+---------
+ "Bob" | "Alice"
+(1 row)
+
+--
+-- Pattern with anonymous nodes
+--
+-- Find anyone who has any outgoing KNOWS relationship
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE (a)-[:KNOWS]->()
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+ result  
+---------
+ "Alice"
+(1 row)
+
+--
+-- Multiple relationship pattern
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (c:Person)
+    WHERE (a)-[:KNOWS]->()-[:WORKS_WITH]->(c)
+    RETURN a.name, c.name
+    ORDER BY a.name
+$$) AS (a agtype, c agtype);
+ a | c 
+---+---
+(0 rows)
+
+--
+-- Existing EXISTS() syntax still works (backward compatibility)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(b))
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+    a    |   b   
+---------+-------
+ "Alice" | "Bob"
+(1 row)
+
+--
+-- Pattern expression produces same results as EXISTS()
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE (a)-[:KNOWS]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+ result  
+---------
+ "Alice"
+(1 row)
+
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(:Person))
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+ result  
+---------
+ "Alice"
+(1 row)
+
+--
+-- Regular (non-pattern) expressions still work (no regression)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    RETURN (1 + 2)
+$$) AS (result agtype);
+ result 
+--------
+ 3
+(1 row)
+
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (n:Person)
+    WHERE n.name = 'Alice'
+    RETURN (n.name)
+$$) AS (result agtype);
+ result  
+---------
+ "Alice"
+(1 row)
+
+--
+-- Pattern expressions in RETURN (boolean projection)
+--
+-- Each person gets a column showing whether they know someone
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows_someone
+    ORDER BY a.name
+$$) AS (name agtype, knows_someone agtype);
+   name    | knows_someone 
+-----------+---------------
+ "Alice"   | true
+ "Bob"     | false
+ "Charlie" | false
+ "Dave"    | false
+(4 rows)
+
+-- Mix pattern expression with other projections
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)
+    ORDER BY a.name
+$$) AS (name agtype, knows agtype, works_with agtype);
+   name    | knows | works_with 
+-----------+-------+------------
+ "Alice"   | true  | true
+ "Bob"     | false | false
+ "Charlie" | false | false
+ "Dave"    | false | false
+(4 rows)
+
+--
+-- Pattern expressions in CASE WHEN
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name,
+           CASE WHEN (a)-[:KNOWS]->(:Person) THEN 'social'
+                ELSE 'loner'
+           END
+    ORDER BY a.name
+$$) AS (name agtype, kind agtype);
+   name    |   kind   
+-----------+----------
+ "Alice"   | "social"
+ "Bob"     | "loner"
+ "Charlie" | "loner"
+ "Dave"    | "loner"
+(4 rows)
+
+--
+-- Pattern expressions combined with boolean operators in RETURN
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name,
+           (a)-[:KNOWS]->(:Person) AND (a)-[:WORKS_WITH]->(:Person) AS 
has_both,
+           (a)-[:KNOWS]->(:Person) OR (a)-[:WORKS_WITH]->(:Person) AS 
has_either
+    ORDER BY a.name
+$$) AS (name agtype, has_both agtype, has_either agtype);
+   name    | has_both | has_either 
+-----------+----------+------------
+ "Alice"   | true     | true
+ "Bob"     | false    | false
+ "Charlie" | false    | false
+ "Dave"    | false    | false
+(4 rows)
+
+--
+-- Pattern expression in SET (store boolean as property)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    SET a.is_social = (a)-[:KNOWS]->(:Person)
+    RETURN a.name, a.is_social
+    ORDER BY a.name
+$$) AS (name agtype, is_social agtype);
+   name    | is_social 
+-----------+-----------
+ "Alice"   | true
+ "Bob"     | false
+ "Charlie" | false
+ "Dave"    | false
+(4 rows)
+
+--
+-- Pattern expression in WITH (carry boolean through pipeline)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WITH a.name AS name, (a)-[:KNOWS]->(:Person) AS knows
+    WHERE knows
+    RETURN name
+    ORDER BY name
+$$) AS (result agtype);
+ result  
+---------
+ "Alice"
+(1 row)
+
+--
+-- Follow-up coverage (review #2360): pattern expressions in additional
+-- expression contexts opened up by allowing anonymous_path as an expr_atom.
+--
+--
+-- Single-node pattern on an already-bound variable: (a:Label)
+--
+-- NOTE: this is an EXISTS existence check on the bound variable, NOT an
+-- openCypher label predicate. A matching label is therefore always true
+-- (the variable is already bound), and a *different* label is rejected by
+-- AGE's pre-existing "multiple labels for variable" restriction rather than
+-- evaluating to false. Both behaviours are captured here so any future change
+-- to single-node-pattern semantics is caught by this test.
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a:Person)
+    ORDER BY a.name
+$$) AS (name agtype, is_person agtype);
+   name    | is_person 
+-----------+-----------
+ "Alice"   | true
+ "Bob"     | true
+ "Charlie" | true
+ "Dave"    | true
+(4 rows)
+
+-- A non-matching label errors (pre-existing limitation, not a regression)
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a:Animal)
+    ORDER BY a.name
+$$) AS (name agtype, is_animal agtype);
+ERROR:  multiple labels for variable 'a' are not supported
+LINE 3:     RETURN a.name, (a:Animal)
+                            ^
+--
+-- Pattern expressions inside a list literal
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, [(a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)]
+    ORDER BY a.name
+$$) AS (name agtype, flags agtype);
+   name    |     flags      
+-----------+----------------
+ "Alice"   | [true, true]
+ "Bob"     | [false, false]
+ "Charlie" | [false, false]
+ "Dave"    | [false, false]
+(4 rows)
+
+--
+-- Pattern expressions inside a map literal
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, {knows: (a)-[:KNOWS]->(:Person), works: 
(a)-[:WORKS_WITH]->(:Person)}
+    ORDER BY a.name
+$$) AS (name agtype, m agtype);
+   name    |                m                 
+-----------+----------------------------------
+ "Alice"   | {"knows": true, "works": true}
+ "Bob"     | {"knows": false, "works": false}
+ "Charlie" | {"knows": false, "works": false}
+ "Dave"    | {"knows": false, "works": false}
+(4 rows)
+
+--
+-- Pattern expressions as function arguments
+--
+-- collect() shows the per-row boolean values are correct (ORDER BY before
+-- the aggregate so the collected order is deterministic across scan plans).
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WITH a ORDER BY a.name
+    RETURN collect((a)-[:KNOWS]->(:Person))
+$$) AS (vals agtype);
+            vals             
+-----------------------------
+ [true, false, false, false]
+(1 row)
+
+-- count() counts non-null values; a boolean (including false) is non-null,
+-- so this counts every row rather than only the matching ones. This is the
+-- expected SQL aggregate semantics, documented here so the value is not
+-- mistaken for a bug.
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN count((a)-[:KNOWS]->(:Person))
+$$) AS (c agtype);
+ c 
+---
+ 4
+(1 row)
+
+--
+-- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    OPTIONAL MATCH (b:Person) WHERE (a)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+     a     |   b   
+-----------+-------
+ "Alice"   | "Bob"
+ "Bob"     | 
+ "Charlie" | 
+ "Dave"    | 
+(4 rows)
+
+--
+-- EXISTS() and a bare pattern combined in a single predicate
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(:Person)) AND (a)-[:WORKS_WITH]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (name agtype);
+  name   
+---------
+ "Alice"
+(1 row)
+
+--
+-- Cleanup
+--
+SELECT * FROM drop_graph('pattern_expr', true);
+NOTICE:  drop cascades to 5 other objects
+DETAIL:  drop cascades to table pattern_expr._ag_label_vertex
+drop cascades to table pattern_expr._ag_label_edge
+drop cascades to table pattern_expr."Person"
+drop cascades to table pattern_expr."KNOWS"
+drop cascades to table pattern_expr."WORKS_WITH"
+NOTICE:  graph "pattern_expr" has been dropped
+ drop_graph 
+------------
+ 
+(1 row)
+
diff --git a/regress/sql/pattern_expression.sql 
b/regress/sql/pattern_expression.sql
new file mode 100644
index 00000000..fff8476e
--- /dev/null
+++ b/regress/sql/pattern_expression.sql
@@ -0,0 +1,305 @@
+/*
+ * 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('pattern_expr');
+
+--
+-- Setup test data
+--
+SELECT * FROM cypher('pattern_expr', $$
+    CREATE (alice:Person {name: 'Alice'})-[:KNOWS]->(bob:Person {name: 'Bob'}),
+           (alice)-[:WORKS_WITH]->(charlie:Person {name: 'Charlie'}),
+           (dave:Person {name: 'Dave'})
+$$) AS (result agtype);
+
+--
+-- Basic pattern expression in WHERE
+--
+-- Bare pattern: (a)-[:REL]->(b)
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+
+--
+-- NOT pattern expression
+--
+-- Find people who don't KNOW anyone
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE NOT (a)-[:KNOWS]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+
+--
+-- Pattern with labeled first node
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a:Person)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+
+--
+-- Pattern combined with AND
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b) AND a.name = 'Alice'
+    RETURN a.name, b.name
+$$) AS (a agtype, b agtype);
+
+--
+-- Pattern combined with OR
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)-[:KNOWS]->(b) OR (a)-[:WORKS_WITH]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+
+--
+-- Left-directed pattern
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE (a)<-[:KNOWS]-(b)
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+
+--
+-- Pattern with anonymous nodes
+--
+-- Find anyone who has any outgoing KNOWS relationship
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE (a)-[:KNOWS]->()
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+
+--
+-- Multiple relationship pattern
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (c:Person)
+    WHERE (a)-[:KNOWS]->()-[:WORKS_WITH]->(c)
+    RETURN a.name, c.name
+    ORDER BY a.name
+$$) AS (a agtype, c agtype);
+
+--
+-- Existing EXISTS() syntax still works (backward compatibility)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person), (b:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(b))
+    RETURN a.name, b.name
+    ORDER BY a.name
+$$) AS (a agtype, b agtype);
+
+--
+-- Pattern expression produces same results as EXISTS()
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE (a)-[:KNOWS]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(:Person))
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (result agtype);
+
+--
+-- Regular (non-pattern) expressions still work (no regression)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    RETURN (1 + 2)
+$$) AS (result agtype);
+
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (n:Person)
+    WHERE n.name = 'Alice'
+    RETURN (n.name)
+$$) AS (result agtype);
+
+--
+-- Pattern expressions in RETURN (boolean projection)
+--
+-- Each person gets a column showing whether they know someone
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows_someone
+    ORDER BY a.name
+$$) AS (name agtype, knows_someone agtype);
+
+-- Mix pattern expression with other projections
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)
+    ORDER BY a.name
+$$) AS (name agtype, knows agtype, works_with agtype);
+
+--
+-- Pattern expressions in CASE WHEN
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name,
+           CASE WHEN (a)-[:KNOWS]->(:Person) THEN 'social'
+                ELSE 'loner'
+           END
+    ORDER BY a.name
+$$) AS (name agtype, kind agtype);
+
+--
+-- Pattern expressions combined with boolean operators in RETURN
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name,
+           (a)-[:KNOWS]->(:Person) AND (a)-[:WORKS_WITH]->(:Person) AS 
has_both,
+           (a)-[:KNOWS]->(:Person) OR (a)-[:WORKS_WITH]->(:Person) AS 
has_either
+    ORDER BY a.name
+$$) AS (name agtype, has_both agtype, has_either agtype);
+
+--
+-- Pattern expression in SET (store boolean as property)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    SET a.is_social = (a)-[:KNOWS]->(:Person)
+    RETURN a.name, a.is_social
+    ORDER BY a.name
+$$) AS (name agtype, is_social agtype);
+
+--
+-- Pattern expression in WITH (carry boolean through pipeline)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WITH a.name AS name, (a)-[:KNOWS]->(:Person) AS knows
+    WHERE knows
+    RETURN name
+    ORDER BY name
+$$) AS (result agtype);
+
+--
+-- Follow-up coverage (review #2360): pattern expressions in additional
+-- expression contexts opened up by allowing anonymous_path as an expr_atom.
+--
+
+--
+-- Single-node pattern on an already-bound variable: (a:Label)
+--
+-- NOTE: this is an EXISTS existence check on the bound variable, NOT an
+-- openCypher label predicate. A matching label is therefore always true
+-- (the variable is already bound), and a *different* label is rejected by
+-- AGE's pre-existing "multiple labels for variable" restriction rather than
+-- evaluating to false. Both behaviours are captured here so any future change
+-- to single-node-pattern semantics is caught by this test.
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a:Person)
+    ORDER BY a.name
+$$) AS (name agtype, is_person agtype);
+
+-- A non-matching label errors (pre-existing limitation, not a regression)
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, (a:Animal)
+    ORDER BY a.name
+$$) AS (name agtype, is_animal agtype);
+
+--
+-- Pattern expressions inside a list literal
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, [(a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)]
+    ORDER BY a.name
+$$) AS (name agtype, flags agtype);
+
+--
+-- Pattern expressions inside a map literal
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN a.name, {knows: (a)-[:KNOWS]->(:Person), works: 
(a)-[:WORKS_WITH]->(:Person)}
+    ORDER BY a.name
+$$) AS (name agtype, m agtype);
+
+--
+-- Pattern expressions as function arguments
+--
+-- collect() shows the per-row boolean values are correct (ORDER BY before
+-- the aggregate so the collected order is deterministic across scan plans).
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WITH a ORDER BY a.name
+    RETURN collect((a)-[:KNOWS]->(:Person))
+$$) AS (vals agtype);
+
+-- count() counts non-null values; a boolean (including false) is non-null,
+-- so this counts every row rather than only the matching ones. This is the
+-- expected SQL aggregate semantics, documented here so the value is not
+-- mistaken for a bug.
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    RETURN count((a)-[:KNOWS]->(:Person))
+$$) AS (c agtype);
+
+--
+-- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving)
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    OPTIONAL MATCH (b:Person) WHERE (a)-[:KNOWS]->(b)
+    RETURN a.name, b.name
+    ORDER BY a.name, b.name
+$$) AS (a agtype, b agtype);
+
+--
+-- EXISTS() and a bare pattern combined in a single predicate
+--
+SELECT * FROM cypher('pattern_expr', $$
+    MATCH (a:Person)
+    WHERE EXISTS((a)-[:KNOWS]->(:Person)) AND (a)-[:WORKS_WITH]->(:Person)
+    RETURN a.name
+    ORDER BY a.name
+$$) AS (name agtype);
+
+--
+-- Cleanup
+--
+SELECT * FROM drop_graph('pattern_expr', true);
diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y
index c614e1db..b23fa705 100644
--- a/src/backend/parser/cypher_gram.y
+++ b/src/backend/parser/cypher_gram.y
@@ -34,7 +34,7 @@
     do \
     { \
         if ((n) > 0) \
-            current = (rhs)[1]; \
+            current = YYRHSLOC(rhs, 1); \
         else \
             current = -1; \
     } while (0)
@@ -49,6 +49,43 @@
 %locations
 %name-prefix="cypher_yy"
 %pure-parser
+/*
+ * GLR mode handles the ambiguity between parenthesized expressions and
+ * graph patterns.  For example, WHERE (a)-[:KNOWS]->(b) starts with (a)
+ * which is valid as both an expression and a path_node.  The parser forks
+ * at the conflict point and discards the failing path.  %dprec annotations
+ * on expr_var/var_name_opt and '(' expr ')'/anonymous_path resolve cases
+ * where both paths succeed (bare (a) prefers the expression interpretation).
+ */
+%glr-parser
+/*
+ * GLR conflicts are expected and correct for this grammar.  They arise
+ * from the inherent ambiguity between parenthesized expressions and
+ * graph patterns: the shift/reduce conflicts on '-', '<', '{',
+ * PARAMETER and ')' all come from path extension vs. arithmetic or
+ * parenthesized-expression alternatives after a leading '(', and the
+ * reduce/reduce conflicts on ')', '}' and '=' come from the overlap
+ * between expr_var and var_name_opt.  GLR handles all of these by
+ * forking at the conflict point and discarding the failing alternative;
+ * %dprec annotations on expr_var/var_name_opt and '(' expr ')' /
+ * anonymous_path resolve cases where both forks succeed (bare (a)
+ * prefers the expression interpretation).
+ *
+ * The %expect / %expect-rr counts below match the Bison-reported totals
+ * (7 SR / 3 RR on Bison 3.8.2).  Bison treats %expect as exact, not as
+ * a ceiling: any deviation up or down fails the build.  That is the
+ * alarm bell — if a grammar change moves either count, the build stops
+ * and the conflicts must be audited to confirm they remain the inherent
+ * '(' expr ')' vs anonymous_path ambiguities (resolved by %dprec at
+ * runtime) rather than an unintended new ambiguity.  The Makefile
+ * downgrades -Wconflicts-sr / -Wconflicts-rr from errors to warnings
+ * (-Wno-error=conflicts-{sr,rr}) so %expect, not the warning category,
+ * controls the build-fail threshold.  If a future Bison version reports
+ * different counts for the same grammar, update these numbers and note
+ * the version in the commit message.
+ */
+%expect 7
+%expect-rr 3
 
 %lex-param {ag_scanner_t scanner}
 %parse-param {ag_scanner_t scanner}
@@ -292,6 +329,9 @@ static Node 
*build_predicate_function_node(cypher_predicate_function_kind kind,
                                            Node *var, Node *expr,
                                            Node *where, int location);
 
+/* pattern expression helper */
+static Node *make_exists_pattern_sublink(Node *pattern, int location);
+
 /* helper functions */
 static ExplainStmt *make_explain_stmt(List *options);
 static void validate_return_item_aliases(List *items, ag_scanner_t scanner);
@@ -1876,21 +1916,7 @@ expr_func_subexpr:
         }
     | EXISTS '(' anonymous_path ')'
         {
-            cypher_sub_pattern *sub;
-            SubLink    *n;
-
-            sub = make_ag_node(cypher_sub_pattern);
-            sub->kind = CSP_EXISTS;
-            sub->pattern = list_make1($3);
-
-            n = makeNode(SubLink);
-            n->subLinkType = EXISTS_SUBLINK;
-            n->subLinkId = 0;
-            n->testexpr = NULL;
-            n->operName = NIL;
-            n->subselect = (Node *) sub;
-            n->location = @1;
-            $$ = (Node *)node_to_agtype((Node *)n, "boolean", @1);
+            $$ = make_exists_pattern_sublink($3, @1);
         }
     | EXISTS '(' property_value ')'
         {
@@ -2026,7 +2052,7 @@ expr_atom:
 
             $$ = (Node *)n;
         }
-    | '(' expr ')'
+    | '(' expr ')' %dprec 2
         {
             Node *n = $2;
 
@@ -2037,6 +2063,17 @@ expr_atom:
             }
             $$ = n;
         }
+    | anonymous_path %dprec 1
+        {
+            /*
+             * Bare pattern in expression context is semantically
+             * equivalent to EXISTS(pattern).  Example:
+             *   WHERE (a)-[:KNOWS]->(b)
+             * becomes
+             *   WHERE EXISTS((a)-[:KNOWS]->(b))
+             */
+            $$ = make_exists_pattern_sublink($1, @1);
+        }
     | expr_case
     | expr_var
     | expr_func
@@ -2288,7 +2325,7 @@ expr_case_default:
     ;
 
 expr_var:
-    var_name
+    var_name %dprec 2
         {
             ColumnRef *n;
 
@@ -2374,11 +2411,11 @@ var_name_alias:
     ;
 
 var_name_opt:
-    /* empty */
+    /* empty */ %dprec 1
         {
             $$ = NULL;
         }
-    | var_name
+    | var_name %dprec 1
     ;
 
 label_name:
@@ -3585,6 +3622,30 @@ static Node 
*build_predicate_function_node(cypher_predicate_function_kind kind,
     }
 }
 
+/*
+ * Wrap a graph pattern in an EXISTS SubLink.  Used by both
+ * EXISTS(pattern) syntax and bare pattern expressions in WHERE.
+ */
+static Node *make_exists_pattern_sublink(Node *pattern, int location)
+{
+    cypher_sub_pattern *sub;
+    SubLink *n;
+
+    sub = make_ag_node(cypher_sub_pattern);
+    sub->kind = CSP_EXISTS;
+    sub->pattern = list_make1(pattern);
+
+    n = makeNode(SubLink);
+    n->subLinkType = EXISTS_SUBLINK;
+    n->subLinkId = 0;
+    n->testexpr = NULL;
+    n->operName = NIL;
+    n->subselect = (Node *) sub;
+    n->location = location;
+
+    return (Node *)node_to_agtype((Node *)n, "boolean", location);
+}
+
 /* Helper function to create an ExplainStmt node */
 static ExplainStmt *make_explain_stmt(List *options)
 {


Reply via email to