UZMAN
Yeniden Kullanılabilir IQueryable Metotlar — Subquery Tuzağı
Tekrarlayan sorgu mantığını DRY tutmak için IQueryable extension method'ları yazılır. Ama EF Core bu metotları birleştirirken subquery oluşturabilir — bu da gereksiz nested SELECT'lere, index kullanılmamasına ve ciddi performans kaybına yol açar.
Problem: Subquery Olarak Sarmalama
// ❌ Yeniden kullanılabilir IQueryable metodu:
public static class ProductQueries
{
public static IQueryable<Product> Active(this IQueryable<Product> query)
=> query.Where(p => p.IsActive && !p.IsDeleted);
public static IQueryable<Product> InCategory(this IQueryable<Product> query, int categoryId)
=> query.Where(p => p.CategoryId == categoryId);
public static IQueryable<Product> Expensive(this IQueryable<Product> query)
=> query.Where(p => p.Price > 1000);
}
// Kullanım — basit zincir (BU GENELLİKLE SORUNSUZ):
var products = await context.Products
.Active()
.InCategory(5)
.Expensive()
.ToListAsync();
Basit Where zincirleri genellikle düzleştirilir (EF Core ifade ağacını birleştirir):
-- ✅ EF Core Where'leri birleştirir — subquery YOK:
SELECT [p].[Id], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[IsActive] = 1 AND [p].[IsDeleted] = 0
AND [p].[CategoryId] = 5 AND [p].[Price] > 1000;
-- ✅ EF Core Where'leri birleştirir — subquery YOK:
SELECT p.id, p.name, p.price
FROM products AS p
WHERE p.is_active = TRUE AND p.is_deleted = FALSE
AND p.category_id = 5 AND p.price > 1000;
🚨 Ne Zaman Subquery Oluşur?
Sorun, IQueryable değişkenini başka bir sorguda referans aldığında veya Select/projection içerdiğinde ortaya çıkar:
// ❌ TEHLİKELİ — IQueryable değişkenini başka sorguda kullanmak:
var activeProducts = context.Products.Active(); // IQueryable değişken
var result = await context.Orders
.Where(o => activeProducts.Any(p => p.Id == o.ProductId)) // ← Subquery!
.ToListAsync();
// ❌ TEHLİKELİ — IQueryable'ı Join'de kullanmak:
var expensiveProducts = context.Products.Active().Expensive();
var orderDetails = await context.Orders
.Join(expensiveProducts, // ← Subquery olabilir!
o => o.ProductId,
p => p.Id,
(o, p) => new { o.Reference, p.Name, p.Price })
.ToListAsync();
// ❌ EN TEHLİKELİ — Select/Projection içeren IQueryable:
public static IQueryable<ProductDto> AsDto(this IQueryable<Product> query)
=> query.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price });
// Bu metot başka bir sorguda kullanılırsa → kesin subquery:
var dtos = context.Products.Active().AsDto();
var filtered = dtos.Where(d => d.Price > 500).ToListAsync();
// ↑ EF Core bunu şu şekle çevirir:
-- ❌ EF Core 7/8: Join'de IQueryable değişken → subquery:
SELECT [o].[Reference], [t].[Name], [t].[Price]
FROM [Orders] AS [o]
INNER JOIN (
SELECT [p].[Id], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[IsActive] = 1 AND [p].[IsDeleted] = 0 AND [p].[Price] > 1000
) AS [t] ON [o].[ProductId] = [t].[Id];
-- ✅ İdeal SQL (subquery olmasaydı):
SELECT [o].[Reference], [p].[Name], [p].[Price]
FROM [Orders] AS [o]
INNER JOIN [Products] AS [p] ON [o].[ProductId] = [p].[Id]
WHERE [p].[IsActive] = 1 AND [p].[IsDeleted] = 0 AND [p].[Price] > 1000;
-- ❌ EF Core 7/8: Join'de IQueryable değişken → subquery:
SELECT o.reference, t.name, t.price
FROM orders AS o
INNER JOIN (
SELECT p.id, p.name, p.price
FROM products AS p
WHERE p.is_active = TRUE AND p.is_deleted = FALSE AND p.price > 1000
) AS t ON o.product_id = t.id;
-- ✅ İdeal SQL (subquery olmasaydı):
SELECT o.reference, p.name, p.price
FROM orders AS o
INNER JOIN products AS p ON o.product_id = p.id
WHERE p.is_active = TRUE AND p.is_deleted = FALSE AND p.price > 1000;
Performans Etkisi
| Senaryo | Subquery Var mı | Etki |
|---|---|---|
| Basit Where zincirleme | Sorun yok — EF düzleştirir | |
IQueryable değişken + Any() / Contains() |
Correlated subquery → her satır için çalışır | |
IQueryable değişken + Join() |
(EF7/8) | Nested SELECT — optimizer'a bağlı |
| Select/Projection içeren IQueryable | Kesin subquery sınırı — düzleşmez | |
| Count/Sum/Avg içeren IQueryable | Aggregate subquery |
SQL Server genellikle basit subquery'leri optimizer'da düzleştirir (performance farkı küçük olabilir).
PostgreSQL planner'ı daha katı — subquery görünce index kullanmayı bırakabilir, Seq Scan'a düşebilir.
EF Core 9 İyileştirmesi: Uncorrelated Subquery Inlining
EF Core 9 (Kasım 2024) önemli bir iyileştirme getirdi: Uncorrelated (bağımsız) subquery'ler artık tek sorguya inline edilir, iki ayrı round-trip yerine bir SQL'e birleştirilir.
// EF Core 8'de: dotnetPosts ayrı round-trip olarak çalışıyordu
var dotnetPosts = context.Posts.Where(p => p.Title.Contains(".NET"));
var results = await dotnetPosts
.Where(p => p.Id > 2)
.Select(p => new { Post = p, TotalCount = dotnetPosts.Count() })
.Skip(2).Take(10)
.ToArrayAsync();
EF Core 8 vs 9 — SQL Server
-- ❌ EF Core 8: İKİ ayrı sorgu (iki round-trip!)
-- Sorgu 1:
SELECT COUNT(*) FROM [Posts] AS [p] WHERE [p].[Title] LIKE N'%.NET%';
-- Sorgu 2:
SELECT [p].[Id], [p].[Title], [p].[Content]
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY;
-- ✅ EF Core 9: TEK sorgu (inline subquery)
SELECT [p].[Id], [p].[Title], [p].[Content], (
SELECT COUNT(*)
FROM [Posts] AS [p0]
WHERE [p0].[Title] LIKE N'%.NET%')
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY;
Dikkat: Bu iyileştirme sadece uncorrelated (dış sorguya bağımlı olmayan) IQueryable'lar için geçerli. Correlated subquery'ler (Any, All ile dış entity'ye referans) hâlâ subquery olarak kalır.
Doğru Yaklaşım: Expression Tabanlı Yeniden Kullanım
Subquery tuzağından kaçınmanın en güvenli yolu: IQueryable yerine Expression döndüren metotlar kullanmak:
// ✅ DOĞRU — Expression tabanlı yeniden kullanılabilir filtreler:
public static class ProductFilters
{
public static Expression<Func<Product, bool>> IsActive()
=> p => p.IsActive && !p.IsDeleted;
public static Expression<Func<Product, bool>> IsExpensive(decimal threshold = 1000)
=> p => p.Price > threshold;
public static Expression<Func<Product, bool>> InCategory(int categoryId)
=> p => p.CategoryId == categoryId;
// Birden fazla koşulu birleştirmek için:
public static Expression<Func<Product, bool>> ActiveAndExpensive(decimal threshold = 1000)
=> p => p.IsActive && !p.IsDeleted && p.Price > threshold;
}
// Kullanım:
var products = await context.Products
.Where(ProductFilters.IsActive())
.Where(ProductFilters.InCategory(5))
.Where(ProductFilters.IsExpensive())
.ToListAsync();
// → Tek düz WHERE clause, subquery YOK ✓
// Join'de kullanım — güvenli:
var orderDetails = await context.Orders
.Join(context.Products.Where(ProductFilters.IsActive()),
o => o.ProductId,
p => p.Id,
(o, p) => new { o.Reference, p.Name, p.Price })
.ToListAsync();
// → EF Core expression'ı doğrudan JOIN ON clause'a ekler
Expression Birleştirme (LinqKit veya Manuel)
Birden fazla expression'ı AND/OR ile birleştirmek için:
// Seçenek 1: LinqKit (NuGet: LinqKit.Microsoft.EntityFrameworkCore)
using LinqKit;
var filter = PredicateBuilder.New<Product>(true);
filter = filter.And(ProductFilters.IsActive());
filter = filter.And(ProductFilters.IsExpensive(500));
if (categoryId.HasValue)
filter = filter.And(ProductFilters.InCategory(categoryId.Value));
var products = await context.Products
.AsExpandable() // LinqKit gerekli
.Where(filter)
.ToListAsync();
// Seçenek 2: Manuel Expression birleştirme (bağımlılık yok)
public static Expression<Func<T, bool>> AndAlso<T>(
this Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right)
{
var param = left.Parameters[0];
var body = Expression.AndAlso(
left.Body,
Expression.Invoke(right, param));
return Expression.Lambda<Func<T, bool>>(body, param);
}
// Kullanım:
var combined = ProductFilters.IsActive().AndAlso(ProductFilters.IsExpensive(500));
var products = await context.Products.Where(combined).ToListAsync();
Dikkat:
Expression.InvokekullananAndAlsohelper'ı bazı provider'larda (özellikle eski Npgsql sürümleri) SQL'e çevrilemez. Production'da sorun yaşarsan LinqKit kullan —AsExpandable()+PredicateBuilderexpression'ları düzleştirerek provider-safe hale getirir. Alternatif:ExpressionVisitorile parameter rebinding yapan bir versiyon yaz.
IQueryable Extension Method'lar Ne Zaman Güvenli?
| Pattern | Güvenli mi? | Neden |
|---|---|---|
.Where() zincirleme |
EF expression tree'yi düzleştirir | |
.OrderBy() / .Take() / .Skip() |
Tek sorgu, subquery yok | |
.Include() / .ThenInclude() |
EF kendi mekanizmasıyla birleştirir | |
IQueryable değişken + .Any() / .All() |
Correlated subquery — kaçınılmaz ama bilinçli kullan | |
IQueryable değişken + Join() |
EF7/8 subquery, EF9+ inline (basit durumlarda) | |
.Select() / projection içeren IQueryable |
Kesin subquery sınırı — Expression kullan | |
IQueryable + .Count() / .Sum() scalar |
EF9+ inline (uncorrelated), EF8 ayrı round-trip |
Specification Pattern — Doğru Uygulama
Repository pattern ile sık kullanılan Specification pattern'ı da Expression tabanlı olmalıdır:
// ✅ Specification Pattern — Expression tabanlı
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
// IQueryable'a uygulama:
public IQueryable<T> Apply(IQueryable<T> query)
=> query.Where(ToExpression());
}
public class ActiveProductSpec : Specification<Product>
{
public override Expression<Func<Product, bool>> ToExpression()
=> p => p.IsActive && !p.IsDeleted;
}
public class ExpensiveInCategorySpec : Specification<Product>
{
private readonly int _categoryId;
private readonly decimal _minPrice;
public ExpensiveInCategorySpec(int categoryId, decimal minPrice = 1000)
{
_categoryId = categoryId;
_minPrice = minPrice;
}
public override Expression<Func<Product, bool>> ToExpression()
=> p => p.CategoryId == _categoryId && p.Price > _minPrice;
}
// Kullanım:
var spec = new ActiveProductSpec();
var products = await spec.Apply(context.Products)
.OrderByDescending(p => p.Price)
.Take(10)
.ToListAsync();
// → Düz SQL, subquery YOK ✓
Özet: Karar Matrisi
| İhtiyaç | Yöntem | Risk |
|---|---|---|
| Tekrarlayan WHERE koşulu | Expression<Func<T, bool>> |
Sıfır risk |
| Tekrarlayan Where + OrderBy zinciri | IQueryable extension (Where + OrderBy) | Düşük risk |
| Sorguyu başka sorguda kullanma | Expression | Sıfır risk |
| Join'de reusable filtre | Expression (Where içinde) | Sıfır risk |
| Pagination + filter combo | IQueryable extension | Düşük risk (Skip/Take sorun değil) |
| Count/Aggregate + ana sorgu | EF9+ inline / veya ayrı sorgu olarak kabul et | Versiyon bağımlı |
| Projection (Select → DTO) reuse | Ayrı metot olarak bırak, birleştirme | Subquery kaçınılmaz |
🚨 Altın Kural: "IQueryable extension method yazdığımda, bu metot başka bir sorguda referans alınacak mı?" sorusunu sor. Cevap evet ise → Expression kullan. Cevap hayır ise (sadece zincir sonunda ToList/First çağrılacaksa) → IQueryable güvenli.