Dacă există un subiect în SQL care separă „cineva care știe puțin SQL” de „cineva care chiar poate folosi o bază de date relațională”, sunt JOIN-urile. JOIN-urile sunt motivul pentru care există R-ul din RDBMS. Sunt modul în care combini Sales.Orders cu Sales.Customer ca să vezi care client a plasat fiecare comandă, cum legi Inventory.Stock de Catalog.Product ca să vezi ce e de fapt în depozit, cum legi un Support.Ticket înapoi la Shipping.Shipment care a făcut pe cineva să trimită email.
Business-ul Runehold rulează pe JOIN-uri. Marketing-ul vrea „top clienți cu cele mai mari cheltuieli din Italia”. Operațiunile vor „comenzile livrate cu întârziere săptămâna asta”. Finanțele vor „venituri pe cotă de TVA pe țară din ultimul trimestru”. Niciuna din astea nu sunt întrebări dintr-un singur tabel. Toate sunt JOIN-uri.
Erezia diagramei Venn
Ai văzut diagramele Venn. Două cercuri suprapuse. „INNER JOIN e intersecția, LEFT JOIN e cercul stâng plus suprapunerea.” Diagramele sunt pe larg răspândite, în mare parte greșite și o să-ți explic modelul mental real pentru că face JOIN-urile mai grele triviale ulterior.
Adevărul: JOIN-urile produc un rând pentru fiecare combinație validă de rânduri din tabelele de intrare. Uneori asta e intersecția mulțimilor; alteori e o multiplicare. Diagramele Venn implică gândire bazată pe mulțimi, când JOIN-urile sunt de fapt combinatorice de rânduri.
Păstrează imaginea Venn ca mnemonică aproximativă dacă te ajută. Dar când se întâmplă ceva ciudat cu JOIN-urile, recurge la modelul combinatoric al rândurilor. Voi arăta exemple.
INNER JOIN: rânduri care se potrivesc pe ambele părți
INNER JOIN (sau doar JOIN — același lucru) returnează rânduri care au o potrivire în ambele tabele pe baza condiției de JOIN.
-- Orders with the customer's name and country
SELECT o.OrderId,
o.Total,
c.Name,
c.Country
FROM Sales.Orders AS o
INNER JOIN Sales.Customer AS c
ON c.CustomerId = o.CustomerId;
Dacă o comandă are un CustomerId dar acel client nu există în Sales.Customer (rar, datorită cheii străine), comanda este exclusă. Dacă un client nu are comenzi, este exclus. Doar perechile în care ambele părți se potrivesc apar.
ON <condition> este modul în care îi spui motorului ce înseamnă „potrivire”. De obicei e o egalitate între chei, dar poate fi orice expresie booleană. ON c.CustomerId = o.CustomerId e tiparul standard „leagă pe cheia străină”.
LEFT JOIN: tot ce-i din stânga, plus orice potrivire din dreapta
Adesea scris ca LEFT OUTER JOIN, dar OUTER e opțional și toată lumea îl sare.
-- Every customer, with their orders if they have any
SELECT c.CustomerId,
c.Name,
o.OrderId,
o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId;
Clienții fără comenzi tot apar în rezultat. Coloanele din o sunt NULL pentru acele rânduri. Asta e crucial pentru întrebări precum „care clienți nu au comandat niciodată?” și „care produse nu s-au vândut niciodată?”:
-- Customers who have never ordered
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
WHERE o.OrderId IS NULL;
Filtrul o.OrderId IS NULL păstrează doar rândurile în care LEFT JOIN nu a reușit să găsească o potrivire. Acest tipar se numește anti-JOIN și e modul în care răspunzi la fiecare întrebare „X fără Y”.
RIGHT JOIN: nimeni nu scrie asta
RIGHT JOIN este oglinda lui LEFT JOIN — tot ce-i din partea dreaptă, plus potrivirile din stânga. Există de dragul completitudinii.
În practică, nu scrii niciodată RIGHT JOIN în cod nou. De fiecare dată când ești tentat, întoarce ordinea tabelelor și folosește LEFT JOIN — mult mai ușor de citit:
-- These are equivalent:
SELECT ...
FROM A LEFT JOIN B ON ...
SELECT ...
FROM B RIGHT JOIN A ON ...
Consistența câștigă. Rămâi la LEFT.
FULL OUTER JOIN: ambele părți, plus nepotrivirile
Returnează tot din ambele părți. Rândurile care se potrivesc pe ambele părți sunt combinate. Rândurile care nu se potrivesc pe o parte au NULL-uri pentru partea lipsă.
-- Products with sales this month, plus products that exist but didn't sell,
-- plus sales that reference products we no longer carry.
SELECT p.Sku,
p.Name,
s.OrderId,
s.Quantity
FROM Catalog.Product AS p
FULL OUTER JOIN Sales.OrderLine AS s
ON s.ProductId = p.ProductId
WHERE s.OrderDate >= '2026-04-01' OR s.OrderDate IS NULL;
FULL OUTER e cu adevărat util când cauți „nepotriviri pe oricare parte”. Îl folosesc cam o dată pe lună pentru rapoarte de reconciliere.
CROSS JOIN: totul ori totul
SELECT c.Name, p.Name
FROM Sales.Customer AS c
CROSS JOIN Catalog.Product AS p;
Fiecare client împerecheat cu fiecare produs. Dacă ai 50 de clienți și 8.000 de produse, primești 400.000 de rânduri. Util pentru:
- Completarea unei dimensiuni de date (fiecare dată × fiecare țară, pentru grile de raport fără goluri).
- Construirea de matrice (fiecare angajat × fiecare instruire necesară).
- Generarea de date de test.
Folosit rar în interogări de producție. Folosit constant în generarea de rapoarte.
Nu există clauză ON. Orice filtru vrei merge în WHERE.
Predicate de JOIN vs filtre WHERE
Subtil și important. Există o diferență între acestea două:
-- A: filter in the ON clause
SELECT c.Name, o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
AND o.CountryCode = 'IT';
-- B: filter in the WHERE clause
SELECT c.Name, o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
WHERE o.CountryCode = 'IT';
Pentru un INNER JOIN, acestea sunt echivalente. Pentru un LEFT JOIN — ce e mai sus — nu sunt.
Interogarea A: „fiecare client, cu comenzile lor IT dacă există; clienții fără comenzi IT tot apar, cu coloanele din o NULL.”
Interogarea B: „fiecare client, cu toate comenzile lor, apoi filtrează rândurile unde o.CountryCode nu e 'IT'.” WHERE o.CountryCode = 'IT' elimină rândurile unde o.CountryCode e NULL (de la umplerea cu null a outer JOIN-ului), deci clienții fără comenzi IT dispar complet. Ai transformat efectiv LEFT JOIN-ul înapoi într-un INNER JOIN.
Regula: filtrele pe tabelul din dreapta merg în clauza ON a unui LEFT JOIN. Filtrele pe tabelul din stânga merg în clauza WHERE. Filtrele care se aplică la ambele tabele sau care nu țin cont de tipul JOIN-ului merg în WHERE.
Acesta este cel mai comun bug de JOIN în cod real. Învață modelul mental corect o dată și se oprește.
JOIN-uri pe mai multe tabele
Poți înlănțui JOIN-uri pe mai multe tabele. Interogarea „raport de expediere” a Runehold:
SELECT o.OrderId,
c.Name AS CustomerName,
c.Country AS CustomerCountry,
s.TrackingNumber,
s.ShippedAt,
carr.Name AS Carrier
FROM Sales.Orders AS o
JOIN Sales.Customer AS c ON c.CustomerId = o.CustomerId
JOIN Shipping.Shipment AS s ON s.OrderId = o.OrderId
JOIN Shipping.Carrier AS carr ON carr.CarrierId = s.CarrierId
WHERE o.OrderDate >= '2026-04-01'
AND o.OrderDate < '2026-04-08'
ORDER BY s.ShippedAt DESC;
Fiecare nou JOIN adaugă mai multă filtrare (pentru INNER) sau mai multă opționalitate (pentru OUTER). Alias-urile sunt obligatorii aici — fără ele interogarea e imposibil de citit. Alege alias-uri scurte și memorabile. Nu folosi t1, t2, t3. Folosește o, c, s, carr.
Self-JOIN-uri
Un tabel JOIN-uit cu el însuși — comun pentru interogări ierarhice și tipare „compară un rând cu alt rând din același tabel”.
Angajații Runehold au manageri care sunt și ei angajați:
SELECT e.FullName AS Employee,
e.Team AS Team,
m.FullName AS Manager
FROM HR.Employee AS e
LEFT JOIN HR.Employee AS m
ON m.EmployeeId = e.ManagerId;
Același tabel, aliasat ca e (employee) și m (manager), JOIN-uit pe cheia străină ManagerId. Asta e self-JOIN și e perfect. Orice tipar bazat pe rânduri — „găsește clienți care au alt client în același oraș cu LTV mai mare” — e un self-JOIN.
EXISTS și NOT EXISTS: semi-JOIN-uri și anti-JOIN-uri
Uneori nu vrei coloanele JOIN-uite, doar informația de apartenență: „există măcar o comandă pentru acest client?”
-- Customers with at least one order
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
WHERE EXISTS (
SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);
-- Customers with NO orders
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
WHERE NOT EXISTS (
SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);
EXISTS este un semi-JOIN: păstrează rândurile din stânga acolo unde există o potrivire pe dreapta, dar nu duplică rândurile dacă există mai multe potriviri. Comparat cu JOIN:
JOINpoate multiplica rânduri: un client cu cinci comenzi → cinci rânduri.EXISTSpăstrează un rând per potrivire pe partea stângă.
NOT EXISTS este anti-JOIN-ul — păstrează rândurile fără potrivire. Mai sigur decât NOT IN când e implicat NULL (am acoperit asta în lecția 7).
Regulă: folosește EXISTS / NOT EXISTS când ai nevoie doar de apartenență, nu de date din partea cealaltă. Mai curat, de obicei mai rapid și nu este susceptibil la bug-ul „mi-am multiplicat din greșeală rândurile”.
Rulează asta pe propria mașină
USE Runehold;
GO
-- Extend our data with a fresh Customer having zero orders
INSERT INTO Sales.Customer (Name, Country)
VALUES ('Zero-order Zoe', 'ES');
-- Query 1: INNER JOIN — orders + customer info, only matching rows
SELECT o.OrderId,
c.Name,
o.Total,
o.CountryCode
FROM Sales.Orders AS o
JOIN Sales.Customer AS c
ON c.CustomerId = o.CustomerId
ORDER BY o.OrderId;
-- Query 2: LEFT JOIN — every customer, with orders or NULL
SELECT c.CustomerId,
c.Name,
c.Country,
o.OrderId,
o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
ORDER BY c.CustomerId;
-- Query 3: the "customers with no orders" anti-join
SELECT c.CustomerId, c.Name, c.Country
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
WHERE o.OrderId IS NULL;
-- Query 4: same, but with NOT EXISTS (equivalent, often clearer)
SELECT c.CustomerId, c.Name, c.Country
FROM Sales.Customer AS c
WHERE NOT EXISTS (
SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);
-- Query 5: ON vs WHERE demo — spot the difference
-- "A" keeps customers without Italian orders, with NULLs
SELECT c.Name, o.OrderId, o.CountryCode
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
AND o.CountryCode = 'IT'
ORDER BY c.CustomerId;
-- "B" effectively becomes an INNER JOIN, filter drops non-matches
SELECT c.Name, o.OrderId, o.CountryCode
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
ON o.CustomerId = c.CustomerId
WHERE o.CountryCode = 'IT'
ORDER BY c.CustomerId;
Rulează interogările 5A și 5B. Numără rândurile. Convinge-te de ce diferă. Asta este exact capcana în care vei intra în cod real.
Lecția următoare: GROUP BY, HAVING și agregate — construind pe JOIN-urile din această lecție pentru a scrie în sfârșit interogări reale de business cum ar fi „venitul total pe țară” și „valoarea medie a comenzii pe nivel de client”.