Mert Özen Her Satırda Daha İleri
Backend LINQ — #10

LINQ Join ve GroupJoin: Inner Join, Composite Key ve Left Join Pattern'ı

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
LINQ Join ve GroupJoin: Inner Join, Composite Key ve Left Join Pattern'ı

Join'in inner join olarak eşleşmeyeni düşürüp çoklu eşleşmeyi çoğaltması ve dokümante edilen sırası. Query syntax'ta equals'ın yönü ve CS1937/CS1938, composite key ve CS1941, GroupJoin ile hiyerarşik sonuç, GroupJoin + DefaultIfEmpty ile left join pattern'ı ve null tuzağı, çoklu join ve Join'in lookup ile n + m'de çalışması.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Bu yazıda kitapçının müşterileri, siparişleri ve sipariş satırları var. Gerçek bir veritabanından gelmiş gibi, tablolar birbirine id'lerle bağlı. Aşağıdaki dosyayı Data.cs adıyla Program.cs'in yanına koy:

record Customer(int Id, string Name, string City);
record Order(int Id, int CustomerId, DateOnly Date);
record OrderItem(int OrderId, string Isbn, int Quantity, decimal UnitPrice);
record ReturnRequest(int OrderId, string Isbn, string Reason);
record Book(string Isbn, string Title, string Author);

static class Store
{
    public static List<Customer> Customers { get; } = new()
    {
        new(1, "Ada", "London"),
        new(2, "Alan", "Manchester"),
        new(3, "Grace", "New York"),
        new(4, "Linus", "Helsinki"),
    };

    public static List<Order> Orders { get; } = new()
    {
        new(101, 1, new DateOnly(2026, 10, 3)),
        new(102, 2, new DateOnly(2026, 10, 5)),
        new(103, 1, new DateOnly(2026, 10, 9)),
        new(104, 9, new DateOnly(2026, 10, 12)), // 9 numaralı müşteri yok
        new(105, 3, new DateOnly(2026, 10, 20)),
    };

    public static List<OrderItem> OrderItems { get; } = new()
    {
        new(101, "978-0441172719", 1, 12.99m),
        new(101, "978-0451524935", 2, 8.99m),
        new(102, "978-0261102217", 1, 10.99m),
        new(103, "978-0441172719", 1, 12.99m),
        new(104, "978-0553293357", 3, 9.99m),
        new(105, "978-0451524935", 1, 8.99m),
        new(105, "978-0553382563", 2, 7.99m),
    };

    public static List<ReturnRequest> Returns { get; } = new()
    {
        new(101, "978-0451524935", "Damaged cover"),
        new(105, "978-0553382563", "Wrong edition"),
    };

    public static List<Book> Books { get; } = new()
    {
        new("978-0441172719", "Dune", "Frank Herbert"),
        new("978-0553293357", "Foundation", "Isaac Asimov"),
        new("978-0451524935", "1984", "George Orwell"),
        new("978-0261102217", "The Hobbit", "J.R.R. Tolkien"),
        new("978-0553382563", "I, Robot", "Isaac Asimov"),
    };
}

Dört müşteri ve beş sipariş var. Müşterileri siparişleriyle eşleştiriyoruz. Bu kod kaç satır yazdırır ve hangi sırayla?

IEnumerable<string> rows = Store.Customers.Join(
    Store.Orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => $"{c.Name} #{o.Id}");

foreach (string row in rows)
{
    Console.WriteLine(row);
}

Console.WriteLine($"Rows: {rows.Count()}, customers: {Store.Customers.Count}, orders: {Store.Orders.Count}");

Inner Join: Eşleşmeyen Düşer, Çoklu Eşleşme Çoğalır

Çıktı:

Ada #101
Ada #103
Alan #102
Grace #105
Rows: 4, customers: 4, orders: 5

