open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 03 · 05

Условная агрегация: длинные строки в широкие колонки

SUM(CASE WHEN …) и COUNT(*) FILTER (WHERE …) превращают строку-на-категорию в колонку-на-категорию — сводный отчёт без crosstab, сочетается с GROUP BY.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Продакт хочет дашборд: одна строка на день, с отдельными колонками для счёта заказов 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) считает каждую строку, а не только оплаченные?

Закончи аналогию

Заполни пропуск: условная агрегация переформовывает данные из длинного в _______ — одна строка на сущность с одной колонкой на категорию, каждая колонка — агрегат лишь по своим совпавшим строкам.

Вспомните перед уходом
  1. 01
    Что такое условная агрегация и какое преобразование формы она выполняет?
  2. 02
    Дай формы FILTER и CASE для колонки счёта оплаченных заказов и объясни ловушку счёта через CASE.
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.