EF Core ile LINQ Sorguları: SQL Çevirisi, Client Evaluation ve IQueryable Tuzağı
EF Core'da LINQ sorgusunun SQL'e çevrilişini adım adım izliyoruz: deferred execution, ToQueryString ve LogTo, filtreleme, sıralama ve sayfalama, projection, First/Single/Find farkı, son Select'te izin verilen client evaluation ve sessizce bütün tabloyu çeken IEnumerable tuzağı.
Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.
Önce Tahmin Et: Bu Kod Kaç Sorgu Çalıştırır?
var query = db.Books.Where(b => b.PublishedYear < 1970);
Console.WriteLine("query defined");
var count = query.Count();
var list = query.ToList();
Üç satır LINQ var. İlk satır veritabanına gider mi? Toplamda kaç SQL komutu çalışır?
Cevap iki, ve ilk satır hiçbirini çalıştırmaz. Konsolda önce "query defined" yazısı çıkar, ardından iki ayrı komut gelir:
query defined
info: 4.11.2026 00:59:21.068 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 1970
info: 4.11.2026 00:59:21.076 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 1970
query değişkeni bir sonuç değil, bir tarif. Restorana verdiğin sipariş fişi gibi düşün: fişi yazmak mutfağı çalıştırmaz, mutfak ancak fiş içeri gidince çalışır. Count(), ToList(), First(), foreach gibi sonucu gerçekten isteyen her çağrı fişi mutfağa yollar. Aynı tarifi iki kez kullanırsan iki kez pişirilir. Bu yazıda o tarifin nasıl SQL'e dönüştüğünü, hangi parçasının veritabanında, hangisinin bellekte çalıştığını ve bu ayrımın nerede pahalıya patladığını göreceğiz.
Bir LINQ Sorgusu SQL'e Nasıl Dönüşür?
Bu yazıdaki model, önceki yazılardaki Bookstore modelinin sorgulamaya uygun hâli. Veriyi bir önceki yazıda gördüğümüz HasData ile veriyoruz, SQL'i görmek için de LogTo ile yalnızca çalışan komutları logluyoruz:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Bookstore;
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? Country { get; set; }
public List<Book> Books { get; set; } = new();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public decimal Price { get; set; }
public int Pages { get; set; }
public int PublishedYear { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
}
public class BookstoreContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlite("Data Source=bookstore.db")
.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted, CoreEventId.RowLimitingOperationWithoutOrderByWarning });
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Author>().HasData(
new Author { Id = 1, Name = "Ursula K. Le Guin", Country = "USA" },
new Author { Id = 2, Name = "Isaac Asimov", Country = "USA" },
new Author { Id = 3, Name = "Stanislaw Lem", Country = "Poland" });
modelBuilder.Entity<Book>().HasData(
new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 12.50m, Pages = 183, PublishedYear = 1968, AuthorId = 1 },
new Book { Id = 2, Title = "The Left Hand of Darkness", Price = 14.00m, Pages = 304, PublishedYear = 1969, AuthorId = 1 },
new Book { Id = 3, Title = "The Dispossessed", Price = 15.00m, Pages = 387, PublishedYear = 1974, AuthorId = 1 },
new Book { Id = 4, Title = "Foundation", Price = 11.00m, Pages = 255, PublishedYear = 1951, AuthorId = 2 },
new Book { Id = 5, Title = "I, Robot", Price = 10.00m, Pages = 253, PublishedYear = 1950, AuthorId = 2 },
new Book { Id = 6, Title = "Solaris", Price = 13.00m, Pages = 204, PublishedYear = 1961, AuthorId = 3 });
}
}
db.Books bir DbSet<Book>, o da IQueryable<Book>. IQueryable üzerinde yazdığın Where(b => b.PublishedYear < 1970) bir delegate olarak derlenmez; C# derleyicisi onu bir expression tree'ye, yani "PublishedYear property'si 1970'ten küçük mü" diyen bir veri yapısına çevirir. EF Core sorgu çalıştırılacağı an bu ağacı gezer, her düğümü provider'ın SQL lehçesine çevirir ve komutu gönderir.
Her seferinde log okumak zorunda değilsin. EF Core 5 ile gelen ToQueryString(), sorguyu çalıştırmadan üreteceği SQL'i verir:
var minYear = 1960;
var query = db.Books
.Where(b => b.PublishedYear >= minYear && b.Title.Contains("The"))
.OrderBy(b => b.Title);
Console.WriteLine(query.ToQueryString());
.param set @__minYear_0 1960
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE ("b"."PublishedYear" >= @__minYear_0) AND (('The' = '') OR (instr("b"."Title", 'The') > 0))
ORDER BY "b"."Title"
İki şeye dikkat et. Sabit olarak yazdığın "The" SQL'e değer olarak gömüldü, ama yerel bir değişken olan minYear bir parametreye dönüştü. EF Core lambda'nın dışından gelen her değeri parametre yapar; bu hem SQL injection'a karşı korur hem de farklı minYear değerleri için aynı sorgu planının kullanılmasını sağlar. İkincisi, string.Contains SQLite'ta instr fonksiyonuna çevrildi. EF Core 10 aynı sorguda parametreye @minYear adını veriyor ve ('The' = '') kontrolünü atlıyor; mantık aynı.
Filtreleme: Where'in SQL Karşılıkları
C#'ta alıştığın ifadelerin çoğunun doğrudan bir SQL karşılığı var:
var ids = new[] { 1, 4, 6 };
var q1 = db.Books.Where(b => ids.Contains(b.Id)).Select(b => b.Title);
var q2 = db.Books.Where(b => b.Title.StartsWith("The") || b.Pages > 300).Select(b => b.Title);
var q3 = db.Authors.Where(a => a.Country == null).Select(a => a.Name);
EF Core 6'nın ürettiği SQL:
SELECT "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Id" IN (1, 4, 6)
SELECT "b"."Title"
FROM "Books" AS "b"
WHERE ("b"."Title" LIKE 'The%') OR ("b"."Pages" > 300)
SELECT "a"."Name"
FROM "Authors" AS "a"
WHERE "a"."Country" IS NULL
Bir koleksiyonun Contains'i IN oldu, StartsWith LIKE 'The%' oldu, == null karşılaştırması da doğru şekilde IS NULL'a çevrildi. SQL'de Country = NULL hiçbir zaman true dönmez; EF Core C#'taki null semantiğini korumak için bunu senin yerine düşünüyor. EF Core 10'da ilk sorgu farklı görünür: array'in değerleri SQL'e gömülmek yerine IN (@ids1, @ids2, @ids3) şeklinde ayrı parametrelere dönüşür. Bunu EF Core 10 yazısında konuşacağız.
Sıralama ve Sayfalama
Sayfalama Skip ve Take ile yapılır. Üçüncü sayfayı, sayfa başına iki kitapla isteyelim:
var page = 3;
var size = 2;
var titles = db.Books
.OrderBy(b => b.PublishedYear)
.ThenBy(b => b.Id)
.Skip((page - 1) * size)
.Take(size)
.Select(b => b.Title)
.ToList();
SELECT "b"."Title"
FROM "Books" AS "b"
ORDER BY "b"."PublishedYear", "b"."Id"
LIMIT @__p_1 OFFSET @__p_0
ThenBy(b => b.Id) gereksiz görünebilir ama değil. Aynı yılda yayımlanmış iki kitap olsaydı veritabanı onları her seferinde aynı sırada döndürmek zorunda değildi ve bir kitap iki sayfada birden görünebilir ya da hiç görünmeyebilirdi. Sıralamanın sonuna her zaman benzersiz bir kolon ekle.
OrderBy'ı tamamen unutursan EF Core uyarır:
warn: 4.11.2026 00:59:21.107 CoreEventId.RowLimitingOperationWithoutOrderByWarning[10102] (Microsoft.EntityFrameworkCore.Query)
The query uses a row limiting operator ('Skip'/'Take') without an 'OrderBy' operator. This may lead to unpredictable results. If the 'Distinct' operator is used after 'OrderBy', then make sure to use the 'OrderBy' operator after 'Distinct' as the ordering would otherwise get erased.
Bu uyarıyı varsayılan log seviyesinde de görürsün; bir uygulamanın loglarında görüyorsan bir yerde sırası belirsiz bir sayfalama var demektir.
Projection: Sadece İhtiyacın Olanı Çek
Şimdiye kadar çoğu sorgu bütün kolonları çekti. Bir liste ekranında kitabın başlığı, yazarın adı ve yayın yılı yetiyorsa yalnızca onları iste:
var rows = db.Books
.Where(b => b.Author.Country == "USA")
.OrderBy(b => b.PublishedYear)
.Select(b => new { b.Title, AuthorName = b.Author.Name, b.PublishedYear })
.ToList();
SELECT "b"."Title", "a"."Name" AS "AuthorName", "b"."PublishedYear"
FROM "Books" AS "b"
INNER JOIN "Authors" AS "a" ON "b"."AuthorId" = "a"."Id"
WHERE "a"."Country" = 'USA'
ORDER BY "b"."PublishedYear"
1950 I, Robot - Isaac Asimov
1951 Foundation - Isaac Asimov
1968 A Wizard of Earthsea - Ursula K. Le Guin
1969 The Left Hand of Darkness - Ursula K. Le Guin
1974 The Dispossessed - Ursula K. Le Guin
b.Author.Country ve b.Author.Name yazdık, Include çağırmadık; EF Core navigation'ı görünce JOIN'i kendisi ekledi. Seçilen kolonlar üçe indi. Anonymous type yerine bir DTO class'ı da kullanabilirsin; ASP.NET Core Web API serisinde endpoint'lerden dönen modelleri tam olarak bu şekilde üreteceğiz. Anonymous type ya da DTO olarak dönen nesneler change tracker tarafından takip edilmez, bu da onları salt okunur ekranlar için ucuz hâle getirir. Tracking konusuna iki yazı sonra geleceğiz.
Tek Kayıt: First, Single ve Find
Tek bir kayıt istemenin üç yaygın yolu var ve her biri farklı SQL üretir:
var first = db.Books.Where(b => b.AuthorId == 2).OrderBy(b => b.Id).First();
var single = db.Books.Single(b => b.Id == 4);
var found = db.Books.Find(4);
Console.WriteLine($"{first.Title} {single.Title} {ReferenceEquals(single, found)}");
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = 2
ORDER BY "b"."Id"
LIMIT 1
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Id" = 4
LIMIT 2
Foundation Foundation True
First LIMIT 1 ister. Single ise LIMIT 2 ister, çünkü işi "birden fazla kayıt var mı" sorusuna da cevap vermek; ikinci satır gelirse exception fırlatır. Üçüncü çağrı için hiç SQL yok: Find önce change tracker'a bakar, 4 numaralı kitap Single ile zaten yüklendiği için aynı nesneyi döndürür. ReferenceEquals sonucunun True olması bunun kanıtı. Kayıt bulunamazsa First ve Single exception fırlatır, FirstOrDefault, SingleOrDefault ve Find ise null döner.
Client Evaluation Kuralı
Her C# ifadesinin SQL karşılığı yok. Kendi yazdığın bir method'u düşün:
public static class Formatting
{
public static string Label(string title, int year) => $"{title} ({year})";
}
Bu method'u sorgunun son Select'inde kullanmak serbest:
var labels = db.Books
.OrderBy(b => b.Id)
.Select(b => Formatting.Label(b.Title, b.PublishedYear))
.Take(2)
.ToList();
SELECT "b"."Title", "b"."PublishedYear"
FROM "Books" AS "b"
ORDER BY "b"."Id"
LIMIT @__p_0
A Wizard of Earthsea (1968) | The Left Hand of Darkness (1969)
EF Core method'u çeviremeyeceğini bildiği için method'un ihtiyaç duyduğu iki kolonu çekti, Label'ı da her satır gelirken bellekte çağırdı. Buna client evaluation denir ve EF Core 3.0'dan beri yalnızca en üstteki projection'da kendiliğinden yapılır. Aynı method'u Where'e koyduğunda durum değişir:
var matches = db.Books.Where(b => Formatting.Label(b.Title, b.PublishedYear).Length > 20).ToList();
System.InvalidOperationException: The LINQ expression 'DbSet<Book>()
.Where(b => Formatting.Label(
title: b.Title,
year: b.PublishedYear).Length > 20)' could not be translated. Additional information: Translation of method 'Formatting.Label' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information.
Translation of method 'Formatting.Label' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
Bu filtreyi bellekte uygulamak için EF Core'un bütün tabloyu çekmesi gerekirdi. EF Core 2.x bunu yalnızca bir uyarı loglayarak yapıyordu ve küçük test veritabanlarında hiç fark edilmeyen sorgular canlıda milyonlarca satır çekiyordu. 3.0 ile kural değişti: filtre ya SQL'e çevrilir ya da exception alırsın. Bu değişikliğin hikâyesini eski projeleri taşıma yazısında anlatacağız.
Hata mesajı çıkış yolunu da söylüyor. Gerçekten bellekte filtrelemen gerekiyorsa bunu bilerek yap ve önce veritabanında daraltabildiğin kadar daralt:
var titles = db.Books
.Where(b => b.PublishedYear < 1970)
.AsEnumerable()
.Where(b => Formatting.Label(b.Title, b.PublishedYear).Length > 20)
.Select(b => b.Title)
.ToList();
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 1970
AsEnumerable() çizgisinin üstündeki her şey SQL'e, altındaki her şey C#'a aittir. Sınırı sen çizdin, ne kadar veri taşındığını da sen biliyorsun.
IQueryable mı IEnumerable mı?
Aynı çizgi bazen farkında olmadan çizilir. Şu iki yardımcı method'a bak:
public static class BookQueries
{
public static IEnumerable<Book> All(BookstoreContext db) => db.Books;
public static IQueryable<Book> AllQueryable(BookstoreContext db) => db.Books;
}
İkisi de db.Books döndürüyor, tek fark dönüş tipi. Aynı filtreyi ikisinin üzerine uygulayalım:
IEnumerable<Book> books = BookQueries.All(db);
var old = books.Where(b => b.PublishedYear < 1955).Select(b => b.Title).ToList();
SELECT "b"."Id", "b"."AuthorId", "b"."Pages", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
IQueryable<Book> books = BookQueries.AllQueryable(db);
var old = books.Where(b => b.PublishedYear < 1955).Select(b => b.Title).ToList();
SELECT "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 1955
Sonuç ikisinde de "Foundation, I, Robot". Ama ilkinde WHERE yok; bütün tablo bütün kolonlarıyla geldi, filtre bellekte çalıştı. Sebep C#'ın method seçimi. Değişkenin tipi IEnumerable<Book> olunca derleyici Where için Enumerable.Where'i seçer, lambda da expression tree değil sıradan bir delegate olur. EF Core o filtreyi hiç görmez. Hiçbir hata, hiçbir uyarı yok; altı kitapla fark edilmez, altı milyon kitapla sunucu yere yatar. Repository ya da service katmanında sorgu döndüren method'ların dönüş tipine bu yüzden dikkat et. Bu tartışmaya mimari yazısında tekrar döneceğiz.
Sık Düşülen Tuzaklar
SQLite'ta decimal sıralama. SQLite'ın gerçek bir decimal tipi yok, EF Core Price'ı TEXT olarak saklıyor. Karşılaştırma için EF Core 6 kendi ef_compare fonksiyonunu kullanıyor (WHERE ef_compare("b"."Price", '12.0') < 0), ama sıralama çalışmıyor:
var ordered = db.Books.OrderBy(b => b.Price).Select(b => b.Title).ToList();
System.NotSupportedException: SQLite does not support expressions of type 'decimal' in ORDER BY clauses. Convert the values to a supported type, or use LINQ to Objects to order the results on the client side.
EF Core 6'da OrderBy(b => (double)b.Price) yazarsan SQL'e ORDER BY CAST("b"."Price" AS REAL) olarak gider ve çalışır. EF Core 10'da aynı OrderBy(b => b.Price) sorgusu COLLATE EF_DECIMAL ile çalışıyor; Sum ve Average desteği de EF Core 9'da gelmişti. SQL Server'da bu sorunların hiçbiri yok.
Büyük-küçük harf. b.Title.Contains("the") SQLite'ta instr ile çevrildiği için büyük-küçük harfe duyarlı ve "The Left Hand of Darkness"ı bulmuyor. b.Title == "foundation" da SQLite'ta eşleşmiyor. Aynı sorgular, varsayılan collation'ı büyük-küçük harfe duyarsız olan SQL Server'da farklı sonuç verir. LINQ ifadesi aynı olsa da karşılaştırmanın kuralını veritabanının collation'ı belirler. SQLite'ta harf duyarsız arama istiyorsan EF.Functions.Like(b.Title, "%the%") kullanabilirsin; bu sorgu 2 kitap buldu.
Count() > 0 ile varlık kontrolü. db.Books.Count(b => b.Pages > 300) > 0 bütün eşleşen satırları sayar. db.Books.Any(b => b.Pages > 300) ise SELECT EXISTS (...) üretir ve ilk eşleşmede durur. Büyük tablolarda fark ciddi.
Erken ToList(). db.Books.ToList().Where(...) yazmak, IEnumerable tuzağının açık hâli. Materialize eden çağrıyı her zaman sorgunun en sonuna koy.
Küçük Bir Deneme
★☆☆ Sayfa numarasını ve sayfa boyutunu parametre alan bir GetPage(int page, int size) method'u yaz. ToQueryString() ile SQL'i yazdır, sonra OrderBy'ı silip konsoldaki uyarıyı gör.
★★☆ Yazar adını, yazarın kitap sayısını ve en yeni kitabının yılını döndüren bir projection yaz: db.Authors.Select(a => new { a.Name, Count = a.Books.Count, Latest = a.Books.Max(b => b.PublishedYear) }). Üretilen SQL'deki alt sorguları incele ve kaç komut çalıştığını say.
★★★ BookQueries.All method'unu IEnumerable<Book> dönüş tipiyle bırak, ama çağıran tarafta loglardan bakarak tablonun tamamının çekildiğini kanıtla. Sonra Books tablosuna seed ile birkaç bin satır ekleyip iki versiyonun sürelerini Stopwatch ile ölç.
Projection ile ilişkili verinin bir kısmını aldık ama bazen nesnenin kendisini ilişkileriyle birlikte istersin: bir yazarı kitaplarıyla, bir siparişi kalemleriyle. Bir sonraki yazıda Include ve ThenInclude, filtered include, explicit ve lazy loading ile tek sorgu ve split query arasındaki seçimi konuşacağız.