Dört müşteri ve beş siparişten dört satır çıktı, ama bu dört satır ne "her müşteri" ne de "her sipariş". Ada iki siparişi olduğu için iki kez göründü. Linus'un siparişi olmadığı için hiç görünmedi. 104 numaralı sipariş de var olmayan 9 numaralı müşteriye ait olduğu için düştü. Join, SQL'deki INNER JOIN'in karşılığıdır: sadece iki tarafta da eşi olan element'ler sonuca girer ve her eşleşen çift için bir satır üretilir.

Sıra da dokümante edilmiş bir davranış: Join, ilk sequence'ın (outer) sırasını korur ve her outer element için eşleşen ikinci sequence (inner) element'lerini kendi sıralarıyla verir. Bu yüzden Ada'nın iki siparişi yan yana geldi, ardından Alan ve Grace. Parametrelerin sırası da bu yapıyı izliyor: önce inner sequence, sonra outer key selector, sonra inner key selector, en son iki eşleşen element'ten sonuç üreten result selector.

Query Syntax ve equals'ın Yönü

Join, query syntax'ın method syntax'tan daha okunaklı olduğu nadir yerlerden biri:

IEnumerable<string> rows =
    from c in Store.Customers
    join o in Store.Orders on c.Id equals o.CustomerId
    where o.Date.Day < 10
    select $"{c.Name} #{o.Id} {o.Date:yyyy-MM-dd}";

Console.WriteLine(string.Join(" | ", rows));
Ada #101 2026-10-03 | Ada #103 2026-10-09 | Alan #102 2026-10-05

Dikkat edilecek iki şey var. Birincisi, karşılaştırma == ile değil equals keyword'üyle yazılır; çünkü bu genel bir koşul değil, key'lere dayalı bir eşitlik eşleşmesidir (equijoin). İkincisi, equals'ın sol tarafında outer range variable (c), sağ tarafında inner range variable (o) olmalı. Taraflar yer değiştirirse derleyici durdurur:

IEnumerable<string> rows =
    from c in Store.Customers
    join o in Store.Orders on o.CustomerId equals c.Id
    select $"{c.Name} #{o.Id}";
Program.cs(3,31): error CS1937: The name 'o' is not in scope on the left side of 'equals'.  Consider swapping the expressions on either side of 'equals'.
Program.cs(3,51): error CS1938: The name 'c' is not in scope on the right side of 'equals'.  Consider swapping the expressions on either side of 'equals'.

Bu sefer derleyici neyin yanlış olduğunu açıkça söylüyor, hatta çözümü de öneriyor: expression'ları yer değiştir. Kuralın sebebi, query syntax'ın doğrudan Join çağrısına çevrilmesi: sol taraf outer key selector'a, sağ taraf inner key selector'a gider ve her lambda sadece kendi tarafının değişkenini görür.

Composite Key ile Join

İade talepleri sipariş satırına bağlı ve bir satırı tek başına ne sipariş numarası ne de ISBN belirler; ikisi birlikte belirler. Key olarak bir anonymous type kullanırsın:

IEnumerable<string> returned =
    from i in Store.OrderItems
    join r in Store.Returns on new { i.OrderId, i.Isbn } equals new { r.OrderId, r.Isbn }
    select $"#{i.OrderId} {i.Isbn} x{i.Quantity}: {r.Reason}";

Console.WriteLine(string.Join(Environment.NewLine, returned));
#101 978-0451524935 x2: Damaged cover
#105 978-0553382563 x2: Wrong edition

İki anonymous type'ın eşit sayılması için property adları, tipleri ve sıraları aynı olmalı; derleyici ancak o zaman ikisini aynı tip olarak görür. İade tarafında property'ye farklı bir ad verirsen hata alırsın:

IEnumerable<string> returned =
    from i in Store.OrderItems
    join r in Store.Returns on new { i.OrderId, i.Isbn } equals new { r.OrderId, BookIsbn = r.Isbn }
    select r.Reason;
