EF Core Migrations: migrations add, database update, Script ve Bundle
EF Core migration'larını uçtan uca çalıştırıyoruz: migrations add/remove, database update ve geri alma, migration dosyasının anatomisi, SQL script ve --idempotent, EF Core 6'nın migration bundle'ı ve açılışta Migrate() çağırmanın riskleri.
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: EnsureCreated Yeni Kolonu Ekler mi?
Şimdiye kadar veritabanını EnsureCreated() ile oluşturduk. Şimdi şunu düşün: uygulama birkaç gündür çalışıyor, Books tablosunda yüzlerce kayıt var. Book class'ına yeni bir Isbn property'si ekliyorsun ve uygulamayı yeniden başlatıyorsun. EnsureCreated() tabloya Isbn kolonunu ekler mi?
Eklemez. EnsureCreated() yalnızca veritabanı hiç yoksa çalışır. Veritabanı varsa hiçbir şeye dokunmadan false döner; modelin değişip değişmediğine bakmaz. Bu yüzden ilk sorguda "no such column: b.Isbn" hatasını görürsün. Veritabanını silip yeniden oluşturmak da canlıdaki veriyi çöpe atmak demek.
Migration'lar tam bu sorunu çözer. Modeldeki her değişikliği küçük, sıralı ve geri alınabilir bir adım olarak kaydeder, veritabanına da hangi adımların uygulandığını yazar. Git'in kod için yaptığını şema için yapar: her migration bir commit, __EFMigrationsHistory tablosu da veritabanının hangi commit'te olduğunu gösteren işaret.
Bu yazıdaki model kısa tutuldu:
using Microsoft.EntityFrameworkCore;
namespace Bookstore;
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public decimal Price { 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>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite("Data Source=bookstore.db");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>().Property(b => b.Title).HasMaxLength(200);
}
}
İlk Migration: migrations add
Migration komutları dotnet-ef aracından ve projedeki Microsoft.EntityFrameworkCore.Design paketinden gelir. Aracın sürümü paketle aynı olmalı; EF Core 6 projesinde dotnet-ef 6.0.36, EF Core 10 projesinde 10.0.14 kullandım. Aracı proje klasörüne yerel tool olarak kurmak, makinedeki diğer projelerle sürüm çakışmasını önler:
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 6.0.36
dotnet ef migrations add InitialCreate
Çıktı kısa:
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
İlk satıra dikkat et: araç önce projeyi build eder, sonra derlenmiş assembly'yi yükleyip BookstoreContext'i oluşturur ve modeli okur. Yani projen derlenmiyorsa migration da üretilmez. Komut Migrations klasörüne üç dosya bırakır:
Migrations/20261103214436_InitialCreate.cs
Migrations/20261103214436_InitialCreate.Designer.cs
Migrations/BookstoreContextModelSnapshot.cs
Migration Dosyasının Anatomisi
Asıl iş 20261103214436_InitialCreate.cs dosyasında. EF Core 6'nın ürettiği hâli şöyle:
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bookstore.Migrations
{
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Authors", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Books",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Price = table.Column<decimal>(type: "TEXT", nullable: false),
AuthorId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Books", x => x.Id);
table.ForeignKey(
name: "FK_Books_Authors_AuthorId",
column: x => x.AuthorId,
principalTable: "Authors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Books_AuthorId",
table: "Books",
column: "AuthorId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Books");
migrationBuilder.DropTable(
name: "Authors");
}
}
}
Up ileri gitmeyi, Down geri dönmeyi tarif eder. Down'ın tabloları ters sırada sildiğine bak: önce foreign key'i taşıyan Books, sonra Authors. Bu dosya senin dosyan; okunur, incelenir, gerekirse elle düzenlenir. Bir veri dönüşümü gerekiyorsa migrationBuilder.Sql("...") ile araya kendi SQL'ini koyabilirsin.
Dosya adındaki zaman damgası (UTC) sıralamayı belirler; migration'lar bu sırayla uygulanır. EF Core 10'un ürettiği dosya birebir aynıdır, tek fark method'ların üstüne eklenen /// <inheritdoc /> yorumlarıdır.
Diğer iki dosya senin için değil, araç için. .Designer.cs, o migration anındaki modelin fotoğrafını tutar. BookstoreContextModelSnapshot.cs ise modelin en güncel hâlidir. Bir sonraki migrations add komutunda araç veritabanına bakmaz; mevcut modeli bu snapshot ile karşılaştırır ve aradaki farkı yeni migration olarak yazar. Snapshot'ın içinde modelBuilder.HasAnnotation("ProductVersion", "6.0.36") satırı, dosyayı hangi EF Core sürümünün ürettiğini söyler.
database update ve __EFMigrationsHistory
Migration dosyası tek başına veritabanını değiştirmez. Uygulamak için:
dotnet ef database update
Build started...
Build succeeded.
Applying migration '20261103214436_InitialCreate'.
Done.
EF Core 10'da bu çıktının başında bir satır daha görürsün: "Acquiring an exclusive lock for migration application." Bu, EF Core 9 ile gelen migration kilidi; sürüm yazılarında ayrıca bakacağız.
Araç tabloları oluşturdu ve bir tablo daha ekledi. SQLite komut satırı aracıyla bakalım:
sqlite3 bookstore.db "select * from __EFMigrationsHistory"
20261103214436_InitialCreate|6.0.36
EF Core, uygulanan her migration'ı adı ve kendi sürümüyle buraya yazar. database update her çalıştığında bu tabloyu okur, projedeki migration listesiyle karşılaştırır ve yalnızca eksik olanları uygular. Komutu ikinci kez çalıştırırsan "No migrations were applied. The database is already up to date." mesajını alırsın.
Modeli Değiştirmek, Geri Almak, Silmek
Yazının başındaki senaryoya dönelim. Book'a property'yi ekle:
public string? Isbn { get; set; }
Sonra yeni migration'ı üret ve durumu listele:
dotnet ef migrations add AddBookIsbn
dotnet ef migrations list
20261103214436_InitialCreate
20261103214518_AddBookIsbn (Pending)
(Pending) işareti, migration'ın projede olduğunu ama veritabanına henüz uygulanmadığını söyler. Üretilen Up method'u tek bir işlem içeriyor:
migrationBuilder.AddColumn<string>(
name: "Isbn",
table: "Books",
type: "TEXT",
nullable: true);
database update ile uyguladıktan sonra fikrini değiştirdin ve migration'ı silmek istiyorsun diyelim. migrations remove uygulanmış bir migration'ı silmeyi reddeder:
The migration '20261103214518_AddBookIsbn' has already been applied to the database. Revert it and try again. If the migration has been applied to other databases, consider reverting its changes using a new migration instead.
Mesaj iki şey söylüyor. Önce veritabanını bir önceki migration'a geri al, sonra sil. Ama bu migration başka veritabanlarına (bir takım arkadaşının makinesine, test ortamına, canlıya) gittiyse silme; değişikliği geri alan yeni bir migration yaz. Paylaşılmış geçmişi yeniden yazmamak, Git'teki push --force yasağının aynısı.
Yerelde geri almak için hedef migration'ın adını verirsin. Hepsini geri almak istersen ad yerine 0 yazarsın:
dotnet ef database update InitialCreate
dotnet ef migrations remove
Reverting migration '20261103214518_AddBookIsbn'.
Done.
Removing migration '20261103214518_AddBookIsbn'.
Reverting the model snapshot.
Done.
migrations remove yalnızca en son migration'ı siler ve snapshot'ı da bir adım geri alır. Migration dosyalarını elle silmek ise snapshot'ı geride bırakır; bir sonraki migrations add yanlış bir fark hesaplar. Silme işini her zaman araca yaptır.
SQL Script Üretmek ve --idempotent
Canlı ortamda dotnet ef database update çalıştırmak çoğu ekipte istenmez: aracın canlı veritabanına erişmesi, kaynak kodun orada olması gerekir. Daha temiz yol, migration'ları SQL'e çevirip bir DBA'nın ya da pipeline'ın uygulamasıdır:
dotnet ef migrations script InitialCreate AddBookIsbn
İlk argüman başlangıç, ikincisi hedef. Başlangıç verilen migration'ın kendisi script'e girmez; script ondan sonrasını kapsar:
BEGIN TRANSACTION;
ALTER TABLE "Books" ADD "Isbn" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20261103214518_AddBookIsbn', '6.0.36');
COMMIT;
Argümanları ters çevirirsen geri alma script'i üretilir ve burada SQLite'ın bir sınırını görürsün. SQLite'ın ALTER TABLE desteği sınırlı olduğu için EF Core kolonu silmek yerine tabloyu yeniden kurar:
CREATE TABLE "ef_temp_Books" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
"AuthorId" INTEGER NOT NULL,
"Price" TEXT NOT NULL,
"Title" TEXT NOT NULL,
CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Books" ("Id", "AuthorId", "Price", "Title")
SELECT "Id", "AuthorId", "Price", "Title"
FROM "Books";
PRAGMA foreign_keys = 0;
DROP TABLE "Books";
ALTER TABLE "ef_temp_Books" RENAME TO "Books";
PRAGMA foreign_keys = 1;
Yukarıdaki blok gerçek script'in kısaltılmış hâli; araç bu adımları ayrı transaction'lara bölüyor. Büyük bir tabloda bu yeniden kurma işlemi uzun sürer, migration'ı canlıya göndermeden önce script'i okumak için iyi bir sebep.
Script'in bir sorunu var: veritabanının hangi migration'da olduğunu bilmen gerekir. --idempotent parametresi bunu çözer; her migration'ı "bu migration uygulanmadıysa çalıştır" koşuluna sarar, script de hangi veritabanında çalışırsa çalışsın doğru yere varır. SQLite'ta bu seçenek yok:
Generating idempotent scripts for migrations is not currently supported for SQLite. See http://go.microsoft.com/fwlink/?LinkId=723262 for more information.
Aynı modeli SQL Server provider'ıyla üretince ne çıktığını görmek için veritabanına gerek yok; script üretmek bağlantı açmaz. SQL Server için EF Core 6'nın ürettiği script'in bir parçası (EF Core 10 aynı koşulu birkaç satıra bölüyor ve her bloğun arasına GO koymuyor):
IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20261103214732_AddBookIsbn')
BEGIN
ALTER TABLE [Books] ADD [Isbn] nvarchar(max) NULL;
END;
GO
SQL Server ya da PostgreSQL ile çalışan bir projede CI/CD için en güvenli seçenek budur.
Migration Bundle (EF Core 6)
Script'in alternatifi EF Core 6 ile gelen migration bundle. Migration'ları tek bir çalıştırılabilir dosyaya paketler; hedef makinede ne kaynak kod ne de dotnet-ef gerekir. Arada bir değişiklik olduğu için AddBookIsbn migration'ını yeniden ekledim, bu yüzden zaman damgası farklı:
dotnet ef migrations bundle --force
./efbundle --connection "Data Source=prod.db"
Applying migration '20261103214436_InitialCreate'.
Applying migration '20261103214619_AddBookIsbn'.
Done.
--connection verilmezse bundle, OnConfiguring ya da AddDbContext'teki connection string'i kullanır. Bundle da __EFMigrationsHistory tablosuna bakar, ikinci çalıştırmada hiçbir şey yapmaz. Linux sunucuya gidecek bir bundle için --self-contained -r linux-x64 ile .NET runtime'ı da içine koyabilirsin. Script'e göre artısı, mantığın EF Core'un kendisinde kalması; eksisi, çalıştırmadan önce SQL'i okuyamaman.
Uygulama Açılışında Migrate Etmek
En kestirme yol, migration'ları uygulama açılırken koddan uygulamak:
using Bookstore;
using Microsoft.EntityFrameworkCore;
using var db = new BookstoreContext();
var pending = db.Database.GetPendingMigrations().ToList();
Console.WriteLine($"Pending: {string.Join(", ", pending)}");
db.Database.Migrate();
Console.WriteLine($"Applied: {db.Database.GetAppliedMigrations().Count()}");
Pending: 20261103214436_InitialCreate, 20261103214619_AddBookIsbn
Applied: 2
Geliştirme makinesinde ve tek kullanıcılı bir masaüstü uygulamasında bu gayet makul. Bir web uygulamasında ise dikkat ister. Uygulaman üç instance ile ayağa kalkıyorsa üçü de aynı anda migrate etmeye çalışır. Uygulamanın kullandığı veritabanı kullanıcısının tablo oluşturma ve silme yetkisi olması gerekir; bu da güvenlik açısından istenmez. Uzun süren bir migration uygulamanın açılışını bekletir, health check'ler zaman aşımına düşer. Yarıda kalan bir migration da uygulamayı açılamaz hâlde bırakır ve sorunu çözmek için yine kodu değiştirip deploy etmen gerekir.
Microsoft'un dokümantasyonu da canlı ortam için script ya da bundle öneriyor. ASP.NET Core Web API serisinde deploy adımlarını konuşurken bu ayrımı tekrar kullanacağız.
Sık Düşülen Tuzaklar
İlk tuzak EnsureCreated() ile Migrate()'i karıştırmak. EnsureCreated() tabloları migration kullanmadan oluşturur ve __EFMigrationsHistory tablosunu yazmaz. Sonra Migrate() çağırırsan EF Core hiçbir migration uygulanmamış sanır ve tabloları yeniden kurmaya çalışır:
Unhandled exception. Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'table "Authors" already exists'.
İkisinden birini seç; migration kullanan projede EnsureCreated() yalnızca testlerde kalsın.
İkincisi, üretilen migration'ı okumadan uygulamak. Author.Name'i FullName yapınca EF Core bunu yakalayıp RenameColumn üretti. Ama aynı anda tipi de değiştirince (string? Isbn yerine long? IsbnNumber) araç bunu yeniden adlandırma olarak tanımadı:
An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.
migrationBuilder.DropColumn(
name: "Isbn",
table: "Books");
migrationBuilder.AddColumn<long>(
name: "IsbnNumber",
table: "Books",
type: "INTEGER",
nullable: true);
Bu uyarıyı gördüğünde migration'ı aç. Veriyi taşıman gerekiyorsa DropColumn'dan önce migrationBuilder.Sql(...) ile dönüşümü kendin yaz.
Üçüncüsü, modeli değiştirip migration eklemeyi unutmak. EF Core 6'da Migrate() bunu fark etmez, eski migration'ları uygular ve sessizce devam eder; hatayı ilk sorguda görürsün. EF Core 9 ve sonrası bu durumda PendingModelChangesWarning hatası fırlatıyor; bunu sürüm yazılarında göreceğiz.
Son olarak SQLite'ın kendine özgü durumu: HasMaxLength(200) migration'da maxLength: 200 olarak duruyor ama SQLite kolonu yine TEXT olarak oluşturuyor ve uzunluğu zorlamıyor. SQL Server'da aynı ayar nvarchar(200) olur. Provider değiştirdiğinde migration'ları da o provider için yeniden üretmen gerekir; SQLite için üretilmiş migration'lar SQL Server'da çalışmaz.
Küçük Bir Deneme
★☆☆ Author'a string? Country property'si ekle, AddAuthorCountry migration'ını üret, migrations list ile Pending durumunu gör ve database update ile uygula. sqlite3 ile __EFMigrationsHistory tablosuna bakıp kaç satır olduğunu say.
★★☆ dotnet ef database update 0 ile bütün migration'ları geri al. Hangi tabloların kaldığını kontrol et. Sonra dotnet ef migrations script 0 AddAuthorCountry çıktısını bir dosyaya yaz ve okuyarak hangi satırın hangi migration'dan geldiğini işaretle.
★★★ Book.Price'ı PriceInCents adıyla int tipine çevir. Üretilen migration'daki veri kaybı uyarısını gör, sonra Up method'unu elle düzenle: önce yeni kolonu ekle, migrationBuilder.Sql ile mevcut fiyatları kuruşa çevirerek kopyala, en son eski kolonu sil. Down method'unu da buna göre yaz.
Migration'larla şemayı kurduk ama tablolar boş. Bir kitapçının tür listesi, başlangıç yazarları ya da sabit bir kategori tablosu gibi her ortamda bulunması gereken veriler nereden gelecek? Bir sonraki yazıda HasData ile model seeding'i, sınırlarını ve uygulama tarafında seeding yapmayı konuşacağız.