Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #18

EF Core'da Global Query Filter: Soft Delete, Multi-Tenancy ve IgnoreQueryFilters

Mert Özen 5 Kas 2026 9 dk 3 görüntülenme
EF Core'da Global Query Filter: Soft Delete, Multi-Tenancy ve IgnoreQueryFilters

HasQueryFilter ile soft delete ve multi-tenancy kuruyor, Remove'u UPDATE'e çeviriyor, IgnoreQueryFilters ile filtreyi kapatıyor ve required navigation'larda INNER JOIN yüzünden kaybolan satırlar gibi ilişki tuzaklarını gerçek SQL ile inceliyoruz.

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: Silinen Kitap Nereden Kaybolur?

Frank Herbert'in iki kitabı var: Dune ve Dune Messiah. İkisinin de birer sipariş kalemi var. Dune Messiah'ı satıştan kaldırıyoruz, ama kaydı gerçekten silmek yerine IsDeleted bayrağını işaretliyoruz. Modeli ve filtreyi birazdan kuracağız; şimdilik şu üç sorgunun ne döndüreceğini tahmin et:

using (var db = new BookstoreContext())
{
    var books = await db.Books.CountAsync();
    var authors = await db.Authors.Select(a => new { a.Name, BookCount = a.Books.Count }).ToListAsync();
    var items = await db.OrderItems.Include(i => i.Book).ToListAsync();

    Console.WriteLine($"Books: {books}");
    Console.WriteLine($"Frank Herbert's books: {authors[0].BookCount}");
    Console.WriteLine($"Order items: {items.Count}");
}

Kitap sayısı için 1 demek kolay. Yazarın kitap sayısı? Ya sipariş kalemleri? İki kalem var ve biz hiçbir kalemi silmedik. Cevaplar bu yazının en önemli tuzağını taşıyor.

Model şu:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Book> Books { get; set; } = new();
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDeleted { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public int BookId { get; set; }
    public Book Book { get; set; } = null!;
}

HasQueryFilter ile Soft Delete

Soft delete, kaydı silmek yerine "silindi" diye işaretlemek demek. Siparişler, faturalar ve denetim kayıtları gibi geçmişi korunması gereken verilerde yaygın bir yöntem. Sorunu da bilinir: artık her sorguya Where(b => !b.IsDeleted) yazmayı hatırlaman gerekir ve biri unuttuğu an silinmiş kitaplar sitede görünür.

Global query filter bu koşulu modele bir kez yazıp her sorguya EF Core'un eklemesini sağlar:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
}

Artık db.Books üzerinden yazılan her sorgu bu koşulu içeriyor. En sade sorgunun SQL'i:

SELECT "b"."Id", "b"."AuthorId", "b"."IsDeleted", "b"."Title"
FROM "Books" AS "b"
WHERE NOT ("b"."IsDeleted")

Filtre, LINQ'te yazabileceğin her koşulu alabilir ve context'in üyelerine erişebilir; birazdan multi-tenancy'de bunu kullanacağız. Filtreler entity tipine tanımlanır ve inheritance kullanıyorsan yalnız hiyerarşinin kök tipine konabilir.

Remove'u Soft Delete'e Çevirmek

Filtre okuma tarafını çözüyor. Yazma tarafında ise db.Books.Remove(book) hâlâ bir DELETE üretiyor. Kodun geri kalanının normal Remove kullanmaya devam etmesini, ama veritabanına UPDATE gitmesini istiyoruz. Bunun için SaveChangesAsync'i override ediyoruz:

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    foreach (var entry in ChangeTracker.Entries<Book>().Where(e => e.State == EntityState.Deleted))
    {
        entry.State = EntityState.Modified; // DELETE yerine UPDATE
        entry.Entity.IsDeleted = true;
    }

    return base.SaveChangesAsync(cancellationToken);
}

Açılıştaki silme kodu şuydu:

using (var db = new BookstoreContext())
{
    var messiah = await db.Books.SingleAsync(b => b.Title == "Dune Messiah");
    db.Books.Remove(messiah);
    await db.SaveChangesAsync();
}

EF Core 10'un logunda DELETE yok:

info: 
      Executed DbCommand (0ms) [Parameters=[@p3='?' (DbType = Int32), @p0='?' (DbType = Int32), @p1='?' (DbType = Boolean), @p2='?' (Size = 12)], CommandType='Text', CommandTimeout='30']
      UPDATE "Books" SET "AuthorId" = @p0, "IsDeleted" = @p1, "Title" = @p2
      WHERE "Id" = @p3
      RETURNING 1;

Burada Modified'a çektiğimiz için EF Core bütün kolonları güncelliyor. Bu bir sorun değil, ama istersen yalnız IsDeleted'ı değişmiş olarak işaretleyebilirsin. Aynı işi interceptor'lar yazısında SaveChangesInterceptor ile, context'i kirletmeden yapacağız. Silme zamanını ve silen kullanıcıyı tutmak için de doğru yer orası.

Şimdi açılıştaki üç sorgunun çıktısı:

Books: 1
Frank Herbert's books: 1
Order items: 1

İlk iki sonuç beklediğimiz gibi. Filtre, navigation üzerinden yapılan a.Books.Count'a da uygulanıyor:

      SELECT "a"."Name", (
          SELECT COUNT(*)
          FROM "Books" AS "b"
          WHERE NOT ("b"."IsDeleted") AND ("a"."Id" = "b"."AuthorId")) AS "BookCount"
      FROM "Authors" AS "a"

Ama üçüncü sonuç bir sipariş kalemini kaybetti. Biz hiçbir kalemi silmedik. Buna iki başlık sonra döneceğiz.

IgnoreQueryFilters: Çöp Kutusu Sayfası

Bazen filtrenin gizlediği satırlara gerçekten ihtiyacın olur: bir yöneticinin "silinenler" sayfası ya da bir kitabı geri getirme işlemi. IgnoreQueryFilters() o sorgu için bütün global filtreleri kapatır:

var trash = await db.Books
    .IgnoreQueryFilters()
    .Where(b => b.IsDeleted)
    .ToListAsync();
SELECT "b"."Id", "b"."AuthorId", "b"."IsDeleted", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."IsDeleted"
Trash: Dune Messiah

Koşulu artık kendin yazıyorsun; EF Core yalnız senin Where'ini ekledi. Unutma: EF Core 6'da IgnoreQueryFilters hepsi ya da hiçbiri çalışır. Bir entity'de hem soft delete hem tenant filtresi varsa ikisi birden kapanır. Yalnız birini kapatabilmek için EF Core 10 ile adlandırılmış filtreler geldi; sürüm yazılarında göreceğiz.

Filtre bazı beklenmedik yerlerde de çalışır. Find, entity tracker'da yoksa veritabanına bir sorgu atar ve o sorgu da filtreden geçer:

var found = await db.Books.FindAsync(2); // Dune Messiah'ın id'si
Console.WriteLine(found is null ? "Find: null" : $"Find: {found.Title}");
Find: null

Kitap veritabanında duruyor ama "yok" gibi davranıyor. Geri getirme işlemini Find ile yazarsan çalışmaz; IgnoreQueryFilters() ile sorgulaman gerekir.

Multi-Tenancy: Context Başına Filtre

Aynı uygulamayı birden fazla kitapçıya satıyorsun ve hepsinin verisi aynı veritabanında. Her kitapçı yalnız kendi kitaplarını görmeli. Bu, global filtrelerin en değerli kullanımı, çünkü burada unutulan bir Where soft delete'teki gibi küçük bir hata değil, bir veri sızıntısıdır:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDeleted { get; set; }
    public string TenantId { get; set; } = "";
}

public class BookstoreContext : DbContext
{
    private readonly string _tenantId;

    public BookstoreContext(string tenantId) => _tenantId = tenantId;

    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=tenants.db");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(b => !b.IsDeleted && b.TenantId == _tenantId);
    }
}

