JOIN: inner/left/right/full
Маркетинг придумал акцию «мы скучаем»: разослать промокод всем, кто завёл аккаунт в Brew, но пока ни разу не заказал. От тебя — список клиентов с числом заказов. Пишешь очевидное: соединяешь customers с orders по клиенту, считаешь строки, отдаёшь. Список уходит маркетингу, тикет закрыт.
Назавтра прилетает вопрос: «А где Карина? Она зарегистрировалась на прошлой неделе, в админке есть, а в твоём списке её нет». Открываешь — и правда нет. Запрос не упал и не выдал ошибку. Он молча выкинул ровно тех, ради кого акцию и затевали, — клиентов без единого заказа. В цифрах отчёт не наврал. Он наврал умолчанием: строки, которой не должно было не быть, в нём просто не оказалось.
Дело не в запросе целиком, а в одном слове — в том, какой JOIN ты выбрал. Связать две таблицы можно четырьмя способами, и различаются они ровно тем, кого сохраняют, а кого выбрасывают. Карина выпала, потому что взяли вид, который оставляет только совпавшие пары. Дальше — все четыре по очереди, на одной и той же паре customers и orders, с заменой по одному слову.
INNER JOIN — только совпадения
INNER JOIN (можно писать просто JOIN) оставляет строки, у которых пара нашлась с обеих сторон сразу. Клиент без заказа и заказ без клиента не проходят. Это правильный выбор, когда нужны именно совпавшие пары — «заказы вместе с данными клиента, который их сделал». Но для отчёта «все клиенты» он коварен: тех, у кого заказов ещё нет, он молча убирает.
Одна деталь про условие соединения. customers.id — это BIGINT, а orders.customer_id — TEXT (так в схеме Brew: в реальном CDC-потоке заказы и справочник клиентов едут независимо, и заказ держит идентификатор клиента строкой). Поэтому ключи сводим явным приведением c.id::text = o.customer_id. Для разговора про JOIN тип ключа не принципиален — важно лишь, что условие связывает клиента с его заказом.
В данных Brew у Алисы два заказа, у Бориса один: они совпали и попали в результат. У Карины заказов нет, совпадать не с чем — INNER её выбрасывает. Вот почему список маркетинга «потерял» именно её: INNER отвечает на вопрос «покажи пары, которые есть», а не «покажи всех клиентов».
LEFT JOIN — все слева, справа если есть
Отчёт должен был отвечать на другой вопрос: «все клиенты, а заказы — если есть». Это LEFT JOIN. Он сохраняет все строки левой таблицы, а справа подставляет либо пару, либо NULL, если пары нет.
customers LEFT JOIN orders читается как «все клиенты, и их заказы, у кого они есть». Карина возвращается в результат — с order_id = NULL и status = NULL. Это самый частый JOIN в приложениях: «покажи сущность и связанные данные, не теряя сущностей без связей».
Из этого свойства следует удобный приём. LEFT JOIN плюс проверка IS NULL по правой стороне — это «найди строки вообще без пары» (его называют анти-join). Запрос ... LEFT JOIN orders o ON ... WHERE o.id IS NULL вернёт только клиентов без единого заказа — тот самый список для акции, с которого начался урок, и собирается он одним запросом.
RIGHT JOIN — то же зеркально
RIGHT JOIN — это LEFT наоборот: он сохраняет все строки правой таблицы. Поставь заказы слева, клиентов справа — orders RIGHT JOIN customers — и получишь ровно то же, что customers LEFT JOIN orders: всех клиентов, заказы если есть, Карину с NULL.
Поэтому RIGHT в коде почти не пишут. Любой RIGHT превращается в LEFT перестановкой таблиц, а LEFT читается слева направо естественнее: «беру всех клиентов, доклеиваю заказы». Знать RIGHT нужно, чтобы разбирать чужой SQL; в своём почти всегда выбирают LEFT.
FULL JOIN — несовпадения с обеих сторон
FULL JOIN сохраняет несовпавшие строки сразу с обеих сторон: и левые без пары, и правые без пары.
Скажем честно: в данных Brew его не показать. Каждый заказ привязан к существующему клиенту, «осиротевших» заказов справа нет — и FULL выродился бы в обычный LEFT. В прикладном коде он тоже редок: внутри одной нормализованной схемы данные связаны направленно, и почти всегда хватает LEFT. Можно проработать год и не написать ни одного FULL JOIN.
Но один сценарий его оправдывает: когда сводишь два независимых источника, и у каждого могут быть «свои» строки, которых нет у другого. Возьмём сверку остатков в конце дня. Зал пересчитал напитки и сдал лист {1, 2}, склад — лист {2, 4}. Напиток 2 есть в обоих, напиток 1 — только у зала, напиток 4 — только у склада. FULL JOIN по drink_id сводит оба листа в одну таблицу: что попало в оба, что только в зал (склад = NULL), что только на склад (зал = NULL). Это и есть его работа — свести два источника и подсветить, где они разошлись.
Какой JOIN что сохраняет
Все четыре — одна и та же пара множеств под разными правилами. Левая таблица, правая, и зона их пересечения; JOIN решает, какие из трёх зон попадут в результат:
только слева пересечение только справа
┌─────────────────┐ ┌──────────────┐ ┌───────────────────┐
│ Карина │ │ Алиса, Борис │ │ заказ без │
│ (клиент без │ │ (совпали: │ │ клиента │
│ заказа) │ │ есть пара) │ │ (в данных — нет) │
└─────────────────┘ └──────────────┘ └───────────────────┘INNER берёт только среднюю зону. LEFT — среднюю плюс левую (вот и Карина). RIGHT — среднюю плюс правую. FULL — все три сразу. Любая взятая, но несовпавшая сторона приходит в результат как NULL:
| JOIN | сохраняет | где NULL | берём, когда |
|---|---|---|---|
INNER | только пересечение | нигде (пары гарантированы) | нужны именно совпавшие пары |
LEFT | левую зону + пересечение | в правых колонках без пары | «все сущности, связанное если есть» — самый частый |
RIGHT | правую зону + пересечение | в левых колонках без пары | почти никогда: пишут LEFT, поменяв таблицы |
FULL | все три зоны | с любой стороны без пары | сверка двух независимых источников |
Что показывает наш код
В query.sql четыре запроса. Три первых — про customers и orders — отличаются ровно одним словом. Вот INNER:
-- name: InnerCustomersOrders :many
SELECT c.name AS customer, o.id AS order_id, o.status
FROM customers c
JOIN orders o ON o.customer_id = c.id::text
ORDER BY c.id, o.id;Меняем JOIN на LEFT JOIN — и больше ничего:
-- name: LeftCustomersOrders :many
SELECT c.name AS customer, o.id AS order_id, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id::text
ORDER BY c.id, o.id;Те же колонки, та же пара таблиц, тот же ON — разница в одном слове, а результат другой: Карина возвращается. RIGHT-вариант по смыслу ещё короче — это FROM orders o RIGHT JOIN customers c ..., те же таблицы местами.
Тонкость, которую видно только в Go: sqlc замечает, что после LEFT JOIN колонки orders могут стать NULL, и типизирует их как nullable — pgtype.Int8 и pgtype.Text. У INNER-варианта те же колонки приходят простыми int64 и string: там пара гарантирована. Одно слово в SQL меняет даже типы в сгенерированном коде.
Четвёртый запрос — FULL JOIN двух листов пересчёта; имя напитка берём из drinks по COALESCE(f.drink_id, s.drink_id) (ключ есть хотя бы с одной стороны):
-- name: ReconcileFull :many
SELECT d.name AS drink, f.qty AS floor_qty, s.qty AS storage_qty
FROM count_floor f
FULL JOIN count_storage s ON s.drink_id = f.drink_id
JOIN drinks d ON d.id = COALESCE(f.drink_id, s.drink_id)
ORDER BY d.id;cmd/demo/main.go — тонкая обвязка: зовёт типизированные методы из internal/db/ и раскладывает строки по колонкам. Вся логика — в query.sql.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=04-querying-across-tables/04-01-joins-inner-left-right-full T=db-reset
make lecture L=04-querying-across-tables/04-01-joins-inner-left-right-full(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) INNER JOIN customers↔orders — только совпавшие пары (строк: 3):
Алиса Иванова заказ #1 (paid)
Алиса Иванова заказ #3 (shipped)
Борис Петров заказ #2 (created)
→ Карины тут нет: у неё нет заказов, совпадать не с чем.
2) LEFT JOIN customers←orders — все клиенты, заказ если есть (строк: 4):
Алиса Иванова заказ #1 статус paid
Алиса Иванова заказ #3 статус shipped
Борис Петров заказ #2 статус created
Карина Сидорова заказ — статус NULL
→ Карина осталась: заказа нет → order_id и status = NULL.
3) RIGHT JOIN orders→customers — тот же результат, что LEFT (строк: 4):
Алиса Иванова заказ #1 статус paid
Алиса Иванова заказ #3 статус shipped
Борис Петров заказ #2 статус created
Карина Сидорова заказ — статус NULL
→ RIGHT = LEFT с переставленными таблицами; в коде почти всегда пишут LEFT.
4) FULL JOIN — сверка листов пересчёта (зал {1,2} vs склад {2,4}):
напиток зал склад
Эспрессо 10 —
Капучино 5 3
Колд брю — 8
→ строки есть с обеих сторон: только в зале, только на складе, в обоих.Прочитаем вывод по порядку. INNER дал три строки: два заказа Алисы и один Бориса. Карины в нём нет — ровно та потеря, с которой начался урок. LEFT и RIGHT дали по четыре строки: те же три плюс Карина с order_id и status в NULL. Набор клиентов один, но теперь никто не выпал. FULL свёл два листа пересчёта: Капучино попал в оба (5 и 3), Эспрессо посчитали только в зале, Колд брю — только на складе, а каждое расхождение подсветилось через «—», то есть NULL с той стороны, где напитка нет. Один набор данных, четыре JOIN — четыре разных ответа на вопрос «кого оставить».
Заборчик
Что мы упростили.
- На пяти строках
ONпо неиндексированной паре незаметен, но на больших таблицахJOINбез индекса по ключу соединения — это либо hash join (строит хеш-таблицу по одной стороне), либо вложенный цикл (для каждой строки слева ищет пару справа), и стоимость растёт быстро. Как сервер выбирает способ соединения и почему важен индекс под ключ — модуль 06. - Каст
c.id::textвONнужен лишь потому, что в схеме Brewcustomer_idнамеренноTEXT. В своей схеме ключи подJOINдержи одного типа, а лучше — настоящим внешним ключом: тогда и индекс ложится, и приведение не нужно. FULL JOINвнутри одной нормализованной схемы — почти всегда сигнал, что данные стоило связать направленно и хватило быLEFT. Его честное место — стык двух независимых источников, у каждого из которых есть «свои» строки.
Что забрать с собой
INNER JOINоставляет только пары, совпавшие с обеих сторон; для отчёта «все сущности» он молча теряет строки без пары.LEFT JOINсохраняет все строки левой таблицы; нет пары справа → её колонки приходятNULL(sqlc типизирует их как nullable).LEFT JOIN ... WHERE правая.ключ IS NULL— стандартный анти-join «найди строки без пары».RIGHT JOIN— зеркалоLEFT; в коде почти всегда пишутLEFT, поменяв порядок таблиц.FULL JOINсохраняет несовпадения с обеих сторон — это инструмент сверки двух источников, а не повседневныйJOINвнутри схемы.
Карина снова в отчёте: одно слово, LEFT вместо JOIN, вернуло строку, которую INNER терял без всякой ошибки. Но «клиенты и их заказы» — это всего две таблицы. Стоит бизнесу спросить, что именно в заказе, по какой цене и в какой кофейне, как к orders тянутся order_items, drinks и shops — целый чек из нескольких таблиц сразу. А иногда таблицу нужно соединить саму с собой: например, чтобы выстроить, кто кем руководит в смене. Этим займётся следующий юнит, 04-02 «Многотабличные и self-join».