Program.cs(3,5): error CS1941: The type of one of the expressions in the join clause is incorrect.  Type inference failed in the call to 'Join'.

Bu mesaj CS1937'ye göre çok daha az yardımcı. "Type inference failed" diyor ama hangi tip olduğunu söylemiyor. Sebep, iki tarafın key'inin artık iki farklı anonymous type olması; Join'in tek bir TKey'i olduğu için derleyici ortak bir tip bulamıyor. CS1941'i composite key'li bir join'de görürsen ilk olarak iki taraftaki property adlarını yan yana koy ve karşılaştır. Aynı hata, tek field'lı key'lerde tipler uyuşmadığında da çıkar; örneğin bir tarafta int, diğer tarafta string bir id varsa. (int ile long ise sorun çıkarmaz; derleyici ortak tip olarak long'u seçer.)

GroupJoin: Her Müşteriye Siparişleri

Join düz bir tablo üretir. Bazen istediğin şey hiyerarşiktir: her müşteri ve onun sipariş listesi. GroupJoin her outer element'i, eşleşen inner element'lerin tamamıyla birlikte tek seferde verir:

IEnumerable<string> summary = Store.Customers.GroupJoin(
    Store.Orders,
    c => c.Id,
    o => o.CustomerId,
    (c, orders) => $"{c.Name}: [{string.Join(", ", orders.Select(o => o.Id))}], count: {orders.Count()}");

Console.WriteLine(string.Join(Environment.NewLine, summary));
Ada: [101, 103], count: 2
Alan: [102], count: 1
Grace: [105], count: 1
Linus: [], count: 0

Ada bir kez ve iki siparişiyle göründü. Asıl fark Linus'ta: siparişi olmamasına rağmen sonuçta yer aldı, sipariş listesi sadece boş. Dokümantasyon bunu açıkça söylüyor: bir outer element için eşleşme yoksa, eşleşmeler sequence'ı boş olur ama element yine sonuçta görünür. Gruplama yazısında gördüğümüz GroupBy ile benzer görünse de mantık farklı: GroupBy tek bir sequence'ı kendi key'ine göre parçalar, GroupJoin ise outer sequence'ın her element'ine ikinci bir sequence'tan bir grup iliştirir.

Left Join Pattern'ı: GroupJoin ve DefaultIfEmpty

SQL'deki LEFT JOIN düz satırlar üretir ve eşi olmayan sol satırları da tutar. .NET 6'da bunun hazır bir operatörü yok; dokümantasyon da left outer join'in GroupJoin ile yapılabileceğini söylüyor. Pattern üç parçadan oluşuyor:

var methodRows = Store.Customers
    .GroupJoin(Store.Orders, c => c.Id, o => o.CustomerId, (c, orders) => new { Customer = c, Orders = orders })
    .SelectMany(x => x.Orders.DefaultIfEmpty(), (x, o) => new { x.Customer.Name, OrderId = o?.Id }); // eşleşme yoksa o null

Console.WriteLine(string.Join(" | ", methodRows.Select(r => $"{r.Name} {r.OrderId?.ToString() ?? "-"}")));

IEnumerable<string> queryRows =
    from c in Store.Customers
    join o in Store.Orders on c.Id equals o.CustomerId into customerOrders
    from o in customerOrders.DefaultIfEmpty()
    select $"{c.Name} {(o is null ? "no orders" : $"#{o.Id}")}";

Console.WriteLine(string.Join(" | ", queryRows));

IEnumerable<int> orphanOrders = Store.Orders
    .ExceptBy(Store.Customers.Select(c => c.Id), o => o.CustomerId)
    .Select(o => o.Id);
Console.WriteLine($"Orders without a customer: {string.Join(", ", orphanOrders)}");
Ada 101 | Ada 103 | Alan 102 | Grace 105 | Linus -
Ada #101 | Ada #103 | Alan #102 | Grace #105 | Linus no orders
Orders without a customer: 104

