Условная агрегация: длинные строки в широкие колонки
SUM(CASE WHEN …) и COUNT(*) FILTER (WHERE …) превращают строку-на-категорию в колонку-на-категорию — сводный отчёт без crosstab, сочетается с GROUP BY.
Продакт хочет дашборд: одна строка на день, с отдельными колонками для счёта заказов paid, pending и cancelled бок о бок. Простой GROUP BY status даёт обратную форму — одну строку на статус, сложенную вертикально, — бесполезную для широкой таблицы. Инстинкт фронтенд-команды — забрать все строки и развернуть в JavaScript. Неверный ход: условная агрегация разворачивает это в базе, одним запросом, без расширения и без crosstab.
Длинное против широкого
Обычный GROUP BY status даёт длинный вывод: одна строка на категорию, значение под значением. Отчёту обычно нужен широкий вывод: одна строка на сущность (день, пользователь) с одной колонкой на категорию. Разворот (pivot) — это переход между ними, и фокус в том, чтобы каждая выходная колонка была своим условным агрегатом.
Два способа написать колонку разворота
Когда встречаешь отчётный запрос из трёх отдельных блоков SELECT … WHERE status='…', ты видишь антипаттерн, который этот раздел заменяет. Есть две идиомы для написания каждой колонки разворота прямо в запросе, и для счётов и сумм они эквивалентны:
-- Форма FILTER (современная, стандартная, рекомендуемая):
SELECT
created_at::date AS day,
COUNT(*) FILTER (WHERE status = 'paid') AS paid,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
SUM(total) FILTER (WHERE status = 'paid') AS paid_revenue
FROM orders
GROUP BY created_at::date
ORDER BY day;
-- Форма CASE (переносимая, старая, работает везде):
SELECT
created_at::date AS day,
COUNT(*) FILTER (WHERE status = 'paid') AS paid, -- или:
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_count,
SUM(CASE WHEN status = 'paid' THEN total END) AS paid_revenue
FROM orders
GROUP BY created_at::date;Механизм одинаков в обеих: каждая колонка — агрегат, который построчно либо включает значение, либо не вносит ничего. С FILTER агрегат просто пропускает несовпавшие строки. С CASE несовпавшая ветка возвращает 0 (для счёта через SUM) или NULL (который агрегаты игнорируют). FILTER чище и является сеньорским дефолтом; CASE — переносимый запасной вариант для движков без FILTER.
Выигрыш, который важен на масштабе, — число сканов. Обе формы считают все колонки разворота за один проход — движок проверяет условие каждой колонки против каждой строки по мере её протекания. Антипаттерн — один запрос на категорию: три отдельных SELECT count(*) FROM orders WHERE status='paid' (затем 'pending', затем 'cancelled') — это три Seq Scan таблицы. На таблице orders в 12М строк это ~3 × 800мс ≈ 2.4с против ~850мс у одного группового запроса с тремя колонками FILTER — и разрыв растёт линейно с числом колонок разворота: разворот на 7 статусов — это один скан вместо семи. Условная агрегация превращает отчёт из N сканов в отчёт из 1 скана — именно поэтому разворачивают у источника, а не шлют по запросу на колонку из приложения.
Ловушка счёта через CASE
Самый частый баг здесь — счёт с неверным запасным значением:
-- НЕВЕРНО: COUNT считает не-NULL, а 0 — не NULL → считает КАЖДУЮ строку
COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) -- всегда = число всех строк
-- ВЕРНЫЕ варианты:
COUNT(CASE WHEN status = 'paid' THEN 1 END) -- ELSE это NULL → COUNT пропустит
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) -- SUM складывает 1, 0 не вносят вклада
COUNT(*) FILTER (WHERE status = 'paid') -- чище всегоCOUNT считает не-NULL значения; 0 — не NULL, поэтому COUNT(CASE … ELSE 0 END) считает все строки — тихая ошибка «промах на всё». Либо убери ELSE (тогда несовпадения это NULL и COUNT их пропустит), используй SUM из 1/0, либо используй FILTER. Это ровно та ловушка, ради устранения которой добавили FILTER.
Сочетание с GROUP BY и итогами
Условная агрегация компонуется со всем, что ты выучил. Добавь GROUP BY по сущности, чтобы получить одну широкую строку на сущность; наложи ROLLUP из прошлого урока, чтобы добавить строку итогов по всем дням:
SELECT
created_at::date AS day,
COUNT(*) FILTER (WHERE status = 'paid') AS paid,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY ROLLUP (created_at::date)
ORDER BY day NULLS LAST;Это также ручной родственник функции crosstab из расширения tablefunc и оконных функций: оконные функции (следующий раздел, 04-window-functions) сохраняют одну строку на входную строку, добавляя агрегатные колонки, тогда как условная агрегация сворачивает строки и распределяет категории по колонкам. Выбирай условную агрегацию, когда нужна компактная сводная таблица; тянись за окнами, когда нужно сохранить каждую детальную строку рядом с агрегатом её группы.
▸Почему это работает
Зачем разворачивать в SQL, а не в приложении? Три причины. Первая — меньше данных по сети: развёрнутый результат гораздо меньше, чем все сырые строки плюс переформовка на клиенте. Вторая — база может посчитать условные агрегаты в том же скане, что уже делала для GROUP BY, — почти без доплаты. Третья — корректность живёт в одном месте: JS-pivot переизобретает логику группировки, которую движок уже делает идеально, и именно в этом переизобретении заводятся баги (и путаница NULL/ноль). Сеньорское правило: агрегируй и разворачивай у источника; отправляй форму отчёта, а не сырые строки.
Нужна одна строка на день с отдельными колонками счёта paid/pending/cancelled. Какой подход даёт такую форму?
Почему COUNT(CASE WHEN status='paid' THEN 1 ELSE 0 END) считает каждую строку, а не только оплаченные?
Заполни пропуск: условная агрегация переформовывает данные из длинного в _______ — одна строка на сущность с одной колонкой на категорию, каждая колонка — агрегат лишь по своим совпавшим строкам.
- 01Что такое условная агрегация и какое преобразование формы она выполняет?
- 02Дай формы FILTER и CASE для колонки счёта оплаченных заказов и объясни ловушку счёта через CASE.
- 03Когда использовать условную агрегацию против оконной функции для отчёта?
Условная агрегация — это разворот в базе: она переформовывает длинный вывод (одна строка на категорию) в широкий (одна строка на сущность, одна колонка на категорию), делая каждую колонку агрегатом, ограниченным своей категорией. Современная форма — COUNT(*) FILTER (WHERE …) / SUM(total) FILTER (WHERE …); переносимый запасной вариант — SUM(CASE WHEN … THEN … ELSE 0 END). Следи за классической ловушкой — COUNT(CASE … ELSE 0 END) считает каждую строку, ведь 0 не NULL, поэтому убери ELSE, используй SUM из 1/0 или FILTER. Она чисто компонуется с GROUP BY (широкая строка на сущность) и ROLLUP из прошлого урока (строка итогов) и место ей у источника: разворот в SQL отправляет маленькую форму отчёта вместо сырых строк и держит логику группировки в одном корректном месте. Когда же нужно сохранить каждую детальную строку рядом с агрегатом её группы — это работа оконных функций, ровно следующего раздела.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.