Агрегации: count, collect и неявная группировка
Как считать и собирать данные после обхода — и почему в Cypher нет отдельного GROUP BY.
В Cypher группировка неявная: всё, что в
RETURNне обёрнуто в агрегатную функцию, автоматически становится ключом группировки.
Считаем без GROUP BY
В SQL вы пишете GROUP BY. В Cypher группировка выводится сама. «Сколько фильмов у каждого человека»:
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name AS actor, count(m) AS films
ORDER BY films DESCЗдесь p.name не агрегирован — значит, по нему и идёт группировка; count(m) считается внутри каждой группы. Никакого GROUP BY указывать не надо.
Эта неявность поначалу непривычна, но логична: Cypher смотрит на RETURN и делит выражения на два сорта — агрегатные (обёрнуты в count, collect, avg и т.п.) и все остальные. «Все остальные» автоматически становятся составным ключом группировки, а агрегатные считаются внутри каждой группы. То есть строка RETURN p.name, count(m) читается как «для каждого уникального p.name посчитать m». Если неагрегированных выражений несколько, ключ — их комбинация.
Параллель с SQL
Тем, кто пришёл из SQL, полезно увидеть прямое соответствие. Один и тот же запрос на двух языках:
SELECT director, COUNT(*) AS films
FROM directed
GROUP BY director
ORDER BY films DESC;MATCH (d:Person)-[:DIRECTED]->(m:Movie)
RETURN d.name AS director, count(m) AS films
ORDER BY films DESCРазница не только синтаксическая. В SQL GROUP BY явный, и забыть колонку в нём — ошибка компиляции. В Cypher группировка выводится молча, поэтому «ошибка» проявляется не падением, а неожиданными цифрами. Это плата за лаконичность: язык доверяет, что вы написали в RETURN ровно то, что хотели сгруппировать.
collect: связи в список
Самая «графовая» агрегатная функция — collect: она собирает значения группы в массив. «Каждый актёр и список его фильмов одной строкой»:
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name AS actor, collect(m.title) AS films
ORDER BY size(films) DESCВывод (фрагмент):
actor: 'Tom Hanks' films: ['Forrest Gump','Cast Away','Cloud Atlas', ...]
size(films) возвращает длину собранного списка. Это денормализованный, удобный для приложения вид. Вместо тысячи строк «актёр — фильм» вы получаете по одной строке на актёра с массивом фильмов внутри — ровно та форма, которую удобно отдать в JSON фронтенду.
collect можно собирать не только из скалярных значений, но и из целых узлов и даже из выражений-структур (map'ов). «Каждый режиссёр и компактные карточки его фильмов»:
MATCH (d:Person)-[:DIRECTED]->(m:Movie)
RETURN d.name AS director,
collect({title: m.title, year: m.released}) AS movies
ORDER BY size(movies) DESCКаждый элемент списка movies здесь — это объект {title, year}. Так одним запросом строится вложенная структура «директор → его фильмы со свойствами», без отдельного второго похода в базу.
Числовые агрегаты
Привычные avg, sum, min, max тоже есть. «Средний год выхода фильмов по режиссёрам»:
MATCH (d:Person)-[:DIRECTED]->(m:Movie)
RETURN d.name AS director,
count(m) AS movies,
avg(m.released) AS avg_year,
min(m.released) AS first_year
ORDER BY movies DESCСчитаем уникальные и пустые
count(*) считает строки, count(x) — не-null значения, count(DISTINCT x) — уникальные. Различие важно: count(DISTINCT m) в обходе с дублями даст честное число разных фильмов. Разберём на примере, почему это не педантизм: если вы считаете «коллег» Тома через общий фильм, то актёр, с которым он снимался в трёх картинах, породит три строки. count(co) вернёт завышенное число «связей», а count(DISTINCT co) — настоящее число разных коллег. Выбор зависит от вопроса: «сколько совместных работ» или «сколько разных людей».
Многоступенчатая агрегация через WITH
Часто результат одной агрегации нужно агрегировать снова — например, «среднее число фильмов на актёра». Это два уровня свёртки, и связывает их WITH (агрегирующий аналог промежуточного RETURN):
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH p, count(m) AS films_per_actor
RETURN avg(films_per_actor) AS avg_films, max(films_per_actor) AS mostПервый этап (WITH) группирует по актёру и считает его фильмы; второй (RETURN) уже усредняет эти числа по всем актёрам. WITH — ключ к «агрегации над агрегацией»: он закрывает одну группировку и открывает новую область видимости для следующей.
Как работает под капотом
Агрегация в Cypher — оператор, который читает поток строк и схлопывает его по ключам группировки. Ключи — это все неагрегированные выражения в RETURN. Поэтому случайно добавленное неагрегированное поле меняет группировку! Например, добавив в RETURN m.title рядом с count(m), вы получите подсчёт по парам (actor, title), а не по актёру. Это типичный сюрприз для тех, кто привык к явному GROUP BY.
Механически оператор EagerAggregation в плане строит хеш-таблицу: ключ — кортеж значений группировки, значение — накапливаемый аккумулятор (счётчик, сумма, растущий список для collect). Каждая входная строка вычисляет свой ключ, находит или создаёт ячейку и обновляет аккумулятор. Слово Eager («жадный») значит, что оператор обязан прочитать весь вход, прежде чем отдать первую строку результата — ведь пока не пройдены все строки, ни одна группа не закончена. Поэтому агрегация может стать точкой материализации, где запрос «упирается» в память на больших объёмах: вся хеш-таблица групп держится в памяти до конца. Для collect это особенно ощутимо — собранные списки могут быть огромными.
Частые ошибки
- Лишнее неагрегированное поле в RETURN. Оно молча уходит в ключ группировки и ломает подсчёт.
- count вместо count(DISTINCT) в обходах. Дубли путей завышают счётчик; для уникальных —
count(DISTINCT x). - collect с null. После OPTIONAL MATCH
collectможет набрать null-ы — фильтруйте или используйте там, где null исключён. - Путать
count(*)иcount(x). Первый считает все строки группы, второй — только строки с непустымx; на данных с null это разные числа. - Тяжёлый
collectна большой группе. Собранный список держится в памяти целиком; на группе в миллионы элементов это бьёт по памяти — ограничивайте или агрегируйте иначе.
Итоги
- Группировка в Cypher неявная: ключ — все неагрегированные выражения в RETURN.
collectсобирает группу в список — мощный инструмент денормализации для приложения.count,avg,sum,min,maxработают как в SQL; естьcount(DISTINCT).- Лишнее поле в RETURN незаметно меняет группировку — следите за этим.