diff --git a/drivine/cypher/cypher_reader.py b/drivine/cypher/cypher_reader.py index 6d40f30..a2baf04 100644 --- a/drivine/cypher/cypher_reader.py +++ b/drivine/cypher/cypher_reader.py @@ -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 @@ -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, ...]: diff --git a/drivine/cypher/sql/sql_cypher_compiler.py b/drivine/cypher/sql/sql_cypher_compiler.py index 0dce471..4567062 100644 --- a/drivine/cypher/sql/sql_cypher_compiler.py +++ b/drivine/cypher/sql/sql_cypher_compiler.py @@ -2958,6 +2958,21 @@ def _project(self, select: _Select, projection: Projection, final: bool) -> _Pro items = [*starred, *projection.items] if not items: raise DrivineError("A projection has at least one item.") + # An order term, or a part of one, written as an item but for its functions' names' case is that item, as Neo4j + # names a function in any case: so `ORDER BY toupper(p.name)` after `TOUPPER(p.name) AS u` reads the item. + item_keys = [(_grouping_key(it.expression), it.expression) for it in items] + + def as_written(part: Expression) -> Expression | None: + key = _grouping_key(part) + return next((written for k, written in item_keys if written != part and k == key), None) + + projection = dataclasses.replace( + projection, + order_by=tuple( + dataclasses.replace(sort, expression=replaced(sort.expression, as_written)) + for sort in projection.order_by + ), + ) # Before _aggregates_first, which names each variable it groups by as an item of its own. _require_explicit_grouping(items, projection.order_by, select.scope.keys()) made = self._anew_first(select, projection, items) @@ -3438,16 +3453,29 @@ def _aggregates_first( bound (`_aggregating`), so `max(n.title) + ': ' + max(n.first) + ' ' + max(n.last)` wrote each aggregate out again at every level, to 68 MB on Postgres, and ran the compiler out of memory on Oracle (drivine4j#373); and Oracle's CONTAINS binds its CLOBs in a subquery (see its `contains_text`), which reads no aggregate of the query - it is in (ORA-00937). Over the aggregates' columns, the operation binds its values as any item does. None where - the projection does none of these. + it is in (ORA-00937). Over the aggregates' columns, the operation binds its values as any item does. So too + where an item that aggregates reads a key that is a property (`_reads_outside_aggregates`), as `r.role + + count(*)` beside `r.role`: grouped in place, it would be grouped by the variable `r` it names, a row for each + relationship, where Neo4j gives one for each role (drivine4j#491); over the keys' columns, it reads the key's. + None where the projection does none of these. """ terms = [*(it.expression for it in items), *(it.expression for it in projection.order_by)] # An order term reads an item by its name. by_name = {it.name: it.expression for it in items} + # A key that is a property, read by an item that aggregates: grouped by as itself, not by its variable. + properties = { + it.expression + for it in items + if isinstance(it.expression, Property) and not contains(it.expression, is_aggregate) + } if ( not any(contains(it, _collects_distinct) or contains(it, _iterates_aggregate) for it in terms) and not any(self._binds_over_aggregates(it.expression, select) for it in items) and not any(self._binds_over_aggregates(it.expression, select, by_name) for it in projection.order_by) + and not any( + contains(it.expression, is_aggregate) and _reads_outside_aggregates(it.expression, properties) + for it in items + ) ): return None # Named as no variable is, unless quoted so. @@ -3468,9 +3496,45 @@ def found(expression: Expression) -> bool: contains(term, found) keyed = [it.name for it in keys if isinstance(it, Variable)] - # A comprehension's, a quantifier's or a reduce's body is kept as it is, so a variable it names outside its own - # is kept too, and so is a pattern's or a subquery's. A subquery may name any variable: each the projection - # groups by is kept. + # A key is read where it is written in another case too: see `_grouping_key`. + key_names = {_grouping_key(it): name for it, name in keys.items()} + + def kept(term: Expression) -> Expression | None: + key = key_names.get(_grouping_key(term)) if key_names else None + if key is not None: + return Variable(key) + if term in aggregates: + name = aggregates[term] + return ( + Function(DISTINCT_ITEMS, False, (Variable(name),)) if _collects_distinct(term) else Variable(name) + ) + return None + + # A list comprehension's WHERE reads a key by its name too, where it binds no variable the key reads. In its + # projection, a quantifier's WHERE or a reduce's step, Neo4j groups by each variable the key reads as well, so + # the body is kept as it is, and those variables beside: `[t IN collect(m.title) | r.role]` beside `r.role` + # gives a row for each `r`. + def rekeyed(term: Expression, bound: AbstractSet[str] = frozenset()) -> Expression: + def replacement(part: Expression) -> Expression | None: + if (not bound or _grouping_key(part) in key_names) and not any( + it in bound for it in variables_in(part) + ): + found = kept(part) + if found is not None: + return found + if isinstance(part, ListComprehension) and part.where is not None: + return dataclasses.replace( + part, list=rekeyed(part.list, bound), where=rekeyed(part.where, {*bound, part.variable}) + ) + return None + + return replaced(term, replacement) + + rekeyed_items = [dataclasses.replace(it, expression=rekeyed(it.expression)) for it in items] + + # A variable a comprehension's, a quantifier's or a reduce's body names outside its own is kept, but where it is + # read only in a key that `rekeyed` reads by its name; so is a pattern's or a subquery's. A subquery may name + # any variable: each the projection groups by is kept. def named(expression: Expression) -> list[str]: if is_aggregate(expression): return [] @@ -3500,7 +3564,11 @@ def named(expression: Expression) -> list[str]: beside = [ it for it in _ordered( - *(named(item.expression) for item in items if contains(item.expression, is_aggregate)), + *( + named(rekeyed_item.expression) + for item, rekeyed_item in zip(items, rekeyed_items, strict=True) + if contains(item.expression, is_aggregate) + ), *( [name for name in named(sort.expression) if name in keyed] for sort in projection.order_by @@ -3530,20 +3598,6 @@ def named(expression: Expression) -> list[str]: limit=None, ) - # A key is read where it is written in another case too: see `_grouping_key`. - key_names = {_grouping_key(it): name for it, name in keys.items()} - - def kept(term: Expression) -> Expression | None: - key = key_names.get(_grouping_key(term)) if key_names else None - if key is not None: - return Variable(key) - if term in aggregates: - name = aggregates[term] - return ( - Function(DISTINCT_ITEMS, False, (Variable(name),)) if _collects_distinct(term) else Variable(name) - ) - return None - # An order term, or a part of one, that is an item is read by the item's name, as over a DISTINCT projection it # can only be; a variable an item is named after is that item, not the variable of that name before it. item_names = {_grouping_key(it.expression): it.name for it in items} @@ -3557,7 +3611,7 @@ def sorted_by(term: Expression) -> Expression | None: keeping = dataclasses.replace( projection, star=False, - items=tuple(dataclasses.replace(it, expression=replaced(it.expression, kept)) for it in items), + items=tuple(rekeyed_items), order_by=tuple( dataclasses.replace(sort, expression=replaced(sort.expression, sorted_by)) for sort in projection.order_by @@ -6306,6 +6360,25 @@ def contains(expression: Expression, test: Callable[[Expression], bool]) -> bool return test(expression) or any(contains(it, test) for it in children(expression)) +def _reads_outside_aggregates(expression: Expression, parts: AbstractSet[Expression]) -> bool: + """ + Whether the expression, outside its aggregates, holds one of `parts`, in a comprehension's, a quantifier's or a + reduce's body too. + """ + if is_aggregate(expression): + return False + if expression in parts: + return True + bodies: list[Expression] = [] + if isinstance(expression, ListComprehension): + bodies = _present(expression.where, expression.projection) + elif isinstance(expression, Quantifier): + bodies = _present(expression.where) + elif isinstance(expression, Reduce): + bodies = [expression.expression] + return any(_reads_outside_aggregates(it, parts) for it in [*children(expression), *bodies]) + + def variables_outside_aggregates(expression: Expression) -> list[str]: """ The variables the expression names outside its aggregates, each once, in the order it names them: what an @@ -6695,26 +6768,23 @@ def _require_explicit_grouping( items: Sequence[ProjectionItem], order_by: Sequence[SortItem], scope: AbstractSet[str] ) -> None: """ - Refuses what Cypher refuses (Neo4j's 42I18): an item that aggregates and names a variable outside its aggregates - that is not an item of its own, which a graph engine would have to group by without being told; in a - comprehension's, a quantifier's, a reduce's or a pattern's body, a variable of `scope` outside an item that does - not aggregate. So too a term of `order_by` that aggregates, beside items that aggregate, and names a variable of - `scope` outside an item's name and an item that does not aggregate, as Neo4j takes them. As drivine4j's + Refuses what Cypher refuses (Neo4j's 42I18): an item that aggregates and names a variable of `scope` outside its + aggregates and each key it reads, in a comprehension's, a quantifier's, a reduce's or a pattern's body too, which a + graph engine would have to group by without being told. A key is an item that does not aggregate, and Neo4j reads + one there only where it is a variable or a variable's property (`_is_plain_key`): `x.a + count(*)` beside `x.a`, + but not `toUpper(p.name) + count(*)` beside `toUpper(p.name)`; in a pattern's parts, only where it is a variable. + So too a term of `order_by` that aggregates, beside items that aggregate, and names a variable of `scope` outside an + item's name and an item that does not aggregate, whatever that item is, as Neo4j takes them. As drivine4j's requireExplicitGrouping. """ grouping = [it for it in items if not contains(it.expression, is_aggregate)] - keys = {it.expression.name for it in grouping if isinstance(it.expression, Variable)} grouped = [_grouping_key(it.expression) for it in grouping] + keys = [it for it in grouped if _is_plain_key(it)] + variables = {it.name for it in keys if isinstance(it, Variable)} for item in items: if not contains(item.expression, is_aggregate): continue - implicit = [ - it - for it in _ordered( - variables_outside_aggregates(item.expression), _named_in_bodies(item.expression, scope, grouped) - ) - if it not in keys - ] + implicit = [it for it in _named_outside(item.expression, set(), scope, keys) if it not in variables] if implicit: named = ", ".join(implicit) raise DrivineError( @@ -6742,15 +6812,33 @@ def _require_explicit_grouping( def _grouping_key(expression: Expression) -> Expression: """ The expression as it is compared with a key that groups rows: each function's name in lower case, as Neo4j names a - function in any case, so `ORDER BY TOUPPER(x.a) + count(*)` reads the key `toUpper(x.a)`, in - `_require_explicit_grouping` and `_aggregates_first`. The AST keeps the name as written. + function in any case, so `ORDER BY TOUPPER(x.a) + count(*)` reads the key `toUpper(x.a)`, in `_project`, + `_require_explicit_grouping` and `_aggregates_first`; in a comprehension's, a quantifier's or a reduce's body too. + The AST keeps the name as written. As drivine4j's keyOf. """ def lowered(part: Expression) -> Expression | None: - if not isinstance(part, Function): - return None - arguments = tuple(_grouping_key(it) for it in part.arguments) - return dataclasses.replace(part, name=part.name.lower(), arguments=arguments) + if isinstance(part, Function): + arguments = tuple(_grouping_key(it) for it in part.arguments) + return dataclasses.replace(part, name=part.name.lower(), arguments=arguments) + if isinstance(part, ListComprehension): + return dataclasses.replace( + part, + list=_grouping_key(part.list), + where=_grouping_key(part.where) if part.where is not None else None, + projection=_grouping_key(part.projection) if part.projection is not None else None, + ) + if isinstance(part, Quantifier): + where = _grouping_key(part.where) if part.where is not None else None + return dataclasses.replace(part, list=_grouping_key(part.list), where=where) + if isinstance(part, Reduce): + return dataclasses.replace( + part, + initial=_grouping_key(part.initial), + list=_grouping_key(part.list), + expression=_grouping_key(part.expression), + ) + return None return replaced(expression, lowered) @@ -6760,36 +6848,14 @@ def _is_key(expression: Expression, keys: Sequence[Expression]) -> bool: return bool(keys) and _grouping_key(expression) in keys -def _named_in_bodies(expression: Expression, scope: AbstractSet[str], keys: Sequence[Expression]) -> list[str]: +def _is_plain_key(expression: Expression) -> bool: """ - The variables of `scope` that the expression names outside its aggregates and each part of it `keys` holds, in a - comprehension's, a quantifier's, a reduce's or a pattern's body, which `variables_outside_aggregates` does not - read. + Whether the expression, an item that does not aggregate, is a key that an item that aggregates may read, as Neo4j + takes one there: a variable, or a variable's property. Not `x.a.b`, `x['a']` or `toUpper(x.a)`. """ - if is_aggregate(expression) or _is_key(expression, keys): - return [] - if isinstance(expression, ListComprehension): - return _ordered( - _named_in_bodies(expression.list, scope, keys), - *( - _named_outside(it, {expression.variable}, scope, keys) - for it in _present(expression.where, expression.projection) - ), - ) - if isinstance(expression, Quantifier): - return _ordered( - _named_in_bodies(expression.list, scope, keys), - *(_named_outside(it, {expression.variable}, scope, keys) for it in _present(expression.where)), - ) - if isinstance(expression, Reduce): - return _ordered( - _named_in_bodies(expression.initial, scope, keys), - _named_in_bodies(expression.list, scope, keys), - _named_outside(expression.expression, {expression.accumulator, expression.variable}, scope, keys), - ) - if isinstance(expression, (PatternComprehension, PatternPredicate, Exists)): - return _named_outside(expression, set(), scope, keys) - return _ordered(*(_named_in_bodies(it, scope, keys) for it in children(expression))) + return isinstance(expression, Variable) or ( + isinstance(expression, Property) and isinstance(expression.subject, Variable) + ) def _named_outside( @@ -6798,7 +6864,9 @@ def _named_outside( """ The variables of `scope` that the expression names outside its aggregates, and outside each part of it `keys` holds, a comprehension's, a quantifier's, a reduce's or a pattern's body among them, but for those it binds or - `local` holds. A pattern binds each of its variables not of `scope`. A subquery's own query is read for none. + `local` holds. A pattern binds each of its variables not of `scope`, and in its parts, as Neo4j reads them, a key + is only one that is a variable: `q.name = p.name` in a pattern comprehension's WHERE names `p`, though `p.name` is + a key. A subquery's own query is read for none. """ def outside(part: Expression, inner: AbstractSet[str] = local) -> list[str]: @@ -6807,7 +6875,11 @@ def outside(part: Expression, inner: AbstractSet[str] = local) -> list[str]: def patterned(pattern: Pattern, parts: Sequence[Expression]) -> list[str]: named = _ordered(variables_in_pattern(pattern), _present(pattern.variable)) bound = {*local, *(it for it in named if it not in scope)} - return _ordered([it for it in named if it in scope and it not in local], *(outside(it, bound) for it in parts)) + variables = [it for it in keys if isinstance(it, Variable)] + return _ordered( + [it for it in named if it in scope and it not in local], + *(_named_outside(it, bound, scope, variables) for it in parts), + ) if is_aggregate(expression) or _is_key(expression, keys): return [] diff --git a/tests/cypher/VERSION b/tests/cypher/VERSION index 6345c21..4a29f93 100644 --- a/tests/cypher/VERSION +++ b/tests/cypher/VERSION @@ -1 +1 @@ -v0.13.0 +v0.14.0 diff --git a/tests/cypher/queries.yaml b/tests/cypher/queries.yaml index c3d72f9..1149563 100644 --- a/tests/cypher/queries.yaml +++ b/tests/cypher/queries.yaml @@ -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] @@ -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\")." diff --git a/tests/cypher/refusals.yaml b/tests/cypher/refusals.yaml index fb6fea7..47d40ef 100644 --- a/tests/cypher/refusals.yaml +++ b/tests/cypher/refusals.yaml @@ -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" @@ -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 { … }." diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index 95f24c5..18afde0 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -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, ): diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 20f077b..0642a57 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -1053,6 +1053,102 @@ def query(cypher: str) -> list[Any]: ) == [["B", ["b"]], ["A", ["a"]]] +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). + seeded(cypher_pm) + + def query(cypher: str) -> list[Any]: + return 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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]] + + def test_an_optional_call_in_a_writes_subquery_counts_what_it_finds_and_the_row_it_finds_nothing_for( cypher_pm: PersistenceManager, ): diff --git a/tests/unit/test_cypher_reader.py b/tests/unit/test_cypher_reader.py index ccc0286..201172b 100644 --- a/tests/unit/test_cypher_reader.py +++ b/tests/unit/test_cypher_reader.py @@ -154,6 +154,11 @@ def test_a_projection_reads_its_names_distinct_order_skip_and_limit(): assert [it.name for it in ret.items] == ["name", "n.age"] assert [it.descending for it in ret.order_by] == [True, False] assert ret.skip == Literal(2) and ret.limit == Parameter("n") + # As Neo4j names them (#311): an unnamed map projection after its variable, an alias before it, any other item as + # written. + maps = read_cypher("MATCH (p) RETURN p {.name}, p {.name, .age} AS q, p {.name}.name").clauses[-1] + assert isinstance(maps, Return) + assert [it.name for it in maps.projection.items] == ["p", "q", "p {.name}.name"] def test_the_writes_read_as_their_clauses_in_order_with_or_without_a_return(): diff --git a/tests/unit/test_cypher_refusals.py b/tests/unit/test_cypher_refusals.py index a89ff74..55d0366 100644 --- a/tests/unit/test_cypher_refusals.py +++ b/tests/unit/test_cypher_refusals.py @@ -804,3 +804,96 @@ def test_a_body_or_order_term_that_names_only_what_the_projection_groups_by_is_c # Beside drivine4j's #482: a body or an order term that names only a grouping key, an item's name, or a variable of # its own, as Neo4j runs it. compiler("g").compile(cypher) + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + ("cypher", "message"), + [ + ("UNWIND [{a: 1, b: 2}] AS x RETURN x.a AS a, x.b + count(*) AS c", _ungrouped_item("c", "x")), + ( + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, toUpper(p.name) + count(*) AS c ORDER BY u", + _ungrouped_item("c", "p"), + ), + ( + "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", + _ungrouped_item("c", "p"), + ), + ( + "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", + _ungrouped_item("c", "x"), + ), + ( + "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", + _ungrouped_item("c", "x"), + ), + ( + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a + 1 AS k, x.a + 1 + count(*) AS c ORDER BY k", + _ungrouped_item("c", "x"), + ), + ( + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x['a'] AS a, x['a'] + count(*) AS b ORDER BY a", + _ungrouped_item("b", "x"), + ), + ( + "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", + _ungrouped_item("c", "p"), + ), + ( + "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", + _ungrouped_item("c", "p"), + ), + # Unnamed, the map projection is the item p, not the variable. + ( + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, count(*) + size(p.name) AS c ORDER BY p.name", + _ungrouped_item("c", "p"), + ), + ( + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name} AS q, p {.name}.name + count(*) AS c", + _ungrouped_item("c", "p"), + ), + ], +) +def test_an_aggregating_item_that_reads_a_key_neither_a_variable_nor_a_variables_property_is_refused( + compiler: type[SqlCypherCompiler], cypher: str, message: str +) -> None: + # #311, drivine4j's #491: Neo4j reads `x.a` in `x.a + count(*)` as the key `x.a`, but no key of another shape there, + # beside the item's aggregates or in a body (its 42I18), and in a pattern's parts only a key that is a variable. + # drivine4j's CompilerRefusalTest; the spec's "Implicit grouping key in a body or an ORDER BY" holds most of these. + with pytest.raises(DrivineError) as refused: + compiler("g").compile(cypher) + assert str(refused.value) == message + + +@pytest.mark.parametrize("compiler", COMPILERS.values(), ids=COMPILERS.keys()) +@pytest.mark.parametrize( + "cypher", + [ + "MATCH (x) RETURN x.a AS a, x.a + count(*) AS b", + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a, x.a + count(*) AS b ORDER BY x.a", + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a + count(*) AS b, x.a AS a ORDER BY a", + "UNWIND [{a: 1}, {a: 1}, {a: 2}] AS x RETURN x.a AS a, count(*) + x.a AS b, max(x.a) - x.a AS c ORDER BY a", + "MATCH (:Person)-[r:ACTED_IN]->(m) RETURN r.role AS role, " + "CASE WHEN r.role STARTS WITH 'N' THEN count(*) ELSE 0 END AS n ORDER BY role", + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, reduce(s = p.name, t IN collect(m.title) | s + t) AS r " + "ORDER BY p.name", + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p.name, p.name IN collect(m.title) AS r ORDER BY p.name", + "UNWIND [{a: {b: 1}}, {a: {b: 1}}, {a: {b: 2}}] AS x RETURN x.a AS k, [y IN collect(x) | x.a.b] AS c " + "ORDER BY c", + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p AS q, p.name + count(*) AS c ORDER BY c", + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN p {.name}, collect(m.title) AS ts ORDER BY p.name", + "MATCH (p:Person)-[:ACTED_IN]->(m) WITH p {.name}, count(*) AS c RETURN p.name, c ORDER BY p.name", + "UNWIND [{a: 'x'}, {a: 'x'}, {a: 'y'}] AS x RETURN toUpper(x.a), count(*) ORDER BY TOUPPER(x.a) + count(*)", + "MATCH (p:Person)-[:ACTED_IN]->(m) RETURN toUpper(p.name) AS u, COUNT(m) AS c " + "ORDER BY toUpper(p.name) + count(m) DESC", + ], +) +def test_an_aggregating_item_that_reads_a_key_that_is_a_variables_property_is_compiled( + compiler: type[SqlCypherCompiler], cypher: str +) -> None: + # #311, drivine4j's #491: as Neo4j runs them. drivine4j's CompilerRefusalTest. + compiler("g").compile(cypher)