Агрегация: GROUP BY/HAVING
Бизнес редко спрашивает «покажи все строки». Он спрашивает сводками: «сколько напитков в каждой категории и почём?», «сколько заказов у каждого клиента и на какую сумму?», «кто заказывал хотя бы дважды?». Ответ на такие вопросы — агрегация: схлопнуть много строк в одну строку-итог по группе.
И ровно здесь живёт одна из самых дорогих ошибок в отчётах: count(*) и count(колонка) выглядят почти одинаково, а считают разное. На клиенте без заказов это расхождение видно сразу — и если перепутать, отчёт «активность клиентов» тихо соврёт.
GROUP BY и агрегатные функции
GROUP BY режет таблицу на группы по значению колонки (или нескольких), а агрегатная функция считает по каждой группе одно число: count — сколько, sum — сумма, min/max — границы, avg — среднее. Правило: всё в SELECT, что не агрегат, обязано стоять в GROUP BY — иначе непонятно, какое из значений группы показать. Поэтому SELECT category, count(*) ... GROUP BY category корректен, а SELECT name, count(*) ... GROUP BY category — нет (name в группе много).
Среднюю цену мы округляем и приводим к bigint (round(avg(base_price))::bigint): avg возвращает numeric, а нам нужно целое число центов и int64 в Go.
count(*) против count(колонка) — не одно и то же
Это сердце юнита. Две формы count считают разные вещи:
count(*)— сколько строк в группе, неважно, что в них.count(колонка)— сколько строк, где эта колонка не NULL.
На customers LEFT JOIN orders разница вылезает на клиенте без заказов. У Карины LEFT JOIN оставит одну строку с NULL в колонках заказа. Тогда count(*) для неё = 1 (строка-то есть), а count(o.id) = 0 (заказов нет, o.id — NULL). Если в отчёте «сколько у клиента заказов» написать count(*), Карина получит «1 заказ» — хотя заказов у неё ноль. Правильный счётчик заказов здесь — count(o.id).
sum(o.amount) по группе без заказов вернёт NULL (не 0!) — поэтому оборачиваем в COALESCE(..., 0), иначе у Карины была бы пустая выручка вместо нуля.
Все четыре формы на одной и той же строке Карины (LEFT JOIN, заказов нет) дают разное — и каждое отличие легко принять за баг данных, хотя это поведение по определению:
| на строке Карины | даёт | почему |
|---|---|---|
count(*) | 1 | считает строки; LEFT JOIN оставил одну строку с NULL |
count(o.id) | 0 | считает только не-NULL; o.id пуст |
sum(o.amount) | NULL → 0 через COALESCE | нет слагаемых — это NULL, не 0 |
avg(o.amount) | NULL | пустую группу не из чего усреднять |
HAVING фильтрует группы, а не строки
WHERE отсеивает строки до группировки; HAVING отсеивает готовые группы — по значению агрегата. «Клиенты с двумя и более заказами» нельзя записать через WHERE count(o.id) >= 2: на этапе WHERE агрегат ещё не посчитан. Это делает HAVING count(o.id) >= 2 — он применяется после GROUP BY, когда счётчик по каждой группе уже известен.
Шаги запроса выполняются в строгом логическом порядке, и место WHERE против HAVING в нём всё объясняет:
FROM / JOIN → собрать строки из таблиц
WHERE → отсеять строки (фильтр СТРОК, до группировки)
GROUP BY → разрезать на группы
агрегаты → count / sum / min / max / avg по каждой группе
HAVING → отсеять готовые группы (фильтр ГРУПП, по агрегату)
ORDER BY → упорядочить итогWHERE видит ещё отдельные строки, HAVING — уже посчитанные группы; вот почему count(o.id) >= 2 живёт только в HAVING.
Что показывает наш код
Три запроса в query.sql. Сводка меню:
-- name: MenuStatsByCategory :many
SELECT category, count(*) AS drinks, min(base_price)::bigint AS price_min,
max(base_price)::bigint AS price_max, round(avg(base_price))::bigint AS price_avg
FROM drinks GROUP BY category ORDER BY category;И статистика по клиентам с двумя счётчиками рядом — чтобы расхождение было видно:
-- name: CustomerOrderStats: count(*) AS rows_in_group, count(o.id) AS orders, ...
-- ... FROM customers c LEFT JOIN orders o ON o.customer_id = c.id::text GROUP BY c.id, c.name;
-- name: RegularCustomers: ... HAVING count(o.id) >= 2;Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=04-querying-across-tables/04-03-aggregation-group-by-having T=db-reset
make lecture L=04-querying-across-tables/04-03-aggregation-group-by-having(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) Сводка меню по категориям (GROUP BY category):
катег. напитк min max avg
coffee 3 3.00 4.80 4.10
cold 1 5.20 5.20 5.20
tea 1 2.50 2.50 2.50
2) Статистика по клиентам (customers LEFT JOIN orders, GROUP BY клиент):
клиент count(*) count(id) выручка
Алиса Иванова 2 2 20.10
Борис Петров 1 1 3.00
Карина Сидорова 1 0 0.00
→ у Карины count(*)=1 (строка есть), но count(o.id)=0 (заказов нет):
count(*) считает строки, count(колонка) — только не-NULL значения.
3) Постоянные клиенты — HAVING count(o.id) >= 2:
Алиса Иванова заказов: 2, выручка: 20.10
→ HAVING фильтрует уже посчитанные группы; WHERE так не умеет.Карина — наглядный случай: count(*) и count(o.id) расходятся ровно потому, что LEFT JOIN дал ей строку без заказа. HAVING оставил единственного клиента с двумя заказами — Алису.
Заборчик
Что мы упростили.
count(*)иcount(колонка)— не «стилистика», а разные вопросы: «сколько строк» против «сколько непустых значений». В отчётах их путают чаще всего, и баг тихий — числа выглядят правдоподобно.avgпоnumericмы округлили до целых центов осознанно. В проде «средний чек» с точностью до сотой копейки обычно лишний, но округлять надо явно, а не надеяться на отображение.- Выручку мы считали из
orders.amount(записанный итог-заголовок заказа), а не пересчётом из позицийorder_items— это другой источник и в общем случае другая сумма (заголовок легитимно дрейфует от суммы позиций). В реальном отчёте важно зафиксировать, что именно считается выручкой, иначе две «правильные» цифры не сойдутся. - На больших таблицах сама группировка хочет подходящих индексов и иногда упирается в память на сортировку групп — но это уже территория планов (модуль 06).
Что забрать с собой
GROUP BYрежет таблицу на группы, агрегат (count/sum/min/max/avg) считает по каждой одно число.- Всё в
SELECT, что не агрегат, обязано быть вGROUP BY. count(*)считает строки;count(колонка)— только строки с не-NULL значением. НаLEFT JOINэто разные числа.sum/avgпо пустой группе даютNULL, не 0 — оборачивай вCOALESCE, если нужен ноль.WHEREфильтрует строки до группировки,HAVING— готовые группы по значению агрегата.
Агрегаты схлопнули каждую группу в одно число — сколько, на сколько, в среднем. Но бизнесу часто нужна не цифра, а конкретная строка из группы: не «сколько заказов у Алисы», а её последний заказ целиком — дата, сумма, статус. Достать ровно по одной строке на группу одним лаконичным приёмом, который есть именно в Postgres, — это 04-04 «DISTINCT ON».