Рекурсивные запросы в SQLite

Рекурсивные запросы стандарта SQL-1999 хорошо описаны в блоге Postgres Professional на Хабре. В данной статье я покажу их на примере простой базы в SQLite3.

При проектировании БД иногда возникает необходимость хранить древовидные структуры, а затем использовать в запросах эти деревья целиком. Для этого в язык была добавлена конструкция WITH RECURSIVE, где каждая итерация запроса ссылается на результат предыдущей итерации.

По сути, это хвостовая рекурсия — что-то вроде цикла.

Собака гоняется за своим хвостом.
Хвостовая рекурсия
WITH RECURSIVE переменная (колонки)
AS (
    результат первой итерации
    UNION ALL
    результат каждой последующей итерации
)
SELECT * FROM переменная;

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

Например, следующий запрос выведет 10 случайных чисел. Если убрать ссылку на себя (FROM x) в четвертой строчке, запрос станет нерекурсивным и вернет только две строки.

WITH RECURSIVE x (rnd) AS (
    VALUES(random())
    UNION ALL
    SELECT random() FROM x
)
SELECT * FROM x LIMIT 10;

Рекурсивный запрос состоит из двух подзапросов, причем второй ведёт себя примерно как подзапросы в обычных селектах — условие WHERE меняется для каждой строки. Давайте освежим в памяти эту возможность языка.

Подзапросы

Обычно в SQL используют JOIN, а не подзапросы, т.к. с помощью JOIN проще объединить сразу много таблиц.

Вот как подзапросы работают. Пусть есть две таблицы: машины и цвета. На один цвет приходится много машин, где этот цвет базовый — связь один-ко-многим.

ER-диаграмма схемы БД в нотации Мартина (вороньи лапки)

Схема с тестовыми данными: car_colors.sql

Вывести названия цветов всех машин можно с помощью джойна:

SELECT cars.vin, colors.caption
FROM cars
LEFT JOIN colors
    ON cars.base_color_id = colors.id
ORDER BY cars.vin;

Можно получить тот же результат с помощью подзапроса:

SELECT cars.vin, (
    SELECT caption FROM colors
    WHERE id = cars.base_color_id
) AS color
FROM cars
ORDER BY cars.vin;

Запрос получения названия цвета ссылается на значение в текущей строке родительского запроса, т.е. для каждого автомобиля как бы выполняется отдельный запрос получения цвета.

Рекурсивные запросы работают примерно так же.

Добавляем рекурсию

Жизненный пример: у нас есть работники организации, которые находятся на некоторых позициях в отделах организации. Эти отделы являются частями бо́льших подразделений, которые через несколько уровней вложенности входят в подразделения верхнего уровня. Колонка parent_id указывает на строчку подразделения, в которое входит подразделение из текущей строки.

ER-диаграмма схемы БД в нотации Мартина (вороньи лапки)

Тестовые данные: department_tree.sql

Теперь мы хотим получить таблицу сотрудников и названий отделов, в которых они работают.

Очевидный запрос

SELECT e.id, e.first_name, e.last_name, d.caption
FROM positions p
INNER JOIN employees e ON p.employee_id = e.id
INNER JOIN divisions d ON p.division_id = d.id
ORDER BY p.id;

выдаст что-то вроде

id first_name last_name caption
43 Юрий Свиридов Творческая группа 2
44 Юлия Быкова Сборка
45 Янна Смирнова Сборка
46 Ярослав Гальцев Слесарный цех (ЧПУ)
47 Илья Щербаков Слесарный цех (ЧПУ)
48 Оксана Петренко Листогибочный цех
49 Полина Леонова Цех автоматической пайки
50 Павел Абдулов Снабжение
51 Руслан Иванов Внутренняя логистика

Такая таблица мало что скажет большому начальнику, ведь с его перспективы компания состоит из следующих отделов.

SELECT * FROM divisions
WHERE parent_id IS NULL;
id parent_id caption
1 NULL Отдел фундаментальных исследований
2 NULL Отдел стратегического планирования
3 NULL IT-отдел
4 NULL Маркетинговый отдел
5 NULL Проектный отдел
6 NULL Производственный отдел

В отчёте следует показать названия отделов верхнего уровня. Сейчас у нас есть доступ к дочерним отделам, т.е. задача сводится к получению отдела верхнего уровня из любого дочернего отдела.

Вначале, найдём результат для одной строки отчета. На первой итерации берём отдел, непосредственно указанный в позиции:

SELECT id, parent_id, caption
FROM divisions
WHERE caption = 'Листогибочный цех';

Затем сошлёмся на эту строку из следующей итерации. Здесь, во второй части объединения, r означает предыдущую итерацию. То есть вторая часть рекурсивного выражения ссылается на строку, возвращенную на предыдущей итерации, и для каждой такой строки как бы выполняется отдельный запрос.

WITH RECURSIVE r (id, parent_id, caption)
AS (
    SELECT id, parent_id, caption
    FROM divisions
    WHERE caption = 'Листогибочный цех'

    UNION ALL

    SELECT divisions.id,
        divisions.parent_id,
        divisions.caption
    FROM divisions, r
    WHERE (r.parent_id = divisions.id)
)
SELECT * FROM r;

Получаем следующее.

id parent_id caption
623 62 Листогибочный цех
62 6 Изготовление деталей
6 NULL Производственный отдел

Наконец, отбрасываем все строки, кроме верхушки дерева, с помощью проверки на NULL.

