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
20 changes: 13 additions & 7 deletions drivine/cypher/cypher_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,7 @@ def projection(self, context: Context) -> Projection:
return Projection(
distinct=context.DISTINCT() is not None,
star=bool(items.children) and items.children[0].getText() == "*",
items=tuple(
ProjectionItem(
self.expression(item.oC_Expression()),
self.name(item.oC_Variable()) if item.oC_Variable() is not None else item.oC_Expression().getText(),
)
for item in items.oC_ProjectionItem()
),
items=tuple(self.projection_item(item) for item in items.oC_ProjectionItem()),
order_by=tuple(
SortItem(
self.expression(sort.oC_Expression()), sort.DESC() is not None or sort.DESCENDING() is not None
Expand All @@ -323,6 +317,18 @@ def projection(self, context: Context) -> Projection:
limit=self.expression(limit.oC_Expression()) if limit is not None else None,
)

def projection_item(self, item: Context) -> ProjectionItem:
"""
The item, named as Neo4j names it: by its alias; unnamed, a map projection after its variable, so `ORDER BY
p.name` after `RETURN p {.name}, count(*)` reads the item; any other expression as it is written.
"""
expression = self.expression(item.oC_Expression())
if item.oC_Variable() is not None:
return ProjectionItem(expression, self.name(item.oC_Variable()))
if isinstance(expression, MapProjection):
return ProjectionItem(expression, expression.variable)
return ProjectionItem(expression, item.oC_Expression().getText())

# ----- Patterns -----

def patterns(self, context: Context) -> tuple[Pattern, ...]:
Expand Down
212 changes: 142 additions & 70 deletions drivine/cypher/sql/sql_cypher_compiler.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/cypher/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.13.0
v0.14.0
56 changes: 55 additions & 1 deletion tests/cypher/queries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1495,7 +1495,7 @@ cases:
rows: [[9, 1], [8, 2]]

- name: "Implicit grouping key"
description: "A RETURN item that mixes an aggregate with a variable no other item is is refused, as Neo4j refuses it; one that names only what the projection groups by, in a comprehension's body or an order term too, gives Neo4j's rows."
description: "A RETURN item that mixes an aggregate with a variable no other item is is refused, as Neo4j refuses it; one that names only what the projection groups by, a variable's property among them, in a comprehension's body or an order term too, gives Neo4j's rows."
steps:
- note: Neo4j says "implicit grouping"; the compiler names the variable outside its aggregates.
only: [graph]
Expand Down Expand Up @@ -1547,6 +1547,60 @@ cases:
- only: [sql]
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p AS q, count(*) AS c ORDER BY size([(p)-[:KNOWS]->(k) | k]) * 10 - count(*) DESC"
error: "A term of an ORDER BY aggregates, and names p outside its aggregates and the items: Cypher would group by it without being told. Order by an item's name, or by an expression of the items and aggregates."
- note: "An item that aggregates over a key that is a variable's property: grouped by the property, not by its variable, so \"Neo\", which two relationships have, is one row. Neo4j takes no key of another shape there (see refusals.yaml, \"Implicit grouping key in a body or an ORDER BY\")."
only: [neo4j, sql]
cypher: "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a AS a, x.a + count(*) AS b ORDER BY a"
rows: [[1, 3], [2, 3]]
- cypher: "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x WITH x.a AS a, x.a + count(*) AS b RETURN a, b ORDER BY a"
only: [neo4j, sql]
rows: [[1, 3], [2, 3]]
- cypher: "MATCH (:Person)-[r:ACTED_IN]->() RETURN r.role AS role, r.role + count(*) AS n ORDER BY role"
only: [neo4j, sql]
rows: [["Johnny", "Johnny1"], ["Morpheus", "Morpheus1"], ["Neo", "Neo2"], ["Trinity", "Trinity2"]]
- cypher: "MATCH (:Person)-[r:ACTED_IN]->() RETURN r.role AS role, {role: r.role, n: count(*)} AS n ORDER BY role"
only: [neo4j, sql]
rows: [["Johnny", {role: "Johnny", n: 1}], ["Morpheus", {role: "Morpheus", n: 1}], ["Neo", {role: "Neo", n: 2}], ["Trinity", {role: "Trinity", n: 2}]]
- note: "A reduce()'s initial value, and what IN searches for."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, size(reduce(s = p.name, t IN collect(m.title) | s + t)) AS r ORDER BY p.name"
only: [neo4j, sql]
rows: [["Carrie", 35], ["Keanu", 49], ["Laurence", 18]]
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, p.name IN collect(m.title) AS r ORDER BY p.name"
only: [neo4j, sql]
rows: [["Carrie", false], ["Keanu", false], ["Laurence", false]]
- note: "A property of the key x.a."
cypher: "UNWIND [{a: {b: 1}}, {a: {b: 1}}, {a: {b: 2}}] AS x RETURN x.a AS k, x.a.b + count(*) AS c ORDER BY k.b"
only: [neo4j, sql]
rows: [[{b: 1}, 3], [{b: 2}, 3]]
- note: "In a list comprehension's WHERE, the key. In its projection or a quantifier's WHERE, Neo4j groups by the key's variable too: a row for each relationship."
cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, size([t IN collect(m.title) WHERE t STARTS WITH r.role | t]) AS n ORDER BY role"
only: [neo4j, sql]
rows: [["Johnny", 1], ["Morpheus", 0], ["Neo", 0], ["Trinity", 0]]
- cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, [t IN collect(m.title) | r.role] AS n ORDER BY role"
only: [neo4j, sql]
rows: [["Johnny", ["Johnny"]], ["Morpheus", ["Morpheus"]], ["Neo", ["Neo"]], ["Neo", ["Neo"]], ["Trinity", ["Trinity"]], ["Trinity", ["Trinity"]]]
- cypher: "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, any(t IN collect(m.title) WHERE t CONTAINS r.role) AS n ORDER BY role"
only: [neo4j, sql]
rows: [["Johnny", true], ["Morpheus", false], ["Neo", false], ["Neo", false], ["Trinity", false], ["Trinity", false]]
- note: "Unnamed, a map projection is an item named after its variable, which an order term or the next clause reads."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, count(*) AS c ORDER BY p.name"
only: [neo4j, sql]
rows: [[{name: "Carrie"}, 2], [{name: "Keanu"}, 3], [{name: "Laurence"}, 1]]
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name, .born}, count(*) AS c ORDER BY p.born"
only: [neo4j, sql]
rows: [[{name: "Laurence", born: 1961}, 1], [{name: "Keanu", born: 1964}, 3], [{name: "Carrie", born: 1967}, 2]]
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p {.name}, count(*) AS c RETURN p.name, c ORDER BY p.name"
only: [neo4j, sql]
rows: [["Carrie", 2], ["Keanu", 3], ["Laurence", 1]]
- note: "Neo4j names a function in any case: an order term that writes a key's function in another case is the key."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, count(*) AS c ORDER BY TOUPPER(p.name) + count(*) DESC"
only: [neo4j, sql]
rows: [["LAURENCE", 1], ["KEANU", 3], ["CARRIE", 2]]
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN TOUPPER(p.name) AS u, count(*) AS c ORDER BY toupper(p.name)"
only: [neo4j, sql]
rows: [["CARRIE", 2], ["KEANU", 3], ["LAURENCE", 1]]
- cypher: "UNWIND [{a: 'x'}, {a: 'x'}, {a: 'y'}] AS x RETURN toUpper(x.a), count(*) ORDER BY TOUPPER(x.a) + count(*)"
only: [neo4j, sql]
rows: [["X", 2], ["Y", 1]]

- name: "Aggregate beside a WHERE"
description: "A WHERE over what an aggregate gave, and one in a subquery's or a CALL's own query, which aggregates in its projection, filters as Neo4j does. An aggregate in a WHERE is refused (see refusals.yaml, \"Aggregate in a WHERE\")."
Expand Down
28 changes: 27 additions & 1 deletion tests/cypher/refusals.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ cases:
error: "max() aggregates, and a WHERE holds no aggregate, as in Neo4j: aggregate in a WITH first, and filter what it gives in that WITH's WHERE, as in WITH x, count(*) AS c WHERE c > 1."

- name: "Implicit grouping key in a body or an ORDER BY"
description: "An item that aggregates, and names in a comprehension's, a quantifier's, a reduce()'s or a pattern's body a variable outside each item that does not aggregate, is refused, as Neo4j refuses it (its 42I18, \"reference to non-grouping sub-expression\"); so is a term of an ORDER BY that aggregates, after items that aggregate, and names a variable outside the items. What Neo4j takes beside them is in queries.yaml, \"Implicit grouping key\"."
description: "An item that aggregates, and names, beside its aggregates or in a comprehension's, a quantifier's, a reduce()'s or a pattern's body, a variable outside each item that does not aggregate, is refused, as Neo4j refuses it (its 42I18, \"reference to non-grouping sub-expression\"); there, a key is read only where it is a variable or a variable's property, and in a pattern's parts only where it is a variable. So is a term of an ORDER BY that aggregates, after items that aggregate, and names a variable outside the items. What Neo4j takes beside them is in queries.yaml, \"Implicit grouping key\"."
steps:
- note: "A list comprehension's body: z is no grouping key, though x is."
cypher: "UNWIND [1, 2] AS x UNWIND [3] AS z RETURN x, [y IN collect(z) | y + z] AS l"
Expand Down Expand Up @@ -692,3 +692,29 @@ cases:
- note: "The openCypher TCK's ReturnOrderBy6 [4]."
cypher: "MATCH (me:Person)--(you:Person) RETURN count(you.age) AS agg ORDER BY me.age + count(you.age)"
error: "A term of an ORDER BY aggregates, and names me outside its aggregates and the items: Cypher would group by it without being told. Order by an item's name, or by an expression of the items and aggregates."
- note: "An item that aggregates reads as a key only one that is a variable or a variable's property: x.b is no key, though x.a is."
cypher: "UNWIND [{a: 1, b: 2}] AS x RETURN x.a AS a, x.b + count(*) AS c"
error: "c aggregates, and names x outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH x, collect(…) AS items RETURN { … }."
- note: "Nor a key of another shape, beside its aggregates or in a body."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, toUpper(p.name) + count(*) AS c ORDER BY u"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, [t IN collect(m.title) | t + toUpper(p.name)] AS c ORDER BY u"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
- cypher: "UNWIND [{a: {b: 1}}, {a: {b: 1}}, {a: {b: 2}}] AS x RETURN x.a.b AS k, x.a.b + count(*) AS c ORDER BY k"
error: "c aggregates, and names x outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH x, collect(…) AS items RETURN { … }."
- cypher: "UNWIND [{a: {b: 1}}, {a: {b: 1}}, {a: {b: 2}}] AS x RETURN x.a.b AS k, [y IN collect(x) | y.a.b + x.a.b] AS c ORDER BY k"
error: "c aggregates, and names x outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH x, collect(…) AS items RETURN { … }."
- cypher: "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a + 1 AS k, x.a + 1 + count(*) AS c ORDER BY k"
error: "c aggregates, and names x outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH x, collect(…) AS items RETURN { … }."
- cypher: "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x['a'] AS a, x['a'] + count(*) AS b ORDER BY a"
error: "b aggregates, and names x outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH x, collect(…) AS items RETURN { … }."
- note: "In a pattern's parts, a key that is a property is no key: p.name is read as p."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name AS name, size([(q:Person)-[:KNOWS]->() WHERE q.name = p.name | q]) + count(*) AS c ORDER BY name"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name AS name, [x IN collect(m) | size([(x)<-[:ACTED_IN]-(q) WHERE q.name = p.name | q])] AS c ORDER BY name"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
- note: "Unnamed, a map projection is the item p, not the variable p."
cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, count(*) + size(p.name) AS c ORDER BY p.name"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
- cypher: "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name} AS q, p {.name}.name + count(*) AS c"
error: "c aggregates, and names p outside its aggregates, which no other item is: Cypher would group by it without being told. Aggregate in a WITH first, as in WITH p, collect(…) AS items RETURN { … }."
95 changes: 95 additions & 0 deletions tests/integration/test_cypher_compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,101 @@ async def query(cypher: str) -> list[Any]:
) == [["B", ["b"]], ["A", ["a"]]]


