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

EF Core 7 Yenilikleri: ExecuteUpdate, ExecuteDelete, JSON Kolonlar ve TPC

Mert Özen 5 Kas 2026 10 dk 3 görüntülenme
EF Core 7 Yenilikleri: ExecuteUpdate, ExecuteDelete, JSON Kolonlar ve TPC

EF Core 7 ile gelen ExecuteUpdate/ExecuteDelete, owned type'ı JSON kolona yazan ToJson, TPC inheritance, IMaterializationInterceptor, FromSql ve SqlQuery<int>; Encrypt=True ve HasTrigger gibi breaking change'lerle EF Core 6'dan geçiş.

Sürüm notu: Bu yazı EF Core 7 ile gelen yenilikleri anlatır; kodlar EF Core 7 içindir.

Önce Tahmin Et

Kitapçı 2000'den önce basılmış bütün kitaplarda yüzde 10 indirim yapmaya karar verdi. Kemik yapıda öğrendiğimiz yolla kod şöyle:

var oldBooks = db.Books
    .Where(b => b.PublishedYear < 2000)
    .ToList();

foreach (var book in oldBooks)
{
    book.Price = book.Price * 0.9m;
}

db.SaveChanges();

Tabloda bu koşula uyan 1000 kitap olsa, bu kod veritabanına kaç SQL komutu gönderir? Bir mi, iki mi, yoksa bin bir mi? Cevabı ilk bölümde log ile birlikte veriyorum.

7 Kasım 2022'de çıkan EF Core 7, bir STS sürümü ve kemik yapıda "sürüm yazılarında göreceğiz" dediğimiz üç özelliği getiriyor: toplu güncelleme ve silme, JSON kolonlar ve TPC. Bir de güzel bir detay: EF Core 7 net6.0 hedefliyor. Yani .NET 6 projeni yükseltmeden EF tarafını 7'ye çekebilirsin. Bu yazının bütün örneklerini net6.0 hedefleyen bir projede EF Core 7.0.20 ile çalıştırdım.

EF Core 6'da Toplu Güncelleme Böyle Yapılıyordu

Yukarıdaki kodu, koşula uyan iki kitapla çalıştırıp LogTo ile komutları yakaladım:

SELECT "b"."Id", "b"."AuthorId", "b"."Price", "b"."PublishedYear", "b"."Stock", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 2000

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1
RETURNING 1;

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1
RETURNING 1;

Önce bir SELECT, sonra her satır için bir UPDATE. Bin kitap için bin bir komut. SQLite provider'ı burada her UPDATE'i ayrı komut olarak gönderdi; SQL Server'da bunlar batch'lenip daha az round trip ile gider, ama yine de satır başına bir UPDATE cümlesi üretilir. Üstelik bütün satırlar önce belleğe geliyor, change tracker her birini izliyor.

Bu, kütüphanedeki bin kitabın fiyat etiketini değiştirmek için her kitabı raftan indirip masaya getirmek, etiketi değiştirip geri koymak gibi. Raftaki görevliye "2000 öncesi rafındaki her etiketi yüzde 10 düşür" demek varken. EF Core 6'da bunu söylemenin yerleşik tek yolu raw SQL'di: ExecuteSqlInterpolated ile elle UPDATE yazmak, yani LINQ'in tip güvenliğinden vazgeçmek.

ExecuteUpdate ve ExecuteDelete

EF Core 7 ile aynı iş LINQ'ten çıkmadan yapılıyor:

var tracked = db.Books.Single(b => b.Title == "Old Tales");

var updated = db.Books
    .Where(b => b.PublishedYear < 2000)
    .ExecuteUpdate(s => s
        .SetProperty(b => b.Price, b => b.Price * 0.9m)
        .SetProperty(b => b.Stock, 0));

var deleted = db.Books
    .Where(b => b.Stock == 0 && b.PublishedYear < 1950)
    .ExecuteDelete();

Console.WriteLine($"updated: {updated}, deleted: {deleted}");
Console.WriteLine($"tracked: {tracked.Price} {tracked.Stock} {db.Entry(tracked).State}");

Loga düşen iki komut:

UPDATE "Books" AS "b"
SET "Stock" = 0,
    "Price" = ef_multiply("b"."Price", '0.9')
WHERE "b"."PublishedYear" < 2000

DELETE FROM "Books" AS "b"
WHERE "b"."Stock" = 0 AND "b"."PublishedYear" < 1950

