SQL JOIN'ler 2: Self Join, Non-Equi Join, Semi/Anti Join ve CROSS APPLY ile LATERAL
Aynı siparişin kalemlerini ve ödemelerini birlikte join'lemek toplamları çarpar (fan-out); çözüm önce gruplamak sonra join'lemek. CROSS JOIN ile rapor iskeleti, self join ile kategori ağacı, aralıkla non-equi join, EXISTS ile semi join, NOT EXISTS ile anti join (planı LEFT JOIN … IS NULL ile aynı) ve her müşterinin son üç siparişi için T-SQL CROSS/OUTER APPLY ile PostgreSQL ve MySQL'deki standart LATERAL'ı gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, lehçe karşılaştırmaları PostgreSQL 18.6 ve MySQL 8.4 üzerinde çalıştırılmıştır.
Önce Tahmin Et: Bu Siparişin Tutarı Ne Kadar?
372 numaralı siparişte üç kitap var ve müşteri ödemeyi iki parçada yapmış: 200 TL hediye kartı, kalanı kartla. Siparişin kalem toplamını ve ödenen tutarı tek sorguda istiyoruz:
SELECT o.order_id,
SUM(oi.quantity * oi.unit_price) AS items_total,
SUM(p.amount) AS paid_total
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
JOIN dbo.payments AS p ON p.order_id = o.order_id
WHERE o.order_id = 372
GROUP BY o.order_id;
İki rakamın eşit çıkmasını bekliyoruz, çünkü seed verisinde her ödenmiş siparişin ödemeleri kalem toplamına eşit. Çıktı:
order_id items_total paid_total
-------- ----------- ----------
372 2263.00 3394.50
(1 rows affected)
Siparişin gerçek tutarı 1131,50 TL. Sorgu kalem toplamını iki katına, ödenen tutarı üç katına çıkardı. Join'lerin her biri tek başına doğru; hata ikisinin bir arada olmasında. Bu yazının son bölümünde bu fan-out'u parçalarına ayıracağız. Ondan önce önceki yazıda kurduğumuz iki varsayımı, yani join koşulunun hep eşitlik olduğu ve hep iki farklı table'ı birleştirdiğimizi kıracağız: CROSS JOIN, self join, non-equi join, EXISTS ile semi join, NOT EXISTS ile anti join ve CROSS APPLY ile standart LATERAL.
CROSS JOIN
CROSS JOIN koşulsuz bir join'dir: soldaki her row sağdaki her row'la eşleşir ve sonuç Cartesian product olur. Önceki yazıda virgüllü syntax'ta join koşulunu unutunca kazara elde ettiğimiz şeyi burada bilerek istiyoruz. Her müşteriye her Türkçe kitabı önermek için aday listesi:
SELECT COUNT(*) AS combinations
FROM dbo.customers AS c
CROSS JOIN dbo.books AS b
WHERE b.language_code = 'tr';
combinations
------------
160
(1 rows affected)
40 müşteri × 4 kitap = 160 aday. Gerçek bir öneri sistemi bu listeden müşterinin zaten aldıklarını anti join ile çıkarır; birazdan göreceğiz. CROSS JOIN'in en yaygın meşru kullanımı rapor iskeleti üretmek: her dil ve her sipariş durumu için bir satır olsun, verisi olmayan kombinasyonlar da sıfırla görünsün.
SELECT l.language_code, s.status
FROM (VALUES ('en'), ('tr')) AS l(language_code)
CROSS JOIN (VALUES ('Paid'), ('Shipped')) AS s(status)
ORDER BY l.language_code, s.status;
language_code status
------------- -------
en Paid
en Shipped
tr Paid
tr Shipped
(4 rows affected)
Bu iskeleti gerçek verilere LEFT JOIN ile bağladığında boş kombinasyonlar da raporda kalır. Dikkat edilecek tek şey boyut: iki büyük table'ın CROSS JOIN'i row sayılarının çarpımı kadar row üretir.
Self Join: Kategori ve Üst Kategorisi
categories table'ı kendine referans veriyor: parent_category_id aynı table'daki başka bir row'u gösteriyor. Bir kategorinin adını ve üst kategorisinin adını yan yana görmek için table'ı iki farklı alias'la iki kez kullanırız:
SELECT child.category_id, child.name AS category, parent.name AS parent_category
FROM dbo.categories AS child
LEFT JOIN dbo.categories AS parent
ON parent.category_id = child.parent_category_id
ORDER BY child.category_id;
category_id category parent_category
----------- --------------- ---------------
1 Fiction NULL
2 Classics Fiction
3 Science Fiction Fiction
4 Dystopian Science Fiction
5 Fantasy Fiction
6 Mystery Fiction
7 Poetry Fiction
8 Non-Fiction NULL
9 Science Non-Fiction
10 Computing Non-Fiction
11 Programming Computing
12 Algorithms Computing
13 Children NULL
(13 rows affected)
Alias'lar burada zorunlu: aynı table'ın iki kopyasını ayırt etmenin tek yolu bu. LEFT JOIN da bilinçli; INNER JOIN yazsaydık üst kategorisi olmayan üç kök kategori (Fiction, Non-Fiction, Children) kaybolurdu. Bir seviye daha yukarı çıkmak için bir self join daha eklenir:
SELECT c.name AS category, p.name AS parent, gp.name AS grandparent
FROM dbo.categories AS c
JOIN dbo.categories AS p ON p.category_id = c.parent_category_id
JOIN dbo.categories AS gp ON gp.category_id = p.parent_category_id;
category parent grandparent
----------- --------------- -----------
Dystopian Science Fiction Fiction
Programming Computing Non-Fiction
Algorithms Computing Non-Fiction
(3 rows affected)
Her seviye için bir join yazmak, ağacın derinliğini önceden bildiğin sürece çalışır. Derinlik değişkense (kullanıcıların sınırsız alt kategori açabildiği bir sistem) doğru araç recursive CTE (Common Table Expression); 12. yazıda bu ağacı breadcrumb yoluna çevireceğiz. Hiyerarşik verinin tasarım seçenekleri de Solution Pattern serisinin "SQL'de Hiyerarşik Veri: Adjacency List, Recursive CTE, Closure Table ve hierarchyid" yazısında.
Non-Equi Join: Fiyat Bantları
Join koşulu eşitlik olmak zorunda değil; ON'a herhangi bir koşul yazılabilir. Kitapları fiyat bantlarına ayırmak için bantları bir table olarak tanımlayıp aralık koşuluyla join'leyelim:
WITH price_bands AS (
SELECT band, min_price, max_price
FROM (VALUES ('budget', 0.00, 300.00),
('standard', 300.00, 700.00),
('premium', 700.00, 1000.00),
('collector', 1000.00, 99999.99)) AS v(band, min_price, max_price)
)
SELECT pb.band, COUNT(b.book_id) AS book_count, MIN(b.list_price) AS cheapest, MAX(b.list_price) AS most_expensive
FROM price_bands AS pb
LEFT JOIN dbo.books AS b
ON b.list_price >= pb.min_price
AND b.list_price < pb.max_price
GROUP BY pb.band, pb.min_price
ORDER BY pb.min_price;
band book_count cheapest most_expensive
--------- ---------- -------- --------------
budget 14 200.00 290.90
standard 52 300.50 680.50
premium 4 890.00 950.00
collector 7 1150.00 1450.00
(4 rows affected)
14 + 52 + 4 + 7 = 77: her kitap tam bir banda düştü, çünkü aralıklar 6. yazıdaki yarı açık aralık kuralıyla yazıldı (>= alt sınır, < üst sınır). BETWEEN kullansaydık tam 300,00 TL'lik bir kitap iki banda birden düşer ve toplam 77'yi geçerdi. 7. yazıdaki CASE ile aynı işi yaptık, ama bu kez bantlar veri: bir bant eklemek ya da sınırı değiştirmek sorguyu değil, bir table'ı değiştirir. Non-equi join'ler veri kontrolü için de kullanışlı. "Katalog listeleme tarihinden önce sipariş edilmiş kitap var mı?" sorusu JOIN dbo.books AS b ON b.book_id = oi.book_id AND CAST(o.ordered_at AS DATE) < b.listed_on koşuluyla sorulduğunda Bookstore'da sıfır row dönüyor; verinin tutarlı olduğunun kanıtı.
Semi Join: EXISTS
"En az bir Programming kitabı almış müşteriler" sorusu bir semi join: sol table'dan, sağda en az bir eşleşmesi olan row'ları istiyoruz, ama sağ table'ın column'larını değil. Join ile yazınca ne olduğuna bakalım (çıktıyı kısa tutmak için ilk üç müşteriyle):
SELECT c.customer_id, c.first_name
FROM dbo.customers AS c
JOIN dbo.orders AS o ON o.customer_id = c.customer_id
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE b.category_id = 11
AND c.customer_id <= 3
ORDER BY c.customer_id;
16 row geliyor: Ada 4 kez, Alan 2 kez, Grace 10 kez. Her eşleşen sipariş kalemi müşteriyi bir kez daha getiriyor. Önceki yazıda gördüğümüz gibi refleks DISTINCT eklemek olur; doğru araç ise EXISTS:
SELECT c.customer_id, c.first_name
FROM dbo.customers AS c
WHERE EXISTS (
SELECT 1
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE o.customer_id = c.customer_id
AND b.category_id = 11
)
AND c.customer_id <= 3
ORDER BY c.customer_id;
customer_id first_name
----------- ----------
1 Ada
2 Alan
3 Grace
(3 rows affected)
EXISTS bir subquery'nin en az bir row döndürüp döndürmediğini sorar; kaç row döndürdüğü önemli değildir, SELECT 1 yazmamızın sebebi de bu. Her müşteri sonuçta en fazla bir kez görünür. Bütün müşteriler için iki yazımın row sayıları: join 58, EXISTS 25. Fark hem doğruluk hem de iş: DISTINCT'li join önce 58 row üretip sonra tekilleştirir, EXISTS ise bir müşteri için ilk eşleşmeyi bulunca o müşteri için aramayı bırakabilir. Subquery dış sorgudaki c.customer_id'ye referans verdiği için correlated subquery; bu kavramı 11. yazıda ayrıntılı ele alacağız.
Anti Join: NOT EXISTS ve LEFT JOIN … IS NULL
Anti join semi join'in tersi: sağda hiç eşleşmesi olmayan sol row'lar. Katalogda kitabı olmayan yazarları üç yolla soralım:
SELECT
(SELECT COUNT(*) FROM dbo.authors AS a
WHERE NOT EXISTS (SELECT 1 FROM dbo.book_authors AS ba WHERE ba.author_id = a.author_id)) AS not_exists,
(SELECT COUNT(*) FROM dbo.authors AS a
LEFT JOIN dbo.book_authors AS ba ON ba.author_id = a.author_id
WHERE ba.author_id IS NULL) AS left_join_null,
(SELECT COUNT(*) FROM dbo.authors AS a
WHERE a.author_id NOT IN (SELECT ba.author_id FROM dbo.book_authors AS ba)) AS not_in;
not_exists left_join_null not_in
---------- -------------- ------
2 2 2
(1 rows affected)
Üçü de 2 diyor: Octavia E. Butler ve Toni Morrison yazar listesinde var ama katalogda kitapları yok. NOT IN burada doğru sonuç verdi, çünkü book_authors.author_id primary key'in parçası ve NULL olamaz. Subquery'nin column'u nullable olsaydı tek bir NULL bütün sonucu sıfıra indirirdi; 6. yazıda bunu sabit bir listeyle gördük, 11. yazıda subquery ile göreceğiz. NOT EXISTS bu tuzağa düşmez. Peki NOT EXISTS ile LEFT JOIN … IS NULL arasında seçim? SQL Server ikisi için aynı planı üretiyor:
SET SHOWPLAN_TEXT ON;
GO
SELECT a.author_id FROM dbo.authors AS a
WHERE NOT EXISTS (SELECT 1 FROM dbo.book_authors AS ba WHERE ba.author_id = a.author_id);
SELECT a.author_id FROM dbo.authors AS a
LEFT JOIN dbo.book_authors AS ba ON ba.author_id = a.author_id
WHERE ba.author_id IS NULL;
GO
SET SHOWPLAN_TEXT OFF;
|--Nested Loops(Left Anti Semi Join, OUTER REFERENCES:([a].[author_id]))
|--Clustered Index Scan(OBJECT:([sqlt_09].[dbo].[authors].[PK_authors] AS [a]))
|--Top(TOP EXPRESSION:((1)))
|--Clustered Index Scan(OBJECT:([sqlt_09].[dbo].[book_authors].[PK_book_authors] AS [ba]), WHERE:([sqlt_09].[dbo].[book_authors].[author_id] as [ba].[author_id]=[sqlt_09].[dbo].[authors].[author_id] as [a].[author_id]))
İki sorgu için de plan birebir bu: Left Anti Semi Join. Optimizer LEFT JOIN … IS NULL'ın niyetini tanıyıp anti join'e çevirdi. Bu her durumda garanti değil; daha karmaşık sorgularda LEFT JOIN önce bütün eşleşmeleri üretip sonra filtreleyebilir. NOT EXISTS ise niyeti doğrudan söyler, NULL tuzağı yoktur ve okuyana "yokluk" sorusu sorduğunu açıkça gösterir. Varsayılan tercihin o olsun.
CROSS APPLY, OUTER APPLY ve LATERAL
"Her müşterinin son üç siparişi" sıradan bir join ile yazılamaz, çünkü "son üç" müşteriye göre değişen bir alt sorgu. İlk deneme genelde şudur:
SELECT c.customer_id, last3.order_id
FROM dbo.customers AS c
JOIN (
SELECT TOP (3) o.order_id, o.ordered_at
FROM dbo.orders AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.ordered_at DESC
) AS last3 ON 1 = 1;
Msg 4104, Level 16, State 1, Server b8be943222fa, Line 6
The multi-part identifier "c.customer_id" could not be bound.
Derived table, yani FROM'daki subquery, solundaki table'ları göremez; her biri bağımsız değerlendirilir. Standart SQL:1999 bunun için LATERAL anahtar kelimesini getirdi: lateral bir subquery solundaki table'ın o anki row'unu görür. T-SQL'deki karşılığı SQL Server 2005'ten beri var olan APPLY:
SELECT c.customer_id, c.first_name, last3.order_id, last3.ordered_at
FROM dbo.customers AS c
CROSS APPLY (
SELECT TOP (3) o.order_id, o.ordered_at
FROM dbo.orders AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.ordered_at DESC, o.order_id DESC
) AS last3
WHERE c.customer_id IN (1, 3, 37)
ORDER BY c.customer_id, last3.ordered_at DESC;
customer_id first_name order_id ordered_at
----------- ---------- -------- -------------------
1 Ada 369 2026-06-10 21:27:57
1 Ada 357 2026-05-14 05:50:22
1 Ada 356 2026-05-06 13:11:46
3 Grace 377 2026-06-20 16:58:05
3 Grace 375 2026-06-19 17:38:41
3 Grace 371 2026-06-14 10:23:52
(6 rows affected)
Subquery her müşteri için bir kez değerlendiriliyor ve c.customer_id'yi görüyor. 5. yazıdaki tie-breaker kuralı burada da geçerli: aynı saniyede iki sipariş olabileceği için order_id DESC ekledik. 37 numaralı Mert sonuçta yok, çünkü hiç siparişi yok ve CROSS APPLY bir INNER JOIN gibi davranıyor. OUTER APPLY ise LEFT JOIN gibi davranır ve Mert'i NULL'larla birlikte yedinci row olarak getirir.
Aynı sorgunun standart yazımı LEFT JOIN LATERAL (...) AS last3 ON true. PostgreSQL 18.6 subquery'de FETCH FIRST 3 ROWS ONLY ile, MySQL 8.4 ise LIMIT 3 ile aynı yedi row'u döndürüyor (Mert NULL'larla). SQL Server LATERAL'ı tanımıyor ve Incorrect syntax near the keyword 'SELECT' hatası veriyor; T-SQL'de bu iş yalnız APPLY ile yapılır. APPLY'ın bir kullanımı daha var: bir ifadeyi bir kez hesaplayıp hem SELECT'te hem WHERE'de isimle kullanmak. 5. yazıda WHERE'de kullanamadığımız price_with_tax alias'ı CROSS APPLY (SELECT b.list_price * 1.10 AS price_with_tax) AS x ile WHERE x.price_with_tax > 1500 şeklinde kullanılabiliyor ve sorgu Design Patterns ile The Art of Computer Programming'i döndürüyor. Grup başına top-N'nin diğer çözümü ROW_NUMBER(); ikisini 13. yazıda karşılaştıracağız.
Fan-Out: İki One-to-Many İlişkiyi Aynı Anda Join'lemek
Açılıştaki sorguya dönelim. Siparişin kalemlerini ve ödemelerini aynı anda join'leyince ne olduğunu row row görelim:
SELECT oi.book_id, oi.quantity * oi.unit_price AS line_total, p.method, p.amount
FROM dbo.order_items AS oi
JOIN dbo.payments AS p ON p.order_id = oi.order_id
WHERE oi.order_id = 372
ORDER BY oi.book_id, p.method;
book_id line_total method amount
------- ---------- --------- ------
43 300.50 card 931.50
43 300.50 gift_card 200.00
48 430.00 card 931.50
48 430.00 gift_card 200.00
53 401.00 card 931.50
53 401.00 gift_card 200.00
(6 rows affected)
Üç kalem × iki ödeme = altı row. Kalemler ve ödemeler arasında doğrudan bir ilişki yok; ikisi de siparişe bağlı, ve join her kalemi her ödemeyle eşleştirdi. Bu altı row üzerinden SUM(line_total) her kalemi iki kez, SUM(amount) her ödemeyi üç kez sayıyor. Bu fan-out (ya da "chasm trap") hata vermez, sonuç makul görünen ama yanlış bir sayıdır. Bütün siparişler üzerinden aynı join kalem toplamını 450.586,11 TL, ödeme toplamını 821.113,71 TL gösteriyor; doğru toplamlar ise ikisi de 361.707,08 TL. Raporun ödeme tarafı iki kattan fazla şişmiş.
Çözüm, her one-to-many tarafı join'den önce sipariş tanesine indirmek, yani önce gruplamak sonra join'lemek:
SELECT o.order_id, i.items_total, p.paid_total
FROM dbo.orders AS o
JOIN (SELECT order_id, SUM(quantity * unit_price) AS items_total
FROM dbo.order_items
GROUP BY order_id) AS i ON i.order_id = o.order_id
JOIN (SELECT order_id, SUM(amount) AS paid_total
FROM dbo.payments
GROUP BY order_id) AS p ON p.order_id = o.order_id
WHERE o.order_id = 372;
order_id items_total paid_total
-------- ----------- ----------
372 1131.50 1131.50
(1 rows affected)
Her derived table sipariş başına tek row üretiyor; artık join bire bir ve toplamlar doğru. Aynı işi correlated subquery'lerle ya da CROSS APPLY ile de yapabilirsin. Kural şu: aynı parent'a bağlı iki farklı child table'ı aynı sorguda join'leyip ikisinden de aggregate alıyorsan, en az birini join'den önce parent tanesine indir. 6. yazıdaki yorum oranı sorgusunda COUNT(DISTINCT ...) kullanmamızın sebebi de buydu; COUNT için DISTINCT bir yama olabilir, SUM için olamaz, çünkü iki farklı kalemin tutarı tesadüfen aynı olabilir.
Üretimde Dikkat
Fan-out en sık raporlarda ve dashboard'larda görülür ve test verisinde fark edilmez: test siparişlerinin çoğu tek ödemeli olduğu için toplamlar tutar, gerçek veride bölünmüş ödemeler başladığı gün rakamlar şişer. Her rapor sorgusunda sonucun tanesini (bir row neyi temsil ediyor?) bir yorum satırıyla yazmak bu hatayı erken yakalar. ORM (Object-Relational Mapping) tarafında aynı tuzak Entity Framework Core'da (EF Core) birden fazla koleksiyonu tek sorguda Include ettiğinde oluşur ve "cartesian explosion" olarak bilinir; EF Core serisinin "EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query" yazısındaki split query bunun çözümüydü. APPLY ve correlated subquery'ler dış sorgunun her row'u için çalışabilir; dış taraf büyükse ve iç tarafta uygun bir index yoksa maliyet hızla artar. orders (customer_id, ordered_at) gibi bir index, "her müşterinin son üç siparişi" sorgusunu her müşteri için birkaç sayfalık bir okumaya indirir; index tasarımı "SQL Server Derinlemesine" serisinin konusu.
Sık Düşülen Tuzaklar
- Aynı parent'a bağlı iki child table'ı join'leyip ikisinden de
SUMalmak; toplamlar çarpılır. - Semi join'i
JOIN+DISTINCTile yazmak; önce gereksiz row'lar üretilir, sonra atılır. - Anti join'i nullable bir column üzerinde
NOT INile yazmak. - Self join'de
INNER JOINkullanıp kök row'ları (üst kategorisi olmayanları) kaybetmek. - Aralık join'lerinde
BETWEENkullanıp sınırdaki değerleri iki gruba birden düşürmek. - Derived table'dan dış sorgunun column'una referans vermeye çalışmak; bunun için
APPLYya daLATERALgerekir. CROSS APPLYkullanıp eşleşmesi olmayan row'ları kaybetmek; onları korumak içinOUTER APPLY.
Küçük Bir Deneme
★☆☆ Her yazarın adını ve yazdığı kitap sayısını listele; kitabı olmayan iki yazar 0 ile görünsün. Birden fazla yazarı olan kitaplar (Design Patterns gibi) her yazar için ayrı sayılmalı.
★★☆ Her kategori için en pahalı iki kitabı CROSS APPLY ile listele. Kitabı olmayan kategoriler de görünsün diye sorguyu OUTER APPLY'a çevir ve row sayısındaki farkı açıkla.
★★★ Her müşteri için sipariş sayısını, toplam kalem tutarını ve toplam ödeme tutarını tek sorguda hesapla. Önce bilerek fan-out'lu yazımı yap ve hangi müşterilerde rakamların şiştiğini bul; sonra pre-aggregation ile düzelt ve ödenmiş siparişlerde iki toplamın eşit olduğunu doğrula.
Bu yazıda EXISTS, NOT EXISTS ve NOT IN'e subquery'lerle değdik, fan-out'u da GROUP BY ile çözdük. Serinin bir sonraki yazısında aggregate'lerin kendisine geçiyoruz: GROUP BY, HAVING, COUNT'un üç biçimi, SUM(CASE ...) ile conditional aggregation ve ROLLUP ile ara toplamlar.