Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions flowquery-py/src/parsing/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,12 +683,7 @@ def _parse_operand(self, expression: Expression) -> bool:
return True
elif (
self.token.is_left_parenthesis()
and self.peek() is not None
and (
self.peek().is_identifier_or_keyword()
or self.peek().is_colon()
or self.peek().is_right_parenthesis()
)
and self._looks_like_node_pattern()
):
# Possible graph pattern expression
pattern = self._parse_pattern_expression()
Expand Down Expand Up @@ -779,6 +774,34 @@ def _parse_expression(self) -> Optional[Expression]:
return expression
return None

def _looks_like_node_pattern(self) -> bool:
"""Peek ahead from a left parenthesis to determine whether the
upcoming tokens form a graph-node pattern (e.g. (n:Label), (n),
(:Label), ()) rather than a parenthesised expression (e.g.
(variable.property), (a + b)).
"""
saved_index = self._token_index
self.set_next_token() # skip '('
self._skip_whitespace_and_comments()

if self.token.is_colon() or self.token.is_right_parenthesis():
self._token_index = saved_index
return True

if self.token.is_identifier_or_keyword():
self.set_next_token() # skip identifier
self._skip_whitespace_and_comments()
result = (
self.token.is_colon()
or self.token.is_opening_brace()
or self.token.is_right_parenthesis()
)
self._token_index = saved_index
return result

self._token_index = saved_index
return False

def _parse_is_operator(self) -> ASTNode:
"""Parse IS or IS NOT operator."""
# Current token is IS. Look ahead for NOT to produce IS NOT.
Expand Down
58 changes: 58 additions & 0 deletions flowquery-py/tests/parsing/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,3 +1032,61 @@ def test_where_with_not_ends_with(self):
"--- Reference (s)"
)
assert ast.print() == expected

def test_parenthesized_expression_with_addition(self):
"""Test that (variable + number) is parsed as a parenthesized expression, not a node."""
parser = Parser()
ast = parser.parse("WITH 1 AS n RETURN (n + 2)")
expected = (
"ASTNode\n"
"- With\n"
"-- Expression (n)\n"
"--- Number (1)\n"
"- Return\n"
"-- Expression\n"
"--- Expression\n"
"---- Add\n"
"----- Reference (n)\n"
"----- Number (2)"
)
assert ast.print() == expected

def test_parenthesized_expression_with_property_access(self):
"""Test that (obj.property) is parsed as a parenthesized expression, not a node."""
parser = Parser()
ast = parser.parse("WITH {a: 1} AS obj RETURN (obj.a)")
expected = (
"ASTNode\n"
"- With\n"
"-- Expression (obj)\n"
"--- AssociativeArray\n"
"---- KeyValuePair\n"
"----- String (a)\n"
"----- Expression\n"
"------ Number (1)\n"
"- Return\n"
"-- Expression\n"
"--- Expression\n"
"---- Lookup\n"
"----- Identifier (a)\n"
"----- Reference (obj)"
)
assert ast.print() == expected

def test_parenthesized_expression_with_multiplication(self):
"""Test that (variable * number) is parsed as a parenthesized expression, not a node."""
parser = Parser()
ast = parser.parse("WITH 5 AS x RETURN (x * 3)")
expected = (
"ASTNode\n"
"- With\n"
"-- Expression (x)\n"
"--- Number (5)\n"
"- Return\n"
"-- Expression\n"
"--- Expression\n"
"---- Multiply\n"
"----- Reference (x)\n"
"----- Number (3)"
)
assert ast.print() == expected
43 changes: 37 additions & 6 deletions src/parsing/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,12 +798,7 @@ class Parser extends BaseParser {
expression.addNode(lookup);
return true;
}
} else if (
this.token.isLeftParenthesis() &&
(this.peek()?.isIdentifierOrKeyword() ||
this.peek()?.isColon() ||
this.peek()?.isRightParenthesis())
) {
} else if (this.token.isLeftParenthesis() && this.looksLikeNodePattern()) {
// Possible graph pattern expression
const pattern = this.parsePatternExpression();
if (pattern !== null) {
Expand Down Expand Up @@ -865,6 +860,42 @@ class Parser extends BaseParser {
return false;
}

/**
* Peeks ahead from a left parenthesis to determine whether the
* upcoming tokens form a graph-node pattern (e.g. (n:Label), (n),
* (:Label), ()) rather than a parenthesised expression (e.g.
* (variable.property), (a + b)).
*
* The heuristic is:
* • ( followed by `:` or `)` → node pattern
* • ( identifier, then `:` or `{` or `)` → node pattern
* • anything else → parenthesised expression
*/
private looksLikeNodePattern(): boolean {
const savedIndex = this.tokenIndex;
this.setNextToken(); // skip '('
this.skipWhitespaceAndComments();

if (this.token.isColon() || this.token.isRightParenthesis()) {
this.tokenIndex = savedIndex;
return true;
}

if (this.token.isIdentifierOrKeyword()) {
this.setNextToken(); // skip identifier
this.skipWhitespaceAndComments();
const result =
this.token.isColon() ||
this.token.isOpeningBrace() ||
this.token.isRightParenthesis();
this.tokenIndex = savedIndex;
return result;
}

this.tokenIndex = savedIndex;
return false;
}

private parseExpression(): Expression | null {
const expression = new Expression();
while (true) {
Expand Down
58 changes: 58 additions & 0 deletions tests/parsing/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,3 +1098,61 @@ test("Test WHERE with NOT ENDS WITH", () => {
"--- Reference (s)"
);
});

test("Test parenthesized expression with addition", () => {
const parser = new Parser();
const ast = parser.parse("WITH 1 AS n RETURN (n + 2)");
// prettier-ignore
expect(ast.print()).toBe(
"ASTNode\n" +
"- With\n" +
"-- Expression (n)\n" +
"--- Number (1)\n" +
"- Return\n" +
"-- Expression\n" +
"--- Expression\n" +
"---- Add\n" +
"----- Reference (n)\n" +
"----- Number (2)"
);
});

test("Test parenthesized expression with property access", () => {
const parser = new Parser();
const ast = parser.parse("WITH {a: 1} AS obj RETURN (obj.a)");
// prettier-ignore
expect(ast.print()).toBe(
"ASTNode\n" +
"- With\n" +
"-- Expression (obj)\n" +
"--- AssociativeArray\n" +
"---- KeyValuePair\n" +
"----- String (a)\n" +
"----- Expression\n" +
"------ Number (1)\n" +
"- Return\n" +
"-- Expression\n" +
"--- Expression\n" +
"---- Lookup\n" +
"----- Identifier (a)\n" +
"----- Reference (obj)"
);
});

test("Test parenthesized expression with multiplication", () => {
const parser = new Parser();
const ast = parser.parse("WITH 5 AS x RETURN (x * 3)");
// prettier-ignore
expect(ast.print()).toBe(
"ASTNode\n" +
"- With\n" +
"-- Expression (x)\n" +
"--- Number (5)\n" +
"- Return\n" +
"-- Expression\n" +
"--- Expression\n" +
"---- Multiply\n" +
"----- Reference (x)\n" +
"----- Number (3)"
);
});