GroupJoin her müşteriye siparişlerini iliştirir. DefaultIfEmpty(), boş bir sipariş listesini tek element'li bir listeye çevirir ve o tek element default değeri, yani reference type için null'dır. SelectMany da grupları tekrar düz satırlara açar. Sonuçta her siparişi olan müşteri sipariş sayısı kadar, Linus ise bir kez ve null bir siparişle göründü. Bu yüzden sonuç seçerken o?.Id ya da o is null kontrolü şart. Derleyiciye güvenme: aynı sorguyu query syntax ile select o.Id diye yazdığımda nullable reference types açık olmasına rağmen hiçbir uyarı çıkmadı, program ise Linus'a gelince NullReferenceException fırlattı.

10-join-groupjoin-tr.png

Query syntax'ta aynı pattern join ... into ve ardından gelen from o in customerOrders.DefaultIfEmpty() ile yazılır ve çoğu kişiye daha okunaklı gelir. Son satır ise ters yönü, yani müşterisi olmayan siparişleri bir önceki yazıdaki ExceptBy ile buluyor; bir right join'in eşleşmeyen tarafı da böyle elde edilebilir. .NET 10 ile LeftJoin ve RightJoin operatörleri geldi ve bu üç adımlık pattern'ı tek çağrıya indirdi; onları LINQ serisinin .NET 10 yazısında göreceğiz.

Birden Fazla Join

Gerçek raporlar genellikle ikiden fazla tabloyu birleştirir. Her müşterinin toplam harcamasını ve aldığı kitapları bulalım:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var revenue =
    from c in Store.Customers
    join o in Store.Orders on c.Id equals o.CustomerId
    join i in Store.OrderItems on o.Id equals i.OrderId
    join b in Store.Books on i.Isbn equals b.Isbn
    group new { b.Title, Amount = i.Quantity * i.UnitPrice } by c.Name into g
    orderby g.Sum(x => x.Amount) descending
    select new
    {
        Customer = g.Key,
        Total = g.Sum(x => x.Amount),
        Titles = string.Join(" / ", g.Select(x => x.Title).Distinct()),
    };

foreach (var r in revenue)
{
    Console.WriteLine($"{r.Customer}: {r.Total} ({r.Titles})");
}
Ada: 43.96 (Dune / 1984)
Grace: 24.97 (1984 / I, Robot)
Alan: 10.99 (The Hobbit)

Join'ler zincirleniyor: müşteri siparişe, sipariş satırlara, satır kitaba bağlanıyor. Her join önceki adımların bütün range variable'larını görebilir, bu yüzden son group cümlesinde hem c, hem i, hem b kullanılabildi. Inner join'ler zincirinde eşleşmeyen her şey sessizce düşer: Linus siparişi olmadığı için, 104 numaralı sipariş müşterisi olmadığı için rapora girmedi. Rapor "bütün müşteriler" diyorsa, ilk adımı left join'e çevirmen gerekir. Bu sorgu method syntax ile yazıldığında her join için ara anonymous type'lar gerekir; çoklu join'de query syntax gerçekten fark yaratır.

Join Nasıl Çalışır

Join'i iç içe döngüyle de yazabilirsin: her müşteri için siparişlerin tamamında Where. Sonuç aynı çıkar, ama yapılan iş aynı değildir:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

List<Customer> customers = Enumerable.Range(1, 1_000).Select(id => new Customer(id, $"Customer {id}", "London")).ToList();
List<Order> orders = Enumerable.Range(1, 10_000).Select(id => new Order(id, id % 1_000 + 1, new DateOnly(2026, 10, 1))).ToList();

int joinCalls = 0;
int joined = customers.Join(
    orders,
    c => { joinCalls++; return c.Id; },
    o => { joinCalls++; return o.CustomerId; },
    (c, o) => o.Id).Count();

int nestedCalls = 0;
int nested = customers.SelectMany(c => orders.Where(o =>
{
    nestedCalls++;
    return o.CustomerId == c.Id;
})).Count();

