CTE: WITH-выражения

CTE: WITH-выражения

Подзапросы решают многие задачи, но при вложенности в несколько уровней код быстро становится трудночитаемым. Common Table Expressions (CTE) — способ именовать промежуточные результаты, вынести их наверх запроса и работать с ними как с обычными таблицами. Результат — сложная логика, разбитая на понятные именованные шаги.

Синтаксис CTE

WITH cte_name AS (
    SELECT ...
    FROM   ...
)
SELECT *
FROM   cte_name;

WITH предшествует основному запросу. В скобках после имени — любой SELECT. После определения CTE его можно использовать в основном запросе по имени как обычную таблицу.

Базовый пример

Задача из предыдущих уроков — сотрудники с зарплатой выше средней по компании. Через производную таблицу:

SELECT name, salary
FROM   employees
WHERE  salary > (SELECT AVG(salary) FROM employees);

Через CTE:

WITH avg_salary AS (
    SELECT AVG(salary) AS avg
    FROM   employees
)
SELECT e.name, e.salary
FROM   employees e, avg_salary
WHERE  e.salary > avg_salary.avg;

Или через CROSS JOIN:

WITH avg_salary AS (SELECT AVG(salary) AS avg FROM employees)
SELECT e.name, e.salary
FROM   employees e
CROSS JOIN avg_salary
WHERE  e.salary > avg_salary.avg;

Пока это выглядит длиннее. Преимущество CTE раскрывается при нескольких шагах.

Несколько CTE: пошаговое решение

CTE можно определить несколько — через запятую. Каждый следующий может использовать предыдущие:

WITH
dept_stats AS (
    SELECT department,
           AVG(salary) AS avg_salary,
           COUNT(*)    AS headcount
    FROM   employees
    GROUP BY department
),
top_depts AS (
    SELECT department, avg_salary, headcount
    FROM   dept_stats
    WHERE  headcount >= 3
)
SELECT d.department,
       d.avg_salary,
       d.headcount
FROM   top_depts d
ORDER BY d.avg_salary DESC;

Два CTE: dept_stats считает статистику по отделам, top_depts фильтрует отделы с 3+ сотрудниками. Основной запрос читает уже отфильтрованный результат.

Без CTE это была бы вложенная производная таблица или несколько подзапросов. С CTE — три чётких шага с именами.

Проверь себя: можно ли написать то же самое одним SELECT с HAVING? Когда CTE оправданы?

CTE vs производная таблица vs подзапрос

Все три способа часто взаимозаменяемы. Выбор — вопрос читаемости:

-- Производная таблица: вложенность мешает читать
SELECT name FROM (SELECT name, salary * 12 AS annual FROM employees) s WHERE s.annual > 1000000;

-- CTE: линейный порядок, легче читать
WITH annual_salaries AS (
    SELECT name, salary * 12 AS annual FROM employees
)
SELECT name FROM annual_salaries WHERE annual > 1000000;

Ключевое преимущество CTE: видимость порядка вычислений. Запрос читается сверху вниз как алгоритм. Каждый шаг именован и изолирован.

Недостаток: CTE немного многословнее. Для простых задач подзапрос в WHERE компактнее. Но для трёх и более шагов CTE — значительно лучше.

CTE и повторное использование

CTE в пределах одного запроса можно использовать несколько раз:

WITH active_orders AS (
    SELECT * FROM orders WHERE status = 'completed'
)
SELECT COUNT(*) AS total FROM active_orders
UNION ALL
SELECT SUM(amount) FROM active_orders;

Подзапрос пришлось бы повторить дважды. CTE определяется один раз и используется дважды.

Важный нюанс: в PostgreSQL CTE по умолчанию materializes — вычисляется один раз и сохраняется во временном буфере. Это иногда выгодно (один проход по данным), иногда нет (нельзя вытолкнуть условия из внешнего запроса внутрь CTE). PostgreSQL 12+ позволяет управлять этим через MATERIALIZED / NOT MATERIALIZED.

Практический пример: аналитический пайплайн

Задача: топ-5 клиентов по сумме завершённых заказов за прошлый месяц, с долей от общей выручки:

WITH
completed_orders AS (
    SELECT *
    FROM   orders
    WHERE  status = 'completed'
      AND  created_at >= DATE_TRUNC('month', NOW() - INTERVAL '1 month')
      AND  created_at <  DATE_TRUNC('month', NOW())
),
customer_totals AS (
    SELECT customer_id,
           SUM(amount) AS total_spent
    FROM   completed_orders
    GROUP BY customer_id
),
overall_revenue AS (
    SELECT SUM(total_spent) AS grand_total
    FROM   customer_totals
)
SELECT c.name,
       ct.total_spent,
       ROUND(ct.total_spent * 100.0 / r.grand_total, 2) AS pct_of_revenue