WITH RECURSIVE r (id, parent_id, caption)
AS (
    SELECT id, parent_id, caption
    FROM divisions
    WHERE caption = 'Листогибочный цех'

    UNION ALL

    SELECT divisions.id,
        divisions.parent_id,
        divisions.caption
    FROM divisions, r
    WHERE (r.parent_id = divisions.id)
)
SELECT * FROM r
WHERE parent_id IS NULL;
id parent_id caption
6 NULL Производственный отдел

Условие второго запроса меняется для каждой строки предыдущей итерации, аналогично тому, как работают подзапросы вообще, что было показано в первой половине статьи.

Будет нагляднее, если на первой итерации достать несколько строк.

WITH RECURSIVE r (id, parent_id, caption)
AS (
    SELECT id, parent_id, caption
    FROM divisions
    WHERE (caption like '%цех%')
    OR (caption = 'Творческая группа 2')

    UNION ALL

    SELECT divisions.id,
        divisions.parent_id,
        divisions.caption
    FROM divisions, r
    WHERE (r.parent_id = divisions.id)
)
SELECT * FROM r;
id parent_id caption
52 5 Творческая группа 2
621 62 Слесарный цех (ЧПУ)
623 62 Листогибочный цех
624 62 Покрасочный цех
5 NULL Проектный отдел
62 6 Изготовление деталей
62 6 Изготовление деталей
62 6 Изготовление деталей
6 NULL Производственный отдел
6 NULL Производственный отдел
6 NULL Производственный отдел

Как видите, вторая итерация была выполнена с разными условиями для каждой строки, полученной в ходе первой итерации, и это справедливо для всех последующих итераций.

Встроим рекурсивный запрос как подзапрос туда, где раньше выводились названия подразделений.

SELECT e.id, e.first_name, e.last_name, (
    WITH RECURSIVE r (id, parent_id, caption)
    AS (
        SELECT id, parent_id, caption
        FROM divisions
        WHERE id = d.id

        UNION ALL

        SELECT
            divisions.id,
            divisions.parent_id,
            divisions.caption
        FROM divisions, r
        WHERE (r.parent_id = divisions.id)
    )
    SELECT caption FROM r
    WHERE parent_id IS NULL
) AS top_caption
FROM positions p
INNER JOIN employees e ON p.employee_id = e.id
INNER JOIN divisions d ON p.division_id = d.id
ORDER BY p.id;

Проблема решена.

id first_name last_name top_caption
43 Юрий Свиридов Проектный отдел
44 Юлия Быкова Производственный отдел
45 Янна Смирнова Производственный отдел
46 Ярослав Гальцев Производственный отдел
47 Илья Щербаков Производственный отдел
48 Оксана Петренко Производственный отдел
49 Полина Леонова Производственный отдел
50 Павел Абдулов Производственный отдел
51 Руслан Иванов Производственный отдел

То же можно сделать с помощью джойна с рекурсивным запросом. Вначале получим таблицу названий верхнего уровня для всех отделов.

WITH RECURSIVE r
(
    initial_id,
    id,
    parent_id,
    caption
)
AS (
    SELECT id, id, parent_id, caption
    FROM divisions

    UNION ALL

    SELECT
        r.initial_id,
        divisions.id,
        divisions.parent_id,
        divisions.caption
    FROM divisions, r
    WHERE (r.parent_id = divisions.id)
)
SELECT
    initial_id AS id,
    caption AS top_caption
FROM r
WHERE parent_id IS NULL;
id top_caption
1 Отдел фундаментальных исследований
2 Отдел стратегического планирования
3 IT-отдел
4 Маркетинговый отдел
5 Проектный отдел
6 Производственный отдел
11 Отдел фундаментальных исследований
12 Отдел фундаментальных исследований
13 Отдел фундаментальных исследований
14 Отдел фундаментальных исследований
21 Отдел стратегического планирования
22 Отдел стратегического планирования
51 Проектный отдел
52 Проектный отдел

Присоединим, используя ON вместо WHERE.

SELECT e.id, e.first_name, e.last_name,
    div_view.top_caption
FROM positions p
INNER JOIN employees e ON p.employee_id = e.id
INNER JOIN divisions d ON p.division_id = d.id
INNER JOIN (
    WITH RECURSIVE r
    (
        initial_id,
        id,
        parent_id,
        caption
    )
    AS (
        SELECT id, id, parent_id, caption
        FROM divisions
        UNION ALL
        SELECT
            r.initial_id,
            divisions.id,
            divisions.parent_id,
            divisions.caption
        FROM divisions, r
        WHERE (r.parent_id = divisions.id)
    )
    SELECT
        initial_id AS id,
        caption AS top_caption
    FROM r
    WHERE parent_id IS NULL
) AS div_view ON div_view.id = d.id
ORDER BY p.id;

Наконец, можно вынести WITH-выражение за пределы селекта.

WITH RECURSIVE div_view
(
    initial_id,
    id,
    parent_id,
    top_caption
)
AS (
    SELECT id, id, parent_id, caption
    FROM divisions
    UNION ALL
    SELECT
        div_view.initial_id,
        divisions.id,
        divisions.parent_id,
        divisions.caption
    FROM divisions, div_view
    WHERE (div_view.parent_id = divisions.id)
)
SELECT e.id, e.first_name, e.last_name,
    div_view.top_caption
FROM positions p
INNER JOIN employees e ON p.employee_id = e.id
INNER JOIN divisions d ON p.division_id = d.id
INNER JOIN div_view
    ON (div_view.parent_id is NULL)
    AND (div_view.initial_id = d.id)
ORDER BY p.id;

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

Документация по CTE: