Baza wiedzy / SQL

SELF JOIN w SQL: pracownicy, przełożeni i unikalne pary

Na jednej tabeli pracowników pokazuję, jak odczytać relację z przełożonym, zachować brakujące dopasowania i zbudować unikalne pary osób.

Kajo RudzińskiOpublikowano Aktualizacja 6 min czytania

Dwa zestawy klocków pokazują różne role tej samej tabeli i połączenia między odpowiadającymi im rekordami.

SELF JOIN to użycie tej samej tabeli po obu stronach złączenia. Jedna tabela przechowuje pracowników, a jej dwa aliasy pozwalają potraktować wiersze raz jako pracowników, raz jako przełożonych. Nie piszesz osobnego słowa kluczowego SELF JOIN. Wybierasz zwykłe INNER JOIN albo LEFT JOIN i określasz, które wiersze mają do siebie pasować.

W tym haśle przechodzę od relacji pracownik-przełożony do par osób z tego samego zespołu. Najważniejsze pytanie brzmi: czy wynik ma zachować osobę, dla której nie ma dopasowania? Ta decyzja zmienia odpowiedni typ złączenia i miejsce, w którym umieścisz filtr. Wszystkie poniższe zapytania i tabele wynikowe sprawdzono w SQLite 3.53.4 przez Node 24. Dane są syntetyczne.

Jedna tabela, dwie role

Przygotuj nową, pustą bazę i wykonaj ten blok:

-- example: fixture
CREATE TABLE employees (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    manager_id INTEGER,
    team TEXT NOT NULL
);
INSERT INTO employees VALUES
    (1, 'Ada', NULL, 'core'),
    (2, 'Ben', 1, 'ops'),
    (3, 'Cleo', 1, 'ops'),
    (4, 'Dan', 2, 'ops'),
    (5, 'Eva', 99, 'qa');

id jednoznacznie identyfikuje osobę, a manager_id wskazuje ID jej bezpośredniego przełożonego. Ada nie ma przełożonego w tych danych. Eva ma wpisane 99, którego nie ma w kolumnie id. Celowo zostawiam błędne powiązanie, żeby pokazać różnicę między brakiem przełożonego a brakującym rekordem.

Dlatego ćwiczenie nie deklaruje klucza obcego dla manager_id. W rzeczywistym modelu rozważ taki klucz i jego egzekwowanie. Zależnie od reguł systemu zapobiegnie to zapisowi nieistniejącego ID. Sama poprawność odwołania nie wyklucza jednak cykli w hierarchii ani sytuacji, w której ktoś wskazuje siebie.

Alias e oznacza tutaj pracownika, a m przełożonego. To dwie nazwy użycia tabeli w jednym zapytaniu. Nie tworzymy kopii danych ani drugiej trwałej tabeli. Kwalifikowanie kolumn, na przykład e.id i m.id, wyjaśnia, z której roli korzystamy, i usuwa niejednoznaczność nazw.

LEFT JOIN zachowuje osobę bez dopasowania

Chcę zobaczyć wszystkich pracowników i, jeśli istnieje dopasowanie, imię przełożonego:

-- example: left
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.id
ORDER BY e.id;
id employee manager
1 Ada NULL
2 Ben Ada
3 Cleo Ada
4 Dan Ben
5 Eva NULL

Wynik ma pięć wierszy. Ada pozostaje na liście z NULL po stronie przełożonego. Eva również pozostaje, choć przyczyna braku dopasowania jest inna. Wiersz Dana wskazuje Bena, bo 2 z e.manager_id pasuje do 2 z m.id.

NULL w tabeli wynikowej oznacza brak wartości SQL, a nie tekst składający się z czterech liter. Warunek równości nie dopasowuje NULL do NULL. Przy LEFT JOIN brak dopasowania oznacza uzupełnienie kolumn prawej strony wartościami NULL, co opisuje dokumentacja złączeń SQLite.

Tutaj każda osoba pojawia się dokładnie raz, ponieważ prawa strona jest dopasowywana po unikalnym kluczu m.id. Złączenie po imieniu nie dawałoby tej gwarancji: dwie osoby o tym samym imieniu mogłyby pomnożyć wiersze. Nie usuwaj takiego efektu od razu przez DISTINCT; najpierw sprawdź klucz i zamierzoną relację.

INNER JOIN zwraca tylko dopasowane relacje

Jeśli interesują mnie wyłącznie osoby z istniejącym rekordem przełożonego, zmieniam typ złączenia:

-- example: inner
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees AS e
INNER JOIN employees AS m ON e.manager_id = m.id
ORDER BY e.id;
id employee manager
2 Ben Ada
3 Cleo Ada
4 Dan Ben

Zostają trzy wiersze. Ada i Eva odpadają, bo dla żadnej nie znaleziono pasującego m.id. Wynik nie oznacza, że osoby zniknęły z tabeli. Zmienił się tylko warunek obecności w zestawieniu.

W raporcie kontroli jakości danych zwykle potrzebujesz właśnie tych pominiętych przypadków, dlatego wygodniejszy będzie LEFT JOIN. W raporcie przedstawiającym wyłącznie istniejące relacje przełożony-pracownik INNER JOIN może być zgodny z celem. Najpierw nazwij zbiór, który chcesz otrzymać, potem wybierz operator.

Odróżnij brak przełożonego od błędnego ID

Samo wyświetlenie m.name nie mówi, skąd wziął się NULL. Sprawdzam więc wartość po lewej stronie oraz klucz dopasowania po prawej:

-- example: classification
SELECT e.id, e.manager_id,
       CASE
           WHEN e.manager_id IS NULL THEN 'no_manager'
           WHEN m.id IS NULL THEN 'missing_reference'
           ELSE 'matched'
       END AS link_status
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.id
ORDER BY e.id;
id manager_id link_status
1 NULL no_manager
2 1 matched
3 1 matched
4 2 matched
5 99 missing_reference

no_manager oznacza puste wskazanie w wierszu pracownika. missing_reference oznacza wskazanie, którego nie dało się znaleźć. To techniczna klasyfikacja przykładu; nie rozstrzyga, czy brak przełożonego jest dozwolony dla danej roli.

Testuję m.id IS NULL, ponieważ klucz rekordu przełożonego nie może być NULL. W modelu dopuszczającym brak imienia test m.name IS NULL mieszałby dwa przypadki: niedopasowaną osobę oraz poprawnie dopasowaną osobę bez imienia. Dobrze wybrana kolumna kontrolna pozwala uniknąć takiego błędu.

Schemat połączeń między klockami pokazuje węzeł główny i osobne, niedopasowane odwołanie.

Filtr w ON i filtr w WHERE dają różne wyniki

Załóżmy, że chcę pokazać wszystkich pracowników, ale imię przełożonego tylko wtedy, gdy ten należy do zespołu core. Warunek na przełożonego umieszczam w ON:

-- example: on-filter
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees AS e
LEFT JOIN employees AS m
    ON e.manager_id = m.id AND m.team = 'core'
ORDER BY e.id;
id employee manager
1 Ada NULL
2 Ben Ada
3 Cleo Ada
4 Dan NULL
5 Eva NULL

Dan nadal jest na liście, lecz jego przełożony Ben należy do ops, więc nie spełnia warunku dopasowania. Dostajemy NULL po prawej stronie. Warunek nie bada zespołu Dana; bada m.team, czyli zespół przełożonego.

Teraz przenoszę filtr do WHERE:

-- example: where-filter
SELECT e.id, e.name AS employee, m.name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.id
WHERE m.team = 'core'
ORDER BY e.id;
id employee manager
2 Ben Ada
3 Cleo Ada

Tym razem wynik zachowuje tylko osoby z dopasowanym przełożonym z core. Wiersze z NULL w m.team nie spełniają równości i odpadają. Skutek zależy od konkretnego predykatu: nie każdy filtr w WHERE zmienia charakter zewnętrznego złączenia w ten sam sposób. Przykładowo warunek szukający m.id IS NULL wybierałby właśnie niedopasowane wiersze.

