Тема 04

Соединения и подзапросы

Соединения и подзапросы

В теме 3 были введены операции реляционной алгебры — соединения, полусоединение, антисоединение, деление — и запрос SELECT к одной таблице. Но данные «Книжного узла», как и любой нормализованной базы, разнесены по многим таблицам: название книги хранится в book, имя автора — в author, связь между ними — в book_author, а покупатель, заказ, позиции заказа и оплата — в четырёх разных таблицах. Почти любой практический вопрос — «какие книги купил покупатель», «какие издательства не выпустили ни одной книги по Python», «сколько заплатили за заказ» — требует собрать данные из нескольких таблиц. Остаётся открытым вопрос, как алгебраические соединения и подзапросы записываются в SQL и чем SQL с его NULL и мультимножествами отличается здесь от алгебры.

Тема отвечает на этот вопрос в два шага. Первый — соединения: внутренние, внешние, самосоединения и соединения многих таблиц по пути внешних ключей. Здесь возникают две ловушки, на которых ошибаются и опытные разработчики: условие в WHERE, незаметно превращающее внешнее соединение во внутреннее, и размножение строк при соединении с двумя независимыми таблицами «многих». Второй шаг — подзапросы: скалярные, в FROM, в условиях с IN, EXISTS, ANY/ALL, коррелированные и LATERAL. Подзапросы дают в SQL прямые аналоги полусоединения, антисоединения и деления; рядом с ними — третья ловушка, NOT IN при наличии NULL.

Все примеры выполнены на учебной базе bookstore масштаба scale=1, числа строк приведены из реального вывода. Группировка и агрегатные функции рассматриваются в теме 5; здесь функции count, sum и avg используются только в простейшем виде — для подсчёта строк и суммы по всем строкам результата или подзапроса. Как СУБД выполняет соединения (вложенные циклы, хеширование, слияние), разбирается в теме 11.

Внутреннее соединение

От декартова произведения к JOIN

Если в предложении FROM перечислить две таблицы через запятую или соединить их ключевыми словами CROSS JOIN, результатом будет декартово произведение: каждая строка первой таблицы в паре с каждой строкой второй 5. В учебной базе 2010 книг и 4 склада, поэтому SELECT count(*) FROM book CROSS JOIN warehouse возвращает 8040. Произведение бывает нужно само по себе — например, чтобы получить все сочетания «книга — склад» и затем найти отсутствующие остатки, — но чаще за ним следует отбор осмысленных пар, то есть тета-соединение темы 3.

Исторически соединение записывали именно так: произведение в FROM и условие соединения в WHERE. Стандарт SQL-92 ввёл явный синтаксис соединения (англ. explicit join syntax), в котором условие соединения записывается рядом с таблицей в предложении ON 41:

-- старый стиль: произведение и условие в WHERE
SELECT b.title, p.name
FROM book b, publisher p
WHERE p.publisher_id = b.publisher_id AND b.book_id <= 3;

-- явное соединение
SELECT b.title, p.name
FROM book b
JOIN publisher p ON p.publisher_id = b.publisher_id
WHERE b.book_id <= 3;
          title           |  name
--------------------------+--------
 Война и мир              | АСТ
 Преступление и наказание | Эксмо
 Евгений Онегин           | Азбука
(3 rows)

Для внутреннего соединения обе формы эквивалентны, и PostgreSQL строит для них одинаковый план. Явная форма предпочтительнее по двум причинам. Условия соединения отделены от условий отбора, и запрос легче читать. И главное — пропущенное условие соединения в явной форме вызывает синтаксическую ошибку (JOIN требует ON или USING), а в старой форме молча превращает запрос в декартово произведение. Слово JOIN означает INNER JOIN — внутреннее соединение (англ. inner join): в результат попадают только пары строк, для которых условие истинно. Строка без пары исчезает: книг 2010, а book JOIN publisher возвращает 1915 строк, потому что у 95 книг publisher_id равен NULL, а сравнение NULL = значение не истинно.

Буквы b и p — псевдонимы таблиц (англ. table alias), реализация переименования ρ. Когда в запросе несколько таблиц, имена столбцов принято уточнять псевдонимом (b.title), даже если имя уникально: запрос становится понятнее и не ломается, если в другую таблицу позже добавят столбец с тем же именем. Если же имя столбца встречается в двух таблицах, уточнение обязательно, иначе PostgreSQL сообщает об ошибке column reference "…" is ambiguous.

USING и NATURAL JOIN

Когда соединение идёт по равенству одноимённых столбцов, вместо ON можно написать USING: book JOIN book_author USING (book_id) означает ON book.book_id = book_author.book_id, но в результате остаётся один столбец book_id, а не два 5. Это удобно и безопасно: столбцы соединения перечислены явно.

NATURAL JOIN идёт дальше: он соединяет по всем одноимённым столбцам, не перечисляя их, — это естественное соединение алгебры, введённое ещё Коддом 12. Именно поэтому им почти не пользуются в рабочем коде. Условие соединения зависит от схемы, а не от запроса, и меняется вместе с ней. В учебной базе у order_item и book два общих имени: book_id и price. Пока цена книги в каталоге совпадает с ценой в заказах, естественное соединение возвращает все 59 910 позиций заказов. Изменим в транзакции цену «Войны и мира» и посмотрим ещё раз:

BEGIN;
UPDATE book SET price = 1390 WHERE book_id = 1;
SELECT count(*) FROM order_item NATURAL JOIN book;           -- 58702
SELECT count(*) FROM order_item JOIN book USING (book_id);   -- 59910
ROLLBACK;

