Mert Özen Her Satırda Daha İleri
Veritabanı SQL Temelleri — #09

SQL JOIN'ler 2: Self Join, Non-Equi Join, Semi/Anti Join ve CROSS APPLY ile LATERAL

Mert Özen 8 Kas 2026 13 dk 2 görüntülenme
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 SUM almak; toplamlar çarpılır.
  • Semi join'i JOIN + DISTINCT ile yazmak; önce gereksiz row'lar üretilir, sonra atılır.
  • Anti join'i nullable bir column üzerinde NOT IN ile yazmak.
  • Self join'de INNER JOIN kullanıp kök row'ları (üst kategorisi olmayanları) kaybetmek.
  • Aralık join'lerinde BETWEEN kullanı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 APPLY ya da LATERAL gerekir.
  • CROSS APPLY kullanıp eşleşmesi olmayan row'ları kaybetmek; onları korumak için OUTER 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.