JOIN: типи з'єднань таблиць
Зміст
У реляційній базі дані навмисно розкидані по таблицях: замовлення не зберігає імʼя клієнта, воно тримає лише customer_id — посилання на рядок в іншій таблиці (див. Реляційна модель). Це економить місце й уникає розсинхрону, але означає, що жодна окрема таблиця не показує повної картини. Щоб побачити «замовлення № 101 клієнта Ігоря з Києва на 3 позиції», ці шматки треба зібрати назад. Саме це й робить JOIN — зʼєднує рядки двох чи більше таблиць за умовою відповідності.
Для QA JOIN — це не академічна вправа, а щоденний інструмент і майже гарантоване питання на співбесіді. Ним звіряють, що UI показує правильно зшиті дані; шукають «сирітські» (orphan) записи, які посилаються в нікуди; ловлять роздуті звіти, де сума подвоїлась через невдалий JOIN. Помилка в одному ключовому слові — LEFT замість INNER чи умова не в тому місці — тихо змінює результат, і зіпсований звіт виглядає правдоподібно. Тому механіку треба розуміти, а не завчати назви.
INNER JOIN: лишаються тільки збіги
INNER JOIN (або просто JOIN — це синоніми) бере рядок лівої таблиці, шукає для нього відповідні рядки правої за умовою ON, і повертає лише ті пари, де умова істинна. Рядок, для якого пари немає з жодного боку, у результат не потрапляє взагалі.
SELECT o.id, o.total, c.name
FROM orders AS o
INNER JOIN customers AS c ON c.id = o.customer_id;
Умова після ON — це серце зʼєднання. Найчастіше вона звіряє зовнішній ключ (foreign key) однієї таблиці з первинним ключем (primary key) іншої: o.customer_id = c.id. Але ON — звичайний логічний вираз: там можуть бути кілька умов через AND, нерівності, порівняння діапазонів. Аліаси (o, c) не обовʼязкові, та коли таблиць кілька або їхні стовпці однойменні, без них запит стає нечитабельним і неоднозначним.
Ключовий наслідок, який часто забувають: INNER JOIN симетричний і водночас відсіює. Якщо в orders є замовлення з customer_id, якого немає в customers, — таке замовлення зникне з результату мовчки. Якщо клієнт не зробив жодного замовлення — його теж не буде. INNER JOIN показує лише перетин.
LEFT JOIN: зберегти все зліва, доповнити NULL
Часто перетину замало. «Покажи всіх клієнтів разом із їхніми замовленнями, а хто нічого не купив — теж покажи» — це вже про LEFT JOIN (повна назва LEFT OUTER JOIN). Він гарантує: кожен рядок лівої таблиці буде в результаті. Якщо для нього знайшлась пара справа — стовпці правої таблиці заповнені; якщо ні — вони заповнені NULL.
SELECT c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;
Клієнт без замовлень тут зʼявиться одним рядком, де order_id дорівнює NULL. Це прямий місток до теми пасток NULL: NULL після outer-джойна означає не «нуль» і не «порожньо», а «пари не знайшлося».
RIGHT JOIN — дзеркальна версія: зберігає всі рядки правої таблиці. На практиці його пишуть рідко, бо будь-який RIGHT JOIN переписується як LEFT JOIN простою перестановкою таблиць, а читати запит, де всі джойни ліві, легше. Знати RIGHT треба, застосовувати — здебільшого ні.
Сирітські записи: анти-джойн
LEFT JOIN дає елегантний спосіб знайти записи без пари — те, що на співбесідах формулюють як «знайди клієнтів, які нічого не замовляли». Роблять outer-джойн, а потім лишають тільки ті рядки, де права сторона виявилась NULL:
-- клієнти без жодного замовлення
SELECT c.id, c.name
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.id IS NULL;
Цей патерн називають анти-джойном (anti-join). Він же ловить биту цілісність — замовлення, що посилається на неіснуючого клієнта:
-- сирітські замовлення: customer_id вказує в нікуди
SELECT o.id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c ON c.id = o.customer_id
WHERE c.id IS NULL;
Для QA це готова перевірка посилальної цілісності: у здоровій базі з правильними зовнішніми ключами такий запит має повертати нуль рядків. Якщо повертає — маєте баг даних, і це канонічна тема глави Тестування БД.
FULL OUTER і CROSS
FULL OUTER JOIN — обʼєднання обох боків: зберігає і рядки лівої таблиці без пари, і рядки правої без пари, доповнюючи відсутню сторону NULL. Він відповідає на питання «покажи все з обох таблиць, а де немає відповідності — познач». Зручно для звірки двох наборів: які записи є лише зліва, які лише справа, які збіглися.
SELECT c.name, o.id
FROM customers AS c
FULL OUTER JOIN orders AS o ON o.customer_id = c.id;
Пастка діалектів: PostgreSQL FULL OUTER JOIN підтримує, а MySQL історично — ні. У MySQL його емулюють обʼєднанням лівого й правого джойнів через UNION. Якщо запит із FULL OUTER JOIN падає з синтаксичною помилкою — перше, що варто перевірити, це яка саме СУБД під запитом.
CROSS JOIN стоїть окремо: у нього немає ON. Він дає декартів добуток (Cartesian product) — кожен рядок лівої таблиці склеюється з кожним рядком правої. Дві таблиці по 10 рядків дадуть 100. Іноді це саме те, що треба — згенерувати всі комбінації розмірів і кольорів:
SELECT s.size, cl.color
FROM sizes AS s
CROSS JOIN colors AS cl;
Але значно частіше декартів добуток трапляється випадково — коли таблиці перелічені через кому, а умову зʼєднання забули у WHERE. FROM orders o, customers c без WHERE — це той самий CROSS JOIN, тільки замаскований під старий синтаксис. Результат роздувається, запит гальмує, а звіт бреше. Явний JOIN ... ON тим і кращий за кому, що умова зʼєднання видима: PostgreSQL INNER JOIN без ON узагалі не пропустить, а от MySQL пропустить і мовчки зробить декартів добуток — там JOIN, INNER JOIN і CROSS JOIN синтаксично взаємозамінні.
Self-join: таблиця зʼєднана сама із собою
Іноді звʼязок веде всередину тієї самої таблиці. Класика — співробітники, де кожен має manager_id, що посилається на id іншого співробітника в тій самій таблиці employees. Щоб поряд із іменем працівника показати імʼя його керівника, таблицю зʼєднують саму із собою — це self-join. Технічно нічого нового: та сама таблиця згадується двічі під різними аліасами, і без аліасів тут ніяк — інакше СУБД не зрозуміє, про який із двох екземплярів ідеться.
SELECT e.name AS employee, m.name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id;
LEFT JOIN тут не випадковий: у найвищого керівника manager_id дорівнює NULL, і INNER JOIN викинув би його з результату. Self-join застосовують і для ієрархій категорій, і щоб знайти пари рядків з одним значенням (наприклад, двох користувачів з однаковим email) — усюди, де рядки таблиці посилаються одне на одного.
Дублікати після JOIN: пастка 1:N
Найпідступніший ефект джойна виникає на звʼязку «один до багатьох» (1:N). Один клієнт має багато замовлень; одне замовлення — багато позицій (order_items). Коли ви зʼєднуєте «одиничну» таблицю з «множинною», рядок з боку «один» повторюється стільки разів, скільки в нього пар з боку «багато».
Сам по собі це не баг — так і має бути, ми ж просили рядок на кожну пару. Баг зʼявляється, коли поверх такого джойна рахують агрегати. Якщо після зʼєднання orders з order_items порахувати SUM(o.total), сума кожного замовлення додасться стільки разів, скільки в ньому позицій, — і виторг магічно виросте. Так само COUNT(*) рахуватиме не клієнтів і не замовлення, а рядки роздутого набору.
-- НЕБЕЗПЕЧНО: total подвоюється на кожну позицію замовлення
SELECT c.name, SUM(o.total)
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.name;
Це одне з найчастіших питань на співбесіді формату «чому цифра неправильна». Ознака — результат більший за очікуваний рівно в кілька разів. Лікування залежить від задачі: агрегувати «множинну» таблицю окремо (у підзапиті чи CTE — див. Підзапити і CTE) до зʼєднання, або застосувати COUNT(DISTINCT ...) замість COUNT(*). Детальніше про правила агрегації поверх джойнів — у главі Агрегація: GROUP BY і HAVING. Головне — памʼятати, що JOIN за звʼязком 1:N розмножує рядки, і кожен агрегат після нього треба перевіряти окремо.
Пастка ON vs WHERE у LEFT JOIN
Ось де ламається інтуїція навіть у тих, хто впевнено знає різницю INNER/LEFT. Питання: куди покласти умову-фільтр на праву таблицю — в ON чи у WHERE? Для INNER JOIN різниці в результаті немає. Для LEFT JOIN вона фундаментальна.
Щоб зрозуміти чому, треба знати логічний порядок обробки: спершу виконується зʼєднання за умовою ON (і саме тут outer-джойн додає рядки без пари, доповнені NULL), і лише потім по всьому зʼєднаному набору проходить WHERE.
Тепер порівняйте два майже однакові запити:
-- НЕ те, що очікуєш: фактично INNER JOIN
SELECT c.name, o.id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'paid';
-- Правильно: фільтр — усередині ON
SELECT c.name, o.id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status = 'paid';
У першому запиті LEFT JOIN чесно зберіг клієнтів без замовлень, доповнивши їхній o.status значенням NULL. Але потім WHERE o.status = 'paid' перевіряє цей NULL — і NULL = 'paid' дає не «істина» і не «хиба», а «невідомо», тож рядок відсіюється. У результаті всі клієнти без оплачених замовлень зникли, і LEFT JOIN тихо перетворився на INNER JOIN. У другому запиті умова стоїть в ON: вона впливає на те, які замовлення прикріпляться під час зʼєднання, але не викидає клієнтів — хто без оплачених замовлень, той лишається з NULL у стовпці замовлення.
Правило: фільтр на праву (nullable) таблицю LEFT JOIN кладуть в ON, а не у WHERE — інакше зникає сам сенс outer-джойна. Виняток свідомий: якщо WHERE right.key IS NULL — це вже навмисний анти-джойн, і саме там WHERE доречний.
JOIN трьох і більше таблиць
Реальні запити рідко обмежуються двома таблицями. Щоб зібрати «клієнт, місто, замовлення, товар», джойни ставлять ланцюгом — кожен наступний приєднує ще одну таблицю до вже зʼєднаного набору:
SELECT c.name, ci.name AS city, o.id AS order_id, p.name AS product
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
JOIN cities AS ci ON ci.id = c.city_id
JOIN order_items AS oi ON oi.order_id = o.id
JOIN products AS p ON p.id = oi.product_id;
СУБД обчислює це послідовно, але оптимізатор вільний змінювати фізичний порядок зʼєднань — на результат це не впливає, лише на швидкодію (тема глави Індекси і швидкодія поки що попереду). Що справді впливає на результат — це тип кожного джойна в ланцюгу. Варто памʼятати одну річ: щойно ви зробили LEFT JOIN, а наступним кроком INNER JOIN до тієї самої, потенційно NULL-ової таблиці, — INNER викине рядки з NULL, і ваш LEFT знеціниться. Якщо треба зберегти рядки лівого боку до кінця ланцюга, усі наступні джойни по цій гілці теж мають бути LEFT.
Побіжно про синоніми: USING (customer_id) — це скорочення для ON a.customer_id = b.customer_id, коли стовпці однойменні (об'єднує їх в один у виводі). А от NATURAL JOIN, який сам зʼєднує за всіма однойменними стовпцями, у продакшн-запитах краще не застосовувати — досить комусь додати таблиці ще один випадково однойменний стовпець (наприклад, created_at), і умова зʼєднання тихо зміниться. Явний ON завжди надійніший.
Типові помилки
LEFT JOIN, а рядків стало менше, ніж у лівій таблиці. Виглядає як загублені дані, а насправді умову на праву таблицю поклали уWHEREзамістьON— і outer-джойн виродився в inner.SUM/COUNTпісля джойна завищені в кілька разів. Виглядає як дублікати в даних, а насправді звʼязок 1:N розмножив рядки «одиничної» таблиці, і агрегат порахував їх повторно.- Рядки з
NULLу ключі зникли післяINNER JOIN. Виглядає як втрата записів, а насправдіNULLне дорівнює нічому, навіть іншомуNULL, тож пари для нього не знаходиться ніколи. FULL OUTER JOINпадає з синтаксичною помилкою. Виглядає як помилка у вашому SQL, а насправді це MySQL, якийFULL OUTER JOINне підтримує — потрібна емуляція черезUNION.- Таблиці через кому — і рядків раптом десятки тисяч. Виглядає як звичайний джойн, а насправді забута умова зʼєднання перетворила його на
CROSS JOINз декартовим добутком. NATURAL JOINповертає не те. Виглядає як зручне скорочення, а насправді він зʼєднав за випадково однойменним стовпцем, про який ви не подумали.
Підсумок
INNER JOINлишає тільки збіги;LEFT/RIGHT/FULL OUTERзберігають рядки без пари, доповнюючи відсутню сторонуNULL.- Фільтр на праву таблицю в
LEFT JOINкладуть уON; той самий фільтр уWHEREмовчки перетворює його наINNER JOIN. - Зʼєднання за звʼязком 1:N розмножує рядки «одиничного» боку — будь-який
COUNT/SUMпісля такого джойна перевіряй окремо. - Сирітські записи й записи без пари шукають анти-джойном:
LEFT JOIN ... WHERE right_table.key IS NULL. - У ланцюгу кількох таблиць тип кожного джойна важить:
INNERпісляLEFTпо nullable-гілці знецінюєLEFT.
Що питають на співбесіді
- «Чим
INNER JOINвідрізняється відLEFT JOIN?» — базове питання-фільтр. Інтервʼюер хоче почути не лише «LEFT зберігає ліву таблицю», а й звідки берутьсяNULLу результаті та які рядки зникають приINNER. - «Знайдіть клієнтів, які не зробили жодного замовлення». Очікують анти-джойн (
LEFT JOIN ... WHERE o.id IS NULL) або еквівалент черезNOT EXISTS; слабка відповідь — спроба черезNOT IN, який ламається наNULL. - «Після JOIN сума виторгу подвоїлась — чому?» — перевірка розуміння 1:N. Дивляться, чи згадаєте розмноження рядків і чи запропонуєте агрегувати до джойна або
COUNT(DISTINCT). - «У чому різниця між умовою в
ONі уWHEREдляLEFT JOIN?» — сеньйорський сигнал. Сильний кандидат пояснює логічний порядок обробки:ONдіє під час зʼєднання,WHERE— після, колиNULL-рядки вже додані. - «Що таке
CROSS JOINі коли він трапляється випадково?» — перевіряють, чи розумієте декартів добуток і звідки беруться роздуті результати.
Головне, на що дивиться інтервʼюер, — чи міркуєте ви про які рядки лишаються і звідки береться NULL, а не переказуєте завчені означення типів джойна. Уміння накидати анти-джойн і пояснити пастку 1:N цінується вище за знання, що RIGHT JOIN існує.
Джерела
- PostgreSQL: Table Expressions — Joined Tables — точна семантика
INNER/LEFT/RIGHT/FULL/CROSS,ON/USING/NATURAL. - MySQL: JOIN clause — синтаксис джойнів у MySQL і підтверджена відсутність
FULL OUTER JOIN. - ISO/IEC 9075 (SQL) — міжнародний стандарт мови SQL, де формально визначено операції зʼєднання; діалекти СУБД відхиляються від нього в деталях.
Примітка: SQL і робота з БД не входять до силабусу ISTQB CTFL 4.0 — це прикладна навичка тестувальника, тож канонічні джерела тут — документація СУБД і стандарт мови, а не силабус.
Навіщо взагалі потрібен JOIN — чому не тримати всі дані в одній таблиці?
Одна велика таблиця означала б дублювання: імʼя клієнта повторюється в кожному його замовленні, і зміна імені вимагає оновити тисячі рядків з ризиком розсинхрону. Тому реляційна модель зберігає кожен факт один раз, а між таблицями лишає посилання — замовлення знає тільки customer_id. Ціна такого дизайну: повної картини немає в жодній таблиці окремо, і щоб показати «замовлення № 101 клієнта Ігоря з Києва», розкидані шматки доводиться збирати запитом. Інструмент цього збирання — JOIN: він зіставляє рядки таблиць за умовою відповідності. QA користується ним щодня — звірити, чи UI зшив дані правильно, і виловити записи, що посилаються в нікуди.
Що робить INNER JOIN і які рядки він відсіює?
INNER JOIN (те саме, що просто JOIN) повертає лише ті комбінації рядків двох таблиць, для яких умова ON дала істину, — фактично перетин. Усе, що пари не знайшло, відсіюється, причому з обох боків одразу. Практичний наслідок, який часто забувають: замовлення з customer_id, відсутнім у customers, випаде з вибірки без жодного попередження — і клієнт, який нічого не купував, теж. Тож INNER JOIN працює одночасно і як зʼєднання, і як мовчазний фільтр.
Чим INNER JOIN відрізняється від LEFT JOIN?
Це базове питання-фільтр на співбесіді. INNER JOIN лишає тільки збіги — рядки, для яких знайшлася пара з обох боків. LEFT JOIN (повна назва LEFT OUTER JOIN) поводиться інакше: жоден рядок лівої таблиці не пропаде, знайшлася йому пара чи ні. За наявності пари права частина рядка містить реальні дані, за відсутності — самі NULL. Інтервʼюер хоче почути не лише «LEFT зберігає ліву таблицю», а й звідки беруться NULL та які саме рядки зникають при INNER. Класичний приклад: «покажи всіх клієнтів разом із замовленнями, а хто нічого не купив — теж покажи» — це рівно LEFT JOIN.
Звідки в результаті LEFT JOIN беруться NULL?
Ці NULL кажуть «відповідності справа не існує» — це не число нуль і не порожній рядок. Механіка проста: LEFT JOIN зобовʼязаний віддати рядок лівої таблиці навіть без пари, але стовпці правої таблиці мусить чимось зайняти — і єдиний чесний варіант тут NULL. Тому клієнт без замовлень приходить одним рядком з NULL в order_id. Це прямий місток до пасток NULL: такий рядок не можна фільтрувати звичайним порівнянням, бо NULL = щось дає не «істина» і не «хиба», а «невідомо». Розуміння, що NULL тут — маркер відсутньої пари, а не значення, відрізняє того, хто розуміє механіку, від того, хто завчив означення.
Навіщо потрібен RIGHT JOIN, якщо є LEFT?
RIGHT JOIN — дзеркальна версія LEFT: виживають усі рядки правої таблиці, а ліву сторону там, де пари немає, займає NULL. У реальних запитах він майже не трапляється: досить поміняти таблиці місцями — і той самий результат дає LEFT JOIN, а коли всі джойни в запиті ліві, оку не треба перемикати напрямок читання. Тому знати RIGHT треба, щоб зрозуміти чужий код, а застосовувати у власному — здебільшого ні. Це радше питання на розуміння, ніж на щоденну практику: сильний кандидат згадає, що RIGHT існує, але одразу додасть, що звів би все до LEFT.
Як знайти клієнтів, які не зробили жодного замовлення?
Це канонічне питання, і очікують від нього анти-джойн (anti-join). Ідея: LEFT JOIN чесно зберігає всіх клієнтів, а фільтр після нього відбирає лише рядки, де замість замовлення стоїть NULL, — тобто пара так і не знайшлася:
SELECT c.id, c.name
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.id IS NULL;
Еквівалент — через NOT EXISTS. Слабка відповідь — спроба через NOT IN, який ламається на NULL у підзапиті. Саме тому інтервʼюери люблять це питання: воно одночасно перевіряє і знання outer-джойна, і розуміння поведінки NULL.
Що таке сирітські записи і як їх шукають джойном?
Сирітський (orphan) запис — це рядок, що посилається зовнішнім ключем на неіснуючий рядок іншої таблиці: замовлення з customer_id, якого в customers уже немає. Шукають їх тим самим анти-джойном — LEFT JOIN плюс WHERE на NULL з боку батьківської таблиці:
SELECT o.id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c ON c.id = o.customer_id
WHERE c.id IS NULL;
Для QA це готова перевірка посилальної цілісності (referential integrity): коли зовнішні ключі налаштовані правильно, відповідь буде порожньою. Кожен рядок у відповіді — баг даних і прямий кандидат у дефект, а не косметика.
Чому NOT IN небезпечний для пошуку записів без пари?
NOT IN тихо ламається, коли в списку значень трапляється NULL. Механізм такий: x NOT IN (1, 2, NULL) розкривається у x <> 1 AND x <> 2 AND x <> NULL, а останнє порівняння дає «невідомо», через що весь вираз ніколи не стає істинним — і запит повертає порожньо, хоча даних насправді повно. Тому для «знайди тих, у кого немає пари» надійніші анти-джойн (LEFT JOIN ... WHERE key IS NULL) або NOT EXISTS, які коректно поводяться з NULL. Це улюблена пастка на співбесідах: кандидат пише начебто правильний NOT IN, а він мовчки повертає не те. Розуміння цієї межі — ознака, що людина реально працювала з брудними даними.
Що таке CROSS JOIN і коли декартів добуток виникає випадково?
CROSS JOIN стоїть окремо від решти: у нього немає ON. Він повертає декартів добуток (Cartesian product) — усі можливі комбінації рядків, тож дві таблиці по 10 рядків дадуть 100. Зрідка це задумано — наприклад, згенерувати всі пари «розмір × колір». Типовіший сценарій — випадковий: старий синтаксис перелічення таблиць через кому (FROM orders o, customers c), де умову зʼєднання мали написати у WHERE, але забули, — і СУБД слухняно перемножила таблиці. Вибірка розростається на порядки, запит гальмує, цифри у звіті втрачають сенс. Явний JOIN ... ON захищає краще: PostgreSQL відхилить INNER JOIN без умови як синтаксичну помилку. Щоправда, MySQL пропустить — там JOIN, INNER JOIN і CROSS JOIN взаємозамінні, тож пильність потрібна все одно.
Що робить FULL OUTER JOIN і чому запит із ним може несподівано впасти?
FULL OUTER JOIN поєднує поведінку LEFT і RIGHT одночасно: непарні рядки виживають з обох боків, а бракуючу половину кожного такого рядка займає NULL. Найкорисніший сценарій — звірка двох наборів даних: одразу видно, що існує лише зліва, що лише справа, а що збіглося. Пастка тут діалектна: у PostgreSQL він працює, а MySQL його не має досі. Тому синтаксична помилка на FULL OUTER JOIN — привід спитати не «що не так з моїм SQL», а «яка СУБД під запитом»; на MySQL той самий результат складають з лівого і правого джойнів через UNION. Помилка виглядає як баг у вашому SQL, а насправді це обмеження двигуна.
Що таке self-join і навіщо він потрібен?
Self-join — це зʼєднання таблиці самої із собою, коли звʼязок веде всередину тієї самої таблиці. Класика — співробітники, де кожен має manager_id, що посилається на id іншого співробітника в тій самій таблиці employees; щоб поряд з іменем працівника показати імʼя його керівника, таблицю джойнять саму на себе. З погляду синтаксису це звичайний джойн, просто одна таблиця фігурує в запиті двічі — і саме тому аліаси перестають бути опційними: без них СУБД не розрізнить, який із двох екземплярів мається на увазі.
SELECT e.name AS employee, m.name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id;
LEFT JOIN тут обрано свідомо: найвищий керівник не має свого керівника, його manager_id — NULL, тож INNER JOIN втратив би цей рядок. Self-join застосовують і для ієрархій категорій, і щоб знайти пари рядків з однаковим значенням.
Після JOIN сума виторгу подвоїлась — чому і як це полагодити?
Це перевірка розуміння звʼязку «один до багатьох» (1:N). Джойн повертає рядок на кожну знайдену пару, тому сторона «один» у результаті дублюється: клієнт із трьома замовленнями зʼявиться тричі. Це коректна поведінка, а не дефект — рівно доти, доки поверх результату не зʼявляється агрегат. Щойно після зʼєднання orders з order_items рахують SUM(o.total), кожен total потрапляє в суму по разу на позицію замовлення — і виторг завищується. Впізнати проблему легко: цифра відрізняється від очікуваної в ціле число разів, а не хаотично. Лікування залежить від задачі — агрегувати «множинну» таблицю окремо (у підзапиті чи CTE) до зʼєднання або застосувати COUNT(DISTINCT ...) замість COUNT(*).
У чому різниця між умовою в ON і у WHERE для LEFT JOIN?
Це сеньйорський сигнал. Для INNER JOIN різниці в результаті немає, а для LEFT JOIN вона фундаментальна. Розгадка в тому, коли що обчислюється: етап ON формує зʼєднаний набір — у outer-джойна саме тут народжуються NULL-рядки для лівих рядків без пари, — а WHERE фільтрує вже готовий набір. Фільтр на праву таблицю у WHERE (скажімо, WHERE o.status = 'paid') наткнеться на той свіжий NULL, порівняння дасть «невідомо», рядок не пройде — і від LEFT лишиться поведінка INNER: клієнти без оплачених замовлень випаруються. Той самий фільтр в ON (ON o.customer_id = c.id AND o.status = 'paid') впливає лише на те, які замовлення прикріпляться, але клієнтів не викидає. Правило: фільтр на праву (nullable) таблицю LEFT JOIN кладуть в ON, а не у WHERE.
Який логічний порядок обробки джойна і чому він важливий?
СУБД обробляє запит не в тому порядку, у якому він написаний: спершу FROM і JOIN ... ON формують зʼєднаний набір, потім WHERE фільтрує його, далі йдуть GROUP BY, HAVING, SELECT і ORDER BY. Ключове для джойнів — що outer-джойн додає свої NULL-рядки на етапі ON, а WHERE бачить їх уже готовими. Саме тому одна й та сама умова в ON і у WHERE дає різний результат для LEFT JOIN: перша діє під час зʼєднання, друга — після, коли NULL-рядки вже в наборі. Оптимізатор може переставляти фізичний порядок зʼєднань як завгодно — це лише питання швидкодії; що саме повернеться, визначають логічний порядок обробки і тип кожного джойна.
У ланцюгу з кількох таблиць чому INNER після LEFT може «знецінити» LEFT?
Реальні запити рідко обмежуються двома таблицями — джойни ставлять ланцюгом, кожен наступний приєднує ще одну таблицю до вже зʼєднаного набору. Тип кожного джойна в ланцюгу важить окремо. Пастка виглядає так: перший крок — LEFT JOIN до гілки, де пари може не бути; другий — INNER JOIN, що чіпляється до тієї самої гілки. У рядків без пари ключ гілки — NULL, умова наступного INNER на ньому не спрацює, і все, що LEFT дбайливо зберіг, вилетить на цьому кроці. Тому якщо треба зберегти рядки лівого боку до кінця ланцюга, усі наступні джойни по цій гілці теж мають бути LEFT. Це типова причина «загублених» рядків у складному звіті, де перший джойн начебто правильний.
Чим USING відрізняється від ON і чому NATURAL JOIN краще не застосовувати?
USING (customer_id) — синтаксичний цукор: замість повного ON a.customer_id = b.customer_id пишете одну назву спільного стовпця, а у виводі замість двох однакових стовпців лишається один. Цукор зручний і безпечний. А NATURAL JOIN іде далі й сам зʼєднує за всіма однойменними стовпцями обох таблиць — і саме тому в продакшн-запитах його краще не застосовувати. Уявіть: у другій таблиці зʼявився стовпець created_at, який випадково існує і в першій, — від цього моменту NATURAL JOIN звіряє ще і його, умова зʼєднання вже інша, а запит без жодної помилки повертає інший результат. Явний ON завжди надійніший, бо умова зʼєднання видима й не залежить від майбутніх змін схеми. На співбесіді згадка про цю крихкість NATURAL JOIN — хороший сигнал зрілості.
Три кейси, де вибір джойна вирішує, чи звіт каже правду: таблиця рішень «який JOIN під яку задачу», діагностика роздутого виторгу на звʼязку 1:N і анти-джойн як перевірка посилальної цілісності, яку можна ганяти прямо з автотесту. Скрізь — що дивитися і чому.
Кейс 1. Таблиця рішень: який JOIN обрати під задачу
Найчастіша помилка новачка — узяти INNER JOIN там, де потрібен LEFT, і мовчки загубити рядки. Питання, на яке треба відповісти перед вибором: чи потрібні рядки лівої таблиці, для яких немає пари справа? Якщо так — outer-джойн, якщо ні — inner.
| Задача словами | Тип джойна | Що станеться з рядками без пари |
|---|---|---|
| Замовлення разом з іменем клієнта (клієнт завжди є) | INNER JOIN | не буває — кожне замовлення має клієнта |
| Усі клієнти + їхні замовлення, включно з тими, хто нічого не купив | LEFT JOIN | клієнт без замовлень лишається, order_id = NULL |
| Тільки клієнти, які нічого не замовляли | LEFT JOIN + WHERE o.id IS NULL | лишаються рівно ті, у кого пари немає |
| Звірка двох наборів: що є лише зліва, лише справа, спільне | FULL OUTER JOIN | зберігаються обидва боки, відсутня сторона = NULL |
| Усі комбінації розмірів і кольорів | CROSS JOIN | пар немає взагалі — кожен з кожним |
| Працівник разом з його керівником | self-LEFT JOIN | верхівка з manager_id IS NULL теж лишається |
Розбір, чому саме так:
LEFTзамістьINNER— коли важливі «нулі». «Скільки замовлень у кожного клієнта» черезINNER JOINтихо викине клієнтів з нулем замовлень, і звіт покаже завищене середнє.LEFT JOINзбереже їх зNULL, якийCOUNT(o.id)чесно порахує як нуль (а отCOUNT(*)дав би 1 — він рахує рядки, не значення).FULL OUTER— інструмент звірки, а не щоденний джойн. Його беруть, коли треба побачити розбіжність двох джерел одразу з обох боків. Памʼятайте про MySQL: там його немає, емуляція черезUNIONлівого й правого.CROSS JOINпишуть явно рідко. Якщо він зʼявився у вас незапланованим — швидше за все, ви перелічили таблиці через кому й забули умову уWHERE.
Кейс 2. Виторг у звіті подвоївся: діагностика пастки 1:N
Аналітик скаржиться: сумарний виторг по клієнтах у новому звіті помітно більший, ніж у старому, хоча замовлень не додавалось. Запит виглядає невинно:
-- НЕБЕЗПЕЧНО: total подвоюється на кожну позицію замовлення
SELECT c.name, SUM(o.total) AS revenue
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.name;
Симптом-підказка: цифра завищена рівно в кілька разів, не хаотично. Це підпис звʼязку «один до багатьох». Джойн orders з order_items розмножив кожне замовлення стільки разів, скільки в ньому позицій, а SUM(o.total) слухняно додав total за кожен дубль. Замовлення на 500 грн з трьома позиціями внесло у виторг 1500.
Як довести діагноз за 10 секунд — прибрати агрегат і подивитись на сирі рядки:
SELECT c.name, o.id AS order_id, o.total, oi.id AS item_id
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
WHERE c.name = 'Ігор';
Якщо один order_id повторюється трьома рядками з різними item_id, а total у всіх однаковий — діагноз підтверджено: SUM рахує по роздутому набору.
Лікування — агрегувати «множинну» таблицю до зʼєднання, щоб на один рядок замовлення лишався один total:
SELECT c.name, SUM(o.total) AS revenue
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.name;
Тут order_items узагалі не потрібна для суми виторгу — її приєднання й було зайвим. Якщо ж позиції справді потрібні (наприклад, порахувати їх кількість), рахують COUNT(DISTINCT oi.id) і SUM по заздалегідь згорнутій у підзапиті сумі, а не наосліп поверх джойна. Мораль для QA: будь-який COUNT/SUM після джойна перевіряй окремо — «завищено в N разів» майже завжди означає 1:N.
Кейс 3. Анти-джойн як перевірка цілісності — прямо з автотесту
Посилальну цілісність (referential integrity) не обовʼязково перевіряти очима. Якщо база доступна тесту, анти-джойн стає оракулом: у здоровій базі сирітських записів немає, тож запит має повернути нуль рядків. Будь-який рядок у відповіді — це баг даних.
-- сирітські замовлення: customer_id вказує в нікуди
SELECT o.id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c ON c.id = o.customer_id
WHERE c.id IS NULL;
Механіка та сама, що й у пошуку «клієнтів без замовлень», тільки перевернута: LEFT JOIN зберігає всі замовлення, а WHERE c.id IS NULL лишає рівно ті, для яких клієнта не знайшлося. Загорнути це в перевірку можна навіть без браузера — через прямий запит до БД у Playwright-тесті:
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('немає сирітських замовлень: кожен customer_id існує', async () => {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
const { rows } = await db.query(`
SELECT o.id, o.customer_id
FROM orders AS o
LEFT JOIN customers AS c ON c.id = o.customer_id
WHERE c.id IS NULL
`);
// здорова база: сиріт немає, отже нуль рядків
expect(rows, `знайдено сирітські замовлення: ${JSON.stringify(rows)}`).toHaveLength(0);
await db.end();
});
Що дивитися і чому:
- Порожній результат — це зелений тест, не помилка. Тут «нуль рядків» і є очікуваний стан. Асерт
toHaveLength(0)з описом у повідомленні одразу покаже, які самеidосиротіли, якщо перевірка почервоніє. - Не плутайте анти-джойн з фільтром, що зіпсував
LEFT. ТутWHERE ... IS NULL— навмисний, у цьому весь сенс. А осьWHERE c.name = 'X'на nullable-боці мовчки виродив биLEFTвINNERі зламав перевірку. Різницю дає той самий логічний порядок «спершуON, потімWHERE». - Ту саму ідею масштабують на будь-який зовнішній ключ.
order_items→orders,orders→customers,products→categories: один шаблон анти-джойна покриває всі звʼязки. Це дешевий і надійний набір санітарних перевірок даних після міграцій.
Типи джойнів: що лишається в результаті
- Розумію, навіщо
JOINузагалі потрібен: дані навмисно розкидані по таблицях (замовлення тримає лишеcustomer_id), і жодна окрема таблиця не показує повної картини. - Знаю, що
INNER JOINі простоJOIN— синоніми, і що він лишає тільки перетин: пари, де умоваONістинна, а рядки без пари з будь-якого боку зникають мовчки. - Можу пояснити, що
LEFT JOINгарантує кожен рядок лівої таблиці в результаті, а стовпці правої заповнюєNULL, коли пари немає. - Розумію, що
RIGHT JOIN— дзеркалоLEFT, і чому на практиці його зводять доLEFTперестановкою таблиць. - Знаю, що
FULL OUTER JOINзберігає рядки без пари з обох боків, і що MySQL історично його не підтримує (емуляція черезUNION). - Розумію, що
CROSS JOINне маєONі дає декартів добуток — кожен рядок з кожним.
NULL і анти-джойн
- Розумію, що
NULLпісля outer-джойна означає «пари не знайшлося», а не «нуль» чи «порожньо». - Можу написати анти-джойн для «знайди записи без пари»:
LEFT JOIN ... WHERE right_table.key IS NULL. - Знаю, що цей самий патерн ловить сирітські (orphan) записи й бите посилання зовнішнього ключа — готова перевірка посилальної цілісності для QA.
- Розумію, чому
NOT INнебезпечний наNULLу підзапиті (весь вираз стає «невідомо» і повертає порожньо), і що надійнішіNOT EXISTSабо анти-джойн.
Пастка ON vs WHERE у LEFT JOIN
- Знаю, що для
INNER JOINрізниці між умовою вONі уWHEREнемає, а дляLEFT JOINвона фундаментальна. - Можу пояснити логічний порядок: спершу
ON(тут outer-джойн додаєNULL-рядки), потімWHEREпо всьому набору. - Розумію, чому фільтр на праву таблицю у
WHERE(WHERE o.status = 'paid') тихо перетворюєLEFTнаINNER, а той самий фільтр вON— ні. - Памʼятаю виняток:
WHERE right.key IS NULL— це навмисний анти-джойн, і тамWHEREдоречний.
Дублікати 1:N і агрегати
- Розумію, що зʼєднання за звʼязком «один до багатьох» (1:N) розмножує рядки «одиничного» боку — це не баг сам по собі.
- Можу пояснити, чому
SUM/COUNT(*)після такого джойна завищені, і впізнаю симптом: результат більший за очікуваний рівно в кілька разів. - Знаю способи лікування: агрегувати «множинну» таблицю окремо в підзапиті/CTE до джойна або застосувати
COUNT(DISTINCT ...).
Кілька таблиць, self-join і синтаксис
- Розумію self-join: таблиця зʼєднана сама із собою під різними аліасами (працівник → керівник), і чому тут беруть
LEFT, щоб не втратити верхівку зmanager_id IS NULL. - Знаю, що в ланцюгу джойнів
INNERпісляLEFTпо nullable-гілці знецінюєLEFT, і що для збереження лівих рядків усі наступні джойни гілки теж мають бутиLEFT. - Розумію, що
USING (col)— безпечне скорочення дляON a.col = b.col, аNATURAL JOINкрихкий: тихо зʼєднає за будь-яким випадково однойменним стовпцем.
Що повертає INNER JOIN двох таблиць?
Питання
Навіщо потрібен JOIN?