1208 позиций с «Войной и миром» исчезли из естественного соединения без всякого предупреждения: столбец order_item.price хранит цену на момент заказа, book.price — текущую, и условие order_item.price = book.price для них стало ложным. Такие ошибки трудно заметить, потому что запрос продолжает работать и возвращает правдоподобные данные. Обратный случай тоже возможен: warehouse NATURAL JOIN publisher соединяет склады с издательствами по городу — случайному общему имени, о котором автор запроса мог и не думать. Если же общих имён нет совсем, NATURAL JOIN становится декартовым произведением.

Соединение нескольких таблиц: путь по внешним ключам

Вопрос, затрагивающий несколько таблиц, решается соединением по цепочке внешних ключей (англ. join path): от таблицы к таблице, на каждом шаге — по паре «внешний ключ — первичный ключ». Чтобы найти этот путь, удобно держать перед глазами схему базы. Например, для вопроса «из каких рубрик книги в заказе 1» путь такой: orders → order_item (по order_id) → book (по book_id) → category (по category_id).

SELECT o.order_id, o.created_at::date AS d, b.title,
       cat.name AS category, oi.quantity, oi.price
FROM orders o
JOIN order_item oi ON oi.order_id = o.order_id
JOIN book b        ON b.book_id = oi.book_id
JOIN category cat  ON cat.category_id = b.category_id
WHERE o.order_id = 1
ORDER BY b.book_id;
 order_id |     d      |          title           |  category  | quantity |  price
----------+------------+--------------------------+------------+----------+---------
        1 | 2023-09-27 | Хроники баз данных 286   | Фантастика |        1 | 1489.99
        1 | 2023-09-27 | Практика времени 345     | Математика |        1 | 1479.99
        1 | 2023-09-27 | История старого дома 756 | Фантастика |        1 | 2559.99
(3 rows)

Синтаксически соединения в FROM выполняются слева направо: a JOIN b ON … JOIN c ON … означает (a JOIN b ON …) JOIN c ON …, и в условии ON можно ссылаться только на таблицы, уже появившиеся левее 7. Для внутренних соединений порядок записи не влияет ни на результат (соединение коммутативно и ассоциативно), ни на скорость: планировщик сам выбирает порядок выполнения (тема 11). Для внешних соединений порядок важен, к этому вернёмся ниже.

Полезно уметь оценивать мощность результата до выполнения. Соединение «многие к одному» по внешнему ключу, обязательному (NOT NULL) и ссылочно целостному, сохраняет число строк левой таблицы: каждая позиция заказа находит ровно одну книгу, каждая книга — ровно одну рубрику, поэтому три позиции заказа дают три строки. Соединение «один ко многим» может увеличить число строк — об этом отдельный раздел.

Самосоединение: рубрики и их родители

Таблица category хранит дерево рубрик: столбец parent_id ссылается на ту же таблицу. Чтобы рядом с рубрикой вывести название родителя, таблицу соединяют саму с собой — это самосоединение (англ. self-join). Псевдонимы здесь обязательны: две копии одной таблицы играют разные роли, и их надо различать, как при переименовании ρ в алгебре. Добавив третью копию, получим «дедушку» — рубрику верхнего уровня:

SELECT c.name AS category, p.name AS parent, g.name AS grandparent
FROM category c
JOIN category p ON p.category_id = c.parent_id
JOIN category g ON g.category_id = p.parent_id
ORDER BY c.category_id;
        category        |      parent      |        grandparent
------------------------+------------------+---------------------------
 Python                 | Программирование | Компьютерная литература
 C и C++                | Программирование | Компьютерная литература
 Java                   | Программирование | Компьютерная литература
 PostgreSQL             | Базы данных      | Компьютерная литература
 NoSQL                  | Базы данных      | Компьютерная литература
 Космическая фантастика | Фантастика       | Художественная литература
(6 rows)

В рубрикаторе 23 рубрики, но в ответе шесть: внутреннее соединение отбросило рубрики, у которых нет родителя или «дедушки», — четыре рубрики верхнего уровня и тринадцать рубрик второго уровня. Каждое следующее самосоединение спускается на один уровень дерева, поэтому дерево произвольной глубины самосоединениями не обойти; для этого служат рекурсивные запросы (тема 6).

Другой типичный пример самосоединения — пары строк одной таблицы, связанных общим значением. Соавторы — это две строки book_author с одной книгой и разными авторами; условие ba2.author_id > ba1.author_id оставляет каждую пару один раз и исключает пары автора с самим собой. Для первых десяти книг такой запрос возвращает две строки — Аркадий и Борис Стругацкие для «Пикника на обочине» и «Трудно быть богом».

Соединение по неравенству

Условие соединения не обязано быть равенством. Соединение по произвольному условию — тета-соединение алгебры; когда условие отлично от равенства, его называют неэквисоединением (англ. non-equi join). Типичный случай — соединение с таблицей интервалов: тарифной сеткой, шкалой скидок, ценовыми классами. Справочник интервалов не обязательно хранить в базе: конструктор VALUES с псевдонимом и именами столбцов создаёт маленькую таблицу прямо в запросе 5.

SELECT b.book_id, b.title, b.price, band.label
FROM book b
JOIN (VALUES ('дешёвая', 0, 500),
             ('средняя', 500, 1500),
             ('дорогая', 1500, 100000)) AS band (label, lo, hi)
  ON b.price >= band.lo AND b.price < band.hi
WHERE b.book_id <= 6
ORDER BY b.book_id;
 book_id |              title               |  price  |  label
---------+----------------------------------+---------+---------
       1 | Война и мир                      | 1290.00 | средняя
       2 | Преступление и наказание         |  450.00 | дешёвая
       3 | Евгений Онегин                   |  390.00 | дешёвая
       4 | Пикник на обочине                |  520.00 | средняя
       5 | Трудно быть богом                |  540.00 | средняя
       6 | Убийство в «Восточном экспрессе» |  480.00 | дешёвая
