0 / 63 (0%)

JOIN: inner/left/right/full

Маркетинг придумал акцию «мы скучаем»: разослать промокод всем, кто завёл аккаунт в Brew, но пока ни разу не заказал. От тебя — список клиентов с числом заказов. Пишешь очевидное: соединяешь customers с orders по клиенту, считаешь строки, отдаёшь. Список уходит маркетингу, тикет закрыт.

Назавтра прилетает вопрос: «А где Карина? Она зарегистрировалась на прошлой неделе, в админке есть, а в твоём списке её нет». Открываешь — и правда нет. Запрос не упал и не выдал ошибку. Он молча выкинул ровно тех, ради кого акцию и затевали, — клиентов без единого заказа. В цифрах отчёт не наврал. Он наврал умолчанием: строки, которой не должно было не быть, в нём просто не оказалось.

Дело не в запросе целиком, а в одном слове — в том, какой JOIN ты выбрал. Связать две таблицы можно четырьмя способами, и различаются они ровно тем, кого сохраняют, а кого выбрасывают. Карина выпала, потому что взяли вид, который оставляет только совпавшие пары. Дальше — все четыре по очереди, на одной и той же паре customers и orders, с заменой по одному слову.

INNER JOIN — только совпадения

INNER JOIN (можно писать просто JOIN) оставляет строки, у которых пара нашлась с обеих сторон сразу. Клиент без заказа и заказ без клиента не проходят. Это правильный выбор, когда нужны именно совпавшие пары — «заказы вместе с данными клиента, который их сделал». Но для отчёта «все клиенты» он коварен: тех, у кого заказов ещё нет, он молча убирает.

Одна деталь про условие соединения. customers.id — это BIGINT, а orders.customer_idTEXT (так в схеме 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 решает, какие из трёх зон попадут в результат:

plaintext
     только слева         пересечение        только справа
   ┌─────────────────┐ ┌──────────────┐ ┌───────────────────┐
   │     Карина      │ │ Алиса, Борис │ │   заказ без        │
   │ (клиент без     │ │  (совпали:   │ │   клиента          │
   │  заказа)        │ │  есть пара)  │ │ (в данных — нет)   │
   └─────────────────┘ └──────────────┘ └───────────────────┘

INNER берёт только среднюю зону. LEFT — среднюю плюс левую (вот и Карина). RIGHT — среднюю плюс правую. FULL — все три сразу. Любая взятая, но несовпавшая сторона приходит в результат как NULL:

JOINсохраняетгде NULLберём, когда
INNERтолько пересечениенигде (пары гарантированы)нужны именно совпавшие пары
LEFTлевую зону + пересечениев правых колонках без пары«все сущности, связанное если есть» — самый частый
RIGHTправую зону + пересечениев левых колонках без парыпочти никогда: пишут LEFT, поменяв таблицы
FULLвсе три зоныс любой стороны без парысверка двух независимых источников

Что показывает наш код

В query.sql четыре запроса. Три первых — про customers и orders — отличаются ровно одним словом. Вот INNER:

sql
-- 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 — и больше ничего:

sql
-- 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) (ключ есть хотя бы с одной стороны):

sql
-- 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:

sh
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.)

Вывод:

plaintext
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 нужен лишь потому, что в схеме Brew customer_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».

·Модуль 05

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Запросы по таблицам / JOIN: inner/left/right/full