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

EF Core 8 Yenilikleri: Complex Type, Primitive Collection ve SqlQuery<T>

Mert Özen 5 Kas 2026 10 dk 2 görüntülenme
EF Core 8 Yenilikleri: Complex Type, Primitive Collection ve SqlQuery<T>

EF Core 8 LTS ile gelen primitive collection'lar ve json_each, value object için ComplexProperty, entity olmayan tipler için SqlQuery<T>, no-tracking lazy loading, SQLite'ta JSON ve HierarchyId; Contains'in OPENJSON'a geçmesi ve UseCompatibilityLevel(120).

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

Önce Tahmin Et

Kitaplara serbest anahtar kelimeler eklemek istiyorsun. Ayrı bir tablo kurmaya değmez gibi geliyor, property'yi doğrudan listeye çeviriyorsun:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public int PublishedYear { get; set; }
    public List<string> Keywords { get; set; } = new();
    public int AuthorId { get; set; }
    public virtual Author Author { get; set; } = null!;
}

EF Core 6 bu model için ne yapar: listeyi yok mu sayar, virgülle ayrılmış bir string'e mi çevirir, yoksa hata mı verir? Peki EF Core 8 aynı modeli nasıl saklar? İki cevap da ilk bölümde.

14 Kasım 2023'te çıkan EF Core 8 bir LTS sürümü ve sahada bugün en çok karşına çıkacak modern EF sürümü bu. Bu yazının örneklerini net8.0 hedefleyen bir projede EF Core 8.0.33 ile çalıştırdım.

Primitive Collection'lar

EF Core 6.0.36'da EnsureCreated çağırdığımda aldığım hata:

InvalidOperationException: The entity type 'List<string>' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'. For more information on keyless entity types, see https://go.microsoft.com/fwlink/?linkid=2141943.

EF Core 6, List<string>'i bir entity sanıyor ve key'ini arıyor. Kemik yapıda bunun için iki yol vardı: ayrı bir tablo ya da elle yazılmış bir value converter. EF Core 8 ise aynı modeli hiçbir ayar yapmadan bir JSON kolonuna yazıyor. GenerateCreateScript() çıktısında:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "PublishedYear" INTEGER NOT NULL,
    "Keywords" TEXT NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);

Asıl güzel kısım, bu kolonun sorgulanabilmesi. Value converter ile saklanan bir string'in içine LINQ ile giremezdin; burada girebiliyorsun:

var dotnetBooks = db.Books
    .Where(b => b.Keywords.Contains("dotnet"))
    .ToList();
SELECT "b"."Id", "b"."AuthorId", "b"."Keywords", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE 'dotnet' IN (
    SELECT "k"."value"
    FROM json_each("b"."Keywords") AS "k"
)

SQLite'ın json_each fonksiyonu JSON array'ini satırlara açıyor, EF de sonra normal bir IN yazıyor. Aynı mekanizma, parametre olarak verdiğin koleksiyonlarda da devreye giriyor ve bu kısım kemik yapıdaki sorgularını değiştiriyor. ids.Contains(b.Id) sorgusu EF Core 6'da sabitlerle üretiliyordu:

var ids = new[] { 1, 2, 3 };
var books = db.Books.Where(b => ids.Contains(b.Id)).ToList();
SELECT "b"."Id", "b"."AuthorId", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Id" IN (1, 2, 3)

EF Core 8'de aynı satır tek bir JSON parametresine dönüşüyor:

.param set @__ids_0 '[1,2,3]'

SELECT "b"."Id", "b"."AuthorId", "b"."Keywords", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Id" IN (
    SELECT "i"."value"
    FROM json_each(@__ids_0) AS "i"
)

Neden? EF Core 6'da listede 3 eleman varken bir SQL, 4 eleman varken başka bir SQL çıkıyordu. Veritabanının plan cache'i her farklı uzunluk için ayrı plan tutuyordu. Tek parametreyle SQL metni hep aynı kalıyor. Bu değişikliğin SQL Server'daki bir yan etkisini geçiş bölümünde göreceğiz.

Complex Type'lar: Value Object'in Doğru Karşılığı