async def test_an_aggregating_item_over_a_key_that_is_a_property_groups_by_the_property_as_neo4j_does(
cypher_pm: PersistenceManager,
):
# #311: beside the shapes now refused, those Neo4j takes. drivine4j's CypherQueryContract of
# liberation-data/drivine4j#491; the rows are Neo4j's (2026.10).
await seeded(cypher_pm)

async def query(cypher: str) -> list[Any]:
return await cypher_pm.query(spec(cypher))

roles = ["Johnny", "Morpheus", "Neo", "Trinity"]
# Grouped by the property, not by its variable: one row for "Neo", which two relationships have.
assert await query("UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a AS a, x.a + count(*) AS b ORDER BY a") == [
[1, 3],
[2, 3],
]
assert await query(
"UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x WITH x.a AS a, x.a + count(*) AS b RETURN a, b ORDER BY a"
) == [[1, 3], [2, 3]]
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->() RETURN r.role AS role, r.role + count(*) AS n ORDER BY role"
) == [[role, n] for role, n in zip(roles, ["Johnny1", "Morpheus1", "Neo2", "Trinity2"], strict=True)]
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->() RETURN r.role AS role, {role: r.role, n: count(*)} AS n ORDER BY role"
) == [[role, {"role": role, "n": n}] for role, n in zip(roles, [1, 1, 2, 2], strict=True)]
assert await query(
"MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, size(reduce(s = p.name, t IN collect(m.title) | s + t)) AS r "
"ORDER BY p.name"
) == [["Carrie", 35], ["Keanu", 49], ["Laurence", 18]]
assert await query(
"MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, p.name IN collect(m.title) AS r ORDER BY p.name"
) == [["Carrie", False], ["Keanu", False], ["Laurence", False]]
assert await query(
"UNWIND [{a: {b: 1}}, {a: {b: 1}}, {a: {b: 2}}] AS x RETURN x.a AS k, x.a.b + count(*) AS c ORDER BY k.b"
) == [[{"b": 1}, 3], [{"b": 2}, 3]]
# In a list comprehension's WHERE, the key; in its projection or a quantifier's WHERE, Neo4j groups by its variable
# too.
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, "
"size([t IN collect(m.title) WHERE t STARTS WITH r.role | t]) AS n ORDER BY role"
) == [[role, n] for role, n in zip(roles, [1, 0, 0, 0], strict=True)]
each = ["Johnny", "Morpheus", "Neo", "Neo", "Trinity", "Trinity"]
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, [t IN collect(m.title) | r.role] AS n ORDER BY role"
) == [[it, [it]] for it in each]
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, any(t IN collect(m.title) WHERE t CONTAINS r.role) "
"AS n ORDER BY role"
) == [[it, it == "Johnny"] for it in each]
# Unnamed, a map projection is the item p, which an order term reads.
assert await query("MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, count(*) AS c ORDER BY p.name") == [
[{"name": "Carrie"}, 2],
[{"name": "Keanu"}, 3],
[{"name": "Laurence"}, 1],
]
assert await query("MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name, .born}, count(*) AS c ORDER BY p.born") == [
[{"name": "Laurence", "born": 1961}, 1],
[{"name": "Keanu", "born": 1964}, 3],
[{"name": "Carrie", "born": 1967}, 2],
]
assert await query(
"MATCH (p:Person)-[:ACTED_IN]->(m) WITH p {.name}, count(*) AS c RETURN p.name, c ORDER BY p.name"
) == [["Carrie", 2], ["Keanu", 3], ["Laurence", 1]]
# A null key is one group; two keys of one variable group by both, not by the variable (two rows have a = 1, b = 1).
assert await query("MATCH (m:Movie) RETURN m.tagline AS t, {t: m.tagline, n: count(*)} AS n ORDER BY t") == [
["Welcome to the Real World", {"t": "Welcome to the Real World", "n": 1}],
[None, {"t": None, "n": 2}],
]
assert await query(
"UNWIND [{a: 1, b: 1, c: 1}, {a: 1, b: 1, c: 2}, {a: 1, b: 2, c: 1}, {a: 2, b: 2, c: 1}] AS x "
"RETURN x.a AS a, x.b AS b, x.a * 10 + x.b + count(*) AS n ORDER BY a, b"
) == [[1, 1, 13], [1, 2, 13], [2, 2, 23]]
# In a comprehension's WHERE inside another's WHERE, the key too; but not where a comprehension binds the key's
# variable.
assert await query(
"MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, "
"size([t IN collect(m.title) WHERE size([u IN [t] WHERE u CONTAINS r.role]) > 0]) AS n ORDER BY role"
) == [[role, n] for role, n in zip(roles, [1, 0, 0, 0], strict=True)]
assert await query(
"UNWIND [{a: 1}, {a: 2}, {a: 2}] AS x RETURN x.a AS a, size([x IN [{a: 5}] WHERE x.a > 1]) + count(*) AS n "
"ORDER BY a"
) == [[1, 2], [2, 3]]
# A function named in another case is the same function.
assert await query(
"MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, count(*) AS c "
"ORDER BY TOUPPER(p.name) + count(*) DESC"
) == [["LAURENCE", 1], ["KEANU", 3], ["CARRIE", 2]]
assert await query(
"MATCH (p:Person)-[:ACTED_IN]->(m) RETURN TOUPPER(p.name) AS u, count(*) AS c ORDER BY toupper(p.name)"
) == [["CARRIE", 2], ["KEANU", 3], ["LAURENCE", 1]]
assert await query(
"UNWIND [{a: 'x'}, {a: 'x'}, {a: 'y'}] AS x RETURN toUpper(x.a), count(*) ORDER BY TOUPPER(x.a) + count(*)"
) == [["X", 2], ["Y", 1]]


async def test_an_optional_call_in_a_writes_subquery_counts_what_it_finds_and_the_row_it_finds_nothing_for(
cypher_pm: PersistenceManager,
):
Expand Down
Loading
Loading