Skip to content

Commit 3220028

Browse files
authored
Merge pull request #95 from microsoft/functionality_improvements
Functionality improvements
2 parents b080750 + 6110897 commit 3220028

8 files changed

Lines changed: 356 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,23 @@ WHERE 'Alice' IN COLLECT { MATCH (p)-[:KNOWS]->(f:Person) RETURN f.name }
854854
RETURN p.name
855855
```
856856

857+
**Bare-pattern shorthand:** `EXISTS` and `COUNT` also accept a graph pattern directly, without the `MATCH` keyword (with an optional trailing `WHERE`). `COLLECT` does not support this form because it needs a `RETURN` to know which value to collect.
858+
859+
```cypher
860+
// EXISTS with a bare pattern
861+
MATCH (p:Person)
862+
WHERE EXISTS { (p)-[:KNOWS]->(:Person) }
863+
RETURN p.name
864+
865+
// COUNT with a bare pattern, in RETURN
866+
MATCH (p:Person)
867+
RETURN p.name, COUNT { (p)-[:KNOWS]->(:Person) } AS friendCount
868+
869+
// COUNT with a bare pattern and inner WHERE
870+
MATCH (p:Person)
871+
RETURN p.name, COUNT { (p)-[:KNOWS]->(f:Person) WHERE f.age > 30 } AS closeFriends
872+
```
873+
857874
**Node reference reuse across MATCH clauses:**
858875

859876
```cypher
@@ -992,6 +1009,7 @@ RETURN f.name, f.description, f.category
9921009
│ ENDS WITH · NOT ENDS WITH │
9931010
│ EXISTS { subquery } · NOT EXISTS { subquery } │
9941011
│ COUNT { subquery } · COLLECT { subquery } │
1012+
│ EXISTS { pattern } · COUNT { pattern } -- bare pattern │
9951013
├─────────────────────────────────────────────────────────────┤
9961014
│ EXPRESSIONS │
9971015
├─────────────────────────────────────────────────────────────┤

‎flowquery-py/src/parsing/parser.py‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,11 +1911,9 @@ def _parse_case(self) -> Optional[Case]:
19111911
self._expect_and_skip_whitespace_and_comments()
19121912
parts += 1
19131913
else_ = self._parse_else()
1914-
if else_ is None:
1915-
raise ValueError("Expected ELSE")
1916-
else:
1914+
if else_ is not None:
19171915
case.add_child(else_)
1918-
self._expect_and_skip_whitespace_and_comments()
1916+
self._expect_and_skip_whitespace_and_comments()
19191917
if not self.token.is_end():
19201918
raise ValueError("Expected END")
19211919
self.set_next_token()
@@ -2323,8 +2321,39 @@ def _parse_subquery_expression(self) -> Optional[SubqueryExpression]:
23232321
outer_state = self._state
23242322
self._state = ParserState()
23252323
self._state.inherit_variables_from(outer_state)
2326-
subquery_ast = self._parse_sub_query()
2324+
# Bare-pattern shorthand: EXISTS/COUNT accept a graph pattern
2325+
# directly (no MATCH keyword), e.g. COUNT { (a)-[:R]->(b) }.
2326+
# COLLECT is excluded: it needs a RETURN to know what to collect.
2327+
if self.token.is_opening_brace() and self.next_significant_token().is_left_parenthesis():
2328+
if mode == SubqueryMode.COLLECT:
2329+
self._state = outer_state
2330+
raise ValueError("COLLECT subquery must contain a RETURN clause")
2331+
subquery_ast: Optional[ASTNode] = self._parse_pattern_sub_query()
2332+
else:
2333+
subquery_ast = self._parse_sub_query()
23272334
self._state = outer_state
23282335
if subquery_ast is None:
23292336
raise ValueError(f"Expected opening brace after {keyword}")
23302337
return SubqueryExpression(mode, subquery_ast)
2338+
2339+
def _parse_pattern_sub_query(self) -> ASTNode:
2340+
"""Parse a bare graph pattern (with optional trailing WHERE) inside
2341+
an EXISTS/COUNT subquery into a MATCH-only sub-query body,
2342+
equivalent to ``{ MATCH <pattern> [WHERE ...] }``.
2343+
"""
2344+
self.set_next_token() # consume '{'
2345+
self._skip_whitespace_and_comments()
2346+
patterns = list(self._parse_patterns())
2347+
if len(patterns) == 0:
2348+
raise ValueError("Expected graph pattern")
2349+
root = ASTNode()
2350+
match = Match(patterns, False)
2351+
root.add_child(match)
2352+
where = self._parse_where()
2353+
if where is not None:
2354+
match.add_sibling(where)
2355+
self._skip_whitespace_and_comments()
2356+
if not self.token.is_closing_brace():
2357+
raise ValueError("Expected closing brace for sub-query")
2358+
self.set_next_token()
2359+
return root