Satır sayısı ne olursa olsun tek UPDATE, tek DELETE. Hiçbir satır belleğe gelmiyor. SetProperty'nin ilk lambda'sı hangi kolonun değişeceğini, ikincisi yeni değeri söylüyor; ikinci lambda mevcut satırın değerini kullanabiliyor. ef_multiply da SQLite'a özgü bir ayrıntı: SQLite'ta gerçek bir decimal tipi olmadığı için EF bu çarpmayı kendi kaydettiği bir fonksiyonla yapıyor. Her iki method da etkilenen satır sayısını döndürüyor.

Asıl dikkat etmen gereken satır çıktının sonunda:

updated: 2, deleted: 1
tracked: 20.0 3 Unchanged

"Old Tales" veritabanında önce güncellendi, sonra silindi. Ama aynı context'te izlenen instance hâlâ eski fiyatı ve stoğu gösteriyor, state'i de Unchanged. ExecuteUpdate ve ExecuteDelete change tracker'ı tamamen atlıyor. Bu yüzden ikisini, aynı context'te izlediğin entity'lerle karıştırma; gerekiyorsa sonrasında ChangeTracker.Clear() çağır ya da yeni bir context aç. Bir de kemik yapıdaki SaveChanges interceptor'ları ve audit alanları bu komutlarda çalışmaz, çünkü ortada SaveChanges yok.

JSON Kolonları: ToJson

Kemik yapıda owned type'ları gördük: yazarın iletişim bilgisini ayrı bir class'ta tutup OwnsOne ile aynı tablonun kolonlarına yaydık. EF Core 7 aynı owned type'ı tek bir JSON kolonuna yazabiliyor:

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

public class ContactInfo
{
    public string Email { get; set; } = null!;
    public string City { get; set; } = null!;
}

modelBuilder.Entity<Author>()
    .OwnsOne(a => a.Contact, b => b.ToJson());

Yerine dikkat et: ToJson() doğrudan OwnsOne'ın arkasına zincirlenmiyor, owned type'ı yapılandıran lambda'nın içinde çağrılıyor. Koleksiyonlar için OwnsMany ile de aynı şekilde çalışıyor.

Burada bir sınır var: EF Core 7'de JSON kolonlar yalnız SQL Server'da destekleniyor; SQLite desteği EF Core 8'de geldi. SQL Server için Docker'da bir instance gerekiyor. Ama SQL'i görmek için bağlantıya ihtiyacın yok: GenerateCreateScript() ve ToQueryString() veritabanına gitmeden çalışıyor. SQL Server provider'ıyla aldığım çıktı:

CREATE TABLE [Authors] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Contact] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Authors] PRIMARY KEY ([Id])
);

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.City') AS nvarchar(max)) = N'London'

İlki tablo: Email ve City kolonları yok, yerlerinde tek bir Contact kolonu var. İkincisi db.Authors.Where(a => a.Contact.City == "London") sorgusu: LINQ'te normal bir property gibi yazdığın şey, SQL'de JSON_VALUE ile JSON'un içine giren bir ifadeye dönüşüyor. Dokümana göre JSON içindeki tek bir alanı değiştirip SaveChanges çağırdığında EF bütün belgeyi yeniden yazmıyor, JSON_MODIFY ile yalnız değişen yolu güncelliyor.

TPC: Tablo Başına Somut Tip

Kemik yapıda TPH ve TPT'yi gördük. EF Core 7 üçüncü stratejiyi ekliyor: TPC, yani hiyerarşideki her somut tip için tam bir tablo. Ayrı bir context'te kitapçıya e-kitap ve sesli kitap ekleyelim:

public class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; } = null!;
}

public class EBook : Book
{
    public int FileSizeMb { get; set; }
}

public class AudioBook : Book
{
    public string Narrator { get; set; } = null!;
}

modelBuilder.Entity<Book>().UseTpcMappingStrategy();
modelBuilder.Entity<EBook>();
modelBuilder.Entity<AudioBook>();

Oluşan şemada üç tablo var ve her biri kendi tipinin bütün kolonlarını taşıyor:

CREATE TABLE "AudioBook" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_AudioBook" PRIMARY KEY,
    "Title" TEXT NOT NULL,
    "Narrator" TEXT NOT NULL
);

CREATE TABLE "Books" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY,
    "Title" TEXT NOT NULL
);

CREATE TABLE "EBook" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_EBook" PRIMARY KEY,
    "Title" TEXT NOT NULL,
    "FileSizeMb" INTEGER NOT NULL
);

