EF Core (Entity Framework Core) veritabanı sorgularını LINQ ile yazmanızı sağlar: context.Blogs.Where(b => b.Url.Contains("dotnet")) gibi tür denetimli bir C# ifadesi yazarsınız, EF Core bu ifadenin bir temsilini veritabanı sağlayıcısına verir, sağlayıcı da onu ilişkisel veritabanları için SQL'e çevirir. Sorgu, sonuçlar tüketildiği anda veritabanında çalışır; dönen varlıklar context içinde zaten bulunsa bile sorgu yine veritabanına gider.

Bu yazıda temel LINQ sorgularını, bir sorgunun EF Core içinde geçtiği aşamaları, sorgunun tam olarak ne zaman çalıştığını, sabit ve değişken kullanımının sorgu önbelleğine etkisini, C# ile SQL arasındaki null mantığı farkını ve büyük/küçük harf duyarlılığını belirleyen collation kavramını resmî belgelere dayanarak anlatıyoruz. İlişkili veri yükleme, takip davranışı ve ham SQL gibi komşu konular serinin ayrı yazılarında; tüm başlıklar için EF Core öğrenme rehberi sayfasına bakabilirsiniz.

Temel sorgu kalıpları

Resmî belgelerdeki örnekler bir BloggingContext ve onun Blogs kümesi üzerinden ilerler. DbSet<Blog> özelliği aynı zamanda bir IQueryable<Blog> olduğu için üzerine standart LINQ operatörlerini ekleyebilirsiniz. En sık ihtiyaç duyulan üç kalıp şunlardır: tablodaki tüm satırları yüklemek, tek bir varlığı getirmek ve bir koşula göre filtrelemek.

csharp
using (var context = new BloggingContext())
{
    // Tüm bloglar
    var blogs = await context.Blogs.ToListAsync();

    // Tek bir blog
    var blog = await context.Blogs
        .SingleAsync(b => b.BlogId == 1);

    // Filtreleme
    var dotnetBlogs = await context.Blogs
        .Where(b => b.Url.Contains("dotnet"))
        .ToListAsync();
}

Bu örneklerdeki ToListAsync ve SingleAsync asenkron sürümlerdir. Belgelerin performans bölümü, uygulamanın ölçeklenebilmesi için senkron API'ler yerine asenkron olanların tercih edilmesini ve aynı uygulamada iki yaklaşımın karıştırılmamasını önerir; bu konunun ayrıntıları ve sık görülen hatalar EF Core async ve "second operation" hatası yazısında. Context nesnesinin nasıl oluşturulduğu, ömrü ve bağımlılık enjeksiyonu ile kaydı ise DbContext yapılandırma ve yaşam döngüsü yazısının konusudur.

Where içindeki Contains çağrısı burada bir .NET string metodu gibi görünse de bellekte çalışmaz; ifade ağacı olarak EF Core'a gider ve veritabanının anlayacağı bir karşılığa çevrilir. Çevrilemeyen bir ifade kullandığınızda ne olduğunu, hangi durumda EF Core'un hata verdiğini ve istemci tarafında değerlendirmenin sınırlarını client evaluation hatası yazısında ayrıca ele alıyoruz.

Bir sorgunun yaşam döngüsü

Bir sorgunun yaşam döngüsü: 1. İşleme ve önbellek, 2. Sağlayıcı ve veritabanı, 3. Nesneye dönüştürme

Belgeler, her sorgunun geçtiği süreci üst düzeyde üç adımda özetler. Belgenin kendisinde bu sayfanın kısmen eskidiği ve sorgu hattındaki tasarım değişikliklerine göre güncellenmesi gerektiği notu bulunur; yine de genel akışı anlamak için iyi bir çerçevedir.

  • 1. İşleme ve önbellek: EF Core LINQ sorgusunu, veritabanı sağlayıcısının işleyebileceği bir temsile dönüştürür. Bu sonuç önbelleğe alınır; böylece aynı sorgu her çalıştırıldığında bu işlemin baştan yapılması gerekmez.
  • 2. Sağlayıcı ve veritabanı: Sağlayıcı, sorgunun hangi kısımlarının veritabanında değerlendirilebileceğini belirler, bu kısımları veritabanına özgü dile (ilişkisel veritabanlarında SQL) çevirir ve sorguyu gönderir. Dönen sonuç kümesi varlık nesneleri değil, veritabanından gelen değerlerdir.
  • 3. Nesneye dönüştürme: Sonuç kümesindeki her satır için, sorgu takip eden (tracking) bir sorguysa EF Core bu verinin context'in change tracker'ında zaten bulunan bir varlığa ait olup olmadığına bakar. Varsa mevcut nesneyi döndürür; yoksa yeni bir nesne oluşturur, takibini başlatır ve onu döndürür. Takip etmeyen (no-tracking) bir sorguda ise her zaman yeni bir nesne oluşturulur.

