Рано или поздно почти любой отчёт упирается в одну и ту же задачу: данные лежат «в высоту», а смотреть на них хочется «в ширину».
Например, в таблице заказов каждая строка — отдельный заказ:
| user_id |
status |
amount |
| 1 |
paid |
1200 |
| 1 |
pending |
500 |
| 1 |
cancelled |
300 |
| 2 |
paid |
900 |
| 2 |
paid |
700 |
А в отчёте хочется видеть одну строку на пользователя и отдельные колонки под каждый статус:
| user_id |
paid |
pending |
cancelled |
| 1 |
1200 |
500 |
300 |
| 2 |
1600 |
NULL |
NULL |
Такой разворот называют пивотом: мы превращаем длинную таблицу в широкую кросс-таблицу.
В PostgreSQL для простого пивота не нужны расширения, временные таблицы и сложные подзапросы. Очень часто достаточно агрегатной функции с условием FILTER (WHERE ...).
Главная идея FILTER
Обычный агрегат считает все строки внутри группы:
SELECT
user_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
Такой запрос отвечает на вопрос:
Сколько денег принёс каждый пользователь по всем заказам?
А теперь хочется детальнее:
Сколько денег у пользователя в оплаченных заказах, сколько в ожидающих, сколько в отменённых?
Вот здесь и появляется FILTER.
SELECT
user_id,
SUM(amount) FILTER (WHERE status = 'paid') AS paid,
SUM(amount) FILTER (WHERE status = 'pending') AS pending,
SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY user_id;
Читается почти как обычный русский текст:
- сгруппируй заказы по пользователю;
- в колонку
paid сложи только строки со статусом paid;
- в колонку
pending сложи только строки со статусом pending;
- в колонку
cancelled сложи только строки со статусом cancelled.
То есть каждый агрегат живёт внутри общего GROUP BY, но видит только свои строки.
Один агрегат — одна колонка отчёта
Самый понятный способ думать о таком пивоте:
каждая будущая колонка — это отдельный агрегат со своим условием.
Например, можно одновременно посчитать и суммы, и количество заказов:
SELECT
user_id,
SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount,
SUM(amount) FILTER (WHERE status = 'pending') AS pending_amount,
SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled_amount,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_count,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_count
FROM orders
GROUP BY user_id;
Здесь в одной строке отчёта лежит сразу несколько ответов:
- сколько денег в оплаченных заказах;
- сколько денег в ожидающих заказах;
- сколько денег в отменённых заказах;
- сколько было оплаченных заказов;
- сколько было отменённых заказов.
И всё это считается одним запросом, без ручной склейки результатов.
FILTER работает не только с SUM и COUNT
FILTER можно использовать почти с любым агрегатом.
Например, средний чек только по оплаченным заказам:
SELECT
user_id,
AVG(amount) FILTER (WHERE status = 'paid') AS avg_paid_amount
FROM orders
GROUP BY user_id;
Максимальная сумма отменённого заказа:
SELECT
user_id,
MAX(amount) FILTER (WHERE status = 'cancelled') AS max_cancelled_amount
FROM orders
GROUP BY user_id;
Список id оплаченных заказов:
SELECT
user_id,
ARRAY_AGG(id) FILTER (WHERE status = 'paid') AS paid_order_ids
FROM orders
GROUP BY user_id;
Это удобно: условие относится не ко всему запросу, а только к конкретному агрегату.
WHERE отфильтровал бы строки для всего запроса сразу. А FILTER позволяет каждому агрегату выбрать свой набор строк.
Пример с JOIN: отчёт по странам
Представим, что есть таблица пользователей и таблица заказов:
users хранит пользователей и их страны;
orders хранит заказы.
Нужно получить отчёт по странам: сколько оплаченных и отменённых заказов было в каждой стране.
SELECT
u.country,
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE o.status = 'cancelled') AS cancelled_orders
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.country
ORDER BY u.country;
Здесь JOIN сначала соединяет пользователей с заказами, а потом GROUP BY собирает строки по странам.
После этого каждый COUNT считает только строки со своим статусом.
Такой запрос хорошо читается даже новичком: слева группировка, справа отдельные счётчики по условиям.
MAX FILTER: как достать значение по ключу
Есть ещё один частый случай: данные хранятся в формате «ключ-значение».
Например, таблица транзакций выглядит так:
| user_id |
kind |
amount |
| 1 |
deposit |
1000 |
| 1 |
withdrawal |
300 |
| 2 |
deposit |
700 |
А хочется получить так:
| user_id |
deposit |
withdrawal |
| 1 |
1000 |
300 |
| 2 |
700 |
NULL |
Для этого часто используют MAX(...) FILTER (...).
SELECT
user_id,
MAX(amount) FILTER (WHERE kind = 'deposit') AS deposit,
MAX(amount) FILTER (WHERE kind = 'withdrawal') AS withdrawal
FROM tx
GROUP BY user_id;
На первый взгляд может быть странно: почему именно MAX, если мы вроде бы не ищем максимум?
Дело в том, что агрегат обязан схлопнуть несколько строк группы в одно значение. Если для каждой пары user_id и kind есть ровно одна строка, то MAX просто вернёт это единственное значение.
То есть MAX здесь используется как аккуратный способ сказать:
возьми значение из нужной строки и положи его в отдельную колонку.
Но есть важный нюанс. Если у одного пользователя окажется несколько строк с kind = 'deposit', то MAX молча выберет наибольшую сумму.
Например:
| user_id |
kind |
amount |
| 1 |
deposit |
1000 |
| 1 |
deposit |
1500 |
В этом случае результатом будет 1500.
Поэтому перед таким приёмом важно понимать данные. Если дубликаты невозможны по бизнес-правилам — всё хорошо. Если возможны — нужно заранее решить, что именно делать: брать максимум, минимум, сумму, последнее значение по дате или сначала чистить дубликаты.
FILTER против SUM(CASE WHEN ...)
До FILTER такие отчёты часто писали через CASE.
Вот так:
SELECT
user_id,
SUM(CASE WHEN status = 'paid' THEN amount END) AS paid,
SUM(CASE WHEN status = 'pending' THEN amount END) AS pending,
SUM(CASE WHEN status = 'cancelled' THEN amount END) AS cancelled
FROM orders
GROUP BY user_id;
Этот вариант тоже рабочий. Логика такая:
- если статус подходит, вернуть
amount;
- если не подходит, вернуть
NULL;
SUM пропускает NULL;
- в итоге суммируются только нужные строки.
Но с FILTER запрос обычно читается проще:
SUM(amount) FILTER (WHERE status = 'paid')
Здесь сразу видно две части:
SUM(amount) — что считаем;
FILTER (WHERE status = 'paid') — по какому условию считаем.
А в варианте с CASE условие прячется внутри выражения:
SUM(CASE WHEN status = 'paid' THEN amount END)
Для коротких запросов это терпимо. Но когда в отчёте 10–20 колонок, FILTER становится заметно приятнее.
Особенно аккуратно с COUNT и CASE
С SUM(CASE WHEN ...) ошибиться сложнее. А вот с COUNT есть классическая ловушка.
Правильный вариант через FILTER:
SELECT
user_id,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_count
FROM orders
GROUP BY user_id;
Аналог через CASE:
SELECT
user_id,
COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_count
FROM orders
GROUP BY user_id;
Почему это работает? Потому что если условие не выполнено, CASE вернёт NULL, а COUNT не считает NULL.
Но если случайно написать ELSE 0, результат сломается:
SELECT
user_id,
COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_count
FROM orders
GROUP BY user_id;
Такой COUNT посчитает все строки, потому что для него и 1, и 0 — это обычные значения. COUNT не интересуется, «правдивое» значение или «ложное». Он просто считает всё, что не NULL.
Поэтому для подсчёта строк FILTER выглядит особенно чисто:
COUNT(*) FILTER (WHERE status = 'paid')
Никаких скрытых NULL, никаких ловушек с ELSE.
Что будет, если строк нет
Важный момент: если под условие FILTER не попала ни одна строка, многие агрегаты вернут NULL.
Например:
SELECT
user_id,
SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount
FROM orders
GROUP BY user_id;
Если у пользователя нет оплаченных заказов, в paid_amount будет NULL, а не 0.
Для аналитики это иногда правильно: NULL означает «нет данных». Но в обычном отчёте чаще хочется видеть ноль.
Тогда используйте COALESCE.
SELECT
user_id,
COALESCE(SUM(amount) FILTER (WHERE status = 'paid'), 0) AS paid_amount,
COALESCE(SUM(amount) FILTER (WHERE status = 'pending'), 0) AS pending_amount,
COALESCE(SUM(amount) FILTER (WHERE status = 'cancelled'), 0) AS cancelled_amount
FROM orders
GROUP BY user_id;
COALESCE берёт первое не-NULL значение. Если сумма не посчиталась и вернулся NULL, он заменит его на 0.
FILTER не делает динамический пивот
У FILTER есть ограничение: колонки нужно заранее прописать в запросе.
Например, если вы пишете:
SELECT
user_id,
SUM(amount) FILTER (WHERE status = 'paid') AS paid,
SUM(amount) FILTER (WHERE status = 'pending') AS pending,
SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY user_id;
то вы заранее решили, что в отчёте будут три колонки:
paid;
pending;
cancelled.
Если завтра появится новый статус refunded, он сам собой в отчёт не добавится. Нужно будет дописать ещё одну колонку:
SUM(amount) FILTER (WHERE status = 'refunded') AS refunded
Это нормально для большинства бизнес-отчётов, где набор колонок известен заранее: статусы заказов, месяцы квартала, типы платежей, роли пользователей.
Но если категории заранее неизвестны и должны превращаться в колонки автоматически, обычный SQL-запрос становится неудобным. В таком случае динамический пивот обычно собирают в коде приложения или через генерацию SQL в PL/pgSQL.
crosstab в PostgreSQL
В PostgreSQL есть и специальный инструмент для пивотов — функция crosstab() из расширения tablefunc.
Пример выглядит так:
CREATE EXTENSION IF NOT EXISTS tablefunc;
SELECT *
FROM crosstab(
'SELECT user_id, status, SUM(amount) FROM orders GROUP BY 1, 2 ORDER BY 1, 2',
'SELECT DISTINCT status FROM orders ORDER BY 1'
) AS ct(
user_id int,
paid numeric,
pending numeric,
cancelled numeric
);
Это мощный инструмент, но для новичка и для обычных отчётов у него есть заметный минус: итоговые колонки всё равно нужно описывать вручную.
Вот эта часть обязательна:
AS ct(
user_id int,
paid numeric,
pending numeric,
cancelled numeric
)
То есть PostgreSQL не может просто посмотреть на значения статусов и сам создать под них колонки в результате обычного запроса.
Поэтому для простых отчётов FILTER часто приятнее:
- не нужно подключать расширение;
- запрос проще читать;
- хорошо работает вместе с
JOIN;
- удобно сочетать с
GROUP BY, HAVING, сортировкой и другими частями запроса;
- легко смешивать разные агрегаты в одном отчёте.
Пример с HAVING
Допустим, нужно показать только страны, где есть хотя бы 10 оплаченных заказов.
SELECT
u.country,
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE o.status = 'cancelled') AS cancelled_orders
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.country
HAVING COUNT(*) FILTER (WHERE o.status = 'paid') >= 10
ORDER BY paid_orders DESC;
Здесь FILTER используется не только в SELECT, но и в HAVING.
То есть мы не просто выводим счётчик оплаченных заказов, а ещё и фильтруем группы по этому счётчику.
Как это выглядит в других базах данных
FILTER — часть стандарта SQL, и в PostgreSQL он доступен начиная с версии 9.4.
Но поддерживают его не все СУБД.
В MySQL 8.x такого синтаксиса нет, поэтому там обычно пишут через CASE:
SELECT
user_id,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled
FROM orders
GROUP BY user_id;
В ClickHouse для таких задач есть специальные функции с суффиксом If:
SELECT
user_id,
sumIf(amount, status = 'paid') AS paid,
sumIf(amount, status = 'pending') AS pending,
sumIf(amount, status = 'cancelled') AS cancelled,
countIf(status = 'paid') AS paid_count
FROM orders
GROUP BY user_id;
Идея та же самая: агрегат считает только строки, которые прошли условие.
Когда использовать FILTER
FILTER особенно хорош, когда:
- вы пишете отчёт в PostgreSQL;
- нужно развернуть несколько известных категорий в колонки;
- хочется получить всё одним
GROUP BY;
- в отчёте много условных сумм, счётчиков, средних значений;
- важна читаемость запроса.
Типичные примеры:
- суммы заказов по статусам;
- количество пользователей по ролям;
- оплаты по типам платежей;
- заявки по этапам воронки;
- события по дням недели;
- метрики по странам;
- значения из модели «ключ-значение».
Главное
Пивот — это разворот данных из длинного формата в широкий: категории из строк становятся отдельными колонками.
В PostgreSQL простой пивот удобно делать через агрегаты с FILTER:
SELECT
user_id,
SUM(amount) FILTER (WHERE status = 'paid') AS paid,
SUM(amount) FILTER (WHERE status = 'pending') AS pending,
SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY user_id;
Главная мысль:
один столбец отчёта — один агрегат со своим условием.
FILTER делает запрос понятным: отдельно видно, что мы считаем, и отдельно видно, какие строки попадают в расчёт.
Если под условие не попала ни одна строка, SUM, MAX, AVG и похожие агрегаты могут вернуть NULL. Для отчётного нуля используйте COALESCE.
Если категории заранее известны, FILTER — один из самых чистых и удобных способов собрать пивот в PostgreSQL: без расширений, без ручной склейки запросов и без лишней магии.
Рано или поздно почти любой отчёт упирается в одну и ту же задачу: данные лежат «в высоту», а смотреть на них хочется «в ширину».
Например, в таблице заказов каждая строка — отдельный заказ:
А в отчёте хочется видеть одну строку на пользователя и отдельные колонки под каждый статус:
Такой разворот называют пивотом: мы превращаем длинную таблицу в широкую кросс-таблицу.
В PostgreSQL для простого пивота не нужны расширения, временные таблицы и сложные подзапросы. Очень часто достаточно агрегатной функции с условием
FILTER (WHERE ...).Главная идея FILTER
Обычный агрегат считает все строки внутри группы:
SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id;Такой запрос отвечает на вопрос:
А теперь хочется детальнее:
Вот здесь и появляется
FILTER.SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid, SUM(amount) FILTER (WHERE status = 'pending') AS pending, SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled FROM orders GROUP BY user_id;Читается почти как обычный русский текст:
paidсложи только строки со статусомpaid;pendingсложи только строки со статусомpending;cancelledсложи только строки со статусомcancelled.То есть каждый агрегат живёт внутри общего
GROUP BY, но видит только свои строки.Один агрегат — одна колонка отчёта
Самый понятный способ думать о таком пивоте:
Например, можно одновременно посчитать и суммы, и количество заказов:
SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount, SUM(amount) FILTER (WHERE status = 'pending') AS pending_amount, SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled_amount, COUNT(*) FILTER (WHERE status = 'paid') AS paid_count, COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_count FROM orders GROUP BY user_id;Здесь в одной строке отчёта лежит сразу несколько ответов:
И всё это считается одним запросом, без ручной склейки результатов.
FILTER работает не только с SUM и COUNT
FILTERможно использовать почти с любым агрегатом.Например, средний чек только по оплаченным заказам:
SELECT user_id, AVG(amount) FILTER (WHERE status = 'paid') AS avg_paid_amount FROM orders GROUP BY user_id;Максимальная сумма отменённого заказа:
SELECT user_id, MAX(amount) FILTER (WHERE status = 'cancelled') AS max_cancelled_amount FROM orders GROUP BY user_id;Список id оплаченных заказов:
SELECT user_id, ARRAY_AGG(id) FILTER (WHERE status = 'paid') AS paid_order_ids FROM orders GROUP BY user_id;Это удобно: условие относится не ко всему запросу, а только к конкретному агрегату.
WHEREотфильтровал бы строки для всего запроса сразу. АFILTERпозволяет каждому агрегату выбрать свой набор строк.Пример с JOIN: отчёт по странам
Представим, что есть таблица пользователей и таблица заказов:
usersхранит пользователей и их страны;ordersхранит заказы.Нужно получить отчёт по странам: сколько оплаченных и отменённых заказов было в каждой стране.
SELECT u.country, COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE o.status = 'cancelled') AS cancelled_orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.country ORDER BY u.country;Здесь
JOINсначала соединяет пользователей с заказами, а потомGROUP BYсобирает строки по странам.После этого каждый
COUNTсчитает только строки со своим статусом.Такой запрос хорошо читается даже новичком: слева группировка, справа отдельные счётчики по условиям.
MAX FILTER: как достать значение по ключу
Есть ещё один частый случай: данные хранятся в формате «ключ-значение».
Например, таблица транзакций выглядит так:
А хочется получить так:
Для этого часто используют
MAX(...) FILTER (...).SELECT user_id, MAX(amount) FILTER (WHERE kind = 'deposit') AS deposit, MAX(amount) FILTER (WHERE kind = 'withdrawal') AS withdrawal FROM tx GROUP BY user_id;На первый взгляд может быть странно: почему именно
MAX, если мы вроде бы не ищем максимум?Дело в том, что агрегат обязан схлопнуть несколько строк группы в одно значение. Если для каждой пары
user_idиkindесть ровно одна строка, тоMAXпросто вернёт это единственное значение.То есть
MAXздесь используется как аккуратный способ сказать:Но есть важный нюанс. Если у одного пользователя окажется несколько строк с
kind = 'deposit', тоMAXмолча выберет наибольшую сумму.Например:
В этом случае результатом будет
1500.Поэтому перед таким приёмом важно понимать данные. Если дубликаты невозможны по бизнес-правилам — всё хорошо. Если возможны — нужно заранее решить, что именно делать: брать максимум, минимум, сумму, последнее значение по дате или сначала чистить дубликаты.
FILTER против SUM(CASE WHEN ...)
До
FILTERтакие отчёты часто писали черезCASE.Вот так:
SELECT user_id, SUM(CASE WHEN status = 'paid' THEN amount END) AS paid, SUM(CASE WHEN status = 'pending' THEN amount END) AS pending, SUM(CASE WHEN status = 'cancelled' THEN amount END) AS cancelled FROM orders GROUP BY user_id;Этот вариант тоже рабочий. Логика такая:
amount;NULL;SUMпропускаетNULL;Но с
FILTERзапрос обычно читается проще:SUM(amount) FILTER (WHERE status = 'paid')Здесь сразу видно две части:
SUM(amount)— что считаем;FILTER (WHERE status = 'paid')— по какому условию считаем.А в варианте с
CASEусловие прячется внутри выражения:SUM(CASE WHEN status = 'paid' THEN amount END)Для коротких запросов это терпимо. Но когда в отчёте 10–20 колонок,
FILTERстановится заметно приятнее.Особенно аккуратно с COUNT и CASE
С
SUM(CASE WHEN ...)ошибиться сложнее. А вот сCOUNTесть классическая ловушка.Правильный вариант через
FILTER:SELECT user_id, COUNT(*) FILTER (WHERE status = 'paid') AS paid_count FROM orders GROUP BY user_id;Аналог через
CASE:SELECT user_id, COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_count FROM orders GROUP BY user_id;Почему это работает? Потому что если условие не выполнено,
CASEвернётNULL, аCOUNTне считаетNULL.Но если случайно написать
ELSE 0, результат сломается:SELECT user_id, COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_count FROM orders GROUP BY user_id;Такой
COUNTпосчитает все строки, потому что для него и1, и0— это обычные значения.COUNTне интересуется, «правдивое» значение или «ложное». Он просто считает всё, что неNULL.Поэтому для подсчёта строк
FILTERвыглядит особенно чисто:COUNT(*) FILTER (WHERE status = 'paid')Никаких скрытых
NULL, никаких ловушек сELSE.Что будет, если строк нет
Важный момент: если под условие
FILTERне попала ни одна строка, многие агрегаты вернутNULL.Например:
SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount FROM orders GROUP BY user_id;Если у пользователя нет оплаченных заказов, в
paid_amountбудетNULL, а не0.Для аналитики это иногда правильно:
NULLозначает «нет данных». Но в обычном отчёте чаще хочется видеть ноль.Тогда используйте
COALESCE.SELECT user_id, COALESCE(SUM(amount) FILTER (WHERE status = 'paid'), 0) AS paid_amount, COALESCE(SUM(amount) FILTER (WHERE status = 'pending'), 0) AS pending_amount, COALESCE(SUM(amount) FILTER (WHERE status = 'cancelled'), 0) AS cancelled_amount FROM orders GROUP BY user_id;COALESCEберёт первое не-NULLзначение. Если сумма не посчиталась и вернулсяNULL, он заменит его на0.FILTER не делает динамический пивот
У
FILTERесть ограничение: колонки нужно заранее прописать в запросе.Например, если вы пишете:
SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid, SUM(amount) FILTER (WHERE status = 'pending') AS pending, SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled FROM orders GROUP BY user_id;то вы заранее решили, что в отчёте будут три колонки:
paid;pending;cancelled.Если завтра появится новый статус
refunded, он сам собой в отчёт не добавится. Нужно будет дописать ещё одну колонку:SUM(amount) FILTER (WHERE status = 'refunded') AS refundedЭто нормально для большинства бизнес-отчётов, где набор колонок известен заранее: статусы заказов, месяцы квартала, типы платежей, роли пользователей.
Но если категории заранее неизвестны и должны превращаться в колонки автоматически, обычный SQL-запрос становится неудобным. В таком случае динамический пивот обычно собирают в коде приложения или через генерацию SQL в
PL/pgSQL.crosstab в PostgreSQL
В PostgreSQL есть и специальный инструмент для пивотов — функция
crosstab()из расширенияtablefunc.Пример выглядит так:
CREATE EXTENSION IF NOT EXISTS tablefunc; SELECT * FROM crosstab( 'SELECT user_id, status, SUM(amount) FROM orders GROUP BY 1, 2 ORDER BY 1, 2', 'SELECT DISTINCT status FROM orders ORDER BY 1' ) AS ct( user_id int, paid numeric, pending numeric, cancelled numeric );Это мощный инструмент, но для новичка и для обычных отчётов у него есть заметный минус: итоговые колонки всё равно нужно описывать вручную.
Вот эта часть обязательна:
AS ct( user_id int, paid numeric, pending numeric, cancelled numeric )То есть PostgreSQL не может просто посмотреть на значения статусов и сам создать под них колонки в результате обычного запроса.
Поэтому для простых отчётов
FILTERчасто приятнее:JOIN;GROUP BY,HAVING, сортировкой и другими частями запроса;Пример с HAVING
Допустим, нужно показать только страны, где есть хотя бы 10 оплаченных заказов.
SELECT u.country, COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE o.status = 'cancelled') AS cancelled_orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.country HAVING COUNT(*) FILTER (WHERE o.status = 'paid') >= 10 ORDER BY paid_orders DESC;Здесь
FILTERиспользуется не только вSELECT, но и вHAVING.То есть мы не просто выводим счётчик оплаченных заказов, а ещё и фильтруем группы по этому счётчику.
Как это выглядит в других базах данных
FILTER— часть стандарта SQL, и в PostgreSQL он доступен начиная с версии 9.4.Но поддерживают его не все СУБД.
В MySQL 8.x такого синтаксиса нет, поэтому там обычно пишут через
CASE:SELECT user_id, SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid, SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending, SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled FROM orders GROUP BY user_id;В ClickHouse для таких задач есть специальные функции с суффиксом
If:SELECT user_id, sumIf(amount, status = 'paid') AS paid, sumIf(amount, status = 'pending') AS pending, sumIf(amount, status = 'cancelled') AS cancelled, countIf(status = 'paid') AS paid_count FROM orders GROUP BY user_id;Идея та же самая: агрегат считает только строки, которые прошли условие.
Когда использовать FILTER
FILTERособенно хорош, когда:GROUP BY;Типичные примеры:
Главное
Пивот — это разворот данных из длинного формата в широкий: категории из строк становятся отдельными колонками.
В PostgreSQL простой пивот удобно делать через агрегаты с
FILTER:SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid, SUM(amount) FILTER (WHERE status = 'pending') AS pending, SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled FROM orders GROUP BY user_id;Главная мысль:
FILTERделает запрос понятным: отдельно видно, что мы считаем, и отдельно видно, какие строки попадают в расчёт.Если под условие не попала ни одна строка,
SUM,MAX,AVGи похожие агрегаты могут вернутьNULL. Для отчётного нуля используйтеCOALESCE.Если категории заранее известны,
FILTER— один из самых чистых и удобных способов собрать пивот в PostgreSQL: без расширений, без ручной склейки запросов и без лишней магии.