Kemik yapıda adres gibi değerleri owned type ile modelledik. Owned type çalışıyor, ama içeride gizli bir key'i olan bir entity; EF onu kimliğiyle takip ediyor. Adres ise kimliği olmayan bir değer: iki adresin sokağı, şehri ve posta kodu aynıysa ikisi aynı adrestir. EF Core 8'in complex type'ları tam bu fikir:

public class Address
{
    public required string Street { get; set; }
    public required string City { get; set; }
    public required string PostCode { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
}

modelBuilder.Entity<Order>().ComplexProperty(o => o.ShippingAddress);
modelBuilder.Entity<Order>().ComplexProperty(o => o.BillingAddress);

Tablo tarafında owned type'tan farkı yok, kolonlar Orders tablosuna yayılıyor:

CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "CreatedAt" TEXT NOT NULL,
    "BillingAddress_City" TEXT NOT NULL,
    "BillingAddress_PostCode" TEXT NOT NULL,
    "BillingAddress_Street" TEXT NOT NULL,
    "ShippingAddress_City" TEXT NOT NULL,
    "ShippingAddress_PostCode" TEXT NOT NULL,
    "ShippingAddress_Street" TEXT NOT NULL
);

Fark davranışta. Fatura ve teslimat adresi aynı olan bir sipariş düşün ve iki property'ye aynı Address instance'ını ver. Aynı modeli OwnsOne ile kurduğumda SaveChanges şunu söyledi:

InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address (Address)' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.

Owned type'ın bir kimliği ve tek bir sahibi var, aynı instance iki yerde duramıyor. Complex type ile kayıt sorunsuz geçiyor. Ama bu kez başka bir şeye dikkat etmen gerekiyor. Teslimat adresinin sokağını değiştirdim:

order.ShippingAddress.Street = "2 Side St";
db.SaveChanges();
UPDATE "Orders" SET "BillingAddress_Street" = @p0, "ShippingAddress_Street" = @p1
WHERE "Id" = @p2
RETURNING 1;

Fatura adresi de değişti, çünkü ikisi aynı nesne. Çözüm, value object'i değiştirilemez yapmak. Bir record bunun için biçilmiş kaftan:

public record Address(string Street, string City, string PostCode);

order.ShippingAddress = order.ShippingAddress with { Street = "2 Side St" };
db.SaveChanges();
UPDATE "Orders" SET "ShippingAddress_Street" = @p0
WHERE "Id" = @p1
RETURNING 1;

Artık yalnız teslimat adresi değişiyor. EF Core 8'de complex type'ların sınırları da var: koleksiyon olamıyor, null olamıyor, JSON'a eşlenemiyor. Üçü de EF Core 10'da kalkıyor; o yazıda tekrar buluşacağız.

SqlQuery<T> ile Entity Olmayan Sonuçlar

Raw SQL yazısında, bir rapor sorgusunun sonucunu okumak için keyless entity tanımlayıp modele eklememiz gerekiyordu. EF Core 7'de gördüğümüz SqlQuery<T> yalnız skaler tipleri alıyordu. EF Core 8 ile T modelde olmayan sıradan bir class olabiliyor:

public class AuthorBookCount
{
    public string Name { get; set; } = null!;
    public int BookCount { get; set; }
}

var counts = db.Database
    .SqlQuery<AuthorBookCount>($"""
        SELECT a.Name, COUNT(b.Id) AS BookCount
        FROM Authors a
        LEFT JOIN Books b ON b.AuthorId = a.Id
        GROUP BY a.Name
        """)
    .Where(x => x.BookCount > 1)
    .ToList();
SELECT "a"."BookCount", "a"."Name"
FROM (
    SELECT a.Name, COUNT(b.Id) AS BookCount
    FROM Authors a
    LEFT JOIN Books b ON b.AuthorId = a.Id
    GROUP BY a.Name
) AS "a"
WHERE "a"."BookCount" > 1

AuthorBookCount hiçbir DbSet'te yok, migration'a da girmiyor. Kolon adları property adlarıyla eşleşiyor, üstüne eklediğin Where da SQL'e çevriliyor. Dapper serisinde yaptığımız "SQL'i yaz, sonucu bir DTO'ya doldur" işini artık EF'ten çıkmadan yapabiliyorsun; parametreler interpolated string sayesinde yine güvenli.

Lazy Loading ve No-Tracking