Üçüncü adım pratikte önemli bir sonuç doğurur: takip eden bir sorgu veritabanına gerçekten gider, ama context aynı anahtarla bir nesneyi zaten izliyorsa size o eski nesneyi verir. Veritabanındaki değer bu arada değişmişse, izlenen nesnenin özellikleri sorgu sonucuyla üzerine yazılmaz. Bu davranışın ayrıntıları, AsNoTracking seçeneği ve kimlik çözümlemesi (identity resolution) AsNoTracking ve tracking sorguları yazısında anlatılıyor.

Sorgu ne zaman veritabanına gider?

Sorgu ne zaman veritabanına gider? Where, OrderBy, Select zinciri kurmak, foreach ile dolaşmak, ToList / ToListAsync, ToArray / ToArrayAsync, Single, First, Count ve async...

LINQ operatörlerini zincirlediğinizde yalnızca sorgunun bellekteki bir temsilini kurmuş olursunuz; veritabanına henüz hiçbir şey gönderilmez. Sorgu, sonuçları tüketildiğinde çalışır. Belgelerin saydığı en yaygın tetikleyiciler şunlardır: sonuçları bir foreach döngüsünde dolaşmak ve ToList, ToArray, Single, Count gibi bir operatör ya da bunların async karşılıklarını çağırmak.

Bu ertelenmiş çalışma, sorguyu koşullara göre parça parça kurmanıza izin verir. Aşağıdaki kalıp belgelerin performans sayfasındaki "basit parametreli" örnekten uyarlanmıştır: önce bir IQueryable<Blog> alınır, gerekirse üzerine Where eklenir ve sorgu ancak CountAsync çağrıldığında tek bir SQL komutu olarak gönderilir.

csharp
IQueryable<Blog> query = context.Blogs;

if (url != null)
{
    query = query.Where(b => b.Url == url);
}

// Sorgu burada çalışır
var count = await query.CountAsync();
İşlemVeritabanına gider mi?Not
Where, OrderBy, Select zinciri kurmakHayırYalnızca sorgu temsili oluşur
foreach ile dolaşmakEvetSonuçlar dolaşırken okunur
ToList / ToListAsync, ToArray / ToArrayAsyncEvetTüm sonuç belleğe alınır
Single, First, Count ve async sürümleriEvetTek değer ya da tek varlık döner
AsAsyncEnumerable ile await foreachEvetSonuçlar tek tek akıtılır

Son satırdaki fark büyük sonuç kümelerinde önem kazanır. Belgeler, ToList ve ToArray çağrılarının tüm sonucu belleğe aldığını (buffering), foreach ya da AsAsyncEnumerable ile dolaşmanın ise satırları birer birer işlediğini (streaming) anlatır. Sonuç üzerinde yeniden LINQ operatörü uygulayacaksanız araya ToList koymak yerine AsEnumerable kullanılması önerilir; aksi hâlde tüm sonuç gereksiz yere bellekte tutulur. Bellek ve gidiş-dönüş maliyetinin genel resmi EF Core performans optimizasyonu yazısında, büyük listeleri sayfalara bölmek ise sayfalama: Skip/Take ve keyset yazısında.

Sabit mi, değişken mi: sorgu önbelleği

EF Core bir LINQ ifade ağacını çalıştırmadan önce onu "derler", yani ondan SQL üretir. Bu ağır bir iş olduğu için sonuç, ağacın biçimine göre önbelleğe alınır; aynı yapıdaki sorgular parametre değerleri farklı olsa bile önbellekteki derlemeyi yeniden kullanır. Belgelerdeki örnek, aynı sorgunun bir kez sabitle, bir kez de değişkenle yazılmasının farkını gösterir:

csharp
// Farklı sabitler: iki ayrı derleme, iki ayrı SQL
var post1 = await context.Posts.FirstOrDefaultAsync(p => p.Title == "post1");
var post2 = await context.Posts.FirstOrDefaultAsync(p => p.Title == "post2");

// Değişken: tek derleme, parametreli SQL
var postTitle = "post1";
var post3 = await context.Posts.FirstOrDefaultAsync(p => p.Title == postTitle);
postTitle = "post2";
var post4 = await context.Posts.FirstOrDefaultAsync(p => p.Title == postTitle);

Belgelerin örnek çıktısına göre ilk iki sorgu WHERE [b].[Name] = N'post1' ve N'post2' içeren iki farklı SQL üretir; SQL farklı olduğu için veritabanı sunucusu da büyük olasılıkla iki ayrı sorgu planı hazırlar. Değişkenli sürümde ise ağaç biçimi aynı kalır ve belgelerin gösterdiği SQL tek bir parametre kullanır:

sql
SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = @__postTitle_0

Bu SQL belgelerdeki örnek çıktıdır ve parametre adını eski biçimde gösterir. EF Core 10 sürüm notları parametre adlarının sadeleştirildiğini (örneğin @__city_0 yerine @city) belirtir; yani güncel sürümde loglarda daha kısa adlar görürsünüz. Belgeler her sorgunun parametreli olması gerekmediğini de vurgular: sabit içeren sorgular sorun değildir ve bazı optimizasyonlar yalnızca sabitlerle mümkündür. Asıl tehlike, dinamik olarak kurulan sorgularda ifade ağacına her seferinde farklı bir sabit koymaktır; bu hem EF Core'un sorguyu sürekli yeniden derlemesine hem de veritabanı plan önbelleğinin kirlenmesine yol açar.

Değer listeleriyle yapılan ids.Contains(b.Id) türü sorgularda da benzer bir denge vardır. EF Core 10, bu "parametreli koleksiyonları" varsayılan olarak her değer için ayrı bir skaler parametreyle çevirir ve farklı uzunluklar için üretilen SQL sayısını azaltmak üzere parametre listesini doldurur (padding). Bu varsayılan değişikliğin ayrıntıları ve geri dönüş seçenekleri EF Core 10 yenilikler ve breaking changes yazısında.

Null karşılaştırmaları: C# mantığı ile SQL mantığı

C# karşılaştırmalarda iki değerli mantık kullanır: sonuç ya true ya false olur. SQL veritabanları ise üç değerli mantıkla çalışır; bir taraf null ise karşılaştırmanın sonucu da null olur ve WHERE bu satırı eler. EF Core, LINQ'i SQL'e çevirirken bu farkı telafi etmek için gereken yerlere ek null kontrolleri ekler. Belgelerdeki örnek varlıkta iki tam sayı (biri nullable) ve iki string kolonu vardır:

csharp
public class NullSemanticsEntity
{
    public int Id { get; set; }
    public int Int { get; set; }
    public int? NullableInt { get; set; }
    public string String1 { get; set; }
    public string String2 { get; set; }
}

var query2 = context.Entities.Where(e => e.Id == e.NullableInt);
var query3 = context.Entities.Where(e => e.Id != e.NullableInt);
var query5 = context.Entities.Where(e => e.String1 != e.String2);

Belgelere göre e.Id == e.NullableInt için ek terim gerekmez: null ile null olmayan bir değerin eşitliği null verir ve satır zaten elenir, bu da C# anlamıyla örtüşür. Eşitsizlikte durum değişir. NullableInt null olduğunda SQL'deki Id <> NullableInt yine null döner ve satır elenirdi; oysa C# mantığında bu satırın sonuçta yer alması gerekir. EF Core bu yüzden belgelerdeki örnek çıktıda görülen kontrolü ekler:

sql
SELECT [e].[Id], [e].[Int], [e].[NullableInt], [e].[String1], [e].[String2]
FROM [Entities] AS [e]
WHERE ([e].[Id] <> [e].[NullableInt]) OR [e].[NullableInt] IS NULL

İki taraf da nullable olduğunda üretilen ifade daha da uzar ve belgeler != karşılaştırmasının == karşılaştırmasından daha karmaşık, potansiyel olarak daha yavaş bir sorgu ürettiğini not eder. Fonksiyonlar için ise EF Core bir kısaltma yapar: pek çok SQL fonksiyonu yalnızca argümanlarından biri null olduğunda null döner. Örneğin e.String1.Substring(0, e.String2.Length) == null koşulu, SUBSTRING hiç hesaplanmadan String1 IS NULL OR String2 IS NULL biçimine indirgenir.

Daha verimli null karşılaştırmaları

  • Nullable olmayan kolonları karşılaştırmak, nullable kolonları karşılaştırmaktan daha basit ve hızlıdır. Belgeler mümkün olan her yerde kolonları zorunlu (non-nullable) işaretlemeyi önerir; bunun model tarafı property ve kolon yapılandırma yazısında.
  • Eşitlik (==) kontrolü, eşitsizlikten (!=) daha basittir, çünkü sorgunun null ile false sonucunu ayırt etmesi gerekmez. Ancak == karşılaştırmasını yalnızca olumsuzlamak fiilen != ile aynıdır ve performans kazandırmaz.
  • Null değerler sonuç için önemsizse ya da kolonda hiç yoksa, sorguya e.String1 != null && e.String2 != null gibi açık bir filtre eklemek EF Core'un o kolonları nullable olmayan gibi ele almasını ve daha sade SQL üretmesini sağlar.

Bu telafiyi tamamen kapatıp doğrudan ilişkisel null semantiği kullanmak da mümkündür: OnConfiguring içinde sağlayıcıya özgü seçenek oluşturucusunda UseRelationalNulls() çağrılır. Belgeler bu modda LINQ sorgularınızın artık C#'taki anlamını taşımadığını ve beklediğinizden farklı sonuçlar dönebileceğini açıkça uyarır; bu yüzden bu ayar ancak etkisini tam olarak bildiğiniz durumlarda kullanılmalıdır.

csharp
new SqlServerDbContextOptionsBuilder(optionsBuilder).UseRelationalNulls();

Büyük/küçük harf duyarlılığı ve collation

Metin karşılaştırmasında sonucu belirleyen, veritabanının collation ayarıdır: metinlerin nasıl sıralanacağını ve hangi durumda eşit sayılacağını tanımlayan kurallar kümesi. Veritabanları burada belirgin biçimde ayrışır. Belgelere göre SQLite ve PostgreSQL varsayılan olarak büyük/küçük harfe duyarlıdır, SQL Server ve MySQL ise duyarsızdır. Belgeler ayrıca büyük/küçük harf duyarsızlığının kültüre bağlı olduğunu vurgular ve örnek olarak Türkçeyi verir: Türkçede i ile I farklı harfleri temsil eder. Bu yüzden tek bir "harf duyarsız" collation yoktur, her birinin kendi kuralları vardır. Kullandığınız veritabanının hangi collation'ları sunduğu ve adlandırma biçimi veritabanına özgüdür; ayrıntı için sağlayıcınızın belgelerine bakmanız gerekir.

EF Core basit string eşitliğini bilerek doğrudan SQL eşitliğine çevirir. C#'ta s1 == s2 sıralı (ordinal) ve harfe duyarlı bir karşılaştırmadır; ama aynı ifade SQL'e çevrildiğinde sonucu veritabanının ve kolonun collation'ı belirler. Belgeler bu tercihin gerekçesini açıklar: veritabanlarının varsayılanları farklıdır ve basit eşitliğin index kullanabilmesi istenir. StringComparison parametresi alan string.Equals aşırı yüklemeleri ise tasarım gereği çevrilmez; bunları sorguda kullanmak bir istisnaya yol açar, çünkü EF Core hangi collation'ın kullanılacağını bilemez ve bir collation uygulamak çoğu durumda index kullanımını engeller.

Collation'ı doğru yerde tanımlamak

Collation üç düzeyde belirlenebilir. Veritabanı düzeyindeki collation, aksi belirtilmedikçe tüm metin işlemlerine uygulanır ve genellikle veritabanı oluşturulurken seçilir; belgeler var olan bir veritabanının collation'ını değiştirmenin sorun çıkarabileceğini, bu yüzden seçimin baştan yapılmasını önerir. Migration kullanıyorsanız OnModelCreating içinde veritabanı ve kolon düzeyini şöyle tanımlarsınız:

csharp
// Veritabanı düzeyi (SQL Server, harfe duyarlı)
modelBuilder.UseCollation("SQL_Latin1_General_CP1_CS_AS");

// Kolon düzeyi (bu kolon harfe duyarsız)
modelBuilder.Entity<Customer>().Property(c => c.Name)
    .UseCollation("SQL_Latin1_General_CP1_CI_AS");

Aynı kolonun bir sorguda harfe duyarlı, başka bir sorguda duyarsız karşılaştırılması gerekiyorsa sorgu içinde EF.Functions.Collate kullanılabilir. Belgelere göre bu çağrı SQL'de bir COLLATE ifadesi üretir ve kolon ya da veritabanı ayarından bağımsız olarak belirtilen collation'ı uygular:

csharp
var customers = await context.Customers
    .Where(c => EF.Functions.Collate(c.Name, "SQL_Latin1_General_CP1_CS_AS") == "John")
    .ToListAsync();

Burada performans tarafı kritik. Index'ler kolonlarının collation'ını devralır; sorgu farklı bir collation belirttiğinde collation'lar artık eşleşmez ve sorgu genellikle o kolondaki index'i kullanamaz. Aynı etki, harfe duyarlı bir veritabanında duyarsız karşılaştırma yapmak için string.ToLower çağırdığınızda da ortaya çıkar. Belgelerin önerisi nettir: collation'ı mümkün olduğunca kolon ya da veritabanı düzeyinde tanımlayın ki tüm sorgular onu örtük olarak kullansın ve index'ten yararlansın. PostgreSQL ve SQLite gibi bazı veritabanları index oluştururken collation belirtmeye izin verir; böylece aynı kolon için hem duyarlı hem duyarsız aramaları hızlandıran iki ayrı index tanımlanabilir. Büyük veri üzerinde çalışan kritik sorgularda sorgu planını mutlaka inceleyin; index tasarımının EF Core tarafı index ve unique index yazısında, veritabanı tarafındaki plan analizi ise PostgreSQL performans optimizasyonu yazısında.

Çalışan bir mini örnek: arama sorgusu

Şimdi bu parçaları tek bir senaryoda birleştirelim. Bir yönetim panelinde kullanıcının girdiği metne göre blog arayan bir metot düşünün. Arama terimi bir değişkende tutulduğu için sorgu parametreli olur ve önbellekten yararlanır; sorgu yalnızca gerekirse filtrelenir, sonuç sayısı sınırlandırılır ve gösterilecek alanlar projeksiyonla seçilir. Bu kod belgelerdeki kalıplardan uyarlanmıştır ve çalıştırılmamıştır.

csharp
public async Task<List<string>> BlogAraAsync(BloggingContext context, string? terim)
{
    IQueryable<Blog> query = context.Blogs;

    if (!string.IsNullOrWhiteSpace(terim))
    {
        query = query.Where(b => b.Url.Contains(terim));
    }

    return await query
        .OrderBy(b => b.BlogId)
        .Select(b => b.Url)
        .Take(25)
        .ToListAsync();
}

Bu metotta dikkat edilecek birkaç nokta var. terim bir yerel değişken olduğu için sorguya sabit olarak değil parametre olarak girer. Select(b => b.Url) yalnızca gereken kolonu çeker; belgeler, tüm varlığı yüklemenin gereksiz kolonları da taşıdığını ve salt okunur sorgularda projeksiyonun daha verimli olduğunu belirtir. Take(25) ise test veritabanında az veri varken fark edilmeyen, gerçek veride ortaya çıkan büyük sonuç kümesi sorununu baştan sınırlar. Arama sonucunun harfe duyarlı olup olmayacağını kodunuz değil, Url kolonunun collation'ı belirler; bu yüzden aynı kod SQL Server ile SQLite üzerinde farklı sonuç verebilir.