Rozróżnienie ON i WHERE opisuje także dokumentacja PostgreSQL. Korzystam z niej jako źródła wyjaśnienia; wykonanie przykładów w tym materiale sprawdzono w SQLite, bez deklaracji testu w PostgreSQL.

Unikalne pary osób z jednego zespołu

SELF JOIN przydaje się również wtedy, gdy nie masz kolumny wskazującej przełożonego. Chcę utworzyć każdą parę osób należących do tego samego zespołu dokładnie raz:

-- example: pairs
SELECT a.id AS first_id, b.id AS second_id, a.team
FROM employees AS a
INNER JOIN employees AS b
    ON a.team = b.team AND a.id < b.id
ORDER BY a.id, b.id;
first_id second_id team
2 3 ops
2 4 ops
3 4 ops

Warunek a.team = b.team łączy osoby z tego samego zespołu. Warunek a.id < b.id wyklucza parę osoby z nią samą i wybiera jeden z dwóch możliwych kierunków. Dostaję parę 2-3, ale już nie 3-2. To dobry wzorzec dla par nieuporządkowanych, w których zamiana stron niczego nie zmienia.

Samo a.id <> b.id usunęłoby pary z samym sobą, lecz zachowałoby oba kierunki. Z kolei pominięcie warunku na ID dałoby w trzyosobowym zespole dziewięć kombinacji, w tym trzy połączenia osoby z samą sobą. Zastanów się więc, czy twój problem dotyczy relacji kierunkowej, czy par bez wyróżnionej strony.

Dla zespołu liczącego n osób liczba takich nieuporządkowanych par wynosi n × (n - 1) / 2. Trzy osoby dają trzy pary, cztery osoby sześć. Przy dużych grupach wynik może szybko rosnąć. To liczba wierszy wynikająca z definicji zadania, nie uniwersalna ocena szybkości konkretnego silnika. Plan zapytania i czas wykonania trzeba sprawdzić na własnych danych.

Granice przykładu i szybkie sprawdzenie wyniku

Jedno złączenie pokazuje jeden poziom relacji: pracownik i jego bezpośredni przełożony. Nie rozwija automatycznie całej hierarchii. Do przejścia przez dowolną liczbę poziomów potrzebujesz innego rozwiązania, na przykład rekurencyjnego CTE, oraz zasad obsługi cykli.

Przed użyciem wzorca w raporcie sprawdź trzy rzeczy: znaczenie obu aliasów, unikalność klucza dopasowania i oczekiwaną liczbę wierszy. Potem dodaj przypadek bez powiązania. Jeśli raport ma obejmować wszystkich pracowników, po takim teście nadal powinieneś widzieć wiersz tej osoby.

Nazwy aliasów w przykładach są prostymi identyfikatorami bez cudzysłowów. Apostrofy służą tu do zapisu wartości tekstowych, takich jak 'core'. Nie korzystam z akceptowanych przez niektóre silniki wyjątków pozwalających zapisywać aliasy jak literały tekstowe.

Jeśli dopiero utrwalasz podstawy łączenia danych, zobacz też przykład CROSS JOIN. Porównanie liczby kombinacji ułatwia zauważenie, co faktycznie ogranicza warunek ON w samodzielnym złączeniu tabeli.

Najważniejsze w pigułce

Czy SELF JOIN jest osobnym słowem kluczowym SQL?

Nie. To nazwa wzorca, w którym ta sama tabela występuje po obu stronach zwykłego JOIN. Aliasy rozróżniają role, a warunek ON określa dopasowanie.

Dlaczego LEFT JOIN pokazuje pracownika bez przełożonego?

LEFT JOIN zachowuje wiersz z lewej strony także bez dopasowania. Kolumny prawej strony mają wtedy NULL. INNER JOIN pomija taki wiersz.

Jak uniknąć par A-B i B-A przy łączeniu tabeli z nią samą?

Dla par nieuporządkowanych użyj nierówności między unikalnymi ID, na przykład a.id < b.id. Wyklucza ona pary z samym sobą i zachowuje jeden kierunek każdej pary.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.