Все разборы

SQL и данные

Как ответить на вопрос «Чем WHERE отличается от HAVING в GROUP BY» на собеседовании QA

Порядок выполнения SQL-запроса, GROUP BY и агрегаты, WHERE до группировки и HAVING после неё — с живыми задачами в PostgreSQL и типичными ловушками.

Зачем это спрашивают

Агрегация — второй по частоте навык SQL после JOIN, который нужен тестировщику в работе. Сколько заказов в каждом статусе после прогона тестов? Нет ли пользователей с подозрительно большим числом неуспешных оплат? Сходится ли сумма по заказам с отчётом в админке? Все эти вопросы решаются через GROUP BY.

Интервьюер обычно начинает с теории — разница WHERE и HAVING, — а затем просит написать запрос вживую. Проверяют две вещи: понимаете ли вы порядок, в котором база выполняет запрос, и не путаете ли фильтр по строкам с фильтром по группам.

Как построить сильный ответ

Объясните разницу через порядок выполнения, покажите запрос, где есть и WHERE, и HAVING, и назовите правило про колонки в SELECT.

Порядок выполнения запроса

Пишем запрос в одном порядке, а логически база выполняет его в другом:

  1. FROM и JOIN — откуда берём строки;
  2. WHERE — отбрасываем лишние строки;
  3. GROUP BY — собираем оставшиеся строки в группы;
  4. агрегаты — COUNT, SUM, AVG, MIN, MAX для каждой группы;
  5. HAVING — отбрасываем лишние группы;
  6. SELECT — вычисляем итоговые колонки;
  7. 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 поможет блиц.

Что спросят следом

  • Чем COUNT(*) отличается от COUNT(column)?
    COUNT(*) считает все строки группы. COUNT(column) считает только строки, где в этой колонке не NULL. После LEFT JOIN это особенно важно: COUNT(*) даст единицу пользователю без заказов, а COUNT(o.id) — честный ноль.
  • Можно ли использовать псевдоним из SELECT в HAVING или WHERE?
    В PostgreSQL нет: WHERE и HAVING вычисляются раньше SELECT, и псевдонима на этом шаге ещё не существует, запрос упадёт с ошибкой про несуществующую колонку. Повторите выражение целиком, например HAVING COUNT(*) > 3. А в ORDER BY псевдоним использовать можно, потому что сортировка идёт после SELECT.
  • Как посчитать несколько разных количеств в одном запросе?
    В PostgreSQL удобно использовать агрегат с FILTER: COUNT(*) FILTER (WHERE status = 'failed') считает только неуспешные заказы внутри группы, а рядом можно посчитать оплаченные. Переносимый вариант для других СУБД — SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END).
  1. Q-01
    Какие виды JOIN вы знаете? Чем INNER JOIN отличается от LEFT JOIN?
  2. Q-02
    Что вы проверяете при тестировании API?
  3. Q-03
    Как понять, где баг: на фронтенде или на бэкенде?
  4. Q-04
    Что такое Kafka и как тестировать систему с ней?

Отрепетировать ответ с куратором

В сопровождении — весь банк вопросов, мок-собеседование и разбор каждого настоящего собеседования, пока не придёт оффер. Гарантий трудоустройства не даём — готовим к тому, что спросят.