Kemik yapıda lazy loading proxy'lerini kurup risklerini konuştuk. Bir sınırı da vardı. UseLazyLoadingProxies() ve virtual navigation'larla kurulu bir modelde, EF Core 6'da AsNoTracking() ile yüklediğim yazarın kitaplarına eriştiğimde:

InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Infrastructure.DetachedLazyLoadingWarning': An attempt was made to lazy-load navigation 'Books' on a detached entity of type 'AuthorProxy'. Lazy loading is not supported for detached entities or entities that are loaded with 'AsNoTracking'. This exception can be suppressed or logged by passing event ID 'CoreEventId.DetachedLazyLoadingWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

EF Core 8'de aynı kod çalışıyor:

var authors = db.Authors.AsNoTracking().ToList();
Console.WriteLine($"books: {authors[0].Books.Count}");
SELECT "a"."Id", "a"."Name"
FROM "Authors" AS "a"

SELECT "b"."Id", "b"."AuthorId", "b"."Keywords", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = @__p_0
books: 2

İkinci SELECT, Books'a dokunduğumuz anda gitti. Bunun bir bedeli var: dokümana göre no-tracking entity'ler lazy loading için context'e bir referans tutmaya devam ediyor. Context dispose edilince yükleme çalışmıyor, uzun yaşayan entity'lerde de bellek sızıntısına dikkat etmek gerekiyor. Lazy loading'i belirli bir navigation için kapatmak istersen Navigation(a => a.Books).EnableLazyLoading(false) da bu sürümde geldi.

SQLite'ta JSON, SQL Server'da HierarchyId

EF Core 7 yazısında, ToJson()'un SQLite'ta model kurulurken patladığını görmüştük. EF Core 8'de aynı OwnsOne(a => a.Contact, b => b.ToJson()) modeli SQLite'ta çalışıyor ve a.Contact.City == "London" sorgusu SQLite'ın JSON operatörüne çevriliyor:

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE "a"."Contact" ->> 'City' = 'London'

SQL Server tarafında ise hierarchyid tipi EF'e geldi. Bunun için Microsoft.EntityFrameworkCore.SqlServer.HierarchyId paketi ve Docker'da bir SQL Server gerekiyor. Tag'leri ağaç olarak düşün: /1/ programlama, /1/1/ .NET, /1/1/1/ EF Core:

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public HierarchyId Path { get; set; } = null!;
}

options.UseSqlServer(connectionString, x => x.UseHierarchyId());

var parent = HierarchyId.Parse("/1/");
var children = db.Tags
    .Where(t => t.Path.GetAncestor(1) == parent)
    .ToList();
DECLARE @__parent_0 hierarchyid = hierarchyid::Parse('/1/');

SELECT [t].[Id], [t].[Name], [t].[Path]
FROM [Tags] AS [t]
WHERE [t].[Path].GetAncestor(1) = @__parent_0

SQL'i yine bağlantı kurmadan, ToQueryString() ile aldım. GetAncestor, IsDescendantOf ve GetLevel gibi method'lar doğrudan SQL Server'ın kendi fonksiyonlarına gidiyor.

EF Core 8'e Geçiş: Contains ve OPENJSON

EF Core 8 net8.0 istiyor, yani .NET 6 ya da 7 projesinde önce TFM'i yükseltmen gerekiyor:

<TargetFramework>net8.0</TargetFramework>

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

Dokümanın "High" diye işaretlediği iki değişiklik az önce gördüğümüz Contains ile ilgili. SQL Server'da parametre koleksiyonu OPENJSON ile açılıyor ve OPENJSON SQL Server 2016'da, compatibility level 130 ile geldi. Daha eski bir sunucuda ya da compatibility level'ı 130'un altında kalan bir veritabanında, EF Core 6'da çalışan sorgun 8'de hata verir. Dokümanın özellikle uyardığı bir durum: şirket içi bir SQL Server'dan Azure SQL'e taşınmış veritabanları eski compatibility level'ı yanlarında getirebiliyor. Ayrıca bazı sorgularda planlar kötüleşebiliyor. İki SQL'i SQL Server provider'ıyla, bağlantı kurmadan aldım:

// Varsayılan ayar
options.UseSqlServer(connectionString);