Filtrenin içindeki _tenantId bir sabit değil, context'in bir field'ı. EF Core bunu fark ediyor ve sorguya bir parametre olarak koyuyor. Model bir kez oluşturulup önbelleğe alınsa bile her context kendi değerini gönderiyor:

using (var harbor = new BookstoreContext("harbor"))
using (var corner = new BookstoreContext("corner"))
{
    Console.WriteLine(harbor.Books.ToQueryString());
    Console.WriteLine();
    Console.WriteLine("harbor: " + string.Join(", ", await harbor.Books.Select(b => b.Title).ToListAsync()));
    Console.WriteLine("corner: " + string.Join(", ", await corner.Books.Select(b => b.Title).ToListAsync()));
}
.param set @ef_filter___tenantId 'harbor'

SELECT "b"."Id", "b"."IsDeleted", "b"."TenantId", "b"."Title"
FROM "Books" AS "b"
WHERE NOT ("b"."IsDeleted") AND "b"."TenantId" = @ef_filter___tenantId

harbor: Dune
corner: Neuromancer

Çıktı EF Core 10'dan. EF Core 6'da SQL aynı, yalnız parametrenin adı @__ef_filter___tenantId_0. Silinmiş Dune Messiah harbor'da da görünmüyor, çünkü iki koşulu aynı filtrede birleştirdik.

ASP.NET Core'da tenant bilgisi genellikle isteğin kendisinden gelir: bir header, bir subdomain ya da kullanıcının token'ındaki bir claim. Bu durumda constructor'a string yerine, isteğin tenant'ını çözen scoped bir service enjekte edersin. Önceki yazıdaki uyarıyı hatırla: AddDbContextPool kullanıyorsan context'in kendi field'ları kiralar arasında sıfırlanmaz ve bir önceki tenant'ın id'si bir sonraki isteğe taşınabilir. Tenant filtresi olan bir context'te pooling kullanacaksan tenant'ı her kiralamada yeniden ayarlayan bir düzen kurman gerekir.

İlişkilerle Etkileşim: Required Navigation Tuzağı

Kaybolan sipariş kalemine dönelim. OrderItem.Book zorunlu bir ilişki: her kalemin bir kitabı olmak zorunda. EF Core, Include(i => i.Book)'u bu yüzden INNER JOIN ile çeviriyor. Ama Book'un bir filtresi var ve EF Core bu filtreyi join'in içine koyuyor:

SELECT "o"."Id", "o"."BookId", "o"."Quantity", "b0"."Id", "b0"."AuthorId", "b0"."IsDeleted", "b0"."Title"
FROM "OrderItems" AS "o"
INNER JOIN (
    SELECT "b"."Id", "b"."AuthorId", "b"."IsDeleted", "b"."Title"
    FROM "Books" AS "b"
    WHERE NOT ("b"."IsDeleted")
) AS "b0" ON "o"."BookId" = "b0"."Id"

Silinmiş kitap subquery'den çıkınca, ona bağlı kalem de INNER JOIN yüzünden sonuçtan çıkıyor. Kalemi kimse silmedi, ama bir kitabın filtrelenmesi kalemi de görünmez yaptı. Include'u kaldırıp yalnız OrderItems'ı sayınca 2, Include ile listeleyince 1 almak, ekranda çok kafa karıştırıcı hatalara yol açar. Filtreleri kapatıp aynı sorguyu çalıştırınca iki kalem de geliyor:

var all = await db.OrderItems.IgnoreQueryFilters().Include(i => i.Book).ToListAsync();
Order items (ignore filters): 2

EF Core bu durumu model oluşturulurken zaten fark ediyor ve bir uyarı yazıyor. Uyarı Warning seviyesinde olduğu için logları açık tutan herkes görür:

warn: 
      Entity 'Book' has a global query filter defined and is the required end of a relationship with the entity 'OrderItem'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.

Uyarı iki çıkış yolu öneriyor. Birincisi ilişkiyi opsiyonel yapmak: foreign key nullable olur, EF Core LEFT JOIN kullanır ve kalem gelir, ama Book navigation'ı null olur. İkincisi, ilişkinin iki ucuna uyumlu filtreler koymak:

modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
modelBuilder.Entity<OrderItem>().HasQueryFilter(i => !i.Book.IsDeleted);

Bu durumda silinmiş bir kitabın kalemleri, Include olsun olmasın, her sorguda tutarlı şekilde gizlenir; Include'suz sayım da artık EF Core 6'da ve 10'da 1 döndürüyor. Hangisini seçeceğin iş kuralına bağlı. Bir sipariş geçmişi ekranında, kitap satıştan kalktı diye eski siparişin kalemi kaybolmamalı; orada ya filtreyi kapatırsın ya da kitabı silmek yerine "satışta değil" gibi ayrı bir durumla işaretlersin. Soft delete'in her şeyin cevabı olmadığını burada görüyorsun.

Sık Düşülen Tuzaklar

İki ayrı HasQueryFilter çağrısı. Soft delete ve tenant filtresini iki satıra bölmek mantıklı görünür:

modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
modelBuilder.Entity<Book>().HasQueryFilter(b => b.TenantId == "harbor"); // ilkini ezer

Ama EF Core 6'da bir entity'nin tek bir filtresi olur ve ikinci çağrı birincinin yerine geçer. Ürettiği SQL'de IsDeleted koşulu yok ve silinmiş kitap geri geliyor:

SELECT "b"."Id", "b"."IsDeleted", "b"."TenantId", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."TenantId" = 'harbor'
two filters: Dune, Dune Messiah

EF Core 10'da da adsız filtreler için davranış aynı. Koşulları tek bir lambda'da && ile birleştir.

Filtreyi atlayan yollar. Global filtre yalnız LINQ sorgularına uygulanır. ExecuteSqlRaw ile yazılan bir komut filtreyi bilmez; tenant'ı da silinme durumunu da kendin yazmalısın. FromSqlRaw ise DbSet üzerinden çağrıldığı ve EF Core sorguyu sardığı için filtreyi alır.

Unique index ve soft delete. Title'da unique index varsa, silinmiş bir kitapla aynı adda yeni bir kitap eklenemez; silinmiş satır hâlâ tabloda. Index'i filtreli tanımlamak gerekir (HasFilter) ya da unique kuralı yeniden düşünülmelidir.

Testte filtreyi unutmak. Testlerin farklı bir tenant id ile kurulan context'i kullanırsa, testte "veri yok" görürsün ve hata koddaymış gibi görünür. Test verisini doğru tenant ile oluştur.

Küçük Bir Deneme

★☆☆ Author'a IsDeleted ekle ve aynı soft delete filtresini yazarlara da uygula. Bir yazarı sildiğinde, db.Books.Include(b => b.Author) sorgusunda ne oluyor? Model oluşturulurken hangi uyarıyı görüyorsun?

★★☆ Bir RestoreBookAsync(int id) method'u yaz: silinmiş kitabı IgnoreQueryFilters() ile bul, IsDeleted'ı geri al ve kaydet. Aynı işi FindAsync ile yazmayı dene ve neden çalışmadığını açıkla.

★★★ OrderItem.BookId'yi int? yapıp ilişkiyi opsiyonele çevir. Include(i => i.Book) sorgusunun SQL'inin LEFT JOIN'e döndüğünü ve silinmiş kitabın kaleminde Book'un null geldiğini gör. Bu davranışı sipariş geçmişi ekranında nasıl göstereceğine karar ver.

Global filtrelerle modelin bir parçasını her sorguya gizlice eklemeyi öğrendik. Bir sonraki yazıda modelin kendisine başka bir açıdan bakıyoruz: inheritance. Aynı tabloda farklı türde kitaplar, discriminator kolonu, TPH ve TPT stratejileri ve hangisinin ne zaman seçileceği.