EFEF Core Handbook

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.

Veritabanı sağlayıcısı Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.

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.Invoke kullanan AndAlso helper'ı bazı provider'larda (özellikle eski Npgsql sürümleri) SQL'e çevrilemez. Production'da sorun yaşarsan LinqKit kullan — AsExpandable() + PredicateBuilder expression'ları düzleştirerek provider-safe hale getirir. Alternatif: ExpressionVisitor ile 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.