Рекурсивные CTE — это одна из самых мощных функций SQL, позволяющая работать с иерархическими данными и древовидными структурами. В этом уроке мы изучим, как использовать рекурсивные общие табличные выражения для запроса данных с отношениями родитель-потомок, такими как организационные схемы, деревья категорий и спецификации материалов.
Рекурсивная CTE — это общее табличное выражение, которое ссылается на само себя, позволяя обходить иерархические структуры данных. В отличие от обычных CTE, которые выполняются один раз, рекурсивные CTE выполняются итеративно до тех пор, пока не будет выполнено условие завершения.
Распространенные случаи использования рекурсивных CTE:
Общий синтаксис рекурсивной CTE:
WITH RECURSIVE имя_cte AS (
-- Начальный член (базовый случай)
SELECT ...
FROM таблица
WHERE условие
UNION ALL
-- Рекурсивный член (рекурсивный случай)
SELECT ...
FROM таблица
JOIN имя_cte ON условие
)
SELECT * FROM имя_cte;
Компоненты:
Процесс выполнения:
Начнем с простого примера, генерирующего последовательность чисел:
WITH RECURSIVE последовательность_чисел AS (
-- Начальный член: начать с 1
SELECT 1 AS n
UNION ALL
-- Рекурсивный член: добавить 1 к предыдущему значению
SELECT n + 1
FROM последовательность_чисел
WHERE n < 10
)
SELECT n
FROM последовательность_чисел;
Результат:
n
--
1
2
3
4
5
6
7
8
9
10
Как это работает:
11 + 1 = 22 + 1 = 3n < 10 не станет ложным10, но 10 < 10 ложно, поэтому рекурсия останавливаетсяСоздадим таблицу, представляющую организационную структуру:
-- Пример таблицы сотрудников
CREATE TABLE employee (
employee_id INT PRIMARY KEY,
employee_name VARCHAR(100),
manager_id INT,
title VARCHAR(100)
);
-- Пример данных
INSERT INTO employee VALUES
(1, 'Алиса Джонсон', NULL, 'Генеральный директор'),
(2, 'Боб Смит', 1, 'Вице-президент по разработке'),
(3, 'Кэрол Уайт', 1, 'Вице-президент по продажам'),
(4, 'Дэвид Браун', 2, 'Руководитель разработки'),
(5, 'Ева Дэвис', 2, 'Руководитель разработки'),
(6, 'Фрэнк Миллер', 3, 'Руководитель отдела продаж'),
(7, 'Грейс Уилсон', 4, 'Старший разработчик'),
(8, 'Генри Мур', 4, 'Разработчик'),
(9, 'Айви Тейлор', 5, 'Разработчик'),
(10, 'Джек Андерсон', 6, 'Торговый представитель');
Чтобы найти всех сотрудников, подчиняющихся конкретному руководителю (прямо или косвенно):
WITH RECURSIVE подчиненные AS (
-- Начало: Начать с руководителя
SELECT
employee_id,
employee_name,
manager_id,
title,
0 AS уровень
FROM
employee
WHERE
employee_name = 'Боб Смит'
UNION ALL
-- Рекурсия: Найти прямых подчиненных
SELECT
e.employee_id,
e.employee_name,
e.manager_id,
e.title,
s.уровень + 1
FROM
employee e
INNER JOIN
подчиненные s ON e.manager_id = s.employee_id
)
SELECT
employee_id,
employee_name,
title,
уровень
FROM
подчиненные
ORDER BY
уровень, employee_name;
Результат:
employee_id | employee_name | title | уровень
------------|-----------------|-----------------------------|---------
2 | Боб Смит | Вице-президент по разработке| 0
4 | Дэвид Браун | Руководитель разработки | 1
5 | Ева Дэвис | Руководитель разработки | 1
7 | Грейс Уилсон | Старший разработчик | 2
8 | Генри Мур | Разработчик | 2
9 | Айви Тейлор | Разработчик | 2
Чтобы отобразить полную иерархию от генерального директора вниз:
WITH RECURSIVE оргсхема AS (
-- Начало: Начать с генерального директора (нет руководителя)
SELECT
employee_id,
employee_name,
manager_id,
title,
0 AS уровень,
CAST(employee_name AS VARCHAR(1000)) AS путь
FROM
employee
WHERE
manager_id IS NULL
UNION ALL
-- Рекурсия: Добавить каждый уровень
SELECT
e.employee_id,
e.employee_name,
e.manager_id,
e.title,
oc.уровень + 1,
CONCAT(oc.путь, ' > ', e.employee_name)
FROM
employee e
INNER JOIN
оргсхема oc ON e.manager_id = oc.employee_id
)
SELECT
REPEAT(' ', уровень) || employee_name AS иерархия,
title,
уровень,
путь
FROM
оргсхема
ORDER BY
путь;
Результат:
иерархия | title | уровень | путь
-------------------------------|------------------------------|---------|----------------------------------------
Алиса Джонсон | Генеральный директор | 0 | Алиса Джонсон
Боб Смит | Вице-президент по разработке | 1 | Алиса Джонсон > Боб Смит
Дэвид Браун | Руководитель разработки | 2 | Алиса Джонсон > Боб Смит > Дэвид Браун
Грейс Уилсон | Старший разработчик | 3 | Алиса Джонсон > Боб Смит > Дэвид Браун > Грейс Уилсон
Генри Мур | Разработчик | 3 | Алиса Джонсон > Боб Смит > Дэвид Браун > Генри Мур
Ева Дэвис | Руководитель разработки | 2 | Алиса Джонсон > Боб Смит > Ева Дэвис
Айви Тейлор | Разработчик | 3 | Алиса Джонсон > Боб Смит > Ева Дэвис > Айви Тейлор
Кэрол Уайт | Вице-президент по продажам | 1 | Алиса Джонсон > Кэрол Уайт
Фрэнк Миллер | Руководитель отдела продаж | 2 | Алиса Джонсон > Кэрол Уайт > Фрэнк Миллер
Джек Андерсон | Торговый представитель | 3 | Алиса Джонсон > Кэрол Уайт > Фрэнк Миллер > Джек Андерсон
Поработаем с иерархией категорий продуктов:
-- Пример таблицы категорий
CREATE TABLE category (
category_id INT PRIMARY KEY,
category_name VARCHAR(100),
parent_category_id INT
);
-- Пример данных
INSERT INTO category VALUES
(1, 'Электроника', NULL),
(2, 'Компьютеры', 1),
(3, 'Телефоны', 1),
(4, 'Ноутбуки', 2),
(5, 'Настольные ПК', 2),
(6, 'Игровые ноутбуки', 4),
(7, 'Бизнес-ноутбуки', 4),
(8, 'Смартфоны', 3),
(9, 'Обычные телефоны', 3);
Чтобы найти все подкатегории под "Компьютеры":
WITH RECURSIVE дерево_категорий AS (
-- Начало: Начать с Компьютеров
SELECT
category_id,
category_name,
parent_category_id,
0 AS глубина,
CAST(category_name AS VARCHAR(1000)) AS путь
FROM
category
WHERE
category_name = 'Компьютеры'
UNION ALL
-- Рекурсия: Получить все подкатегории
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ct.глубина + 1,
CONCAT(ct.путь, ' > ', c.category_name)
FROM
category c
INNER JOIN
дерево_категорий ct ON c.parent_category_id = ct.category_id
)
SELECT
category_id,
REPEAT(' ', глубина) || category_name AS иерархия_категорий,
глубина,
путь
FROM
дерево_категорий
ORDER BY
путь;
Результат:
category_id | иерархия_категорий | глубина | путь
------------|---------------------|---------|--------------------------------------
2 | Компьютеры | 0 | Компьютеры
4 | Ноутбуки | 1 | Компьютеры > Ноутбуки
6 | Игровые ноутбуки| 2 | Компьютеры > Ноутбуки > Игровые ноутбуки
7 | Бизнес-ноутбуки | 2 | Компьютеры > Ноутбуки > Бизнес-ноутбуки
5 | Настольные ПК | 1 | Компьютеры > Настольные ПК
Чтобы найти все родительские категории конкретной категории:
WITH RECURSIVE предки_категории AS (
-- Начало: Начать с Игровых ноутбуков
SELECT
category_id,
category_name,
parent_category_id,
0 AS уровень_выше
FROM
category
WHERE
category_name = 'Игровые ноутбуки'
UNION ALL
-- Рекурсия: Получить родительские категории
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ca.уровень_выше + 1
FROM
category c
INNER JOIN
предки_категории ca ON c.category_id = ca.parent_category_id
)
SELECT
category_id,
category_name,
уровень_выше
FROM
предки_категории
ORDER BY
уровень_выше;
Результат:
category_id | category_name | уровень_выше
------------|------------------|--------------
6 | Игровые ноутбуки | 0
4 | Ноутбуки | 1
2 | Компьютеры | 2
1 | Электроника | 3
Классический случай использования рекурсивных CTE — это изучение спецификаций материалов (деталей и поддеталей):
-- Пример таблицы деталей
CREATE TABLE parts (
part_id INT PRIMARY KEY,
part_name VARCHAR(100),
quantity INT
);
-- Пример таблицы спецификации материалов
CREATE TABLE bom (
parent_part_id INT,
child_part_id INT,
quantity_needed INT,
PRIMARY KEY (parent_part_id, child_part_id)
);
-- Пример данных
INSERT INTO parts VALUES
(1, 'Велосипед', 1),
(2, 'Рама', 1),
(3, 'Колесо', 2),
(4, 'Шина', 1),
(5, 'Обод', 1),
(6, 'Спица', 36);
INSERT INTO bom VALUES
(1, 2, 1), -- Велосипеду нужна 1 Рама
(1, 3, 2), -- Велосипеду нужно 2 Колеса
(3, 4, 1), -- Колесу нужна 1 Шина
(3, 5, 1), -- Колесу нужен 1 Обод
(5, 6, 36); -- Ободу нужно 36 Спиц
Чтобы найти все детали, необходимые для сборки велосипеда:
WITH RECURSIVE разбор_деталей AS (
-- Начало: Начать с продукта верхнего уровня
SELECT
p.part_id,
p.part_name,
1 AS количество,
0 AS уровень,
CAST(p.part_name AS VARCHAR(1000)) AS путь
FROM
parts p
WHERE
p.part_name = 'Велосипед'
UNION ALL
-- Рекурсия: Разобрать спецификацию
SELECT
p.part_id,
p.part_name,
pe.количество * b.quantity_needed,
pe.уровень + 1,
CONCAT(pe.путь, ' > ', p.part_name)
FROM
разбор_деталей pe
INNER JOIN
bom b ON pe.part_id = b.parent_part_id
INNER JOIN
parts p ON b.child_part_id = p.part_id
)
SELECT
part_id,
REPEAT(' ', уровень) || part_name AS иерархия_деталей,
количество,
уровень,
путь
FROM
разбор_деталей
ORDER BY
путь;
Результат:
part_id | иерархия_деталей | количество | уровень | путь
--------|------------------|------------|---------|---------------------------
1 | Велосипед | 1 | 0 | Велосипед
2 | Рама | 1 | 1 | Велосипед > Рама
3 | Колесо | 2 | 1 | Велосипед > Колесо
4 | Шина | 2 | 2 | Велосипед > Колесо > Шина
5 | Обод | 2 | 2 | Велосипед > Колесо > Обод
6 | Спица | 72 | 3 | Велосипед > Колесо > Обод > Спица
Обратите внимание, что нам нужно всего 72 спицы: 2 колеса × 1 обод на колесо × 36 спиц на обод = 72 спицы.
Рекурсивные CTE могут создавать бесконечные циклы, если в ваших данных есть циклические ссылки. Вот стратегии, чтобы предотвратить это:
WITH RECURSIVE ограниченная_рекурсия AS (
SELECT
category_id,
category_name,
parent_category_id,
0 AS глубина
FROM
category
WHERE
parent_category_id IS NULL
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
lr.глубина + 1
FROM
category c
INNER JOIN
ограниченная_рекурсия lr ON c.parent_category_id = lr.category_id
WHERE
lr.глубина < 10 -- Ограничение максимальной глубины
)
SELECT * FROM ограниченная_рекурсия;
WITH RECURSIVE безопасный_обход AS (
SELECT
category_id,
category_name,
parent_category_id,
ARRAY[category_id] AS посещенные_id
FROM
category
WHERE
parent_category_id IS NULL
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
st.посещенные_id || c.category_id
FROM
category c
INNER JOIN
безопасный_обход st ON c.parent_category_id = st.category_id
WHERE
NOT (c.category_id = ANY(st.посещенные_id)) -- Предотвращение циклов
)
SELECT * FROM безопасный_обход;
Всегда индексируйте столбцы, используемые в рекурсивных соединениях:
CREATE INDEX idx_employee_manager ON employee(manager_id);
CREATE INDEX idx_category_parent ON category(parent_category_id);
Используйте предложения WHERE для ограничения области рекурсии:
WITH RECURSIVE подчиненные AS (
SELECT employee_id, employee_name, manager_id, 0 AS уровень
FROM employee
WHERE employee_name = 'Боб Смит'
UNION ALL
SELECT e.employee_id, e.employee_name, e.manager_id, s.уровень + 1
FROM employee e
INNER JOIN подчиненные s ON e.manager_id = s.employee_id
WHERE s.уровень < 3 -- Только 3 уровня вглубь
)
SELECT * FROM подчиненные;
INNER JOIN, когда вам нужны только совпадающие строкиLEFT JOIN, когда хотите включить конечные узлы без потомковРаспространенный паттерн веб-приложений — это вложенные комментарии или форумные треды:
CREATE TABLE comments (
comment_id INT PRIMARY KEY,
parent_comment_id INT,
user_name VARCHAR(100),
comment_text TEXT,
created_at TIMESTAMP
);
WITH RECURSIVE тред_комментариев AS (
-- Начало: Комментарии верхнего уровня
SELECT
comment_id,
parent_comment_id,
user_name,
comment_text,
0 AS глубина,
CAST(comment_id AS VARCHAR(1000)) AS путь_сортировки
FROM
comments
WHERE
parent_comment_id IS NULL
UNION ALL
-- Рекурсия: Вложенные ответы
SELECT
c.comment_id,
c.parent_comment_id,
c.user_name,
c.comment_text,
ct.глубина + 1,
CONCAT(ct.путь_сортировки, '-', LPAD(c.comment_id::TEXT, 10, '0'))
FROM
comments c
INNER JOIN
тред_комментариев ct ON c.parent_comment_id = ct.comment_id
)
SELECT
REPEAT(' ', глубина) || user_name AS пользователь_с_отступом,
comment_text,
глубина
FROM
тред_комментариев
ORDER BY
путь_сортировки;
Рекурсивные CTE — это незаменимый инструмент для работы с древовидными и иерархическими данными в SQL. Они превращают сложные многозапросные операции в элегантные решения с одним запросом.
В следующем модуле мы изучим оконные функции для расширенного анализа данных.