// Eski sunucu ya da düşük compatibility level için
options.UseSqlServer(connectionString, o => o.UseCompatibilityLevel(120));
DECLARE @__ids_0 nvarchar(4000) = N'[1,2,3]';

SELECT [b].[Id], [b].[AuthorId], [b].[Keywords], [b].[PublishedYear], [b].[Title]
FROM [Books] AS [b]
WHERE [b].[Id] IN (
    SELECT [i].[value]
    FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
)

SELECT [b].[Id], [b].[AuthorId], [b].[Keywords], [b].[PublishedYear], [b].[Title]
FROM [Books] AS [b]
WHERE [b].[Id] IN (1, 2, 3)

İlki varsayılan, ikincisi UseCompatibilityLevel(120) ile. EF ikinci durumda EF Core 6'daki sabit listesine geri dönüyor. Bir değişiklik daha EF Core 7'de JSON kolon kullananları ilgilendiriyor: JSON içindeki enum'lar artık string değil int olarak saklanıyor. Var olan veride string enum'lar varsa okurken sorun çıkar; dokümanın önerdiği çözüm, ilgili property'ye HasConversion<string>() vermek.

Bir de güzel bir araç geldi: dotnet ef migrations has-pending-model-changes komutu ve Database.HasPendingModelChanges(), modelde migration'a dönüşmemiş bir değişiklik olup olmadığını söylüyor. CI'da migration eklemeyi unutan commit'leri yakalamak için birebir. Destek tarafında ise EF Core 8 LTS olmasına rağmen 10 Kasım 2026'da EF Core 9 ile birlikte destek dışı kalıyor. 8'deysen hedefin 10 olmalı.

Sık Düşülen Tuzaklar

Birincisi, primitive collection'ı küçük bir tablo yerine kullanmak. JSON kolon, listeyi tek tek güncellemeyi, foreign key ile bütünlüğü ve index'i zorlaştırıyor. Liste gerçekten sadece o satıra ait bir değer listesiyse harika; başka tablolarla ilişki kuruyorsa, kemik yapıdaki many-to-many hâlâ doğru cevap. Seride tag'leri ayrı bir entity olarak tutmamızın sebebi de bu.

İkincisi, complex type'ı değiştirilebilir bırakıp instance paylaşmak. Yukarıda gördük: tek satırlık bir atama iki adresi birden değiştirdi ve EF bunu sessizce yaptı. Value object'leri record ya da sadece constructor ile set edilen class olarak yaz.

Üçüncüsü, geçişte SQL Server sürümünü kontrol etmemek. Testlerin LocalDB ya da yeni bir Docker imajı üzerinde koşuyorsa OPENJSON sorunu orada görünmez; production'daki eski sunucuda patlar. Production'ın compatibility level'ını öğren, gerekiyorsa UseCompatibilityLevel ver.

Dördüncüsü, no-tracking lazy loading'i bir performans kazancı sanmak. Takip etmiyor olman, her navigation erişiminin ayrı bir SQL olduğu gerçeğini değiştirmiyor; N+1 problemi aynen duruyor. Listeyi dolaşırken navigation'a dokunacaksan Include ya da projection hâlâ doğru araç.

Küçük Bir Deneme

★☆☆ Book'a List<string> Keywords ekle, birkaç kitap kaydet ve SQLite dosyasını bir veritabanı aracıyla açıp kolonun içeriğine bak. Sonra b.Keywords.Count > 1 filtresinin SQL'ini ToQueryString() ile incele.

★★☆ Order'a Address'i önce değiştirilebilir bir class olarak, sonra record olarak complex type ile bağla. İki durumda da aynı instance'ı iki property'ye verip birini değiştir ve loga düşen UPDATE'leri karşılaştır.

★★★ Sipariş toplamlarını döndüren bir rapor için SqlQuery<T> yaz: her yazar için toplam satılan kitap adedi. Sonucun üstüne OrderByDescending ve Take(3) ekle ve ürettiği SQL'de bunların nereye yerleştiğine bak. Aynı raporu Dapper ile yazsaydın neyi farklı yapardın?

EF Core 8, LTS olarak uzun süre sahada kalacak. Bir sonraki yazıda bu kez bir STS sürümüne, seeding'i ve migration'ları daha güvenli hâle getiren EF Core 9'a bakacağız.