TPT'deki gibi JOIN yok, TPH'deki gibi NULL dolu kolonlar yok. Bedeli, bütün hiyerarşiyi sorguladığında ödeniyor. db.Books ve db.Books.OfType<EBook>() için ToQueryString() çıktıları:

SELECT "b"."Id", "b"."Title", NULL AS "Narrator", NULL AS "FileSizeMb", 'Book' AS "Discriminator"
FROM "Books" AS "b"
UNION ALL
SELECT "a"."Id", "a"."Title", "a"."Narrator", NULL AS "FileSizeMb", 'AudioBook' AS "Discriminator"
FROM "AudioBook" AS "a"
UNION ALL
SELECT "e"."Id", "e"."Title", NULL AS "Narrator", "e"."FileSizeMb", 'EBook' AS "Discriminator"
FROM "EBook" AS "e"

SELECT "e"."Id", "e"."Title", "e"."FileSizeMb", 'EBook' AS "Discriminator"
FROM "EBook" AS "e"

Kural basit: sorguların çoğu tek bir alt tipe gidiyorsa TPC parlıyor, çünkü o sorgu tek tabloya iniyor. Sürekli bütün hiyerarşiyi listeliyorsan UNION ALL pahalılaşır; orada TPH genelde daha iyi. Key'in neden Guid olduğunu da tuzaklar bölümünde göreceğiz. Bu arada TPT için de artık açık bir API var: UseTptMappingStrategy().

Interceptor'lar ve Raw SQL'deki Yenilikler

Kemik yapıda SaveChangesInterceptor ve DbCommandInterceptor yazdık. EF Core 7 listeye yenilerini ekliyor; en kullanışlısı IMaterializationInterceptor. Veritabanından gelen her entity instance'ı oluşturulurken araya girebiliyorsun:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

// Book : IHasRetrieved, Retrieved property'si [NotMapped]
options
    .UseSqlite("Data Source=bookstore7b.db")
    .AddInterceptors(RetrievedInterceptor);

Bu örnekte entity'nin ne zaman yüklendiğini map'lenmemiş bir property'ye yazıyoruz; aynı noktada entity'ye bir service enjekte etmek de mümkün. Listede IQueryExpressionInterceptor (LINQ ifade ağacını derlenmeden önce değiştirmek), concurrency hatası fırlatılmadan önce araya giren ThrowingConcurrencyException noktası ve connection string'i ilk bağlantı anında vermeye izin veren connection interception'ı da var.

Raw SQL tarafında iki ekleme var. FromSql adı geri döndü, bu kez yalnız interpolated string alan güvenli hâliyle. Database.SqlQuery<T> ise skaler sonuç döndüren sorgular yazmanı sağlıyor ve üstüne LINQ eklenebiliyor:

var minYear = 2000;
var recent = db.Books
    .FromSql($"SELECT * FROM Books WHERE PublishedYear >= {minYear}")
    .OrderBy(b => b.Title)
    .ToList();

var years = db.Database
    .SqlQuery<int>($"SELECT PublishedYear AS Value FROM Books")
    .Where(y => y > 1900)
    .ToList();

İkinci sorgunun ürettiği SQL, senin yazdığını bir alt sorgu olarak sarıyor:

SELECT "t"."Value"
FROM (
    SELECT PublishedYear AS Value FROM Books
) AS "t"
WHERE "t"."Value" > 1900

Kolon adının Value olması şart, EF sarmalayan sorguda bu adı arıyor. EF Core 7'de T yalnız skaler bir tip olabiliyor; entity olmayan bir class'a sonuç doldurmayı EF Core 8'de göreceğiz.

EF Core 7'ye Geçiş

EF Core 6 projesinin paket tarafı tek satır; net6.0 olduğu gibi kalabiliyor:

<TargetFramework>net6.0</TargetFramework>

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.20" />

dotnet-ef aracını da 7.0.20'ye çek. Kırıcı değişikliklerden ikisi SQL Server kullanan herkesi etkiliyor. Birincisi Microsoft.Data.SqlClient 4.0 ile gelen Encrypt=True varsayılanı: geliştirme makinendeki geçerli sertifikası olmayan bir SQL Server'a bağlanırken "The certificate chain was issued by an authority that is not trusted" hatası alırsın. Yalnız geliştirme ortamında connection string'e şunu eklemek yeter:

Server=localhost;Database=Bookstore;User Id=sa;Password=...;TrustServerCertificate=True

İkincisi trigger'lar. EF Core 7, kaydetmeyi hızlandırmak için SQL Server'da OUTPUT clause'unu kullanıyor ve SQL Server, trigger'ı olan bir tabloda bu kullanıma izin vermiyor. Trigger'ı olan her tabloyu EF'e bildirmen gerekiyor:

modelBuilder.Entity<Book>()
    .ToTable(tb => tb.HasTrigger("TRG_Books_Audit"));

EF bu tablo için EF Core 6'daki SQL'e geri dönüyor. SQLite'ta benzer bir değişiklik RETURNING clause'u ile geldi: AFTER trigger'ı olan ya da virtual table olan tablolarda dokümana göre özel ayar gerekiyor. Bir de dokümanın "yeniden exception fırlatıyor" dediği birkaç uyarı var; EF Core 6'daki bir hata yüzünden yalnız loglanan bu uyarılar 7'de tekrar varsayılan olarak exception oluyor.

Destek tarafı: EF Core 7 STS'ti ve 14 Mayıs 2024'te destek dışı kaldı, yani bugün kimse onda kalmamalı. Ama bu yazıdaki API'lerin hepsi EF Core 8 ve 10'da aynen duruyor; 7, onların ilk geldiği yer.

Sık Düşülen Tuzaklar

Birincisi, ExecuteUpdate sonrası izlenen entity'lere güvenmek. Yukarıda gördük: veritabanında silinmiş bir satır, change tracker'da Unchanged olarak durabiliyor. Aynı context'te ikisini karıştırıyorsan önce toplu işlemi yap, sonra entity'leri yükle.

İkincisi, SQLite'ta HasTrigger'ın bir şey değiştirdiğini sanmak. Kod SQLite'ta da derleniyor, ama loga baktığımda EF Core 7.0.20 INSERT'i hâlâ RETURNING "Id" ile gönderiyordu. EF Core 7'de SQLite için çözüm, bütün context'i eski SQL üreticisine çevirmek: ReplaceService<IUpdateSqlGenerator, SqliteLegacyUpdateSqlGenerator>(). Bunu yaptığımda derleyici EF1001 uyarısı verdi, çünkü bu class internal bir API. EF Core 8'de bunun yerine tablo bazında UseSqlReturningClause(false) geldi.

Üçüncüsü, EF Core 7'de JSON kolonu SQLite'ta denemek. Anlamlı bir hata bile almıyorsun: OwnsOne(..., b => b.ToJson()) ile SQLite'ta EnsureCreated çağırdığımda model kurulurken RelationalModel.CreateTableMapping içinden bir NullReferenceException geldi.

Dördüncüsü, TPC'de int key kullanmak. Ortak bir tablo olmadığı için identity kolonu ortak bir sayaç olamaz; SQLite'ta sequence da yok. Aynı hiyerarşiyi int key ile kurup bir Book ve bir EBook eklediğimde, ikisinin key'i de 0 kaldı ve Add sırasında şu hatayı aldım:

InvalidOperationException: The instance of entity type 'EBook' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.

SQL Server'da EF bunun için varsayılan olarak bir sequence kullanıyor. SQLite'ta Guid key en sorunsuz yol.

Küçük Bir Deneme

★☆☆ Stoğu sıfır olan bütün kitapların fiyatını ExecuteUpdate ile sıfırla, sonra aynı koşulla ExecuteDelete çağır. Dönen sayıların veritabanındaki satır sayısıyla eşleştiğini Count() ile doğrula.

★★☆ Yukarıdaki TPC hiyerarşisini TPH ile de kur. İki modelde db.Books.ToList() ve db.Books.OfType<AudioBook>().ToList() için ToQueryString() çıktılarını yan yana koy ve hangisinin hangi stratejide daha basit olduğuna bak.

★★★ IMaterializationInterceptor ile, yüklenen her Book'un Title'ını değil, map'lenmemiş bir DisplayTitle property'sini Title ve yayın yılından üret. Sonra aynı sorguyu AsNoTracking() ile çalıştır; interceptor iki durumda da çalışıyor mu?

EF Core 7'nin JSON kolonları owned type üzerine kuruluydu ve SQLite'ı desteklemiyordu. Bir sonraki yazıda EF Core 8 LTS ile ikisinin de nasıl değiştiğine, complex type'lara ve primitive collection'lara bakacağız.