Урок курса

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 возвращает TRUE
  • 2 <> NULL возвращает UNKNOWN
  • 2 <> 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 (правый столбец).

Варианты в левом столбце:

  1. 5 NOT IN (SELECT val FROM t), где t содержит строки (1), (NULL)
  2. 5 NOT IN (SELECT val FROM t), где t совершенно пуста (0 строк)
  3. NOT EXISTS (SELECT 1 FROM t WHERE t.val = 5), где t содержит строки (1), (NULL)
  4. 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 — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 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 — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку