Eski Projeler: EF Core 3.1 ve EF Core 5'ten EF Core 6'ya Geçiş
EF Core 3.0'da client evaluation'ın kalkması, FromSql'in FromSqlRaw/FromSqlInterpolated'a bölünmesi, EF Core 5'in many-to-many, filtered include ve split query'si, EF Core 6'ya geçerken Nullable'ın migration'lara etkisi ve destek durumları.
Sürüm notu: Bu yazı EF Core 3.1 ve EF Core 5 ile gelen değişiklikleri anlatır; kodlar EF Core 3.1 ve EF Core 5 içindir, geçiş bölümü EF Core 6 içindir.
Önce Tahmin Et
Kemik yapı yazılarının hepsini EF Core 6 üzerine kurduk. Ama işe başladığın ilk hafta önüne gelen repo büyük ihtimalle öyle görünmeyecek. csproj'de Microsoft.EntityFrameworkCore.SqlServer 3.1.x ya da 5.0.x yazacak, kodda yıllar önce yazılmış ve "çalışıyor, dokunma" denmiş sorgular olacak. Sürüm yazılarına bu yüzden zamanda geri giderek başlıyoruz.
EF Core 2.2 döneminde yazılmış şu sorguya bak. Projeyi EF Core 3.1'e yükselttin, kod sorunsuz derlendi:
static bool IsClassic(Book book) => book.PublishedYear < 1950;
var classics = db.Books
.Where(b => IsClassic(b))
.ToList();
Uygulama çalışınca ne olur? Liste boş mu gelir, doğru sonuç mu gelir, yoksa başka bir şey mi? Bir de şunu düşün: EF Core 2.2'de bu sorgu veritabanına ne gönderiyordu? Cevaplar ilk bölümde, ama tahminini şimdiden bir kenara yaz.
EF Core 3.0: Client Evaluation'ın Sonu
EF Core 2.x, SQL'e çeviremediği bir ifadeyle karşılaşınca sessizce pes ediyordu. IsClassic bizim yazdığımız bir C# method'u, veritabanı onu tanımaz. 2.x bu durumda WHERE olmadan bütün Books tablosunu çekip filtreyi bellekte uyguluyordu. Loga bir uyarı düşüyordu, o kadar. Geliştirme makinesinde 20 satırlık tabloda kimse fark etmiyordu; production'da iki milyon satırlık tabloda ise sunucunun belleği şişiyordu.
EF Core 3.0 LINQ altyapısını baştan yazdı ve bu davranışı kaldırdı. Aynı kodu EF Core 3.1.32 ile çalıştırdığımda aldığım exception şu:
InvalidOperationException: The LINQ expression 'DbSet<Book>
.Where(b => Program.IsClassic(b))' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to either AsEnumerable(), AsAsyncEnumerable(), ToList(), or ToListAsync(). See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
Yani tahminin "boş liste" ise yanıldın: sorgu hiç çalışmıyor. Ve bu bir derleme hatası değil, çalışma anı hatası. Testin yoksa bunu kullanıcı bulur. Mesaj iki çıkış yolu öneriyor:
// 1. Sorguyu SQL'e çevrilebilecek şekilde yeniden yaz (doğru olan bu)
var classics = db.Books
.Where(b => b.PublishedYear < 1950)
.ToList();
// 2. Bilerek istemciye geç: bütün tablo belleğe gelir
var classicsInMemory = db.Books
.AsEnumerable()
.Where(IsClassic)
.ToList();
// En üstteki Select'te C# method'u çağırmak hâlâ serbest
static string Label(Book book) => book.PublishedYear < 1950 ? "classic" : "modern";
var labels = db.Books
.Select(b => new { b.Title, Label = Label(b) })
.ToList();
İkinci yol 2.x'in yaptığının aynısı, tek farkla: artık bunu kodda açıkça görüyorsun. AsEnumerable() satırı code review'da "burada bütün tabloyu çekiyoruz" diye bağırıyor. Üçüncü örnek ise kemik yapıda gördüğümüz kuralın kendisi: istemcide değerlendirmeye yalnız son Select'te izin var, çünkü orada veri zaten gelmiş durumda ve satır sayısını değiştiremiyorsun.
Eski bir projeyi 3.x'e taşırken asıl iş bu. Paket sürümünü değiştirmek beş dakika sürer, ama hangi sorgunun 2.x'te gizlice istemcide çalıştığını bulmak saatler alabilir. En kestirme yol, yükseltmeden hemen sonra bütün sorgu yollarını gerçekten çalıştıran testleri koşmak ve eski loglarda client evaluation uyarısı arayıp o sorgulardan başlamak.
FromSql Neden İkiye Bölündü
3.0'ın ikinci büyük değişikliği raw SQL tarafında. 2.x'te FromSql hem düz string hem interpolated string alıyordu ve hangi overload'un seçildiği bazen göründüğü kadar açık değildi. Yanlış overload, parametre olması gereken değeri SQL'in içine gömebiliyordu. 3.1'de eski çağrıyı derlemeye çalıştım:
Program.cs(73,19): error CS0619: 'RelationalQueryableExtensions.FromSql<TEntity>(IQueryable<TEntity>, FormattableString)' is obsolete: 'For returning objects from SQL queries using plain strings, use FromSqlRaw instead. For returning objects from SQL queries using interpolated string syntax to create parameters, use FromSqlInterpolated instead. Call either new method directly on the DbSet at the root of the query.'
Program.cs(74,9): warning CS0618: 'RelationalDatabaseFacadeExtensions.ExecuteSqlCommand(DatabaseFacade, RawSqlString, params object[])' is obsolete: 'For the execution of SQL queries using plain strings, use ExecuteSqlRaw instead. For the execution of SQL queries using interpolated string syntax to create parameters, use ExecuteSqlInterpolated instead.'
Dikkat et: FromSql derleme hatası veriyor, ExecuteSqlCommand ise sadece uyarı. Yani build'in geçmesi, raw SQL tarafının temizlendiği anlamına gelmiyor. Yeni isimler niyeti açıkça söylüyor:
var minYear = 1950;
// Interpolated: {minYear} otomatik olarak parametre olur
var a = db.Books
.FromSqlInterpolated($"SELECT * FROM Books WHERE PublishedYear >= {minYear}")
.ToList();
// Raw: parametreleri {0}, {1} ile ayrıca verirsin
var b = db.Books
.FromSqlRaw("SELECT * FROM Books WHERE PublishedYear >= {0}", minYear)
.ToList();
İkisi de aynı parametreli SQL'i üretiyor. EF Core 5'te gelen ToQueryString() ile ilkine baktığımda çıktı şuydu:
.param set p0 1950
SELECT * FROM Books WHERE PublishedYear >= @p0
Kemik yapıdaki raw SQL yazısında bu iki method'u zaten kullandık; bu bölüm onların nereden geldiğini anlatıyor. Bir not daha: EF Core 7'de FromSql adı geri geldi, ama bu kez yalnız interpolated string alan güvenli hâliyle. Onu bir sonraki yazıda göreceğiz.
3.x'in Sessiz Değişiklikleri
Exception fırlatmayan ama davranışı değiştiren birkaç madde daha var. Eski kodu okurken bunları bilmek, "bu satır neden burada" sorusunu çok hızlı cevaplıyor.
Include artık tek sorgu üretiyor. 2.x, her koleksiyon için ayrı bir SQL atıyordu; 3.0 hepsini JOIN'lerle tek komuta topladı. İki koleksiyon include ettiğinde satır sayısı çarpılıyor, yani cartesian explosion riski 3.0 ile geldi. Çözümü 5.0'da gelecek.
Query type'lar kaldırıldı. 2.x'te view ya da key'siz sonuçlar için DbQuery<T> ve modelBuilder.Query<T>() kullanılıyordu. 3.0'dan beri bunlar keyless entity: normal bir DbSet<T> ve HasNoKey().
dotnet ef SDK'dan çıktı. 2.x'te SDK ile birlikte geliyordu; 3.0'dan beri ayrı bir .NET tool ve paket sürümüyle aynı sürümde kurulması gerekiyor. Ekibe yeni katılan birinin makinesinde "dotnet ef komutu bulunamadı" hatası görüyorsan sebep bu.
Cascade delete artık hemen uygulanıyor. Bir principal'ı Remove ettiğinde, bağımlı entity'ler SaveChanges'i beklemeden Deleted state'ine geçiyor. Bir de 3.1, .NET Framework üzerinde çalışabilen son EF Core sürümü. 5.0 netstandard2.1 hedeflediği için .NET Framework projeleri 3.1'de kalmak zorunda.
EF Core 5: Join Entity'siz Many-to-Many
Bir kitabın birden fazla tag'i, bir tag'in birden fazla kitabı olsun. 3.1'de iki tarafa da koleksiyon koyup bıraktığımda EnsureCreated şunu söyledi:
InvalidOperationException: Unable to determine the relationship represented by navigation property 'Book.Tags' of type 'List<Tag>'. Either manually configure the relationship, or ignore this property using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.
3.1'de many-to-many için ara tabloyu bir entity olarak yazmak, composite key'ini Fluent API ile vermek ve iki tarafı ona bağlamak gerekiyordu. Eski projelerde gördüğün BookTag ya da BookTags class'larının çoğu, aslında hiçbir ek kolon taşımayan bu zorunlu ara entity'ler. EF Core 5 ile aynı model doğrudan çalışıyor:
public class Book
{
public int Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public int PublishedYear { get; set; }
public decimal Price { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; }
public List<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; }
public List<Book> Books { get; set; } = new List<Book>();
}
EF Core 5.0.17'de GenerateCreateScript() ile aldığım çıktıda ara tablo convention ile oluşuyor:
CREATE TABLE "BookTag" (
"BooksId" INTEGER NOT NULL,
"TagsId" INTEGER NOT NULL,
CONSTRAINT "PK_BookTag" PRIMARY KEY ("BooksId", "TagsId"),
CONSTRAINT "FK_BookTag_Books_BooksId" FOREIGN KEY ("BooksId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_BookTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);
Kemik yapıda gördüğümüz skip navigation bu. Sorguda ara tabloyu hiç yazmıyorsun, EF onu kendisi JOIN'liyor. Eski bir projeyi 5'e taşırken var olan ara entity'leri silmek zorunda değilsin; ara tablo ek kolon taşıyorsa zaten silmemelisin. Ama tablo ve kolon adları convention'la eşleşmiyorsa, entity'yi silip skip navigation'a geçmek bir migration üretir. O migration'ı mutlaka oku.
EF Core 5: Filtered Include, Split Query ve ToQueryString
5.0'ın günlük işe en çok dokunan üç özelliği aynı sorguda birleşiyor:
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=bookstore5.db");
options.LogTo(Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name },
LogLevel.Information);
}
var authors = db.Authors
.Include(a => a.Books.Where(b => b.PublishedYear >= 2000))
.AsSplitQuery()
.ToList();
3.1'de Include'un içine Where yazamıyordun; ya bütün koleksiyonu yüklüyor ya da ayrı bir sorguyla elle dolduruyordun. AsSplitQuery() ise 3.0'ın getirdiği cartesian explosion riskine cevap: sorguyu yeniden 2.x'teki gibi parçalara bölüyor, ama bunu sen istediğinde yapıyor. LogTo da 3.1'deki ILoggerFactory kurulumunun yerini tek satırla alıyor. EF Core 5'te loga düşen iki komut:
SELECT "a"."Id", "a"."Name"
FROM "Authors" AS "a"
ORDER BY "a"."Id"
SELECT "t"."Id", "t"."AuthorId", "t"."Description", "t"."Price", "t"."PublishedYear", "t"."Title", "a"."Id"
FROM "Authors" AS "a"
INNER JOIN (
SELECT "b"."Id", "b"."AuthorId", "b"."Description", "b"."Price", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" >= 2000
) AS "t" ON "a"."Id" = "t"."AuthorId"
ORDER BY "a"."Id"
Filtre ikinci sorgunun içinde, yani yalnız 2000 sonrası kitaplar geliyor. ToQueryString() ve ChangeTracker.DebugView da 5.0 ile geldi; bir sorgunun SQL'ini görmek için artık çalıştırman gerekmiyor. Listeye TPT'yi de ekle: 5.0'da hiyerarşideki her tipe ToTable ile ayrı tablo vermen yeterli. Açık UseTptMappingStrategy() çağrısı 7.0'da geldi.
EF Core 6'ya Geçiş ve Nullable
3.1 ya da 5 projesini 6'ya taşımanın paket tarafı kısa:
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.36" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.36" />
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 6.0.36
Asıl sürpriz ikinci satırda. .NET 6 şablonları <Nullable>enable</Nullable> ile geliyor ve geçiş yaparken bunu da açmak cazip. EF Core ise 3.0'dan beri nullable reference type'lara bakıyor: NRT açıkken string NOT NULL, string? NULL demek. Modele dokunmadan, sadece bu bayrağı açıp dotnet ef migrations add EnableNullable çalıştırdım. Araç önce "An operation was scaffolded that may result in the loss of data" uyarısı verdi, ardından üç kolon için şunu üretti (ilkini gösteriyorum):
migrationBuilder.AlterColumn<string>(
name: "Title",
table: "Books",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
Aynısı Books.Description ve Authors.Name için de var. Title ve Name için bu belki istediğin şey, ama Description gerçekten opsiyonel bir alan. Migration'ı uygulayıp açıklamasız bir kitap kaydettiğimde:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Books.Description'.
Çözüm, NRT'yi açtığın anda her string'e "bu gerçekten zorunlu mu" diye sormak:
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public string? Description { get; set; }
public int PublishedYear { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
}
public class BookstoreContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
}
Bu modelle yeniden ürettiğim migration'da yalnız Title ve Name kaldı, Description nullable olarak durdu. = null!, derleyicinin CS8618 uyarısını susturur; değer EF tarafından doldurulduğu için zararsız. DbSet'leri Set<T>() ile yazmak da aynı uyarıdan kurtulmanın EF dokümanlarında önerilen yolu.
Destek durumuna gelince: EF Core 3.1 LTS'ti ve 13 Aralık 2022'de, EF Core 5 STS'ti ve 10 Mayıs 2022'de destek dışı kaldı. EF Core 6 da 12 Kasım 2024'te bitti. Yani 6 bir varış noktası değil, ilk durak. Ama doğru durak: kemik yapının API'si burada, sonraki her sürüm bunun üzerine ekleme yapıyor.
Sık Düşülen Tuzaklar
Birincisi, derleme geçti diye geçişi bitmiş saymak. Client evaluation hataları da, NOT NULL hataları da çalışma anında çıkıyor. Yükseltmeden önce sorguları gerçekten çalıştıran entegrasyon testlerin yoksa, ilk iş onları yazmak.
İkincisi, sürüm atlarken migration'ları okumamak. 5'e geçerken ara entity'yi skip navigation'a çevirmek ya da 6'da NRT'yi açmak, sende tek satırlık bir değişiklik gibi görünür ama veritabanına gidecek AlterColumn ve tablo rebuild'leri üretir. SQLite'ta bu işlemler tabloyu baştan kurarak yapılıyor.
Üçüncüsü, dotnet ef sürümünü paketle eşlememek. Global kurulmuş eski bir tool, yeni paketlerle bazen çalışır, bazen anlamsız hatalar verir. Tool manifest ile projenin içine kur, sürümü paketle aynı tut.
Dördüncüsü, Apple Silicon'da eski paketlerin native kütüphanesi. EF Core 5.0.17'nin getirdiği SQLitePCLRaw 2.0.4, M serisi Mac'te e_sqlite3 kütüphanesini bulamayıp DllNotFoundException fırlattı. Projeye SQLitePCLRaw.bundle_e_sqlite3 2.1.2'yi açıkça ekleyince düzeldi. 3.1.32 zaten 2.1.2 ile geliyor.
Beşincisi, EF Core 6'nın SQLite tarafındaki bir davranış değişikliği: Microsoft.Data.Sqlite 6.0 bağlantıları pool'luyor. Bağlantıyı kapatsan da dosya açık kalabiliyor; özellikle Windows'ta testlerde veritabanı dosyasını silmeye çalışırken "dosya kullanımda" hatası görürsen SqliteConnection.ClearAllPools() çağır.
Küçük Bir Deneme
★☆☆ net6.0 hedefleyen boş bir console projesine EF Core 3.1.32'yi kur ve yazının başındaki IsClassic sorgusunu çalıştır. Aynı projeyi 5.0.17'ye, sonra 6.0.36'ya yükselt ve exception mesajının nasıl değiştiğine bak.
★★☆ 3.1'de BookTag ara entity'siyle bir many-to-many kur, migration'ını üret. Sonra projeyi 5'e taşı, ara entity'yi sil, iki tarafa skip navigation koy ve yeni bir migration ekle. Migration'ın içi boş mu, yoksa tabloyu yeniden mi adlandırıyor?
★★★ Nullable kapalı bir projede üç string property'li bir model kur, ilk migration'ı al. Sonra NRT'yi aç, hangi property'nin gerçekten opsiyonel olduğuna karar ver ve tek bir migration ile hem zorunlu hem opsiyonel kolonları doğru hâle getir. İçinde zaten NULL değer olan bir satır varsa defaultValue: "" ne yapıyor, gözlemle.
Bu yazıda eski projeleri EF Core 6'ya getirdik. Kemik yapıda birkaç yerde "bunu sürüm yazılarında göreceğiz" dedik: toplu güncelleme, JSON kolonlar, TPC. Bir sonraki yazıda bunların hepsinin geldiği EF Core 7'ye geçiyoruz.