(6 rows)

Рассуждение о мощности здесь то же, что и для соединения по ключу, но гарантий меньше: ни первичный, ни внешний ключ не обещают, что каждой книге найдётся ровно один интервал. Если интервалы пересекаются, книга на границе попадёт в два класса и строка размножится; если между интервалами есть разрыв, книга в разрыве исчезнет из внутреннего соединения. По всей таблице запрос возвращает 2010 строк — ровно по одной на книгу. Сдвиг нижней границы первого класса с 0 на 1 оставляет 2009 строк: бесплатная книга выпадает в разрыв. А замена < на <= делает интервалы пересекающимися, но на учебных данных по-прежнему даёт 2010 строк, потому что ни одна книга не стоит ровно 500 или 1500 рублей. Ошибка в запросе есть, а на данных она не видна — к этому наблюдению мы вернёмся в конце темы. Поэтому интервалы принято задавать полуоткрытыми (нижняя граница включается, верхняя нет) и примыкающими друг к другу. Для неэквисоединения PostgreSQL не может применить ни соединение хешированием, ни соединение слиянием — обоим методам нужно условие равенства — и выполняет его вложенными циклами (тема 11).

Внешние соединения

LEFT, RIGHT и FULL: дополнение значениями NULL

Внутреннее соединение теряет строки без пары, а вопрос часто требует их сохранить: «все книги с издательствами, в том числе книги без издательства». Для этого служат внешние соединения (англ. outer join) 125:

  • левое внешнее соединение A LEFT JOIN B ON … возвращает все строки внутреннего соединения и, кроме того, каждую строку A, не нашедшую пары, дополненную значениями NULL в столбцах B;
  • правое A RIGHT JOIN B — то же для строк B; оно равносильно B LEFT JOIN A, и на практике обычно пишут левые соединения;
  • полное A FULL JOIN B сохраняет строки без пары с обеих сторон.
SELECT b.book_id, b.title, p.name AS publisher
FROM book b
LEFT JOIN publisher p ON p.publisher_id = b.publisher_id
WHERE b.book_id <= 8
ORDER BY b.book_id;
 book_id |               title               | publisher
---------+-----------------------------------+-----------
       1 | Война и мир                       | АСТ
       2 | Преступление и наказание          | Эксмо
       3 | Евгений Онегин                    | Азбука
       4 | Пикник на обочине                 | АСТ
       5 | Трудно быть богом                 | АСТ
       6 | Убийство в «Восточном экспрессе»  | Эксмо
       7 | Искусство программирования. Том 1 |
       8 | Высоконагруженные приложения      | ДМК Пресс
(8 rows)

Книга Кнута сохранилась, а на месте издательства — NULL. По всей таблице book LEFT JOIN publisher даёт 2010 строк против 1915 у внутреннего соединения. Рисунок ниже сравнивает три вида соединений на двух маленьких таблицах.

Внутреннее, левое и полное соединения
Внутреннее соединение оставляет только пары; левое добавляет строки левой таблицы без пары, дополняя их NULL; полное — строки без пары с обеих сторон

Полное соединение удобно для сверки двух наборов: какие города есть только у складов, какие только у издательств, какие у обоих.

SELECT w.city AS warehouse_city, p.name AS publisher
FROM warehouse w
FULL JOIN publisher p ON p.city = w.city
ORDER BY w.warehouse_id, p.publisher_id;
 warehouse_city  |   publisher
-----------------+---------------
 Москва          | ДМК Пресс
 Москва          | Эксмо
 Москва          | АСТ
 Москва          | Наука
 Москва          | МЦНМО
 Москва          | Самокат
 Санкт-Петербург | Питер
 Санкт-Петербург | БХВ
 Санкт-Петербург | Азбука
 Новосибирск     |
 Казань          |
                 | Северный узел
(12 rows)

Склады в Новосибирске и Казани не нашли издательств, а издательство «Северный узел» с неизвестным городом не нашло склада — и все три строки сохранены. Заметим, что NULL в результате внешнего соединения неоднозначен: в последней строке пустой warehouse_city означает «пары нет», но такой же NULL мог бы прийти из самой таблицы. Чтобы отличать эти случаи, проверяют на NULL столбец, который в таблице заведомо не пуст, — первичный ключ.

Условие в ON и условие в WHERE

Для внутреннего соединения безразлично, записано условие в ON или в WHERE: в обоих случаях оно отбирает пары. Для внешнего соединения это различие принципиально, и оно следует из логического порядка выполнения запроса (тема 3). Условие ON — часть соединения: оно определяет, какие строки считаются парой, а строки левой таблицы без пары сохраняются в любом случае. Условие WHERE применяется после соединения к уже построенным строкам, включая дополненные NULL 57.

Рассмотрим вопрос «покупатели из Томска и их заказы за декабрь 2024 года — включая покупателей, которые в декабре ничего не заказывали». Покупателей из Томска 576.

-- условие на дату в ON: 584 строки
SELECT c.customer_id, o.order_id
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
                  AND o.created_at >= '2024-12-01'
WHERE c.city = 'Томск';

-- условие на дату в WHERE: 72 строки
SELECT c.customer_id, o.order_id
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.city = 'Томск' AND o.created_at >= '2024-12-01';

Первый запрос отвечает на поставленный вопрос: 72 строки — декабрьские заказы томичей, ещё 512 строк — томичи без декабрьских заказов, с NULL в order_id; 72 + 512 = 584. Во втором запросе левое соединение сначала строит все заказы томичей за всё время и строки для томичей без заказов вообще, а затем WHERE отбрасывает каждую строку, где created_at не больше границы, — в том числе все строки, дополненные NULL, потому что сравнение NULL >= '2024-12-01' не истинно. Остаются 72 строки, ровно как у внутреннего соединения: условие в WHERE «превратило» внешнее соединение во внутреннее. Схема на рисунке показывает, на каком шаге теряются строки.