Sık yapılan hatalar

Sık yapılan hatalar: Sorgunun ne zaman çalıştığını unutmak, Dinamik sorguda sabit üretmek, Harf duyarsızlık için ToLower kullanmak, StringComparison ile Equals çağırmak
  • Sorgunun ne zaman çalıştığını unutmak: IQueryable üzerinde zincir kurmak sorgu çalıştırmaz. Aynı sorguyu iki kez foreach ile dolaşmak ya da hem CountAsync hem ToListAsync çağırmak iki ayrı veritabanı gidişi demektir.
  • Dinamik sorguda sabit üretmek: Expression API ile ifade kurarken değer için sabit düğüm kullanmak, belgelerin uyardığı gibi sorgunun her çağrıda yeniden derlenmesine ve plan önbelleğinin kirlenmesine yol açar. Mümkünse ifadeyi lambda ve yerel değişkenle kurun.
  • Harf duyarsızlık için ToLower kullanmak: Çalışır gibi görünür ama index kullanımını engelleyebilir. Bunun yerine kolona uygun collation tanımlayın.
  • StringComparison ile Equals çağırmak: EF Core bu aşırı yüklemeleri çevirmez ve istisna fırlatır. Gerekirse EF.Functions.Collate kullanın.
  • Takip edilen nesnenin güncel olduğunu varsaymak: Uzun yaşayan bir context, veritabanında değişmiş satırlar için eski nesneleri döndürebilir. Her iş birimi için yeni bir context kullanın.
  • Gereğinden fazla veri çekmek: Yalnızca birkaç alan gerekiyorsa Select ile projeksiyon yapın, sonuç sayısını sınırlayın. İlişkili verileri yüklemenin doğru yolu ise Include ile eager loading yazısında.

Sık Sorulan Sorular

EF Core sorgusu context'teki nesneleri mi döndürür, veritabanını mı sorgular?

Her zaman veritabanını sorgular. Belgeler, sonuçta dönen varlıklar context'te zaten bulunsa bile sorgunun veritabanında çalıştığını belirtir. Fark, nesneye dönüştürme adımındadır: takip eden sorguda, aynı anahtarlı bir varlık zaten izleniyorsa yeni nesne yerine o nesne döner. Context'e eklenmiş ama henüz kaydedilmemiş varlıklar ise sorgu sonucunda yer almaz.

Türkçe karakterli aramalarda büyük/küçük harf neden tutarsız davranıyor?

Çünkü karşılaştırmayı C# değil veritabanının collation'ı yapar ve belgelerin de belirttiği gibi harf duyarsızlığı kültüre bağlıdır; Türkçedeki i ve I ayrımı bunun bilinen örneğidir. Doğru sonuç için veritabanınızın sunduğu, ihtiyacınıza uygun collation'ı veritabanı ya da kolon düzeyinde seçin ve davranışı kendi verinizle test edin.

Hangi LINQ ifadelerinin SQL'e çevrildiğini nasıl görürüm?

En pratik yol EF Core'un ürettiği SQL'i loglamaktır. LogTo, TagWith ve interceptor seçenekleri SQL loglama: LogTo ve interceptor yazısında anlatılıyor. Çevrilemeyen bir ifade kullanırsanız EF Core bunu genellikle çalışma anında bir istisna ile bildirir.

Join ve GroupBy de LINQ ile yazılabilir mi?

Evet. Join, GroupJoin, SelectMany, GroupBy ve EF Core 10 ile desteklenen LeftJoin/RightJoin operatörleri Join, GroupBy ve LeftJoin yazısında ele alınıyor. LINQ ile ifade edemediğiniz ya da verimsiz SQL ürettiğini gördüğünüz sorgular için FromSql ile ham SQL sorguları seçeneği vardır.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: EF Core öğrenme rehberi · AsNoTracking ve tracking sorguları · Include ile eager loading · Client evaluation hatası · Join, GroupBy ve LeftJoin · C# nedir