Skip to content

Commit 46d1c37

Browse files
authored
Merge pull request #37 from microsoft/fixing_bugs_and_making_improvements
Fixing bugs and making improvements
2 parents 1dbb536 + eccd9c6 commit 46d1c37

File tree

4 files changed

+171
-39
lines changed

4 files changed

+171
-39
lines changed

flowquery-py/src/parsing/parser.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def _parse_with(self) -> Optional[With]:
214214
distinct = True
215215
self.set_next_token()
216216
self._expect_and_skip_whitespace_and_comments()
217-
expressions = list(self._parse_expressions(AliasOption.REQUIRED))
217+
expressions = self._parse_expressions(AliasOption.REQUIRED)
218218
if len(expressions) == 0:
219219
raise ValueError("Expected expression")
220220
if distinct or any(expr.has_reducers() for expr in expressions):
@@ -254,7 +254,7 @@ def _parse_return(self) -> Optional[Return]:
254254
distinct = True
255255
self.set_next_token()
256256
self._expect_and_skip_whitespace_and_comments()
257-
expressions = list(self._parse_expressions(AliasOption.OPTIONAL))
257+
expressions = self._parse_expressions(AliasOption.OPTIONAL)
258258
if len(expressions) == 0:
259259
raise ValueError("Expected expression")
260260
if distinct or any(expr.has_reducers() for expr in expressions):
@@ -353,7 +353,7 @@ def _parse_call(self) -> Optional[Call]:
353353
self._expect_previous_token_to_be_whitespace_or_comment()
354354
self.set_next_token()
355355
self._expect_and_skip_whitespace_and_comments()
356-
expressions = list(self._parse_expressions(AliasOption.OPTIONAL))
356+
expressions = self._parse_expressions(AliasOption.OPTIONAL)
357357
if len(expressions) == 0:
358358
raise ValueError("Expected at least one expression")
359359
call.yielded = expressions # type: ignore[assignment]
@@ -791,16 +791,30 @@ def _parse_order_by(self) -> Optional[OrderBy]:
791791

792792
def _parse_expressions(
793793
self, alias_option: AliasOption = AliasOption.NOT_ALLOWED
794-
) -> Iterator[Expression]:
794+
) -> List[Expression]:
795+
"""Parse a comma-separated list of expressions with deferred variable
796+
registration. Aliases set by earlier expressions in the same clause
797+
won't shadow variables needed by later expressions
798+
(e.g. ``RETURN a.x AS a, a.y AS b``)."""
799+
parsed = list(self.__parse_expressions(alias_option))
800+
for expression, variable_name in parsed:
801+
if variable_name is not None:
802+
self._state.variables[variable_name] = expression
803+
return [expression for expression, _ in parsed]
804+
805+
def __parse_expressions(
806+
self, alias_option: AliasOption
807+
) -> Iterator[Tuple[Expression, Optional[str]]]:
795808
while True:
796809
expression = self._parse_expression()
797810
if expression is not None:
811+
variable_name: Optional[str] = None
798812
alias = self._parse_alias()
799813
if isinstance(expression.first_child(), Reference) and alias is None:
800814
reference = expression.first_child()
801815
assert isinstance(reference, Reference) # For type narrowing
802816
expression.set_alias(reference.identifier)
803-
self._state.variables[reference.identifier] = expression
817+
variable_name = reference.identifier
804818
elif (alias_option == AliasOption.REQUIRED and
805819
alias is None and
806820
not isinstance(expression.first_child(), Reference)):
@@ -809,8 +823,8 @@ def _parse_expressions(
809823
raise ValueError("Alias not allowed")
810824
elif alias_option in (AliasOption.OPTIONAL, AliasOption.REQUIRED) and alias is not None:
811825
expression.set_alias(alias.get_alias())
812-
self._state.variables[alias.get_alias()] = expression
813-
yield expression
826+
variable_name = alias.get_alias()
827+
yield expression, variable_name
814828
else:
815829
break
816830
self._skip_whitespace_and_comments()

flowquery-py/tests/compute/test_runner.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4339,4 +4339,59 @@ async def test_delete_virtual_node_leaves_other_nodes_intact(self):
43394339
match = Runner("MATCH (n:PyKeepNode) RETURN n")
43404340
await match.run()
43414341
assert len(match.results) == 1
4342-
assert match.results[0]["n"]["name"] == "Keep"
4342+
assert match.results[0]["n"]["name"] == "Keep"
4343+
4344+
@pytest.mark.asyncio
4345+
async def test_return_alias_shadowing_graph_variable(self):
4346+
"""Test that RETURN alias doesn't shadow graph variable in same clause.
4347+
4348+
When RETURN mentor.displayName AS mentor is followed by mentor.jobTitle,
4349+
the alias 'mentor' should not overwrite the graph node variable before
4350+
subsequent expressions are parsed.
4351+
"""
4352+
await Runner(
4353+
"""
4354+
CREATE VIRTUAL (:PyMentorUser) AS {
4355+
UNWIND [
4356+
{id: 1, displayName: 'Alice Smith', jobTitle: 'Senior Engineer', department: 'Engineering'},
4357+
{id: 2, displayName: 'Bob Jones', jobTitle: 'Staff Engineer', department: 'Engineering'},
4358+
{id: 3, displayName: 'Chloe Dubois', jobTitle: 'Junior Engineer', department: 'Engineering'}
4359+
] AS record
4360+
RETURN record.id AS id, record.displayName AS displayName, record.jobTitle AS jobTitle, record.department AS department
4361+
}
4362+
"""
4363+
).run()
4364+
4365+
await Runner(
4366+
"""
4367+
CREATE VIRTUAL (:PyMentorUser)-[:PY_MENTORS]-(:PyMentorUser) AS {
4368+
UNWIND [
4369+
{left_id: 1, right_id: 3},
4370+
{left_id: 2, right_id: 3}
4371+
] AS record
4372+
RETURN record.left_id AS left_id, record.right_id AS right_id
4373+
}
4374+
"""
4375+
).run()
4376+
4377+
runner = Runner(
4378+
"""
4379+
MATCH (mentor:PyMentorUser)-[:PY_MENTORS]->(mentee:PyMentorUser)
4380+
WHERE mentee.displayName = "Chloe Dubois"
4381+
RETURN mentor.displayName AS mentor, mentor.jobTitle AS mentorJobTitle, mentor.department AS mentorDepartment
4382+
"""
4383+
)
4384+
await runner.run()
4385+
results = runner.results
4386+
4387+
assert len(results) == 2
4388+
assert results[0] == {
4389+
"mentor": "Alice Smith",
4390+
"mentorJobTitle": "Senior Engineer",
4391+
"mentorDepartment": "Engineering",
4392+
}
4393+
assert results[1] == {
4394+
"mentor": "Bob Jones",
4395+
"mentorJobTitle": "Staff Engineer",
4396+
"mentorDepartment": "Engineering",
4397+
}

src/parsing/parser.ts

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ class Parser extends BaseParser {
224224
this.setNextToken();
225225
this.expectAndSkipWhitespaceAndComments();
226226
}
227-
const expressions = Array.from(this.parseExpressions(AliasOption.REQUIRED));
227+
const expressions = this.parseExpressions(AliasOption.REQUIRED);
228228
if (expressions.length === 0) {
229229
throw new Error("Expected expression");
230230
}
@@ -279,7 +279,7 @@ class Parser extends BaseParser {
279279
this.setNextToken();
280280
this.expectAndSkipWhitespaceAndComments();
281281
}
282-
const expressions = Array.from(this.parseExpressions(AliasOption.OPTIONAL));
282+
const expressions = this.parseExpressions(AliasOption.OPTIONAL);
283283
if (expressions.length === 0) {
284284
throw new Error("Expected expression");
285285
}
@@ -393,7 +393,7 @@ class Parser extends BaseParser {
393393
this.expectPreviousTokenToBeWhitespaceOrComment();
394394
this.setNextToken();
395395
this.expectAndSkipWhitespaceAndComments();
396-
const expressions = Array.from(this.parseExpressions(AliasOption.OPTIONAL));
396+
const expressions = this.parseExpressions(AliasOption.OPTIONAL);
397397
if (expressions.length === 0) {
398398
throw new Error("Expected at least one expression");
399399
}
@@ -903,36 +903,52 @@ class Parser extends BaseParser {
903903
return new OrderBy(fields);
904904
}
905905

906-
private *parseExpressions(
907-
alias_option: AliasOption = AliasOption.NOT_ALLOWED
908-
): IterableIterator<Expression> {
906+
/**
907+
* Parses a comma-separated list of expressions with deferred variable
908+
* registration. Aliases set by earlier expressions in the same clause
909+
* won't shadow variables needed by later expressions
910+
* (e.g. `RETURN a.x AS a, a.y AS b`).
911+
*/
912+
private parseExpressions(alias_option: AliasOption = AliasOption.NOT_ALLOWED): Expression[] {
913+
const parsed = Array.from(this._parseExpressions(alias_option));
914+
for (const [expression, variableName] of parsed) {
915+
if (variableName !== null) {
916+
this._state.variables.set(variableName, expression);
917+
}
918+
}
919+
return parsed.map(([expression]) => expression);
920+
}
921+
922+
private *_parseExpressions(
923+
alias_option: AliasOption
924+
): IterableIterator<[Expression, string | null]> {
909925
while (true) {
910926
const expression: Expression | null = this.parseExpression();
911-
if (expression !== null) {
912-
const alias = this.parseAlias();
913-
if (expression.firstChild() instanceof Reference && alias === null) {
914-
const reference: Reference = expression.firstChild() as Reference;
915-
expression.setAlias(reference.identifier);
916-
this._state.variables.set(reference.identifier, expression);
917-
} else if (
918-
alias_option === AliasOption.REQUIRED &&
919-
alias === null &&
920-
!(expression.firstChild() instanceof Reference)
921-
) {
922-
throw new Error("Alias required");
923-
} else if (alias_option === AliasOption.NOT_ALLOWED && alias !== null) {
924-
throw new Error("Alias not allowed");
925-
} else if (
926-
[AliasOption.OPTIONAL, AliasOption.REQUIRED].includes(alias_option) &&
927-
alias !== null
928-
) {
929-
expression.setAlias(alias.getAlias());
930-
this._state.variables.set(alias.getAlias(), expression);
931-
}
932-
yield expression;
933-
} else {
927+
if (expression === null) {
934928
break;
935929
}
930+
let variableName: string | null = null;
931+
const alias = this.parseAlias();
932+
if (expression.firstChild() instanceof Reference && alias === null) {
933+
const reference: Reference = expression.firstChild() as Reference;
934+
expression.setAlias(reference.identifier);
935+
variableName = reference.identifier;
936+
} else if (
937+
alias_option === AliasOption.REQUIRED &&
938+
alias === null &&
939+
!(expression.firstChild() instanceof Reference)
940+
) {
941+
throw new Error("Alias required");
942+
} else if (alias_option === AliasOption.NOT_ALLOWED && alias !== null) {
943+
throw new Error("Alias not allowed");
944+
} else if (
945+
[AliasOption.OPTIONAL, AliasOption.REQUIRED].includes(alias_option) &&
946+
alias !== null
947+
) {
948+
expression.setAlias(alias.getAlias());
949+
variableName = alias.getAlias();
950+
}
951+
yield [expression, variableName];
936952
this.skipWhitespaceAndComments();
937953
if (!this.token.isComma()) {
938954
break;
@@ -1360,7 +1376,7 @@ class Parser extends BaseParser {
13601376
this.setNextToken();
13611377
this.expectAndSkipWhitespaceAndComments();
13621378
}
1363-
func.parameters = Array.from(this.parseExpressions(AliasOption.NOT_ALLOWED));
1379+
func.parameters = this.parseExpressions(AliasOption.NOT_ALLOWED);
13641380
this.skipWhitespaceAndComments();
13651381
if (!this.token.isRightParenthesis()) {
13661382
throw new Error("Expected right parenthesis");
@@ -1394,7 +1410,7 @@ class Parser extends BaseParser {
13941410
this.setNextToken(); // skip function name
13951411
this.setNextToken(); // skip left parenthesis
13961412
this.skipWhitespaceAndComments();
1397-
asyncFunc.parameters = Array.from(this.parseExpressions(AliasOption.NOT_ALLOWED));
1413+
asyncFunc.parameters = this.parseExpressions(AliasOption.NOT_ALLOWED);
13981414
this.skipWhitespaceAndComments();
13991415
if (!this.token.isRightParenthesis()) {
14001416
throw new Error("Expected right parenthesis");

tests/compute/runner.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4050,3 +4050,50 @@ test("Test delete virtual node leaves other nodes intact", async () => {
40504050
expect(match.results.length).toBe(1);
40514051
expect(match.results[0].n.name).toBe("Keep");
40524052
});
4053+
4054+
test("Test RETURN alias shadowing graph variable in same RETURN clause", async () => {
4055+
// Create User nodes with displayName, jobTitle, and department
4056+
await new Runner(`
4057+
CREATE VIRTUAL (:MentorUser) AS {
4058+
UNWIND [
4059+
{id: 1, displayName: 'Alice Smith', jobTitle: 'Senior Engineer', department: 'Engineering'},
4060+
{id: 2, displayName: 'Bob Jones', jobTitle: 'Staff Engineer', department: 'Engineering'},
4061+
{id: 3, displayName: 'Chloe Dubois', jobTitle: 'Junior Engineer', department: 'Engineering'}
4062+
] AS record
4063+
RETURN record.id AS id, record.displayName AS displayName, record.jobTitle AS jobTitle, record.department AS department
4064+
}
4065+
`).run();
4066+
4067+
// Create MENTORS relationships
4068+
await new Runner(`
4069+
CREATE VIRTUAL (:MentorUser)-[:MENTORS]-(:MentorUser) AS {
4070+
UNWIND [
4071+
{left_id: 1, right_id: 3},
4072+
{left_id: 2, right_id: 3}
4073+
] AS record
4074+
RETURN record.left_id AS left_id, record.right_id AS right_id
4075+
}
4076+
`).run();
4077+
4078+
// This query aliases mentor.displayName AS mentor, which shadows the graph variable "mentor".
4079+
// Subsequent expressions mentor.jobTitle and mentor.department should still reference the graph node.
4080+
const runner = new Runner(`
4081+
MATCH (mentor:MentorUser)-[:MENTORS]->(mentee:MentorUser)
4082+
WHERE mentee.displayName = "Chloe Dubois"
4083+
RETURN mentor.displayName AS mentor, mentor.jobTitle AS mentorJobTitle, mentor.department AS mentorDepartment
4084+
`);
4085+
await runner.run();
4086+
const results = runner.results;
4087+
4088+
expect(results.length).toBe(2);
4089+
expect(results[0]).toEqual({
4090+
mentor: "Alice Smith",
4091+
mentorJobTitle: "Senior Engineer",
4092+
mentorDepartment: "Engineering",
4093+
});
4094+
expect(results[1]).toEqual({
4095+
mentor: "Bob Jones",
4096+
mentorJobTitle: "Staff Engineer",
4097+
mentorDepartment: "Engineering",
4098+
});
4099+
});

0 commit comments

Comments
 (0)