Условие в ON и условие в WHERE для LEFT JOIN
Условие в ON участвует в поиске пары, и покупатель без декабрьских заказов сохраняется с NULL; условие в WHERE применяется после соединения и отбрасывает дополненные NULL строки

Правило, которое из этого следует: при LEFT JOIN условия на правую таблицу пишутся в ON, условия на левую — в WHERE. Условие WHERE на столбец правой таблицы допустимо только в одном случае — когда оно проверяет IS NULL и намеренно оставляет строки без пары. PostgreSQL сам замечает, что WHERE с таким условием отбрасывает дополненные строки, и выполняет соединение как внутреннее; в плане выполнения это видно по узлу Join без слова Left (тема 11).

Тот же эффект возникает в цепочке соединений: a LEFT JOIN b ON … JOIN c ON c.id = b.c_id — внутреннее соединение с c отбрасывает строки, где b дополнено NULL, и внешнее соединение с b теряет смысл. Если строки a должны сохраниться, вся цепочка после первого внешнего соединения тоже должна быть внешней, либо b JOIN c заключается в скобки как единое целое: a LEFT JOIN (b JOIN c ON …) ON ….

Строки без пары: антисоединение через LEFT JOIN

Внешнее соединение даёт и способ найти строки без пары — антисоединение темы 3. Левое соединение сохраняет строки левой таблицы без пары, дополняя их NULL; остаётся оставить только их, проверив на NULL первичный ключ правой таблицы. Покупатели, не сделавшие ни одного заказа:

SELECT count(*)
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.customer_id IS NULL;   -- 621

Проверять на NULL нужно именно столбец, который в правой таблице не может быть NULL, — столбец условия соединения или первичный ключ: проверка необязательного столбца, например o.warehouse_id IS NULL, смешала бы строки без пары с настоящими заказами без склада. Другие формулировки антисоединения — NOT EXISTS, NOT IN, EXCEPT — рассмотрены в разделе о подзапросах.

Размножение строк при соединении

Мощность соединения «один ко многим»

Соединение по внешнему ключу «один ко многим» с точки зрения стороны «один» размножает строки: заказ 1 соединяется с тремя своими позициями и превращается в три строки, и все атрибуты заказа — дата, покупатель, статус — повторяются трижды. Это не ошибка: каждая строка результата описывает одну позицию, и соединение отвечает на вопрос о позициях. Ошибка возникает, когда результат такого соединения используют для вопроса о заказах — например, считают заказы или складывают атрибуты заказа. Размножение строк (англ. row multiplication, также fan-out) — самая частая причина неверных отчётов, потому что запрос выполняется без ошибок и даёт число правдоподобного масштаба.

Мощность соединения можно оценить заранее. Если каждая строка A соединяется в среднем с k строками B, то |A ⋈ B| ≈ k · |A|. В учебной базе 20 000 заказов и 59 910 позиций, в среднем три позиции на заказ, поэтому orders JOIN order_item даёт 59 910 строк. Любая сумма по атрибуту orders, посчитанная после такого соединения, окажется примерно втрое больше правильной.

Две независимые связи «один ко многим»

Опаснее всего случай, когда к одной строке присоединяются две независимые таблицы «многих». У заказа есть позиции (order_item) и платежи (payment); позиции и платежи между собой не связаны, связь у каждого — только с заказом. Соединение orders JOIN order_item JOIN payment сочетает каждую позицию заказа с каждым его платежом, то есть строит декартово произведение внутри каждого заказа: при трёх позициях и одном платеже платёж повторяется трижды, при трёх позициях и двух платежах получилось бы шесть строк. Родственную ситуацию в ER-моделировании называют ловушкой «веер» (англ. fan trap, тема 7); рисунок ниже показывает её на заказе 1.

Размножение строк при соединении с двумя таблицами «многих»
Заказ с тремя позициями и одним платежом после соединения с обеими таблицами превращается в три строки, и сумма платежа учитывается трижды

Посчитаем сумму платежей за ноябрь 2024 года правильно и «с соединением», которое понадобилось бы, например, чтобы заодно вывести названия купленных книг:

-- платежи без соединения
SELECT count(*), sum(amount)
FROM payment
WHERE paid_at >= '2024-11-01' AND paid_at < '2024-12-01';

-- те же платежи, соединённые с позициями заказов
SELECT count(*), sum(p.amount)
FROM payment p
JOIN order_item oi ON oi.order_id = p.order_id
WHERE p.paid_at >= '2024-11-01' AND p.paid_at < '2024-12-01';
 count |    sum
-------+------------
   553 | 3456109.22
(1 row)

 count |     sum
-------+-------------
  1658 | 10361057.69
(1 row)

553 платежа превратились в 1658 строк, а выручка выросла втрое: каждый платёж сложен столько раз, сколько позиций в его заказе. Ошибку выдаёт именно число строк: если запрос отвечает на вопрос о платежах, строк должно быть столько же, сколько платежей. Отсюда простое правило самопроверки — перед суммированием посчитать строки и сравнить с числом объектов, о которых задан вопрос.

Как избежать размножения

Способов три, и все они сводятся к тому, чтобы не соединять независимые таблицы «многих» напрямую.

Первый — не соединять, если атрибуты второй таблицы не нужны в результате. Если таблица в запросе нужна только для условия («платежи по заказам, в которых есть книги Кнута»), её место не в JOIN, а в полусоединении EXISTS (раздел о подзапросах): полусоединение по определению не размножает строки.

