Skip to content

Commit 31c2b3a

Browse files
dylanbstoreyDylan Bobby Storey
andauthored
feat: pattern predicates in WHERE clause (openCypher 9) (#32)
* feat: pattern predicates in WHERE clause (openCypher 9 compliance) Support bare relationship patterns as boolean expressions in WHERE clauses per the openCypher 9 PatternPredicate spec. Previously required explicit EXISTS() wrapper; now `WHERE NOT (n)-[:REL]->()` works directly as syntactic sugar for `WHERE NOT EXISTS((n)-[:REL]->())`. Also fixes pre-existing bug where EXISTS(pattern) ignored relationship direction — incoming (<-) and undirected (-) patterns now generate correct SQL. * chore: bump version to 0.3.10 --------- Co-authored-by: Dylan Bobby Storey <dstorey@dstorey-personal-m3.local>
1 parent 7135c89 commit 31c2b3a

File tree

8 files changed

+2162
-1565
lines changed

8 files changed

+2162
-1565
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
id: pattern-predicates-in-where-clause
3+
level: task
4+
title: "Pattern predicates in WHERE clause (bare relationship patterns as boolean expressions)"
5+
short_code: "GQLITE-T-0138"
6+
created_at: 2026-03-20T15:40:21.527463+00:00
7+
updated_at: 2026-03-20T23:17:50.143661+00:00
8+
parent:
9+
blocked_by: []
10+
archived: false
11+
12+
tags:
13+
- "#task"
14+
- "#feature"
15+
- "#phase/active"
16+
17+
18+
exit_criteria_met: false
19+
initiative_id: NULL
20+
---
21+
22+
# Pattern predicates in WHERE clause (bare relationship patterns as boolean expressions)
23+
24+
## Objective
25+
26+
Support bare relationship patterns as boolean expressions in WHERE clauses, per the openCypher 9 specification (`<PatternPredicate> ::= <RelationshipsPattern>`). Currently GraphQLite requires the explicit `EXISTS(pattern)` form; the spec also allows the shorthand where a relationship pattern in boolean context is implicitly coerced to an existence check.
27+
28+
## Background
29+
30+
**Reported query:**
31+
```cypher
32+
MATCH (n {entity_type: 'Note'})
33+
WHERE NOT (n)-[:BELONGS_TO]->() AND NOT (n)-[:RELATES_TO]->()
34+
RETURN n.id, n.title
35+
```
36+
Fails with: `syntax error, unexpected ':'` at col 48 (the `:` in `-[:BELONGS_TO]->`).
37+
38+
**Workaround:** Wrap in `EXISTS()`:
39+
```cypher
40+
WHERE NOT EXISTS((n)-[:BELONGS_TO]->()) AND NOT EXISTS((n)-[:RELATES_TO]->())
41+
```
42+
43+
**Spec basis:** openCypher 9 defines `PatternPredicate` — a `RelationshipsPattern` in a boolean site is semantically equivalent to `EXISTS { MATCH pattern RETURN 1 }`. Neo4j has supported this since at least v2.x. The pattern must contain at least one relationship to be valid.
44+
45+
## Acceptance Criteria
46+
47+
## Acceptance Criteria
48+
49+
## Acceptance Criteria
50+
51+
- [x] `WHERE (n)-[:REL]->()` parses and evaluates as existence check
52+
- [x] `WHERE NOT (n)-[:REL]->()` parses and evaluates as non-existence check
53+
- [x] Works with all relationship directions: `->`, `<-`, `-`
54+
- [x] Works with typed and untyped relationships
55+
- [x] Works combined with AND/OR/XOR and other predicates
56+
- [x] Bare node pattern `(n)` without a relationship is NOT accepted as a pattern predicate
57+
- [x] Unit tests and functional SQL tests added
58+
- [x] Existing `EXISTS(pattern)` behavior unaffected
59+
60+
## Implementation Notes
61+
62+
### Technical Approach
63+
64+
**Grammar (`cypher_gram.y`):** Add a `pattern_predicate` production to the `expr` rule that matches a relationship pattern (node-rel-node) in expression context. The parser currently only reaches relationship patterns via the `simple_path` rule inside `MATCH`. A new rule would recognize `node_pattern rel_pattern node_pattern` within `expr` and produce an AST node (e.g., `AST_NODE_PATTERN_PREDICATE`).
65+
66+
**Transform:** In `transform_expression()`, handle `AST_NODE_PATTERN_PREDICATE` by reusing the same SQL generation path as `EXISTS(pattern)` — emit a `EXISTS (SELECT 1 FROM ...)` subquery.
67+
68+
**Key concern:** GLR conflicts. Adding relationship patterns to `expr` will likely introduce S/R or R/R conflicts since `(expr)` parenthesized expressions overlap with `(node_pattern)`. Careful precedence/disambiguation will be needed. Consider restricting the rule to require at least one `-[...]->` component to disambiguate.
69+
70+
### Dependencies
71+
- None — the EXISTS(pattern) transform already exists and can be reused.
72+
73+
### Risk Considerations
74+
- Grammar conflicts are the main risk. The GLR parser can handle ambiguity but conflict counts (`%expect`) may need updating and careful testing.
75+
76+
## Status Updates
77+
78+
### 2026-03-20: Implementation complete
79+
80+
**Files modified:**
81+
- `src/backend/parser/cypher_gram.y` — Added two `expr` productions for pattern predicates (3-element and 5-element paths). Updated `%expect` from 4 to 9 S/R conflicts (all GLR-safe ambiguities from `(IDENTIFIER)` being parseable as both `(expr)` and `node_pattern`). R/R conflicts unchanged at 3.
82+
- `src/backend/transform/transform_expr_predicate.c` — Fixed pre-existing bug where `EXISTS(pattern)` always assumed outgoing direction. Now respects `left_arrow`/`right_arrow` flags for incoming (`<-`) and undirected (`-`) relationship patterns.
83+
- `src/generated/cypher_gram.tab.{c,h}` — Regenerated.
84+
- `tests/functional/14_pattern_predicates.sql` — 17 test cases covering all directions, typed/untyped, NOT/AND/OR/XOR combinations, and equivalence with EXISTS().
85+
86+
**Approach:** Pattern predicates reuse the existing `make_exists_pattern_expr()` AST constructor and `transform_exists_expression()` SQL generation. No new AST node types needed — a bare pattern predicate is desugared to an EXISTS expression at parse time.
87+
88+
**Test results:** 5300 unit assertions pass (0 failures), all functional tests pass.

bindings/python/src/graphqlite/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .utils import escape_string, sanitize_rel_type, CYPHER_RESERVED
99
from ._platform import get_loadable_path
1010

11-
__version__ = "0.3.9"
11+
__version__ = "0.3.10"
1212
__all__ = [
1313
"BulkInsertResult",
1414
"Connection", "connect", "wrap", "load", "loadable_path",

bindings/rust/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "graphqlite"
3-
version = "0.3.9"
3+
version = "0.3.10"
44
edition = "2021"
55
description = "SQLite extension for graph queries using Cypher"
66
license = "MIT"

src/backend/parser/cypher_gram.y

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ int cypher_yylex(CYPHER_YYSTYPE *yylval, CYPHER_YYLTYPE *yylloc, cypher_parser_c
2929
/*
3030
* Expected grammar conflicts - handled correctly by GLR parsing.
3131
* These arise from pattern comprehension syntax [(...)-[r]->(...) | expr]
32-
* where the parser can't immediately distinguish a node pattern from
33-
* a parenthesized expression until it sees more context.
32+
* and pattern predicates (n)-[:REL]->() in boolean context, where the
33+
* parser can't immediately distinguish a node pattern from a parenthesized
34+
* expression until it sees more context (e.g., a following rel_pattern).
3435
*/
35-
%expect 4
36+
%expect 9
3637
%expect-rr 3 /* One for IDENTIFIER, one for BQIDENT, one for END_P in variable_opt */
3738

3839
%union {
@@ -1056,6 +1057,38 @@ expr:
10561057
| expr IS NULL_P { $$ = (ast_node*)make_null_check($1, false, @2.first_line); }
10571058
| expr IS NOT NULL_P { $$ = (ast_node*)make_null_check($1, true, @2.first_line); }
10581059
| '(' expr ')' { $$ = $2; }
1060+
/* Pattern predicate: bare relationship pattern as boolean expression.
1061+
* Per openCypher 9 spec, a RelationshipsPattern in boolean context
1062+
* is an implicit existence check: (n)-[:REL]->() ≡ EXISTS((n)-[:REL]->())
1063+
* Requires at least one relationship to distinguish from parenthesized expr.
1064+
*/
1065+
| node_pattern rel_pattern node_pattern
1066+
{
1067+
/* Build a path from the pattern elements */
1068+
ast_list *elements = ast_list_create();
1069+
ast_list_append(elements, (ast_node*)$1);
1070+
ast_list_append(elements, (ast_node*)$2);
1071+
ast_list_append(elements, (ast_node*)$3);
1072+
cypher_path *path = make_path(elements);
1073+
/* Wrap in pattern list and create EXISTS expression */
1074+
ast_list *pattern_list = ast_list_create();
1075+
ast_list_append(pattern_list, (ast_node*)path);
1076+
$$ = (ast_node*)make_exists_pattern_expr(pattern_list, @1.first_line);
1077+
}
1078+
| node_pattern rel_pattern node_pattern rel_pattern node_pattern
1079+
{
1080+
/* Chained pattern: (a)-[r1]->(b)-[r2]->(c) */
1081+
ast_list *elements = ast_list_create();
1082+
ast_list_append(elements, (ast_node*)$1);
1083+
ast_list_append(elements, (ast_node*)$2);
1084+
ast_list_append(elements, (ast_node*)$3);
1085+
ast_list_append(elements, (ast_node*)$4);
1086+
ast_list_append(elements, (ast_node*)$5);
1087+
cypher_path *path = make_path(elements);
1088+
ast_list *pattern_list = ast_list_create();
1089+
ast_list_append(pattern_list, (ast_node*)path);
1090+
$$ = (ast_node*)make_exists_pattern_expr(pattern_list, @1.first_line);
1091+
}
10591092
| expr '.' IDENTIFIER
10601093
{
10611094
$$ = (ast_node*)make_property($1, $3, @3.first_line);

src/backend/transform/transform_expr_predicate.c

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,28 @@ int transform_exists_expression(cypher_transform_context *ctx, cypher_exists_exp
121121
append_sql(ctx, " AND ");
122122
}
123123

124-
/* Join source node with relationship */
125-
int source_node = i / 2;
126-
int target_node = source_node + 1;
127-
128-
append_sql(ctx, "e%d.source_id = %s.id AND e%d.target_id = %s.id",
129-
rel_index, node_aliases[source_node],
130-
rel_index, node_aliases[target_node]);
124+
/* Join source node with relationship, respecting direction */
125+
int left_node = i / 2;
126+
int right_node = left_node + 1;
127+
128+
if (rel->left_arrow && !rel->right_arrow) {
129+
/* Incoming: <-[]- means edge goes right_node -> left_node */
130+
append_sql(ctx, "e%d.source_id = %s.id AND e%d.target_id = %s.id",
131+
rel_index, node_aliases[right_node],
132+
rel_index, node_aliases[left_node]);
133+
} else if (!rel->left_arrow && !rel->right_arrow) {
134+
/* Undirected: -[]- match either direction */
135+
append_sql(ctx, "((e%d.source_id = %s.id AND e%d.target_id = %s.id) OR (e%d.source_id = %s.id AND e%d.target_id = %s.id))",
136+
rel_index, node_aliases[left_node],
137+
rel_index, node_aliases[right_node],
138+
rel_index, node_aliases[right_node],
139+
rel_index, node_aliases[left_node]);
140+
} else {
141+
/* Outgoing: -[]-> (default) */
142+
append_sql(ctx, "e%d.source_id = %s.id AND e%d.target_id = %s.id",
143+
rel_index, node_aliases[left_node],
144+
rel_index, node_aliases[right_node]);
145+
}
131146

132147
/* Add relationship type constraint if specified */
133148
if (rel->type) {

0 commit comments

Comments
 (0)