FROM   customer_totals ct
JOIN   customers       c  ON ct.customer_id = c.id
CROSS JOIN overall_revenue r
ORDER BY ct.total_spent DESC
LIMIT 5;

Четыре именованных шага: фильтрация, агрегация по клиентам, общая выручка, финальный вывод с долями. Это читается как список шагов алгоритма.

Рекурсивные CTE (обзор)

PostgreSQL поддерживает рекурсивные CTE через WITH RECURSIVE. Они позволяют обходить иерархические данные произвольной глубины — например, организационное дерево:

WITH RECURSIVE hierarchy AS (
    -- Базовый случай: директор
    SELECT id, name, manager_id, 1 AS depth
    FROM   employees WHERE manager_id IS NULL

    UNION ALL

    -- Рекурсивный шаг: подчинённые
    SELECT e.id, e.name, e.manager_id, h.depth + 1
    FROM   employees e
    JOIN   hierarchy h ON e.manager_id = h.id
)
SELECT name, depth FROM hierarchy ORDER BY depth, name;

Рекурсивные CTE — продвинутая тема. Здесь важно знать, что они существуют. Для двух-трёх уровней иерархии достаточно SELF JOIN из урока 6-5.

Отладка сложных запросов через CTE

CTE — отличный инструмент для отладки. Если не понятно, что вычисляет промежуточный шаг, выполните только его:

-- Весь запрос
WITH dept_stats AS (
    SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department
),
top_depts AS (
    SELECT * FROM dept_stats WHERE avg_salary > 90000
)
SELECT * FROM top_depts;

-- Отладка: проверяем только dept_stats
WITH dept_stats AS (
    SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department
)
SELECT * FROM dept_stats;  -- временно заменить основной запрос

Поменять последнюю строку на SELECT * FROM dept_stats — и видим промежуточный результат. Вернуть обратно — и запрос снова работает полностью. Это удобнее, чем выделять подзапрос из глубины вложенных скобок.

CTE и транзакции

CTE исполняются в рамках той же транзакции, что и основной запрос. Если основной запрос — SELECT, все CTE тоже только читают данные. Если внутри CTE INSERT, UPDATE или DELETE (в PostgreSQL это возможно через Writeable CTE) — все изменения происходят атомарно как часть одной транзакции. Подробнее о транзакциях — в модуле 10.

Когда использовать CTE

Используйте CTE когда:

  • Запрос состоит из 3+ логических шагов
  • Одно промежуточное выражение используется несколько раз
  • Нужна читаемость: запрос читают другие люди или вы вернётесь к нему через месяц
  • Сложная аналитика с несколькими агрегациями

Подзапрос в WHERE достаточен когда:

  • Один простой шаг (одно вложение)
  • Однократное использование
  • Запрос пишется для одноразового анализа

CTE как документация

Хорошо написанный CTE — это документация сама по себе. Имена CTE должны отражать содержимое: active_orders, dept_stats, top_customers — лучше, чем cte1, tmp, sub.

-- Плохо: непонятные имена
WITH a AS (SELECT * FROM orders WHERE status = 'completed'),
     b AS (SELECT customer_id, SUM(amount) FROM a GROUP BY 1)
SELECT * FROM b;

-- Хорошо: говорящие имена
WITH completed_orders AS (SELECT * FROM orders WHERE status = 'completed'),
     customer_spending AS (SELECT customer_id, SUM(amount) AS total FROM completed_orders GROUP BY 1)
SELECT c.name, cs.total
FROM customer_spending cs JOIN customers c ON cs.customer_id = c.id;

Второй вариант не нуждается в комментариях — имена CTE объясняют всё. Через месяц вы вернётесь к этому запросу и сразу поймёте логику.

Краткий итог

  • WITH name AS (SELECT ...) — именованный промежуточный результат, используется в основном запросе
  • Несколько CTE через запятую; каждый следующий видит предыдущие
  • CTE превращает сложный запрос в линейный список именованных шагов
  • Можно использовать несколько раз в одном запросе
  • WITH RECURSIVE — для обхода иерархий произвольной глубины

CTE в разных СУБД

WITH-выражения поддерживаются во всех основных СУБД: PostgreSQL, MySQL 8+, SQL Server, SQLite 3.35+. В Oracle они тоже есть. Синтаксис идентичен стандарту SQL. Это делает CTE одним из самых портируемых инструментов SQL — код, написанный в PostgreSQL, с высокой вероятностью заработает в другой СУБД.

Рекурсивные CTE (WITH RECURSIVE) поддерживаются чуть менее универсально, но PostgreSQL, MySQL 8+ и SQL Server их все поддерживают.

Что дальше

Вы завершили модуль 7 — подзапросы, UNION, CTE. Следующий модуль — DML: изменение данных. Вы научитесь вставлять строки (INSERT), обновлять (UPDATE) и удалять (DELETE).

Попробуйте интерактивную версию

Практические задачи, квизы и AI-наставник — бесплатный старт без карты

Перейти к практике