Źle napisany JOIN nie zgłasza błędu. Wykonuje się, zwraca wynik, a wiersze są błędne: brakujący klienci, zawyżone sumy, niewidoczne osierocone rekordy. Najczęściej zapytanie dostało dokładnie to, o co prosiło, a autor wybrał zły typ join albo filtrował w złym miejscu.
Join łączy wiersze z dwóch tabel na podstawie warunku, który sam piszesz. To, które wiersze przetrwają, zależy od typu join, a sześć typów poniżej daje sześć różnych odpowiedzi z tych samych dwóch tabel.
customers zawiera pięć osób. Trzy z nich zostały polecone przez innego klienta, zapisanego w referred_by:
| |
orders zawiera sześć zamówień. Pięć wskazuje na prawdziwego klienta. Szóste, 106, wskazuje na klienta 99, który został usunięty po złożeniu zamówienia, więc zamówienie nie odpowiada już nikomu:
| |
Najszybszy sposób, by śledzić przykłady, to kontener Postgres, skonfigurowany tak jak w Czym Jest Docker Compose i Jak Go Używać. To standardowy SQL. Dwa miejsca, gdzie MySQL zachowuje się inaczej, są opisane poniżej.
INNER JOIN: tylko wiersze, które mają dopasowanie po obu stronach
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
Pięć wierszy. Carol i Erin nie mają zamówień, więc znikają. Zamówienie 106 nie ma klienta, więc też znika. JOIN i INNER JOIN znaczą to samo. INNER jest opcjonalny i większość ludzi go pomija.
Na pięciu wierszach nie widać żadnego spowolnienia. Na prawdziwej tabeli orders kolumna orders.customer_id potrzebuje indeksu, inaczej każdy join na niej skanuje całą tabelę. Jak Działają Indeksy w Bazie Danych i Kiedy Je Dodawać wyjaśnia, kiedy taki indeks się opłaca.
LEFT JOIN: każdy wiersz z lewej strony, z dopasowaniem albo bez
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Carol | NULL | NULL |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| Erin | NULL | NULL |
Każdy klient jest obecny. Carol i Erin dostają NULL w kolumnach zamówienia. To jest join, który piszesz najczęściej: “wszystkie X, z czymkolwiek z Y, co mają”.
RIGHT JOIN: każdy wiersz z prawej strony, z dopasowaniem albo bez
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| NULL | 106 | 10.00 |
Zamówienie 106 wraca, z NULL zamiast nazwy. To jest osierocony wiersz, który inner join skrywał, a right join (albo równoważny left join) pozwala go znaleźć celowo.
RIGHT JOIN rzadko zobaczysz w prawdziwym kodzie. a RIGHT JOIN b zwraca te same wiersze co b LEFT JOIN a, więc większość zespołów używa LEFT JOIN wszędzie i zamiast tego zmienia kolejność tabel. Przepisanie RIGHT JOIN w ten sposób to kwestia konsekwencji. Oryginał nie jest błędny.
FULL OUTER JOIN: wszystko z obu stron
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Carol | NULL | NULL |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| Erin | NULL | NULL |
| NULL | 106 | 10.00 |
Osiem wierszy: każde dopasowanie, plus klienci bez dopasowania, plus zamówienie bez dopasowania. To właśnie czyni z niego join do rekoncyliacji, gdy chcesz wszystko, co po jednej albo drugiej stronie się nie zgadza. Poza tym przypadkiem rzadko go potrzebujesz.
MySQL nie ma FULL OUTER JOIN i też nie przyjmuje NULLS LAST. Żeby uzyskać tam te same wiersze, weź LEFT JOIN, a potem dodaj UNION ALL z RIGHT JOIN przefiltrowanym przez WHERE c.customer_id IS NULL, żeby już dopasowane wiersze się nie powtórzyły. Działa też zwykły UNION dwóch takich zapytań JOIN, ale płacisz za przebieg deduplikacji, a przy tym łączy też wiersze, które są naprawdę identyczne.
CROSS JOIN: każdy wiersz połączony z każdym wierszem
| |
Pięciu klientów razy sześć zamówień: trzydzieści wierszy, bez żadnego warunku join. Większość zapytań z CROSS JOIN na produkcji to wypadki. Ktoś napisał FROM customers, orders i zapomniał o WHERE, które miało je połączyć. Celowo chciałbyś taki join, żeby wygenerować kombinacje, jak każdy rozmiar z każdym kolorem, albo każdy dzień zakresu dat z każdym sklepem, zanim wypełnisz prawdziwe liczby.
Jak działa self-join?
customers.referred_by wskazuje na inny wiersz tej samej tabeli. Żeby pokazać po imieniu, kto kogo polecił, połącz tabelę z samą sobą pod drugim aliasem:
| |
| customer | referred_by |
|---|---|
| Alice | NULL |
| Bob | NULL |
| Carol | Alice |
| Dave | Alice |
| Erin | Bob |
Ten join działa tak samo jak wcześniej. customers c i customers r to dwa aliasy, a planer traktuje je jako dwie osobne tabele, które przypadkiem zawierają te same wiersze. To celowo LEFT JOIN, żeby Alice i Bob, których nikt nie polecił, zostali w wyniku. Każda tabela, w której wiersz wskazuje na inny wiersz tej samej tabeli, działa w ten sposób: pracownicy i menedżerowie, kategorie i kategorie nadrzędne, komentarze i odpowiedzi.
Czemu klauzula WHERE zamienia LEFT JOIN w INNER JOIN?
Chcesz każdego klienta, z jego zamówieniami powyżej 25 $, jeśli jakieś ma:
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
Carol i Erin zniknęły. WHERE wykonuje się po join, a dla nich o.amount to NULL. NULL > 25 nie jest ani prawdą, ani fałszem. Jest nieznane, a WHERE zatrzymuje tylko wiersze, które są prawdziwe. LEFT JOIN je zatrzymał, a filtr je odrzucił.
| |
Użyj pierwszej. Umieszczenie warunku w ON decyduje, które zamówienia mają prawo się dopasować, a każdy klient i tak dostaje przynajmniej jeden wiersz. Druga wersja przepuszcza tylko wiersze, w których join nic nie znalazł. Klient, którego jedyne zamówienie było na 20 $, dostaje wiersz z amount = 20.00, który nie spełnia żadnego z dwóch warunków, i ten klient znowu znika. Na tych danych obie zapytania przypadkiem zatrzymują wszystkich pięciu klientów, i tak druga przechodzi testy niezauważona.
Czemu SUM zwraca złą sumę po połączeniu dwóch tabel?
Dodaj trzecią tabelę, payments, w której jedno zamówienie może być opłacone w kilku ratach:
| |
Zamówienie 101 jest na 50 $, opłacone w dwóch ratach po 25 $. Teraz połącz wszystkie trzy tabele i zsumuj, co zamówił każdy klient:
| |
| name | total_ordered |
|---|---|
| Alice | 100.00 |
| Bob | 75.00 |
Prawdziwa suma Alice to 70 $ (50.00 + 20.00). Zepsuły się dwie rzeczy. Zamówienie 101 dopasowuje się do dwóch płatności, więc join zwraca ten wiersz zamówienia dwa razy, a SUM(o.amount) dodaje jej 50 $ dwa razy. Zamówienie 102 nie ma płatności, więc inner join do payments je usuwa. Dla Alice daje to 50 + 50 + 0 = 100. Dave nie ma żadnej płatności, więc nie pojawia się w wyniku.
Zasada: kiedy agregujesz kolumnę, nie łącz tabel, których agregacja nie potrzebuje. Każdy join jeden-do-wielu może pomnożyć wiersze albo je usunąć. Jeśli chcesz tylko sumy zamówień, zostaw payments na boku:
| |
Jeśli w tym samym raporcie potrzebujesz też sum płatności, zrób najpierw GROUP BY order_id na payments w podzapytaniu albo CTE. To daje jeden wiersz na zamówienie, który możesz bezpiecznie połączyć z zamówieniami.
NOT IN kontra NOT EXISTS: jak znaleźć wiersze bez dopasowania?
“Klienci bez zamówień” wygląda jak zadanie dla NOT IN:
| |
Działa tutaj tylko dlatego, że orders.customer_id jest NOT NULL. Usuń to ograniczenie i wstaw jedno zamówienie z customer_id równym NULL, a zapytanie zwróci zero wierszy, bez żadnego błędu. NOT IN rozwija się w ciąg porównań <> połączonych AND. x <> NULL jest nieznane, więc jeden NULL czyni cały warunek nieznanym dla każdego klienta.
Dwa wzorce nie mają tego problemu:
| |
| name |
|---|
| Carol |
| Erin |
Obie zwracają Carol i Erin, a NULL w orders.customer_id nie wpływa na żadną z nich. Żadna z nich nie buduje listy do porównania. Obie sprawdzają, klient po kliencie, czy istnieje odpowiadające zamówienie. Używaj NOT EXISTS jako domyślnego wyboru. Używaj LEFT JOIN ... IS NULL, gdy zapytanie już łączy obie tabele, żeby pobrać inne kolumny.
Kiedy join zwraca więcej wierszy, niż się oczekujesz, szukaj tabeli jeden-do-wielu między JOIN a GROUP BY. Kiedy zwraca mniej, szukaj warunku WHERE na tabeli, która może być NULL. W obu przypadkach popraw zapytanie wokół samego JOIN, zanim zmienisz jego typ.