Console.WriteLine($"Join:  {joined} rows, {joinCalls:N0} key selector calls");
Console.WriteLine($"Where: {nested} rows, {nestedCalls:N0} predicate calls");
Join:  10000 rows, 11,000 key selector calls
Where: 10000 rows, 10,000,000 predicate calls

Bin müşteri ve on bin siparişte iki yol da aynı 10.000 satırı buldu. İç içe Where, her müşteri için on bin siparişin hepsine baktı: on milyon karşılaştırma. Join ise ilk enumerate edildiğinde inner sequence'ı bir kez dolaşıp key'lerine göre bir lookup kurar, sonra outer'daki her element için o lookup'ta tek bir hash araması yapar. Key selector'lar toplamda 11.000 kez çağrıldı. Bu, n × m ile n + m arasındaki fark; veri büyüdükçe uçurumlaşır. Bu yüzden bellekte iki listeyi eşleştirirken iç içe döngü ya da Where yerine Join kullan. Lookup inner taraf için kurulduğundan, küçük olan listeyi inner tarafa koymak bellek açısından daha ekonomiktir.

Veritabanında durum yine farklı. EF Core Join'i SQL'deki JOIN'e çevirir, ama orada çoğu zaman elle join yazmana gerek kalmaz: ilişkileri navigation property olarak tanımladıysan order.Customer.Name yazman yeterli, EF Core join'i kendisi üretir. Ayrıntılar EF Core serisinin ilişkiler ve LINQ sorguları yazılarında.

Sık Düşülen Tuzaklar

Join'in eşleşmeyen satırları tutacağını sanmak. Inner join'dir; eşi olmayan iki taraf da sessizce düşer. Raporda eksik müşteri varsa ilk bakacağın yer burası.

Çoklu eşleşmenin satırları çoğaltacağını unutmak. Bir müşterinin üç siparişi varsa müşteri üç kez görünür; müşteri bazında bir toplam alırken bunu hesaba kat ya da GroupJoin kullan.

Query syntax'ta equals'ın taraflarını karıştırmak. Sol taraf outer, sağ taraf inner; CS1937 ve CS1938 bunu söyler.

Composite key'lerde property adlarını farklı yazmak. İki anonymous type aynı tip sayılmaz ve CS1941 alırsın. Adları, tipleri ve sırayı eşitle.

Left join'de null'ı unutmak. DefaultIfEmpty()'den gelen element null olabilir; o.Id yerine o?.Id yaz.

Bellekte büyük listeleri iç içe Where ile eşleştirmek. n × m karşılaştırma yapar; Join ya da bir ToLookup n + m'de bitirir.

Küçük Bir Deneme

★☆☆ Sipariş satırlarını kitaplarla join'le ve her satırı "kitap adı, adet, satır tutarı" olarak yazdır. Kitap listesinde olmayan bir ISBN ekleyip o satırın düştüğünü gör.

★★☆ Her müşterinin sipariş sayısını ve toplam harcamasını, siparişi olmayan müşteriler 0 ile görünecek şekilde yazdır. Önce left join pattern'ıyla, sonra GroupJoin ile tek adımda çöz.

★★★ İade talepleri için composite key'li bir left join yaz: her sipariş satırı, iade edildiyse sebebiyle, edilmediyse "-" ile görünsün. Sonra aynı sonucu ToLookup ile kurduğun bir index üzerinden, join kullanmadan üret ve iki sonucun aynı olduğunu SequenceEqual ile kontrol et.

Join'lerle farklı kaynakları birleştirdik. Bir sonraki yazıda sequence'lar hakkında evet/hayır soruları soran Any, All, Contains ve SequenceEqual'a, ve sorgu sonuçlarını somut collection'lara çeviren ToList, ToArray, ToDictionary, ToHashSet, Cast ve OfType'a geçiyoruz.