Зачем это спрашивают
Агрегация — второй по частоте навык SQL после JOIN, который нужен тестировщику в работе. Сколько заказов в каждом статусе после прогона тестов? Нет ли пользователей с подозрительно большим числом неуспешных оплат? Сходится ли сумма по заказам с отчётом в админке? Все эти вопросы решаются через GROUP BY.
Интервьюер обычно начинает с теории — разница WHERE и HAVING, — а затем просит написать запрос вживую. Проверяют две вещи: понимаете ли вы порядок, в котором база выполняет запрос, и не путаете ли фильтр по строкам с фильтром по группам.
Как построить сильный ответ
Объясните разницу через порядок выполнения, покажите запрос, где есть и WHERE, и HAVING, и назовите правило про колонки в SELECT.
Порядок выполнения запроса
Пишем запрос в одном порядке, а логически база выполняет его в другом:
FROMиJOIN— откуда берём строки;WHERE— отбрасываем лишние строки;GROUP BY— собираем оставшиеся строки в группы;- агрегаты —
COUNT,SUM,AVG,MIN,MAXдля каждой группы; HAVING— отбрасываем лишние группы;SELECT— вычисляем итоговые колонки;ORDER BYиLIMIT— сортируем и ограничиваем.
Отсюда всё остальное. В WHERE нельзя написать COUNT(*) > 3: на этом шаге группы ещё не собраны и считать нечего. HAVING работает с уже посчитанными агрегатами.
Как работает GROUP BY
GROUP BY status сворачивает все строки с одинаковым статусом в одну строку результата. Поэтому в SELECT могут стоять только колонки группировки и агрегаты. Колонка вроде user_id без агрегата — ошибка: в группе «paid» десятки разных пользователей, и база не знает, какого показать. Исключение в PostgreSQL — колонки, которые однозначно определяются группировкой по первичному ключу таблицы, но на собеседовании надёжнее держаться основного правила.
Живая задача: посчитать заказы по каждому статусу.
SELECT status, COUNT(*) AS orders_count
FROM orders
GROUP BY status
ORDER BY orders_count DESC;
WHERE и HAVING в одном запросе
Задача: найти пользователей, у которых за последние семь дней больше трёх неуспешных заказов.
SELECT user_id, COUNT(*) AS failed_orders
FROM orders
WHERE status = 'failed'
AND created_at >= now() - INTERVAL '7 days'
GROUP BY user_id
HAVING COUNT(*) > 3
ORDER BY failed_orders DESC;
Статус и дата — свойства отдельной строки, поэтому они в WHERE. Количество — свойство группы, поэтому оно в HAVING. Технически условие status = 'failed' можно написать и в HAVING, если добавить status в группировку, но тогда база сначала сгруппирует все заказы, а потом выбросит лишнее. Фильтровать строки как можно раньше — и понятнее, и обычно быстрее.
NULL в группировке
Строки с NULL в колонке группировки собираются в одну отдельную группу. Например, все гостевые заказы с user_id = NULL попадут в одну строку результата — это стоит учитывать при подсчёте «заказов на пользователя».
Пример ответа
WHERE фильтрует отдельные строки до группировки, а HAVING — уже готовые группы после того, как посчитаны агрегаты. Это следует из порядка выполнения: FROM, WHERE, GROUP BY, агрегаты, HAVING, потом SELECT и ORDER BY. Поэтому COUNT в WHERE написать нельзя — на этом шаге ещё нечего считать. GROUP BY сворачивает строки с одинаковым значением в одну, и в SELECT остаются только колонки группировки и агрегаты. Например, чтобы найти пользователей с более чем тремя неуспешными заказами за неделю, я пишу статус и дату в WHERE, группирую по user_id и в HAVING оставляю COUNT больше трёх. Условия на строки стараюсь ставить в WHERE — так база обрабатывает меньше данных.
Частые ошибки
- Писать агрегат в WHERE:
WHERE COUNT(*) > 3— такой запрос не выполнится. - Говорить «HAVING — это WHERE для GROUP BY» и не объяснять, почему он нужен отдельно.
- Добавлять в SELECT колонку, которой нет в GROUP BY и которая не обёрнута в агрегат.
- Переносить в HAVING условия на отдельные строки, которым место в WHERE.
- Использовать псевдоним из SELECT в HAVING в PostgreSQL.
- Считать
COUNT(*)после LEFT JOIN и получать единицу вместо нуля у записей без пары.
Пример на MarketCove
Заказы учебного магазина https://shop.tomiddle.com хранятся в памяти сервера, но представьте, что после прогона тестов оформления вы выгрузили их в таблицу orders(id, user_id, status, total, created_at) и хотите проверить картину целиком.
Какие вопросы удобно задать через агрегаты:
- сколько заказов в каждом статусе и совпадает ли это с тем, что показывает админка заказов;
- нет ли у одного пользователя нескольких заказов с одинаковой суммой за одну минуту — признак двойной отправки формы;
- сколько гостевых заказов и не попали ли они к случайному пользователю.
Второй вопрос в виде запроса:
SELECT user_id, total, date_trunc('minute', created_at) AS minute, COUNT(*) AS cnt
FROM orders
WHERE user_id IS NOT NULL
GROUP BY user_id, total, date_trunc('minute', created_at)
HAVING COUNT(*) > 1;
Пустой результат — хороший знак, непустой — повод проверить, защищено ли оформление от повторного нажатия. Если для таких проверок нужно связать заказы с пользователями, пригодится разбор видов JOIN.
Задание для практики
Создайте таблицу orders с колонками из примера и добавьте полтора-два десятка строк с разными статусами, пользователями и датами, включая пару гостевых заказов. Напишите запросы: количество заказов по статусам; средний чек по пользователям, у которых больше двух оплаченных заказов; пользователи с более чем тремя неуспешными заказами за неделю. Затем намеренно сломайте каждый запрос — перенесите агрегат в WHERE, добавьте колонку без группировки — и прочитайте сообщения об ошибках PostgreSQL. Вслух объяснять разницу WHERE и HAVING поможет блиц.
