Урок курса
Фильтрация в ON и WHERE при LEFT JOIN
Продвинутый SQL: сложные запросы и задачи для собеседованийТри случая совпадения и отсутствия данных при LEFT JOIN
Для изучения фильтрации при соединении подготовим базу с клиентами и заказами в SQLite:
CREATE TABLE clients (
client_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL,
status TEXT NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id)
);
INSERT INTO clients (client_id, name) VALUES
(1, 'Алиса'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Григорий');
INSERT INTO orders (order_id, client_id, status, amount) VALUES
(10, 1, 'paid', 500),
(11, 1, 'paid', 300),
(12, 1, 'new', 150),
(13, 2, 'new', 200),
(14, 4, 'paid', 100);
Выполним базовый LEFT JOIN без дополнительных условий по статусу:
SELECT c.client_id, c.name, o.order_id, o.status, o.amount
FROM clients c
LEFT JOIN orders o ON c.client_id = o.client_id
ORDER BY c.client_id, o.order_id;
Результат:
| client_id | name | order_id | status | amount |
|---|---|---|---|---|
| 1 | Алиса | 10 | paid | 500 |
| 1 | Алиса | 11 | paid | 300 |
| 1 | Алиса | 12 | new | 150 |
| 2 | Борис | 13 | new | 200 |
| 3 | Вера | NULL | NULL | NULL |
| 4 | Григорий | 14 | paid | 100 |
В этой выборке видны три разных исхода соединения:
- Полное отсутствие связанных строк: у Веры нет ни одной записи в таблице
orders.LEFT JOINсохраняет строку левой таблицы и заполняет все столбцы правой таблицы значениямиNULL. - Полное совпадение: у Бориса есть заказ 13, у Григория — заказ 14. Каждая строка находит соответствие по
client_id. - Размножение строк: у Алисы три заказа, поэтому в выводе появляются три строки с ее данными.
Когда бизнес-задача требует учесть только оплаченные закалы (status = 'paid'), но при этом вывести каждого клиента компании, возникает вопрос: в какой части запроса проверять статус?
Фильтрация в предложении ON
Чтобы ограничить строки правой таблицы, но сохранить всех клиентов из левой, дополнительное условие помещают внутрь предложения ON.
Синтаксис такого соединения:
SELECT c.client_id, c.name, o.order_id, o.status, o.amount
FROM clients c
LEFT JOIN orders o ON c.client_id = o.client_id AND o.status = 'paid'
ORDER BY c.client_id, o.order_id;
Результат запроса:
| client_id | name | order_id | status | amount |
|---|---|---|---|---|
| 1 | Алиса | 10 | paid | 500 |
| 1 | Алиса | 11 | paid | 300 |
| 2 | Борис | NULL | NULL | NULL |
| 3 | Вера | NULL | NULL | NULL |
| 4 | Григорий | 14 | paid | 100 |
Предикат в секции ON вычисляется в момент поиска пар для соединения. Механизм работает следующим образом:
- Для Веры заказов нет вообще. Условие
c.client_id = o.client_idне выполняется ни для одной строкиorders. Так как этоLEFT JOIN, строка Веры сохраняется сNULLво всех полях заказа. - Для Бориса заказ с
order_id = 13существует, но имеет статус'new'. Предикатo.status = 'paid'возвращаетFALSE. Составное условиеONне выполнено ни для одной строкиorders. База данных считает, что подходящей правой строки нет, и формирует строку сNULLв полях заказа — точно так же, как для Веры. - Для Алисы заказ 12 со статусом
'new'отсекается, а заказы 10 и 11 со статусом'paid'удовлетворяют условиюON. Алиса получает две строки в результате, а строка сorder_id = 12отбрасывается без генерацииNULL, поскольку совпадения найдены.
Предикат в ON определяет только то, какие именно строки правой таблицы подходят для присоединения. Он не исключает строки левой таблицы.
Ловушка фильтрации правой таблицы в WHERE
Частая ошибка — перенос фильтра по правой таблице в блок WHERE:
SELECT c.client_id, c.name, o.order_id, o.status, o.amount
FROM clients c
LEFT JOIN orders o ON c.client_id = o.client_id
WHERE o.status = 'paid'
ORDER BY c.client_id, o.order_id;
Результат:
| client_id | name | order_id | status | amount |
|---|---|---|---|---|
| 1 | Алиса | 10 | paid | 500 |
| 1 | Алиса | 11 | paid | 300 |
| 4 | Григорий | 14 | paid | 100 |
Клиенты Борис и Вера полностью исчезли из вывода. Запрос фактически сработал как INNER JOIN.
Причина кроется в логическом порядке выполнения операций. Блок WHERE применяется после того, как FROM и LEFT JOIN построили объединенную таблицу.
- До блока
WHEREстрока Веры содержитNULLв столбцеo.status. ВыражениеNULL = 'paid'даетUNKNOWN. СекцияWHEREоставляет только строки, для которых предикат равенTRUE. Строка отбрасывается. - До блока
WHEREстрока Бориса содержит'new'в столбцеo.status. Выражение'new' = 'paid'даетFALSE. Строка отбрасывается.
Любое равенство или неравенство вида o.column = value отсекает строки, где o.column равен NULL. Исключением служат предикаты, явно рассчитанные на работу с отсутствующими значениями, такие как WHERE o.order_id IS NULL (anti-join для поиска клиентов без заказов). Для отбора только тех заказов, которые должны войти в соединение без потери клиентов, условие обязательно должно находиться в ON.
Подсчёт совпадений: COUNT(ключа) против COUNT(*)
Когда к результату LEFT JOIN применяется группировка, возникает различие между COUNT(*) и подсчетом по первичному ключу правой таблицы.
Выполним агрегацию по клиентам:
SELECT
c.client_id,
c.name,
COUNT(*) AS total_rows,
COUNT(o.order_id) AS paid_count,
COALESCE(SUM(o.amount), 0) AS total_paid
FROM clients c
LEFT JOIN orders o ON c.client_id = o.client_id AND o.status = 'paid'
GROUP BY c.client_id, c.name
ORDER BY c.client_id;
Результат:
| client_id | name | total_rows | paid_count | total_paid |
|---|---|---|---|---|
| 1 | Алиса | 2 | 2 | 800 |
| 2 | Борис | 1 | 0 | 0 |
| 3 | Вера | 1 | 0 | 0 |
| 4 | Григорий | 1 | 1 | 100 |
Значения агрегатов объясняются правилами работы со значениями NULL:
COUNT(*)считает физические строки, попавшие в группу. У Бориса и Веры послеLEFT JOINсформировалось по одной строке с пустыми полями заказа. Для каждого из нихCOUNT(*)возвращает1. ИспользоватьCOUNT(*)для подсчета событий или заказов при внешнем соединении нельзя: он показывает наличие строки в таблице, а не наличие факта покупки.COUNT(o.order_id)проверяет конкретный столбец и игнорирует значенияNULL. Так как у Бориса и Веры в столбцеorder_idнаходитсяNULL, функция возвращает0.SUM(o.amount)при отсутствии строк со значениями (толькоNULL) возвращаетNULL. ОберткаCOALESCE(SUM(o.amount), 0)заменяетNULLна числовой0для клиентов без подходящих заказов.
Проверка COUNT(ключ_правой_таблицы) = 0 подтверждает отсутствие совпадений по условию ON, в то время как COUNT(*) для каждого сохраненного клиента всегда будет равен минимум единице.
Задача 1
Таблица clients: (1,'Алиса'),(2,'Борис'),(3,'Вера'). Таблица orders: (10,1,'paid'),(11,1,'new'),(12,2,'new'). Выполняется запрос:
SELECT c.client_id, o.order_id
FROM clients c
LEFT JOIN orders o
ON c.client_id = o.client_id
WHERE o.status = 'paid';
Сколько строк вернёт запрос и почему клиент 3 отсутствует в результате?
Задача 2
Сопоставьте каждую ситуацию (левый столбец) с тем, что окажется в строке результата после LEFT JOIN clients c LEFT JOIN orders o ON c.client_id = o.client_id AND o.status = 'paid' (правый столбец).
Ситуации:
A) У клиента нет ни одного заказа в таблице orders
B) У клиента есть заказы, но все имеют status = 'new', ни один не 'paid'
C) У клиента есть один заказ с status = 'paid' и один с status = 'new'
Варианты результата:
- Одна строка: client_id заполнен, o.order_id = NULL
- Одна строка: оба столбца заполнены (только 'paid'-заказ)
- Две строки: одна с 'paid'-заказом, одна с NULL для 'new'-заказа
Выберите полное соответствие A,B,C. Один номер результата может использоваться несколько раз.
Задача 3
Заполните пропуски в запросе, который возвращает всех клиентов с числом их заказов в статусе 'shipped'.
clients(client_id INTEGER PRIMARY KEY, name TEXT)
orders(order_id INTEGER PRIMARY KEY, client_id INTEGER, status TEXT)
SELECT c.client_id, c.name,
COUNT(o.order_id) AS shipped_count
FROM clients c
LEFT JOIN orders o
ON c.client_id = o.client_id
AND _______________
GROUP BY c.client_id, c.name;
Выберите единственно верное выражение для пропуска, чтобы клиенты без 'shipped'-заказов возвращались с shipped_count = 0.
Введите только выражение для пропуска, без AND и без точки с запятой.
Задача 4
Аналитик получил задание: «Для каждого клиента показать число заказов в статусе 'cancelled'. Клиенты без отменённых заказов тоже должны быть в результате с 0». Он написал:
SELECT c.client_id, COUNT(*) AS cancelled_count
FROM clients c
LEFT JOIN orders o ON c.client_id = o.client_id
WHERE o.status = 'cancelled'
GROUP BY c.client_id;
Выберите полный диагноз и исправление: (1) что именно этот запрос вернёт неверно для клиентов без отменённых заказов; (2) где находится ошибка и почему она возникает; (3) как исправить запрос.
Задача 5
Оплаченные заказы всех клиентов
Что уже дано
SQLite-таблицы уже созданы и заполнены: clients(client_id INTEGER PRIMARY KEY,name TEXT) и orders(order_id INTEGER PRIMARY KEY,client_id INTEGER,status TEXT,amount INTEGER NOT NULL). Первичные ключи — положительные уникальные INTEGER. name может быть NULL или повторяться; в тексте имён нет | или перевода строки. client_id в orders может быть NULL или ссылаться на отсутствующего клиента. status — 'paid','new','shipped','cancelled' либо NULL. amount — целые копейки от 0 до 2 000 000 000; NULL в amount нет. Не более1000 строк в каждой таблице, суммы помещаются в 64-битный INTEGER. Разные заказы с равной суммой считаются отдельно.
Что нужно сделать
Для каждого клиента из clients посчитайте число заказовstatus='paid' и сумму amount этих заказов. Клиенты без оплаченных заказов тоже входят в отчёт: оба показателя 0. Имя сохраните без замены.
Ввод и вывод
Отправьте один SELECT или WITH-запрос. stdin и печать не используются. Ровно4 столбца: client_id (INTEGER), name (TEXT либо NULL), paid_count (INTEGER), total_paid (INTEGER), в указанном порядке и с этими именами. Одна строка на client_id, сортировка client_id ASC. Пустой справочник даёт пустой результат. В тестах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 6
Сравнение исходного и исправленного отчётов
Что уже дано
SQLite-таблицы уже созданы и заполнены: clients(client_id INTEGER PRIMARY KEY,name TEXT) и orders(order_id INTEGER PRIMARY KEY,client_id INTEGER,status TEXT,amount INTEGER NOT NULL). Первичные ключи — положительные уникальные INTEGER. name может быть NULL или повторяться; в тексте имён нет | или перевода строки. client_id в orders может быть NULL или ссылаться на отсутствующего клиента. status — 'paid','new','shipped','cancelled' либо NULL. amount — целые копейки от 0 до 2 000 000 000; NULL в amount нет. Не более1000 строк в каждой таблице, суммы помещаются в 64-битный INTEGER. Разные заказы с равной суммой считаются отдельно.
Исходный ошибочный отчёт аналитика:
SELECT c.client_id,c.name,COUNT(*) AS cnt,SUM(o.amount) AS total FROM clients c LEFT JOIN orders o ON c.client_id=o.client_id WHERE o.status='paid' GROUP BY c.client_id,c.name;
Что нужно сделать
Постройте таблицу сравнения исходного и исправленного отчётов для всех клиентов. Исправленный отчёт должен содержать client_id,name,число оплаченных заказовpaid_count и их суммуtotal_paid,оба показателя 0при отсутствииpaid-заказов. Рядом покажите cnt и total из приведённого исходного запроса под именамиbroken_count,broken_total. Если исходный запрос вообще не возвращает строку клиента, обаbroken-поля должны быть NULL, а не 0. Это различает потерю клиента и существующий заказ нулевой стоимости.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно6 столбцов в порядке client_id,name,paid_count,total_paid,broken_count,broken_total, с этими именами. client_id и показатели — INTEGER; name — исходный TEXT либо NULL; только broken-показатели могут быть NULL. Одна строка на каждого клиентаclients,сортировка client_id ASC;пустойclientsдаётпустойрезультат. В тестах разделитель |, NULL — пустое поле. Допустим любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