Второй — свести каждую таблицу «многих» к одной строке на ключ до соединения: вычислить сумму позиций заказа коррелированным подзапросом или подзапросом в FROM и только потом соединить с платежами. Предварительная агрегация (англ. pre-aggregation) подзапросом с GROUP BY — основной приём построения отчётов, он подробно разбирается в теме 5.

Третий способ, который часто применяют и который не следует применять, — DISTINCT или count(DISTINCT …) поверх размноженного результата. Он маскирует симптом: два разных платежа на одинаковую сумму по одному заказу DISTINCT склеит в один, и сумма станет меньше правильной. Правильный запрос не порождает лишних строк, а не убирает их после.

Подзапросы

Скалярные подзапросы

Подзапрос (англ. subquery) — запрос SELECT, вложенный в другой запрос и заключённый в скобки. Место подзапроса определяет, что от него требуется. Скалярный подзапрос (англ. scalar subquery) стоит там, где ожидается одно значение, и должен вернуть не более одной строки с одним столбцом; если строк нет, значение подзапроса — NULL, если строк больше одной — возникает ошибка выполнения 8. Средняя цена книги — 1567.74 рубля; книги дороже средней:

SELECT count(*) FROM book WHERE price > (SELECT avg(price) FROM book);   -- 991

Скалярный подзапрос можно использовать и в списке SELECT, например чтобы вывести отклонение цены от средней:

SELECT book_id, title, price,
       round(price - (SELECT avg(price) FROM book), 2) AS diff
FROM book
WHERE book_id <= 5
ORDER BY book_id;
 book_id |          title           |  price  |   diff
---------+--------------------------+---------+----------
       1 | Война и мир              | 1290.00 |  -277.74
       2 | Преступление и наказание |  450.00 | -1117.74
       3 | Евгений Онегин           |  390.00 | -1177.74
       4 | Пикник на обочине        |  520.00 | -1047.74
       5 | Трудно быть богом        |  540.00 | -1027.74
(5 rows)

Этот подзапрос не зависит от внешнего запроса — такой подзапрос называют некоррелированным (англ. uncorrelated), и СУБД вычисляет его один раз. В плане выполнения PostgreSQL он виден как узел InitPlan.

Подзапросы в FROM: производные таблицы

Подзапрос в предложении FROM называется производной таблицей (англ. derived table): его результат участвует в запросе как обычная таблица 5. Стандарт и PostgreSQL до версии 15 включительно требуют дать ему псевдоним; в PostgreSQL 16 псевдоним можно опустить, но давать его по-прежнему принято — по нему ссылаются на столбцы подзапроса. Производные таблицы решают две задачи. Первая — дать имя вычисленному столбцу, чтобы использовать его в WHERE внешнего запроса, обойдя ограничение логического порядка выполнения из темы 3:

SELECT title, new_price
FROM (SELECT title, price * 1.2 AS new_price FROM book) AS t
WHERE new_price > 4000;
               title               | new_price
-----------------------------------+-----------
 Искусство программирования. Том 1 |  4788.000
(1 row)

Вторая — подготовить данные до соединения: свести таблицу «многих» к одной строке на ключ и избежать размножения строк (подробно — в теме 5). Производная таблица — это то же, что подвыражение в дереве алгебры; планировщик PostgreSQL, как правило, «раскрывает» простые подзапросы в FROM, встраивая их в основной запрос, так что вынос выражения в подзапрос не делает запрос медленнее. Для длинных запросов ту же роль удобнее играет общее табличное выражение WITH (тема 6).

IN, ANY и ALL

Подзапрос, возвращающий один столбец и сколько угодно строк, используется в условиях со специальными предикатами 6:

  • x IN (подзапрос) — истинно, если x равно хотя бы одному значению подзапроса;
  • x оп ANY (подзапрос) (синоним SOME) — истинно, если сравнение x оп v истинно хотя бы для одного значения v; x = ANY (…) равносильно IN;
  • x оп ALL (подзапрос) — истинно, если сравнение истинно для всех значений; x <> ALL (…) равносильно NOT IN.

Книги дороже всех книг рубрики «Базы данных» (рубрика 13, самая дорогая — 2979.99):

SELECT count(*) FROM book
WHERE price > ALL (SELECT price FROM book WHERE category_id = 13);   -- 13

Предикат ALL скрывает неочевидный случай: для пустого подзапроса он истинен. В рубрике 1 «Художественная литература» книг нет (книги лежат в её подрубриках), поэтому price > ALL (SELECT price FROM book WHERE category_id = 1) истинно для всех 2010 книг — точно так же, как деление на пустой делитель в теме 3 возвращает всех кандидатов. Формулировка через max (price > (SELECT max(price) …)) в этом случае вернула бы пустой результат, потому что максимум пустого множества — NULL. Две формулировки, кажущиеся равносильными, расходятся именно на граничном случае. Ещё одна тонкость — NULL в подзапросе: если среди значений есть NULL, а для остальных сравнение истинно, ALL даёт неизвестность, и строка не проходит. Это та же ловушка, что и у NOT IN, рассмотренная ниже.

Коррелированные подзапросы

Подзапрос, ссылающийся на столбцы внешнего запроса, называется коррелированным (англ. correlated subquery). Логически он вычисляется заново для каждой строки внешнего запроса — как функция от этой строки. Сумма каждого заказа покупателя 4003:

SELECT o.order_id, o.status,
       (SELECT sum(oi.quantity * oi.price)
        FROM order_item oi
        WHERE oi.order_id = o.order_id) AS total
FROM orders o
WHERE o.customer_id = 4003
ORDER BY o.order_id;
 order_id |  status   |  total
----------+-----------+---------
     5939 | delivered | 3949.97
     8889 | delivered | 5449.97
    10737 | delivered | 4079.97
    19913 | delivered | 3059.96
(4 rows)

Ссылка o.order_id внутри подзапроса связывает его с текущей строкой внешнего запроса. Здесь коррелированный подзапрос сразу решает проблему размножения: для каждого заказа он возвращает ровно одно число, и соединять заказ с позициями не нужно. В условиях WHERE коррелированный подзапрос выражает зависимость «для этой строки существует / не существует / сравнивается с …», на чём построены полусоединение и антисоединение.

«Вычисляется для каждой строки» описывает смысл, а не способ выполнения. Ещё в 1982 году Ким показал, как преобразовывать вложенные запросы в соединения 9, и современные оптимизаторы, включая PostgreSQL, превращают многие коррелированные подзапросы в EXISTS/IN в полусоединения и антисоединения. Скалярный коррелированный подзапрос в списке SELECT PostgreSQL 16 выполняет как SubPlan — действительно по разу на строку, что на больших таблицах бывает медленно (тема 11).

LATERAL, полусоединение, антисоединение и деление

LATERAL: топ-3 в каждой рубрике

Подзапрос в FROM по стандарту не может ссылаться на другие таблицы того же FROM: производная таблица вычисляется независимо. Ключевое слово LATERAL (стандарт SQL:1999, PostgreSQL 9.3) снимает это ограничение: подзапрос, помеченный LATERAL, может ссылаться на столбцы таблиц, стоящих в FROM левее, и вычисляется логически для каждой их строки 57. По сути это коррелированный подзапрос, который возвращает не одно значение, а целую таблицу — несколько строк и столбцов.

Классическая задача для LATERAL — «первые N в каждой группе». Три самые дорогие книги в каждой подрубрике раздела «Базы данных» (рубрика 13):

SELECT c.category_id, c.name, t.book_id, t.title, t.price
FROM category c
CROSS JOIN LATERAL (
        SELECT b.book_id, b.title, b.price
        FROM book b
        WHERE b.category_id = c.category_id
        ORDER BY b.price DESC, b.book_id
        LIMIT 3) AS t
WHERE c.parent_id = 13
ORDER BY c.category_id, t.price DESC, t.book_id;
 category_id |    name    | book_id |            title             |  price
-------------+------------+---------+------------------------------+---------
          21 | PostgreSQL |    1477 | Тайна чисел 1477             | 2969.99
          21 | PostgreSQL |    1849 | Основы северного ветра 1849  | 2959.99
          21 | PostgreSQL |     616 | Основы вычислений 616        | 2919.99
          22 | NoSQL      |      21 | Искусство забытого города 21 | 2949.99
          22 | NoSQL      |    1651 | Хроники северного ветра 1651 | 2949.99
          22 | NoSQL      |    1533 | Введение в алгоритмов 1533   | 2919.99
(6 rows)

Для каждой строки category подзапрос получает её category_id, сортирует книги этой рубрики и оставляет три. Рисунок поясняет, как строится результат. Сделать то же обычным подзапросом в FROM нельзя — у него нет доступа к текущей рубрике, а ORDER BY … LIMIT 3 без LATERAL дал бы три книги на всю таблицу.

LATERAL: подзапрос для каждой строки левой таблицы
Для каждой рубрики подзапрос LATERAL получает её номер, отбирает книги рубрики, сортирует их по цене и возвращает первые три строки; результаты складываются в общую таблицу

Как и обычное соединение, CROSS JOIN LATERAL отбрасывает строки, для которых подзапрос пуст. По всему рубрикатору он возвращает 57 строк — по три для каждой из 19 рубрик с книгами; четыре рубрики верхнего уровня, в которых нет книг, исчезают. Чтобы их сохранить, используется внешний вариант LEFT JOIN LATERAL (…) AS t ON true — он даёт 61 строку, из них четыре с NULL вместо книги. Та же задача решается оконными функциями (row_number() OVER (PARTITION BY …), тема 5); LATERAL выгоден, когда групп мало, строк в группах много и есть индекс по ключу группы и сортировки — тогда для каждой группы читается лишь несколько строк.

EXISTS и NOT EXISTS: полусоединение и антисоединение

Предикат EXISTS (подзапрос) истинен, если подзапрос возвращает хотя бы одну строку; что именно он возвращает, не важно, поэтому принято писать SELECT 1 6. Коррелированный EXISTS — прямая запись полусоединения R ⋉ S, NOT EXISTS — антисоединения R ▷ S. В отличие от соединения, EXISTS не размножает строки: покупатель, оставивший десять отзывов, войдёт в ответ один раз.

Покупатели без заказов — антисоединение — записываются по меньшей мере четырьмя способами, и на учебной базе все дают 621 строку:

-- 1. NOT EXISTS
SELECT c.customer_id FROM customer c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

-- 2. LEFT JOIN ... IS NULL
SELECT c.customer_id FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.customer_id IS NULL;

-- 3. NOT IN
SELECT c.customer_id FROM customer c
WHERE c.customer_id NOT IN (SELECT o.customer_id FROM orders o);

-- 4. EXCEPT
SELECT customer_id FROM customer
EXCEPT
SELECT customer_id FROM orders;

Формы 1 и 2 PostgreSQL выполняет одинаково — узлом Anti Join (форму 2 — только если на NULL проверяется столбец из условия соединения; при проверке o.order_id IS NULL строится внешнее соединение с последующим фильтром, результат тот же). Самосоединение и внешние соединения в задачах «найти строки без пары» подробно разобраны в учебнике 11. Форма 4 возвращает только столбцы, участвующие в разности; чтобы получить имя и город покупателя, её результат пришлось бы снова соединить с customer. Форма 3 работает здесь правильно лишь потому, что orders.customer_id объявлен NOT NULL; в общем случае она опасна.