‎flowquery-py/tests/compute/test_runner.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,19 @@ async def test_range_function_with_unwind_and_case(self):
569569
assert results[1] == {"ret": 2}
570570
assert results[2] == {"ret": 3}
571571

572+
@pytest.mark.asyncio
573+
async def test_case_without_else_returns_null_when_no_match(self):
574+
"""Test case without else returns null when no branch matches."""
575+
runner = Runner(
576+
"unwind range(1, 3) as num return case when num > 1 then num end as ret"
577+
)
578+
await runner.run()
579+
results = runner.results
580+
assert len(results) == 3
581+
assert results[0] == {"ret": None}
582+
assert results[1] == {"ret": 2}
583+
assert results[2] == {"ret": 3}
584+
572585
@pytest.mark.asyncio
573586
async def test_size_function(self):
574587
"""Test size function."""
@@ -7420,6 +7433,89 @@ async def test_exists_as_return_expression(self):
74207433
assert results[1] == {"name": "Bob", "hasFriends": False}
74217434
assert results[2] == {"name": "Charlie", "hasFriends": False}
74227435

7436+
@pytest.mark.asyncio
7437+
async def test_count_subquery_with_bare_pattern(self):
7438+
await Runner("""
7439+
CREATE VIRTUAL (:Person) AS {
7440+
unwind [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Charlie'}] as record
7441+
RETURN record.id as id, record.name as name
7442+
}
7443+
""").run()
7444+
await Runner("""
7445+
CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
7446+
unwind [{left_id: 1, right_id: 2}, {left_id: 1, right_id: 3}, {left_id: 2, right_id: 3}] as record
7447+
RETURN record.left_id as left_id, record.right_id as right_id
7448+
}
7449+
""").run()
7450+
runner = Runner("""
7451+
MATCH (p:Person)
7452+
RETURN p.name AS name, COUNT { (p)-[:KNOWS]->(:Person) } AS friendCount
7453+
""")
7454+
await runner.run()
7455+
results = runner.results
7456+
assert len(results) == 3
7457+
assert results[0] == {"name": "Alice", "friendCount": 2}
7458+
assert results[1] == {"name": "Bob", "friendCount": 1}
7459+
assert results[2] == {"name": "Charlie", "friendCount": 0}
7460+
7461+
@pytest.mark.asyncio
7462+
async def test_exists_subquery_with_bare_pattern(self):
7463+
await Runner("""
7464+
CREATE VIRTUAL (:Person) AS {
7465+
unwind [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Charlie'}] as record
7466+
RETURN record.id as id, record.name as name
7467+
}
7468+
""").run()
7469+
await Runner("""
7470+
CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
7471+
unwind [{left_id: 1, right_id: 2}, {left_id: 2, right_id: 3}] as record
7472+
RETURN record.left_id as left_id, record.right_id as right_id
7473+
}
7474+
""").run()
7475+
runner = Runner("""
7476+
MATCH (p:Person)
7477+
WHERE EXISTS { (p)-[:KNOWS]->(:Person) }
7478+
RETURN p.name AS name
7479+
""")
7480+
await runner.run()
7481+
results = runner.results
7482+
assert len(results) == 2
7483+
assert results[0] == {"name": "Alice"}
7484+
assert results[1] == {"name": "Bob"}
7485+
7486+
@pytest.mark.asyncio
7487+
async def test_count_bare_pattern_with_inner_where(self):
7488+
await Runner("""
7489+
CREATE VIRTUAL (:Person) AS {
7490+
unwind [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Charlie'}] as record
7491+
RETURN record.id as id, record.name as name
7492+
}
7493+
""").run()
7494+
await Runner("""
7495+
CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
7496+
unwind [{left_id: 1, right_id: 2}, {left_id: 1, right_id: 3}, {left_id: 2, right_id: 3}] as record
7497+
RETURN record.left_id as left_id, record.right_id as right_id
7498+
}
7499+
""").run()
7500+
runner = Runner("""
7501+
MATCH (p:Person)
7502+
RETURN p.name AS name,
7503+
COUNT { (p)-[:KNOWS]->(friend:Person) WHERE friend.id > 2 } AS cnt
7504+
""")
7505+
await runner.run()
7506+
results = runner.results
7507+
assert len(results) == 3
7508+
assert results[0] == {"name": "Alice", "cnt": 1}
7509+
assert results[1] == {"name": "Bob", "cnt": 1}
7510+
assert results[2] == {"name": "Charlie", "cnt": 0}
7511+
7512+
def test_collect_rejects_bare_pattern(self):
7513+
with pytest.raises(ValueError, match="COLLECT subquery must contain a RETURN clause"):
7514+
Runner("""
7515+
MATCH (p:Person)
7516+
RETURN COLLECT { (p)-[:KNOWS]->(:Person) } AS friends
7517+
""")
7518+
74237519

74247520
class TestPredicateFunctions:
74257521
"""Test cases for any, all, none, single predicate functions."""

‎flowquery-py/tests/parsing/test_parser.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,24 @@ def test_case_statement(self):
516516
)
517517
assert ast.print() == expected
518518

519+
def test_case_statement_without_else(self):
520+
"""Test case statement without an else clause."""
521+
parser = Parser()
522+
ast = parser.parse("RETURN CASE WHEN 1 THEN 2 END")
523+
expected = (
524+
"ASTNode\n"
525+
"- Return\n"
526+
"-- Expression\n"
527+
"--- Case\n"
528+
"---- When\n"
529+
"----- Expression\n"
530+
"------ Number (1)\n"
531+
"---- Then\n"
532+
"----- Expression\n"
533+
"------ Number (2)"
534+
)
535+
assert ast.print() == expected
536+
519537
def test_functions_with_wrong_number_of_arguments(self):
520538
"""Test functions with wrong number of arguments."""
521539
parser = Parser()

‎src/parsing/logic/case.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ASTNode from "../ast_node";
22
import Else from "./else";
3-
import When from "./when";
43
import Then from "./then";
4+
import When from "./when";
55

66
class Case extends ASTNode {
77
constructor() {
@@ -12,15 +12,18 @@ class Case extends ASTNode {
1212
let i = 0;
1313
let child = this.getChildren()[i];
1414
while (child instanceof When) {
15-
const then = (this.getChildren()[i+1] as Then);
15+
const then = this.getChildren()[i + 1] as Then;
1616
if (child.value()) {
1717
return then.value();
1818
}
1919
i += 2;
2020
child = this.getChildren()[i];
2121
}
22-
return (child as Else).value();
22+
if (child instanceof Else) {
23+
return child.value();
24+
}
25+
return null;
2326
}
2427
}
2528

26-
export default Case;
29+
export default Case;

‎src/parsing/parser.ts‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2226,12 +2226,10 @@ class Parser extends BaseParser {
22262226
parts++;
22272227
}
22282228
const _else = this.parseElse();
2229-
if (_else === null) {
2230-
throw new Error("Expected ELSE");
2231-
} else {
2229+
if (_else !== null) {
22322230
_case.addChild(_else);
2231+
this.expectAndSkipWhitespaceAndComments();
22332232
}
2234-
this.expectAndSkipWhitespaceAndComments();
22352233
if (!this.token.isEnd()) {
22362234
throw new Error("Expected END");
22372235
}
@@ -2749,13 +2747,52 @@ class Parser extends BaseParser {
27492747
const outerState = this._state;
27502748
this._state = new ParserState();
27512749
this._state.inheritVariablesFrom(outerState);
2752-
const subqueryAST = this.parseSubQuery();
2750+
// Bare-pattern shorthand: EXISTS/COUNT accept a graph pattern
2751+
// directly (no MATCH keyword), e.g. COUNT { (a)-[:R]->(b) }.
2752+
// COLLECT is excluded: it needs a RETURN to know what to collect.
2753+
let subqueryAST: ASTNode | null;
2754+
if (this.token.isOpeningBrace() && this.nextSignificantToken().isLeftParenthesis()) {
2755+
if (mode === SubqueryMode.COLLECT) {
2756+
this._state = outerState;
2757+
throw new Error("COLLECT subquery must contain a RETURN clause");
2758+
}
2759+
subqueryAST = this.parsePatternSubQuery();
2760+
} else {
2761+
subqueryAST = this.parseSubQuery();
2762+
}
27532763
this._state = outerState;
27542764
if (subqueryAST === null) {
27552765
throw new Error(`Expected opening brace after ${keyword}`);
27562766
}
27572767
return new SubqueryExpression(mode, subqueryAST);
27582768
}
2769+
2770+
/**
2771+
* Parses a bare graph pattern (with optional trailing WHERE) inside
2772+
* an EXISTS/COUNT subquery into a MATCH-only sub-query body,
2773+
* equivalent to `{ MATCH <pattern> [WHERE ...] }`.
2774+
*/
2775+
private parsePatternSubQuery(): ASTNode {
2776+
this.setNextToken(); // consume '{'
2777+
this.skipWhitespaceAndComments();
2778+
const patterns: Pattern[] = Array.from(this.parsePatterns());
2779+
if (patterns.length === 0) {
2780+
throw new Error("Expected graph pattern");
2781+
}
2782+
const root = new ASTNode();
2783+
const match = new Match(patterns, false);
2784+
root.addChild(match);
2785+
const where = this.parseWhere();
2786+
if (where !== null) {
2787+
match.addSibling(where);
2788+
}
2789+
this.skipWhitespaceAndComments();
2790+
if (!this.token.isClosingBrace()) {
2791+
throw new Error("Expected closing brace for sub-query");
2792+
}
2793+
this.setNextToken();
2794+
return root;
2795+
}
27592796
}
27602797

27612798
export default Parser;

0 commit comments

Comments
 (0)