Проверьте запрос: аналитик хочет получить всех клиентов, включая тех, у кого нет заказов. Какое множество клиентов фактически вернёт запрос и какой механизм это определяет?
WITH customers(id) AS (
VALUES (1), (2), (3)
), orders(customer_id, amount) AS (
VALUES (1, 100), (1, 50), (2, 80)
)
SELECT c.id, SUM(o.amount) AS revenue
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.amount > 60
GROUP BY c.id;
Запрос фактически исключит клиентов без заказов и вернёт только клиентов, у которых есть заказ с amount > 60. Причина в том, что после LEFT JOIN для клиента без заказа поля o.amount равны NULL, а условие WHERE o.amount > 60 не выполняется для NULL. Поэтому фильтр превращает сохранение строк внешним соединением в поведение, эквивалентное INNER JOIN для данного условия.
В реляционном анализе обычное соединение сохраняет только строки, для которых найдена пара в другой таблице. Внешние соединения появились как способ не терять объекты основной таблицы без связанных записей: клиентов без заказов, товары без продаж или сотрудников без назначенных задач.
Однако сохранение таких строк зависит от того, где применяются условия. Условия связи и фильтрации имеют разную семантику, поэтому их расположение в ON и WHERE может изменить результат.
В примере цель — получить всех клиентов и посчитать выручку только по заказам дороже 60. Но условие в WHERE применяется после формирования результата LEFT JOIN. Для клиента с отсутствующими заказами результат соединения содержит NULL в колонках заказа, и такой клиент удаляется.
Это приводит к систематической ошибке в отчётах: клиенты без активности начинают выглядеть так, будто их не существует. Если затем рассчитывать долю активных клиентов, среднюю выручку или размер сегмента, знаменатель может стать неверным.
Если условие относится к присоединяемым заказам, его следует поместить в ON. Тогда сначала будут отобраны только подходящие заказы, но сам LEFT JOIN всё равно сохранит каждого клиента:
Здесь результат содержит клиентов 1, 2 и 3: для клиента 1 учитывается заказ на 100, для клиента 2 — заказ на 80, а для клиента 3 сумма преобразуется из NULL в 0 с помощью COALESCE.
Важен трёхзначный SQL-логический результат: сравнение NULL > 60 даёт не TRUE, а UNKNOWN. WHERE сохраняет только строки, для которых условие равно TRUE, поэтому UNKNOWN удаляется.
Переносить условие в ON нужно не всегда. Если бизнес-смысл действительно требует оставить только клиентов с подходящими заказами, WHERE корректен, а явный INNER JOIN часто лучше выражает намерение. Также нельзя бездумно заменять NULL на ноль: отсутствие заказа и заказ с нулевой суммой могут иметь разный смысл.
Команда строила ежемесячный отчёт по всем зарегистрированным клиентам, включая новых клиентов без покупок. В исходном запросе использовался LEFT JOIN, но фильтр по статусу заказа находился в WHERE, поэтому в отчёте оставались только клиенты с подходящей покупкой.
Рассматривались два варианта. Замена на INNER JOIN была простой, но явно не решала задачу сохранения неактивных клиентов. Удаление фильтра полностью сохраняло клиентов, но включало в сумму заказы, которые не должны были учитываться.
Выбрали перенос фильтра в ON и явное преобразование итоговой суммы NULL в 0. В результате отчёт сохранил полный список клиентов, а метрики покупок считались только по нужным заказам. Дополнительно проверили количество строк до и после соединения и отдельно сравнили число клиентов с нулевой выручкой.
Почему условие o.amount IS NULL в WHERE ведёт себя иначе, чем o.amount > 60?
IS NULL специально проверяет наличие NULL и возвращает TRUE для строки без заказа. Поэтому такое условие может сохранить клиентов без заказов. Сравнение через обычный оператор (=, >, <) с NULL даёт UNKNOWN, а не TRUE, и строка не проходит фильтр.
Всегда ли перенос условия из WHERE в ON сохраняет одинаковый результат?
Нет. Для INNER JOIN результат обычно эквивалентен, поскольку строки без совпадения всё равно удаляются. Для LEFT JOIN перенос меняет семантику: условие ограничивает присоединяемые строки, но не строки левой таблицы. Поэтому перенос допустим только после уточнения, нужно ли сохранять объекты без совпадений.
Как проверить, что отчёт не потерял строки из-за фильтра по правой таблице?
Нужно сравнить число уникальных ключей левой таблицы до соединения и после него, а также отдельно посчитать строки, где ключ правой таблицы равен NULL. Полезно выполнить два варианта запроса — с условием в WHERE и в ON — и объяснить разницу на нескольких пограничных случаях: отсутствие заказа, заказ ниже порога и несколько подходящих заказов. Одного сравнения общей суммы недостаточно, потому что агрегаты могут скрыть потерю объектов.