Урок курса
NOT EXISTS и NOT IN при наличии NULL
Продвинутый SQL: сложные запросы и задачи для собеседованийЛогика антисоединения и шаблон NOT EXISTS
В реляционных базах данных часто требуется отобрать объекты, у которых отсутствуют связанные события заданного типа: клиентов без покупок, пользователей без авторизаций или заказы без возвратов. Такая операция называется антисоединением. В отличие от обычного соединения, антисоединение возвращает строки первой таблицы только тогда, когда во второй таблице не найдено ни одной подходящей записи.
Главное свойство антисоединения — сохранение исходной гранулярности левой таблицы. Каждая строка родительской таблицы либо входит в итоговую выборку ровно один раз, либо не входит вовсе. Если у одного клиента есть десять платежей, антисоединение не размножит строку родительской таблицы, а просто исключит её из результата.
Для проверки работы антисоединения подготовим две таблицы: список клиентов и журнал платежей.
CREATE TABLE clients (
client_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE payments (
payment_id INTEGER PRIMARY KEY,
client_id INTEGER,
amount INTEGER NOT NULL,
status TEXT NOT NULL
);
INSERT INTO clients (client_id, name) VALUES
(1, 'Алиса'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Глеб');
INSERT INTO payments (payment_id, client_id, amount, status) VALUES
(101, 1, 500, 'success'),
(102, 1, 1200, 'success'),
(103, NULL, 300, 'pending'),
(104, 4, 800, 'failed');
В таблице payments у Алисы два успешных платежа, у Глеба — один со статусом 'failed', у платежа 103 клиент не указан (NULL), а у Бориса и Веры платежей нет.
Базовый шаблон антисоединения строится с помощью предиката NOT EXISTS и коррелированного подзапроса:
SELECT c.client_id, c.name
FROM clients c
WHERE NOT EXISTS (
SELECT 1
FROM payments p
WHERE p.client_id = c.client_id
AND p.status = 'success'
)
ORDER BY c.client_id;
Результат выполнения:
| client_id | name |
|---|---|
| 2 | Борис |
| 3 | Вера |
| 4 | Глеб |
Предикат NOT EXISTS истинен тогда и только тогда, когда коррелированный подзапрос возвращает ровно 0 строк. Проекция подзапроса (SELECT 1 или SELECT *) никак не влияет на проверку: движок базы данных проверяет факт наличия строк, а не их значения. Для Алисы найдено две строки со статусом 'success', поэтому подзапрос вернул непустое множество, и NOT EXISTS дал FALSE. Для Бориса, Веры и Глеба подзапрос вернул 0 строк, предикат вернул TRUE, и они вошли в результат с сохранением своего первичного ключа.
Трёхзначная логика NOT IN и потеря строк при NULL
Для поиска отсутствующих элементов часто пытаются применить предикат NOT IN. Однако при наличии значений NULL во вложенной выборке этот предикат приводит к полной потере данных во внешнем запросе.
Рассмотрим следующий запрос, целью которого является поиск клиентов без единого платежа:
SELECT c.client_id, c.name
FROM clients c
WHERE c.client_id NOT IN (
SELECT p.client_id
FROM payments p
)
ORDER BY c.client_id;
Этот запрос возвращает 0 строк. Ни Борис, ни Вера в результат не попадают, хотя их идентификаторов нет в таблице платежей.
Причина кроется в правилах трёхзначной логики SQL. Подзапрос SELECT p.client_id FROM payments возвращает набор значений: {1, 1, NULL, 4}. Конструкция вида val NOT IN (list) разворачивается СУБД в цепочку условий с оператором <> через логическое AND:
c.client_id <> 1 AND c.client_id <> 1 AND c.client_id <> NULL AND c.client_id <> 4
Когда проверяется Борис (client_id = 2), СУБД вычисляет каждое сравнение:
2 <> 1возвращаетTRUE2 <> NULLвозвращаетUNKNOWN2 <> 4возвращаетTRUE
Итоговое выражение принимает вид TRUE AND TRUE AND UNKNOWN AND TRUE. Результатом конъюнкции, содержащей UNKNOWN и ни одного FALSE, является UNKNOWN. Фильтр WHERE пропускает только те строки, для которых условие строго равно TRUE. Значение UNKNOWN отбрасывается. В результате одиночный NULL в дочерней таблице делает истинный ответ для NOT IN математически невозможным для любых внешних строк.
Сравнение NOT EXISTS и NOT IN на пустых и частичных выборках
Чтобы безопасно выбирать между NOT EXISTS и NOT IN, необходимо различать две пограничные ситуации: когда подзапрос возвращает ноль строк и когда подзапрос возвращает одну строку со значением NULL.
Если подзапрос возвращает 0 строк (пустое множество), оба предиката ведут себя одинаково. Проверим случай, когда мы ищем клиентов без платежей со статусом 'refunded'. Таких платежей в базе нет вовсе:
SELECT c.client_id, c.name
FROM clients c
WHERE c.client_id NOT IN (
SELECT p.client_id
FROM payments p
WHERE p.status = 'refunded'
)
ORDER BY c.client_id;
Результат выполнения:
| client_id | name |
|---|---|
| 1 | Алиса |
| 2 | Борис |
| 3 | Вера |
| 4 | Глеб |
В этом случае подзапрос вернул 0 строк. В пустом наборе нет ни одного элемента, равного client_id, поэтому предикат IN возвращает FALSE, а NOT IN возвращает TRUE для каждого клиента. Аналогично EXISTS на пустом множестве возвращает FALSE, а NOT EXISTS — TRUE.
Разница возникает, как только в подзапросе появляется хотя бы одна строка. Если эта строка содержит NULL, то:
- Подзапрос для
NOT INвозвращает непустое множество{NULL}. Предикат превращается вval <> NULL, даётUNKNOWNи отсекает всю выборку. - В подзапросе для
NOT EXISTSусловие связиp.client_id = c.client_idприp.client_id IS NULLдаётNULL = c.client_id, что вычисляется какUNKNOWN. Строка сNULLне проходит фильтрWHEREподзапроса. Подзапрос остаётся пустым (0 строк), иNOT EXISTSвозвращаетTRUE.
Пустой результат подзапроса безопасен для обоих предикатов, но появление строки со значением NULL нарушает работу только предиката NOT IN.
Практический разбор: поиск клиентов без платежей с нераспознанными записями
Сверим поведение запросов на подготовленной базе clients и payments. Задача — вывести клиентов, у которых нет вообще никаких платежей, независимо от их статуса.
В таблице payments строка 103 имеет client_id IS NULL — это нераспознанный платёж без привязки к клиенту. Попытка решить задачу через NOT IN без дополнительной обработки даёт пустую таблицу, как было показано ранее. Чтобы NOT IN отработал корректно, разработчик обязан вручную исключить неопределённые значения во вложенном запросе:
SELECT c.client_id, c.name
FROM clients c
WHERE c.client_id NOT IN (
SELECT p.client_id
FROM payments p
WHERE p.client_id IS NOT NULL
)
ORDER BY c.client_id;
Результат выполнения:
| client_id | name |
|---|---|
| 2 | Борис |
| 3 | Вера |
Подзапрос вернул значения {1, 4}. Для Бориса (id = 2) и Веры (id = 3) условие c.client_id NOT IN (1, 4) вычисляется в TRUE, так как неопределённых элементов больше нет.
Теперь выполним ту же задачу с помощью NOT EXISTS без ручной фильтрации IS NOT NULL:
SELECT c.client_id, c.name
FROM clients c
WHERE NOT EXISTS (
SELECT 1
FROM payments p
WHERE p.client_id = c.client_id
)
ORDER BY c.client_id;
Результат выполнения:
| client_id | name |
|---|---|
| 2 | Борис |
| 3 | Вера |
Оба корректных варианта вернули одинаковые строки: Бориса и Веру. Однако NOT EXISTS устойчив к наличию нераспознанных записей по своей структуре: строка платежа с client_id = NULL не совпадает ни с одним c.client_id при вычислении условия связи и не ломает логику для остальных записей. Кроме того, наличие двух платежей у Алисы не потребовало добавления DISTINCT, так как NOT EXISTS завершает проверку строки, как только обнаружено первое совпадение.
Задача 1
Таблица clients содержит строки: (1, 'Иван'), (2, 'Ольга'). Таблица orders содержит строки: (100, 1), (101, NULL). Аналитик выполняет запрос:
SELECT c.client_id
FROM clients c
WHERE c.client_id NOT IN (SELECT o.client_id FROM orders o);
Каков будет результат выполнения этого запроса?
Задача 2
Даны те же таблицы clients: (1, 'Иван'), (2, 'Ольга') и orders: (100, 1), (101, NULL). Запрос переписан на NOT EXISTS:
SELECT c.client_id
FROM clients c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.client_id = c.client_id
);
Какие строки будут возвращены?
Задача 3
Сопоставьте предикат и состояние подзапроса (левый столбец) с итоговым результатом вычисления условия для внешнего значения x = 5 (правый столбец).
Варианты в левом столбце:
- 5 NOT IN (SELECT val FROM t), где t содержит строки (1), (NULL)
- 5 NOT IN (SELECT val FROM t), где t совершенно пуста (0 строк)
- NOT EXISTS (SELECT 1 FROM t WHERE t.val = 5), где t содержит строки (1), (NULL)
- NOT EXISTS (SELECT 1 FROM t WHERE t.val = 5), где t содержит строки (5), (5)
Выберите полное соответствие пунктов 1, 2, 3, 4.
Задача 4
Пользователи без входов
Что уже дано
Уже заполнены users(user_id INTEGER PRIMARY KEY,email TEXT NOT NULL) и logs(log_id INTEGER PRIMARY KEY,user_id INTEGER,event_type TEXT NOT NULL). Первичные ключи положительны и уникальны. Дочерний user_id может быть NULL или ссылаться на отсутствующий объект. email может повторяться, но не содержит | и перевода строки. event_type принимает 'login', 'view', 'purchase'. В каждой таблице от 0 до 1000 строк. Разные идентификаторы дочерних строк означают разные факты.
Что нужно сделать
Найдите пользователей, у которых нет ни одного события login. Другие события не исключают пользователя. Анонимные события с NULL user_id не относятся ни к одному пользователю. Используйте NOT EXISTS.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 2 столбцов в указанном порядке и с этими именами: user_id, email. user_id — INTEGER без NULL; email — исходный TEXT без NULL. Сортировка: user_id ASC. Каждый подходящий объект выводится один раз; при отсутствии подходящих объектов результат пустой. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 5
Товары без завершённых заказов и история других статусов
Что уже дано
Уже заполнены products(product_id INTEGER PRIMARY KEY,title TEXT NOT NULL) и order_items(item_id INTEGER PRIMARY KEY,product_id INTEGER,status TEXT NOT NULL). Первичные ключи положительны и уникальны. Дочерний product_id может быть NULL или ссылаться на отсутствующий объект. title может повторяться, но не содержит | и перевода строки. status принимает 'completed', 'pending', 'cancelled'. В каждой таблице от 0 до 1000 строк. Разные идентификаторы дочерних строк означают разные факты.
Что нужно сделать
Найдите товары, у которых нет ни одной позиции заказа со статусом completed. Дополнительно покажите other_items_count — число всех позиций этого товара: после исключения completed это число позиций прочих статусов. Товар вообще без позиций сохраняется со счётчиком 0. Так отчёт различает отсутствие заказов и историю только других статусов.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 3 столбцов в указанном порядке и с этими именами: product_id, title, other_items_count. product_id и other_items_count — INTEGER без NULL; title — исходный TEXT без NULL. Сортировка: product_id ASC. Каждый подходящий объект выводится один раз; при отсутствии подходящих объектов результат пустой. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
