TEKNİK YAZI / Developer Tools ve Açık Kaynak

LINQ: C# ile Bildirimsel Veri Sorgulama

LINQ ile C#'ta koleksiyon ve veritabanı sorguları: sorgu ve metot sözdizimi, ertelenmiş yürütme, IEnumerable ile IQueryable farkı ve EF Core çeviri sınırları.

LINQ (Language Integrated Query), koleksiyonlar ve sorgu sağlayıcıları üzerinde filtreleme, dönüştürme, gruplama ve sıralama işlemlerini C# dil yapılarıyla ifade etmeyi sağlar; C# 3.0 ve .NET Framework 3.5 ile birlikte kullanıma girmiştir. Sözdizimi aynı görünse de IEnumerable<T> üzerinde bellekte çalışan sorgular ile IQueryable<T> üzerinden başka bir dile (örneğin SQL’e) çevrilen sorgular farklı yürütme kurallarına sahiptir.

Aşağıda iki yazım biçimi, ertelenmiş yürütme, sağlayıcılar arasındaki farklar, EF Core’un çeviri sınırları ve LINQ’in elle yazılmış SQL ile karşılaştırması örneklerle ele alınıyor.

LINQ Hangi Sorunu Çözer?

İç içe döngüler, koşullar ve geçici listelerle yazılan bir veri dönüşümü LINQ ile daha bildirimsel ifade edilebilir. SQL yazdıysanız Where, OrderBy ve GroupBy gibi işlemler tanıdık gelecektir. LINQ yalnız veritabanı sorguları için değildir; diziler, listeler, sözlükler, XML ve IEnumerable<T> uygulayan her tip üzerinde çalışır.

LINQ’in standart sorgu operatörleri, fonksiyonel programlamadaki map, filter ve reduce işlemlerinin C# karşılığıdır: Select, Where ve Aggregate. Bu kısalık tek başına performans garantisi değildir; sorgunun ne zaman çalıştığını ve sağlayıcının ifadeyi nasıl çevirdiğini bilmek gerekir.

Sorgu Sözdizimi ve Metot Sözdizimi

LINQ iki biçimde yazılabilir. Sorgu sözdizimi (query syntax) SQL’e benzer; metot sözdizimi (method syntax) uzantı metotlarını zincirler. Yazının geri kalanındaki örnekler aşağıdaki products listesini kullanır:

using System;
using System.Collections.Generic;
using System.Linq;

var products = new List<Product>
{
    new(1, "Laptop", 1000m, "Electronics"),
    new(2, "Mouse", 20m, "Electronics"),
    new(3, "Book", 15m, "Education"),
    new(4, "Notebook", 12m, "Education"),
};

// Metot sözdizimi: filtrele, sırala, yansıt
var expensiveItems = products
    .Where(p => p.Price > 50m)
    .OrderByDescending(p => p.Price)
    .Select(p => new { p.Name, p.Price });

// Sorgu sözdizimi: aynı sonuç
var expensiveItemsQuery =
    from p in products
    where p.Price > 50m
    orderby p.Price descending
    select new { p.Name, p.Price };

foreach (var item in expensiveItemsQuery)
{
    Console.WriteLine(item); // { Name = Laptop, Price = 1000 }
}

public record Product(int Id, string Name, decimal Price, string Category);

C# derleyicisi sorgu sözdizimini metot çağrılarına çevirir; iki biçim anlam olarak aynıdır. Seçimde ölçüt okunurluk ve kapsamdır. Count, Sum, Max, First, Take, Skip ve Distinct gibi operatörlerin C# sorgu sözdiziminde anahtar kelimesi yoktur; bunlar metot çağrısıyla yazılır ya da sorgu parantez içine alınıp sonuna eklenir. Buna karşılık birden çok from, join veya let içeren sorgular sorgu sözdiziminde daha okunaklı olabilir. Art arda iki from yan tümcesi derleyici tarafından SelectMany çağrısına çevrilir; SelectMany, monad’lardaki flatMap işleminin LINQ’teki karşılığıdır.

Ertelenmiş Yürütme (Deferred Execution)

Where veya Select çağrısı sorguyu çalıştırmaz, yalnız tanımlar. Sorgu, sonuç gerçekten dolaşıldığında (foreach, ToList(), Count(), First() gibi) çalışır ve her dolaşmada yeniden çalışır:

var numbers = new List<int> { 1, 2, 3 };
var evens = numbers.Where(n => n % 2 == 0); // sorgu tanımlandı, çalışmadı

numbers.Add(4);
Console.WriteLine(string.Join(", ", evens)); // 2, 4

var snapshot = evens.ToList(); // sorgu şimdi çalışır, sonuç kopyalanır
numbers.Add(6);

Console.WriteLine(string.Join(", ", evens)); // 2, 4, 6 (yeniden çalıştı)
Console.WriteLine(string.Join(", ", snapshot)); // 2, 4

Bu davranış gereksiz işi önleyebilir, ama aynı sorgunun birden çok kez dolaşılması bellekte tekrarlanan hesaba, IQueryable sorgularında ise veritabanına tekrarlanan isteklere yol açar. Sonucu bir kez hesaplayıp tekrar kullanmak istediğinizde ToList() veya ToArray() ile somutlaştırın.

