Агрегации: 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 незаметно меняет группировку — следите за этим.
Проверьте себя
1. Как в Cypher задаётся группировка для агрегатов?
AЧерез GROUP BY
BНеявно: все неагрегированные выражения в RETURN становятся ключами группировки
CЧерез WHERE
DГруппировка невозможна
2. Что делает collect(m.title)?
AСчитает фильмы
BСобирает значения title группы в список (массив)
CУдаляет дубли
DСортирует фильмы
3. Что произойдёт, если рядом с count(m) добавить неагрегированное m.title в RETURN?
AНичего не изменится
BГруппировка пойдёт по парам (actor, title), и подсчёт сломается
CБудет синтаксическая ошибка
Dtitle станет агрегатом
4. Как посчитать «среднее число фильмов на актёра» (агрегация над агрегацией)?
AОдним count в RETURN
BЧерез WITH: сперва count(m) по актёру, затем avg(...) этих чисел в RETURN
CЭто невозможно в Cypher
DЧерез два GROUP BY