NOT IN и NULL

Ловушка NOT IN упоминалась в теме 2; здесь она проявляется в полную силу. Условие x NOT IN (v1, v2, v3) означает x <> v1 AND x <> v2 AND x <> v3. Если хотя бы одно vi — NULL, соответствующее сравнение даёт неизвестность, и вся конъюнкция может быть только ложной или неизвестной, но не истинной. Поэтому NOT IN с подзапросом, вернувшим хотя бы один NULL, не возвращает ни одной строки 63.

Найдём склады в городах, где нет издательств:

SELECT city FROM warehouse
WHERE city NOT IN (SELECT city FROM publisher);
 city
------
(0 rows)
SELECT city FROM warehouse w
WHERE NOT EXISTS (SELECT 1 FROM publisher p WHERE p.city = w.city);
    city
-------------
 Казань
 Новосибирск
(2 rows)

Правильный ответ — Казань и Новосибирск, но NOT IN вернул пустой результат: у издательства «Северный узел» город не указан, и для Казани условие превратилось в «Казань ≠ Москва И … И Казань ≠ NULL», то есть в неизвестность. NOT EXISTS такой ловушки не имеет: внутреннее условие p.city = w.city для строки с NULL просто не истинно, и строка-кандидат не находится. Практическое правило: для антисоединения использовать NOT EXISTS (или LEFT JOIN … IS NULL); NOT IN допустим только с подзапросом по столбцу NOT NULL или со списком констант. Кроме корректности, у NOT EXISTS есть и преимущество в скорости: PostgreSQL превращает его в антисоединение, а NOT IN с подзапросом — нет, потому что их семантика при NULL различается (тема 11).

Симметричная, но менее опасная особенность есть и у NOT EXISTS: строка внешнего запроса с NULL в столбце сравнения попадает в ответ антисоединения, так как пары для неё не находится. Покупатели с неизвестным городом войдут в ответ на вопрос «покупатели из городов без издательств» через NOT EXISTS и не войдут через NOT IN даже со списком без NULL. Какой ответ правилен — решает постановка задачи, и это стоит оговаривать явно.

Деление в SQL: покупатели, купившие все книги серии

В SQL нет оператора деления, и вопрос «для всех» переформулируется через двойное отрицание: «покупатель купил все книги серии» равносильно «нет такой книги серии, которую покупатель не купил» 101. Каждое «нет такого» записывается NOT EXISTS. Книги серии «Миры Стругацких» отмечены в attrs (оператор ->> извлекает значение из JSONB, тема 9):

SELECT c.customer_id, c.full_name
FROM customer c
WHERE NOT EXISTS (
        SELECT 1
        FROM book b
        WHERE b.attrs ->> 'series' = 'Миры Стругацких'
          AND NOT EXISTS (
                SELECT 1
                FROM orders o
                JOIN order_item oi ON oi.order_id = o.order_id
                WHERE o.customer_id = c.customer_id
                  AND oi.book_id = b.book_id))
ORDER BY c.customer_id;

Запрос возвращает 17 покупателей (первые — 1 Анна Фролов, 11 Пётр Жуков, 13 Анна Титов). Читается он изнутри наружу: самый внутренний подзапрос проверяет, купил ли покупатель c книгу b; средний ищет книгу серии, для которой такой покупки нет; внешний оставляет покупателей, для которых такой книги не нашлось.

Ту же мысль можно записать через разность: «множество книг серии минус множество книг покупателя пусто».

SELECT count(*)
FROM customer c
WHERE NOT EXISTS (
        SELECT book_id FROM book WHERE attrs ->> 'series' = 'Миры Стругацких'
        EXCEPT
        SELECT oi.book_id
        FROM orders o JOIN order_item oi ON oi.order_id = o.order_id
        WHERE o.customer_id = c.customer_id);      -- 17

Есть и третья формулировка — через подсчёт: покупатель подходит, если число различных купленных им книг серии равно числу книг серии; она требует группировки и рассматривается в теме 5. Все формулировки наследуют особенность алгебраического деления при пустом делителе: если в серии нет книг (например, серия названа с опечаткой), условие «нет такой книги, которую покупатель не купил» истинно для всех, и запрос возвращает всех 5000 покупателей, включая тех, кто не сделал ни одного заказа. Если такой ответ нежелателен, к условию добавляют проверку, что делитель не пуст, или ограничивают кандидатов покупателями, у которых есть заказы.

Эквивалентные формулировки и чтение запросов

Один вопрос — несколько запросов

SQL позволяет записать один и тот же вопрос многими способами, и умение переходить между формулировками полезно и для понимания, и для оптимизации. Возьмём вопрос «покупатели из Казани, оставившие хотя бы один отзыв с оценкой 2» и сравним пять формулировок:

ФормулировкаСтрок
customer JOIN review … WHERE city = 'Казань' AND rating = 2157
то же с SELECT DISTINCT c.customer_id, c.full_name122
WHERE city = 'Казань' AND customer_id IN (SELECT customer_id FROM review WHERE rating = 2)122
WHERE city = 'Казань' AND EXISTS (SELECT 1 FROM review r WHERE r.customer_id = c.customer_id AND r.rating = 2)122
SELECT customer_id FROM customer WHERE city = 'Казань' INTERSECT SELECT customer_id FROM review WHERE rating = 2122

Соединение отвечает на другой вопрос — «отзывы с оценкой 2, оставленные покупателями из Казани»: покупатель с тремя такими отзывами даёт три строки, отсюда 157 вместо 122. DISTINCT исправляет число строк, но, как было показано выше, только если в списке выборки есть уникальный ключ покупателя: без customer_id два разных покупателя с одинаковым именем слились бы в одну строку. IN и EXISTS — полусоединения, они выражают вопрос точнее всего, и PostgreSQL выполняет их одинаково. INTERSECT даёт верное множество номеров, но других столбцов не вернёт.

