PostgreSQL CookbookЗапросы по таблицамАгрегация: GROUP BY/HAVING
0 / 63 (0%)

Агрегация: 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.idNULL). Если в отчёте «сколько у клиента заказов» написать 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)NULL0 через 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 в нём всё объясняет:

plaintext
FROM / JOIN   →  собрать строки из таблиц
WHERE         →  отсеять строки            (фильтр СТРОК, до группировки)
GROUP BY      →  разрезать на группы
агрегаты      →  count / sum / min / max / avg по каждой группе
HAVING        →  отсеять готовые группы    (фильтр ГРУПП, по агрегату)
ORDER BY      →  упорядочить итог

WHERE видит ещё отдельные строки, HAVING — уже посчитанные группы; вот почему count(o.id) >= 2 живёт только в HAVING.

Что показывает наш код

Три запроса в query.sql. Сводка меню:

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;

И статистика по клиентам с двумя счётчиками рядом — чтобы расхождение было видно:

sql
-- 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:

sh
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.)

Вывод:

plaintext
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».

·Модуль 05

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Запросы по таблицам / Агрегация: GROUP BY/HAVING