LINQ Sağlayıcıları: Aynı Sözdizimi, Farklı Yürütme Kuralları

Sözdizimi aynı kalsa da yürütme kuralları sağlayıcıya göre değişir. EF Core 3.0’dan beri, son Select çağrısı (üst düzey projeksiyon) dışında SQL’e çevrilemeyen bir ifade gördüğünde sorguyu sessizce istemcide çalıştırmaz, çalışma zamanında hata fırlatır. Aşağıdaki basitleştirilmiş örnekte db, Products tablosunu içeren bir EF Core DbContext örneğidir:

static bool IsCheap(Product p) => p.Price < 50m;

// Derlenir, ama IsCheap SQL'e çevrilemediği için çalışırken hata verir
var cheap = db.Products.Where(p => IsCheap(p)).ToList();

// Çevrilebilir sürüm: koşulu doğrudan ifade olarak yazın
var cheapOk = db.Products.Where(p => p.Price < 50m).ToList();

Filtreyi bilerek istemcide çalıştırmak gerekiyorsa önce AsEnumerable() veya ToList() çağrılır. Bu durumda filtrelenmemiş bütün satırlar belleğe çekilir; küçük tablolar dışında tercih edilmemelidir.

Metot Zincirleme

LINQ metotları zincirlenebilir; her adım bir öncekinin sonucunu alır:

// Kategoriye, sonra fiyata göre sıralanmış ikinci sayfa (sayfa boyutu 2)
var page = products
    .Where(p => p.Price >= 10m)
    .OrderBy(p => p.Category)
    .ThenByDescending(p => p.Price)
    .ThenBy(p => p.Id) // eşitlikte sırayı belirleyici kılar
    .Skip(2)
    .Take(2)
    .Select(p => p.Name)
    .ToList(); // sorgu burada çalışır

Console.WriteLine(string.Join(", ", page)); // Laptop, Mouse

Skip ve Take ile sayfalama yaparken sıralamanın kararlı ve belirleyici olması gerekir; aksi hâlde veritabanı aynı kaydı iki farklı sayfada döndürebilir. Distinct sonucu ise belgelerde sırasız (unordered) dizi olarak tanımlanır. Tekrarları ayıklamanız gerekiyorsa Distinct’i sıralamadan önce çağırın.

Anonim Tipler ve Gruplama

Select içinde sınıf tanımlamadan yeni bir şekil üretebilirsiniz:

var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new
    {
        Category = g.Key,
        Count = g.Count(),
        Total = g.Sum(p => p.Price)
    });

foreach (var row in byCategory)
{
    Console.WriteLine(row);
}
// { Category = Electronics, Count = 2, Total = 1020 }
// { Category = Education, Count = 2, Total = 27 }

Anonim tipler yalnız tanımlandıkları metot içinde güçlü tiplidir; sonucu metottan döndürmek gerekiyorsa record veya tuple kullanın. GroupBy, SQL’deki GROUP BY’a benzer, ama LINQ to Objects her grubun bütün elemanlarını taşır; SQL ise her grup için tek satır döndürür. SQL tarafında seçilen her sütunun gruplamaya bağlı olması gerekir; MySQL’deki ONLY_FULL_GROUP_BY hatası bu kuraldan doğar.

LINQ ve SQL Dizeleri

LINQ, SQL’den esinlenir ama elle yazılmış SQL dizelerine göre farklı güvenceler verir:

Tür güvenliği: LINQ ifadeleri derleme zamanında tür denetiminden geçer; bir SQL dizesindeki yazım hatası ancak çalışma zamanında ortaya çıkar. IQueryable sorgularında ise ifadenin SQL’e çevrilememesi de çalışma zamanında anlaşılır.

IntelliSense: IDE özellik ve metot adlarını tamamlar.

Yeniden adlandırma: Bir özelliği IDE’nin yeniden adlandırma (refactoring) aracıyla değiştirdiğinizde LINQ ifadeleri de güncellenir; SQL dizeleri güncellenmez. Veritabanındaki sütun adı için ayrıca migration gerekir.

Hata ayıklama: LINQ to Objects sorgularında lambda içine breakpoint konabilir. SQL’e çevrilen bir sorgu ise adım adım izlenemez; üretilen SQL’i EF Core 5.0’dan beri ToQueryString() ile ya da EF Core loglarıyla görebilirsiniz.

Gerçek Dünyada Kullanım

Sonuç

LINQ, C#‘ta koleksiyon dönüşümlerini kısa ve tür güvenli biçimde yazmanın standart yoludur; SQL bilenler için öğrenme eğrisi düşüktür. Bellekteki koleksiyonlarda döngülerin yerini rahatça alır. Veritabanı sorgularında ise sözdizimi aynı kalsa da ertelenmiş yürütmeyi, sağlayıcının neyi SQL’e çevirebildiğini ve üretilen SQL’i göz önünde tutmak gerekir. Performansın kritik olduğu sık çalışan döngülerde LINQ zincirinin ek yükünü ölçüp elle yazılmış döngüyle karşılaştırmak yerinde olur.

Birincil kaynaklar