Выбор формулировки определяется смыслом вопроса. Если результату нужны столбцы обеих таблиц — соединение. Если вторая таблица служит только условием «есть / нет» — EXISTS или NOT EXISTS. Если вопрос содержит «все» — двойное NOT EXISTS. Если нужны «первые N в каждой группе» — LATERAL или оконные функции. Проверить, что две формулировки эквивалентны на данных, можно приёмом из темы 3: обе разности EXCEPT должны быть пусты. Но совпадение на одних данных не доказывает эквивалентности: формулировки с NOT IN и NOT EXISTS совпадают, пока в данных нет NULL, и расходятся, как только NULL появляется. Доказательство эквивалентности — рассуждение на уровне алгебры и трёхзначной логики, а не тест.

Как читать сложный запрос

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

  1. Начать с FROM: какие таблицы соединяются, по каким условиям, какие соединения внешние; построить мысленно путь по внешним ключам и оценить, не размножаются ли строки.
  2. Прочитать WHERE: какие условия относятся к левой таблице, какие к правой; нет ли условия на правую таблицу внешнего соединения, превращающего его во внутреннее.
  3. Каждый подзапрос прочитать как отдельный вопрос: коррелирован ли он (есть ли ссылки на внешние таблицы), что он возвращает — одно значение, столбец, таблицу; какой операции алгебры соответствует (полусоединение, антисоединение, деление).
  4. Проверить граничные случаи: пустой подзапрос (ALL, деление), NULL в подзапросе (NOT IN, ALL), строки без пары (внутреннее соединение их теряет).
  5. Только после этого прочитать список SELECT, DISTINCT и ORDER BY.

Полезно также переписывать запрос в выражение алгебры: если в нём есть отрицание, оно должно стоять на месте разности или антисоединения, если есть «все» — на месте деления. Расхождение формулировки вопроса и структуры запроса обычно и указывает на ошибку.

Итоги темы

Соединения в SQL — прямое воплощение операций алгебры. CROSS JOIN — декартово произведение, JOIN … ON — тета-соединение, USING — эквисоединение по перечисленным одноимённым столбцам, NATURAL JOIN — естественное соединение, которое из-за неявного условия ломается при изменении схемы или данных и поэтому почти не используется. Соединение нескольких таблиц идёт по цепочке внешних ключей, самосоединение с псевдонимами позволяет сравнивать строки одной таблицы и спускаться по уровням дерева рубрик.

Внешние соединения сохраняют строки без пары, дополняя их NULL. Условие на правую таблицу внешнего соединения должно стоять в ON: в WHERE оно отбрасывает дополненные строки и превращает соединение во внутреннее — на учебной базе 584 строки вместо 72. Внешнее соединение с проверкой IS NULL первичного ключа — одна из формулировок антисоединения.

Соединение с таблицей «многих» размножает строки, а соединение с двумя независимыми таблицами «многих» строит произведение внутри каждой группы: сумма платежей за месяц выросла втрое. Защита — не соединять таблицу, нужную только для условия (использовать EXISTS), сводить таблицы «многих» к одной строке на ключ до соединения и сверять число строк с числом объектов вопроса, а не прятать размножение за DISTINCT.

Подзапросы бывают скалярными, табличными в FROM, коррелированными и LATERAL. EXISTS и NOT EXISTS выражают полусоединение и антисоединение, двойное NOT EXISTS — деление. Граничные случаи требуют внимания: ALL и деление истинны на пустом множестве, NOT IN с NULL в подзапросе не возвращает строк. В теме 5 к соединениям и подзапросам добавятся группировка и агрегатные функции, которые позволят строить отчёты без размножения строк, а оконные функции дадут ещё один способ решения задачи «первые N в группе».

Литература

  1. Silberschatz A., Korth H. F., Sudarshan S. Database System Concepts. 7th ed. — McGraw-Hill, 2019.
  2. Garcia-Molina H., Ullman J. D., Widom J. Database Systems: The Complete Book. 2nd ed. — Pearson Prentice Hall, 2008.
  3. Дейт К. Дж. Введение в системы баз данных. 8-е изд. — М.: Вильямс, 2005.
  4. ISO/IEC 9075-2:2023. Information technology — Database languages SQL — Part 2: Foundation (SQL/Foundation). — ISO, 2023.
  5. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 7.2. Table Expressions. — 2024. https://www.postgresql.org/docs/16/queries-table-expressions.html
  6. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 9.23. Subquery Expressions. — 2024. https://www.postgresql.org/docs/16/functions-subquery.html
  7. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. SELECT. — 2024. https://www.postgresql.org/docs/16/sql-select.html
  8. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 4.2. Value Expressions. — 2024. https://www.postgresql.org/docs/16/sql-expressions.html
  9. Kim W. On Optimizing an SQL-like Nested Query. — ACM Transactions on Database Systems, 1982, Т. 7, № 3, С. 443–469, DOI: 10.1145/319732.319745.
  10. Celko J. Joe Celko's SQL for Smarties: Advanced SQL Programming. 5th ed. — Morgan Kaufmann, 2014.
  11. Новиков Б. А., Горшкова Е. А., Графеева Н. Г. Основы технологий баз данных. 2-е изд. — М.: ДМК Пресс, 2020.
  12. Codd E. F. A Relational Model of Data for Large Shared Data Banks. — Communications of the ACM, 1970, Т. 13, № 6, С. 377–387, DOI: 10.1145/362384.362685.