EF Core ile Database-First: Mevcut Veritabanından Model Üretmek, Scaffold Seçenekleri, T4 Template'leri ve Tekrar Scaffold
Mevcut bir veritabanından EF Core modeli dotnet ef dbcontext scaffold ile üretilir; connection string Name= ile configuration'dan okunur, --schema/--table, --use-database-names, --no-pluralize ve --no-onconfiguring çıktıyı belirler. Tekrar scaffold edilecek kodda değişiklikler partial class, OnModelCreatingPartial ve T4 template'lerinde tutulur.
Sürüm notu: Bu yazıdaki kodlar EF Core (Entity Framework Core) 10 ve SQL Server 2025 ile yazılıp denenmiştir.
Önce Tahmin Et
Şimdiye kadar hep code-first çalıştık: önce C# class'larını yazdık, tabloları migration'lar kurdu. Gerçek hayatta çoğu zaman tersi olur. Veritabanı senden önce oradadır, başka bir ekip onu yönetir ve senden yalnız okuman beklenir. Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) raporlama tablolarını DBA (veritabanı yöneticisi) ekibi yönetiyor. Tablolardan biri şöyle:
CREATE TABLE reporting.BOOK_SALES (
SALE_ID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_BOOK_SALES PRIMARY KEY,
ISBN char(13) NOT NULL,
BOOK_TITLE nvarchar(200) NOT NULL,
CATEGORY_ID int NOT NULL CONSTRAINT FK_BOOK_SALES_CATEGORY REFERENCES reporting.CATEGORY (CATEGORY_ID),
SOLD_AT datetime2(0) NOT NULL CONSTRAINT DF_BOOK_SALES_SOLD_AT DEFAULT (SYSUTCDATETIME()),
QUANTITY int NOT NULL CONSTRAINT CK_BOOK_SALES_QUANTITY CHECK (QUANTITY > 0),
UNIT_PRICE decimal(10,2) NOT NULL,
LINE_TOTAL AS (QUANTITY * UNIT_PRICE) PERSISTED,
ROW_VER rowversion NOT NULL
);
Bu tablodan EF Core'a bir entity ürettireceksin. Class'ın adı ne olur? LINE_TOTAL hangi tipte bir property'ye döner? Bir de "isimlere dokunma" anlamına gelen --use-database-names seçeneğini verirsen class BOOK_SALES adını korur mu? Cevaplar aşağıda; son sorunun cevabı çoğu kişiyi şaşırtıyor.
DBA'nın Şeması: Bookstore Raporlama Tabloları
Örnek şema db/reporting-schema.sql dosyasında. reporting schema'sında kategori tablosu, satış tablosu, primary key'i olmayan bir import log tablosu ve günlük satışları toplayan bir view var. dbo'da da bizi ilgilendirmeyen bir ETL (Extract-Transform-Load; veriyi kaynaktan alıp dönüştürerek yükleyen süreç) tablosu duruyor:
CREATE TABLE reporting.IMPORT_LOG (
FILE_NAME nvarchar(260) NOT NULL,
IMPORTED_AT datetime2(0) NOT NULL,
ROW_COUNT int NOT NULL
);
CREATE VIEW reporting.vw_DAILY_SALES
AS
SELECT CAST(s.SOLD_AT AS date) AS SALE_DATE,
c.CATEGORY_NAME,
SUM(s.QUANTITY) AS UNITS,
SUM(s.LINE_TOTAL) AS REVENUE
FROM reporting.BOOK_SALES AS s
JOIN reporting.CATEGORY AS c ON c.CATEGORY_ID = s.CATEGORY_ID
GROUP BY CAST(s.SOLD_AT AS date), c.CATEGORY_NAME;
Bu şemaya migration ile dokunmayacağız. Şemanın sahibi DBA ekibi; bizim işimiz bu tabloları okuyacak bir model üretmek ve şema değiştikçe o modeli güncel tutmak. Database-first dediğimiz şey bu: kaynak veritabanı, model ondan türetiliyor.
İlk Scaffold: Komut ve Connection String
Solution'da iki proje var: üretilen kodun duracağı Bookstore.Reporting class library'si ve onu kullanan Bookstore.Api. Scaffold için gereken parçalar 02 ve 07'deki migration kurulumuyla aynı: dotnet-ef local tool (10.0.15), startup projede Microsoft.EntityFrameworkCore.Design, kodun üretileceği projede provider paketi Microsoft.EntityFrameworkCore.SqlServer.
Komutun ilk argümanı connection string. Doğrudan yazarsan çalışır, ama EF Core onu üretilen OnConfiguring'e olduğu gibi gömer ve üstüne bir #warning koyar:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263.
=> optionsBuilder.UseSqlServer("Server=localhost,14333;Database=BookstoreDb;User Id=sa;Password=<redacted>;TrustServerCertificate=True");
Şifreyi ben sildim; dosyada açık hâliyle duruyordu. Bu dosya commit'lenirse şifre de repo geçmişine girer. Doğru yol Name= syntax'ı: araç connection string'i startup projenin configuration'ından okur. Değeri environment variable, user-secrets ya da appsettings.Development.json verebilir. Windows'ta PowerShell ile:
$env:ConnectionStrings__Reporting = "Server=localhost;Database=BookstoreDb;Trusted_Connection=True;TrustServerCertificate=True"
dotnet ef dbcontext scaffold Name=ConnectionStrings:Reporting Microsoft.EntityFrameworkCore.SqlServer --project src/Bookstore.Reporting --startup-project src/Bookstore.Api --schema reporting --context ReportingContext --context-dir . --output-dir Entities --namespace Bookstore.Reporting.Entities --context-namespace Bookstore.Reporting --no-onconfiguring
Build started...
Build succeeded.
Çıktı bu kadar. Araç startup projeyi build etti, configuration'dan connection string'i aldı, reporting schema'sındaki tabloları ve view'ı okudu, şu dosyaları yazdı:
src/Bookstore.Reporting/ReportingContext.cs
src/Bookstore.Reporting/Entities/BookSale.cs
src/Bookstore.Reporting/Entities/Category.cs
src/Bookstore.Reporting/Entities/ImportLog.cs
src/Bookstore.Reporting/Entities/VwDailySale.cs
--no-onconfiguring verdiğim için context'te OnConfiguring yok; connection string'i uygulama, 16. yazıdaki gibi AddDbContext ile veriyor. Bu seçeneği vermeseydim Name= kullandığım için gömülen değer şifre değil, UseSqlServer("Name=ConnectionStrings:Reporting") olacaktı. O da yalnız DI (dependency injection; bağımlılıkların dışarıdan verilmesi) container'ı içinde çalışır.
Üretilen Kod
Açılıştaki soruların cevabı BookSale.cs'te:
public partial class BookSale
{
public long SaleId { get; set; }
public string Isbn { get; set; } = null!;
public string BookTitle { get; set; } = null!;
public int CategoryId { get; set; }
public DateTime SoldAt { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal? LineTotal { get; set; }
public byte[] RowVer { get; set; } = null!;
public virtual Category Category { get; set; } = null!;
}
Tablo adı tekilleşti (BOOK_SALES → BookSale), alt çizgili büyük harf isimler PascalCase oldu. LINE_TOTAL computed bir kolon ve SQL Server onu NULL olabilen bir kolon olarak raporluyor, bu yüzden decimal? geldi. Proje NRT (nullable reference types; null olabilen ve olamayan referans tiplerini ayıran C# özelliği) açık olduğu için NOT NULL string'ler = null! ile, foreign key de zorunlu bir navigation ile geldi. Mapping ayrıntıları context'te, Fluent API ile:
modelBuilder.Entity<BookSale>(entity =>
{
entity.HasKey(e => e.SaleId);
entity.ToTable("BOOK_SALES", "reporting");
entity.HasIndex(e => e.SoldAt, "IX_BOOK_SALES_SOLD_AT");
entity.Property(e => e.Isbn)
.HasMaxLength(13)
.IsUnicode(false)
.IsFixedLength()
.HasColumnName("ISBN");
entity.Property(e => e.LineTotal)
.HasComputedColumnSql("([QUANTITY]*[UNIT_PRICE])", true)
.HasColumnType("decimal(21, 2)")
.HasColumnName("LINE_TOTAL");
entity.Property(e => e.RowVer)
.IsRowVersion()
.IsConcurrencyToken()
.HasColumnName("ROW_VER");
entity.Property(e => e.SoldAt)
.HasPrecision(0)
.HasDefaultValueSql("(sysutcdatetime())", "DF_BOOK_SALES_SOLD_AT")
.HasColumnName("SOLD_AT");
// ... diğer kolonlar
entity.HasOne(d => d.Category).WithMany(p => p.BookSales)
.HasForeignKey(d => d.CategoryId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("FK_BOOK_SALES_CATEGORY");
});
char(13) üç ayara dönüştü: uzunluk, Unicode değil, sabit uzunluk. rowversion kolonu 14. yazıdaki concurrency token olarak tanındı, default constraint'in adı bile korundu. View ve primary key'i olmayan tablo keyless entity oldu:
modelBuilder.Entity<ImportLog>(entity =>
{
entity
.HasNoKey()
.ToTable("IMPORT_LOG", "reporting");
// ...
});
modelBuilder.Entity<VwDailySale>(entity =>
{
entity
.HasNoKey()
.ToView("vw_DAILY_SALES", "reporting");
// ...
});
Keyless entity'leri 15. yazıda görmüştük: sorgulanır ama track edilmez, SaveChanges ile yazılamaz. View'daki CAST(SOLD_AT AS date) ifadesi DateOnly? olarak geldi; SQL Server ifade kolonlarını NULL olabilir diye raporladığı için view entity'lerinde neredeyse her şey nullable çıkar.
Context'i uygulamaya kaydedip iki endpoint yazdım:
builder.Services.AddDbContext<ReportingContext>(options =>
options
.UseSqlServer(builder.Configuration.GetConnectionString("Reporting"))
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
app.MapGet("/reports/daily-sales", async (ReportingContext db) =>
await db.VwDailySales
.OrderBy(s => s.SaleDate)
.ThenBy(s => s.CategoryName)
.ToListAsync());
app.MapGet("/reports/top-books", async (ReportingContext db) =>
await db.BookSales
.GroupBy(s => s.BookTitle)
.Select(g => new { Title = g.Key, Units = g.Sum(s => s.Quantity) })
.OrderByDescending(x => x.Units)
.Take(3)
.ToListAsync());
GET /reports/top-books
[{"title":"Dune","units":3},{"title":"The Hobbit","units":3},{"title":"Emma","units":1}]
Loglanan SQL'de C#'taki isimler değil, DBA'nın isimleri var: SELECT TOP(@p) [b].[BOOK_TITLE] AS [Title], COALESCE(SUM([b].[QUANTITY]), 0) AS [Units] FROM [reporting].[BOOK_SALES] AS [b] GROUP BY [b].[BOOK_TITLE]. Rapor modeli yalnız okunduğu için tracking'i context seviyesinde kapattım.
Seçenekler: Hangi Tablo, Hangi İsim, Nereye
Komuttaki seçenekleri tek tek açalım. Hepsini aynı şemada denedim.
--schema reporting yalnız o schema'daki tablo ve view'ları alır; dbo.ETL_STATE bu yüzden modelde yok. Tek tek seçmek istersen --table reporting.BOOK_SALES --table reporting.vw_DAILY_SALES yazarsın. Bu durumda ilişkinin öbür ucu seçilmediği için araç uyarır ve foreign key'i atlar:
Skipping foreign key 'FK_BOOK_SALES_CATEGORY' on table 'reporting.BOOK_SALES' since principal table 'reporting.CATEGORY' was not found in the model. This usually happens when the principal table was not included in the selection set.
--use-database-names kolon ve tablo adlarını olduğu gibi bırakır. Ama açılıştaki sorunun cevabı hayır: tablo adı yine tekilleşiyor.
public partial class BOOK_SALE
{
public long SALE_ID { get; set; }
public string ISBN { get; set; } = null!;
public string BOOK_TITLE { get; set; } = null!;
// ...
}
Tekilleştirmeyi kapatan ayrı bir seçenek var: --no-pluralize. Onu verince class BookSales, DbSet'ler de Category ve ImportLog adını aldı. Yani "isimler veritabanındaki gibi kalsın" istiyorsan iki seçeneği birlikte vermen gerekiyor.
--data-annotations mapping'i mümkün olduğunca attribute'lara taşır: [Table("BOOK_SALES", Schema = "reporting")], [Column("UNIT_PRICE", TypeName = "decimal(10, 2)")], [StringLength(13)], [Unicode(false)]. Attribute'la ifade edilemeyenler (IsFixedLength, computed column, default değer, rowversion) yine OnModelCreating'de kalıyor. 04'teki tartışma burada da geçerli; ben Fluent API'yi tercih ediyorum, çünkü entity class'ları sade kalıyor.
--context-dir ve --output-dir dosyaların yerini, --namespace ve --context-namespace namespace'leri belirler. --force ise var olan dosyaların üzerine yazmaya izin verir. Onsuz ikinci çalıştırma durur:
The following file(s) already exist in directory '.../src/Bookstore.Reporting/Entities': ../ReportingContext.cs,BookSale.cs,Category.cs,ImportLog.cs,VwDailySale.cs. Use the Force flag to overwrite these files.
Visual Studio'nun Package Manager Console'unda aynı komut Scaffold-DbContext; seçenekler -Schemas, -Tables, -UseDatabaseNames, -NoPluralize, -DataAnnotations, -NoOnConfiguring, -Force adlarıyla geçer.
Scaffold'un Göremedikleri
Scaffold, veritabanının kendisi hakkında söyleyebildiğini modele çevirir. Söyleyemediğini çeviremez. Bizim şemada iki örnek var: CK_BOOK_SALES_QUANTITY check constraint'i modelde yok, index'in INCLUDE (QUANTITY, UNIT_PRICE) kısmı da üretilen HasIndex satırına girmedi. Okuma modeli için ikisi de sorun değil, ama aynı modelden şema üretmeye kalkarsan eksik bir şema elde edersin.
Dokümanın listelediği daha büyük sınırlar da var. Inheritance hiyerarşileri, yani 19. yazıdaki TPH (bütün hiyerarşiyi tek tabloda tutan strateji) ve TPT (her tipi ayrı tabloda tutan strateji), owned type'lar (20. yazı) ve table splitting şemada iz bırakmaz; bir tablonun TPH olduğunu discriminator kolonuna bakarak tahmin etmek EF Core'un işi değil. Bunlar hiçbir zaman scaffold edilmez; gerekiyorsa sonradan elle kurarsın. Concurrency tarafında yalnız SQL Server'ın rowversion'ı gibi özel tipler tanınır; 14. yazıdaki gibi normal bir kolonu concurrency token yaptıysan scaffold bunu bilemez. Provider'ın desteklemediği kolon tipleri de modele hiç girmez.
Bir Kez mi, Her Değişiklikte mi?
Scaffold'dan sonra iki yol var. Birincisi bir kez scaffold edip kodu sahiplenmek: üretilen kod artık senin kodun olur, şemayı bundan sonra migration'lar yönetir ya da veritabanı değiştikçe modeli elle güncellersin. Şemanın sahibi sensen ve eski bir veritabanını code-first'e taşıyorsan bu yol mantıklı.
Bizim durumumuz ikincisi: şemanın sahibi başka bir ekip, DBA yeni bir kolon eklediğinde modeli yeniden scaffold edeceğiz. Bu durumda üretilen dosyalara elle dokunmak yasak, çünkü --force hepsini baştan yazar. Özelleştirme için EF Core iki imkân bırakıyor: her şey partial üretiliyor ve context'in sonunda boş bir OnModelCreatingPartial çağrısı var. İkisini ayrı dosyalarda kullanıyorum:
// ReportingContext.Custom.cs
public partial class ReportingContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// DBA ekibi SOLD_AT'i UTC yazıyor; okurken Kind'ı UTC işaretle.
modelBuilder.Entity<BookSale>()
.Property(e => e.SoldAt)
.HasConversion(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
=> throw new InvalidOperationException("ReportingContext is read-only.");
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("ReportingContext is read-only.");
}
// Entities/BookSale.Custom.cs
public partial class BookSale
{
public bool IsBulkSale => Quantity >= 20;
}
OnModelCreatingPartial, üretilen konfigürasyondan sonra çalışır. Fluent API'de son yazılan kazandığı için burada scaffold'un yaptığı bir ayarı silmeden ezebilirsin. IsBulkSale gibi bir property, kolonu olmayan ve setter'ı olmayan bir property olduğu için EF Core onu modele almaz. Sonra komutu --force ile tekrar çalıştırdım: .Custom.cs dosyaları yerinde kaldı, diğer beşi baştan yazıldı.
T4 Template ile Üretilen Kodu Değiştirmek
Partial class'lar ekleme yapmana izin verir, ama üretilen kodun şeklini değiştiremez. Bizim okuma modelimizde property'lerin set yerine init olmasını, navigation'ların da virtual olmamasını istiyorum; lazy loading kullanmıyoruz ve rapor satırı okunduktan sonra değişmemeli. Bunun için EF Core 7'den beri T4 (Text Template Transformation Toolkit; .NET'in metin üreten template dili) template'leri var:
dotnet new install Microsoft.EntityFrameworkCore.Templates::10.0.15
cd src/Bookstore.Reporting
dotnet new ef-templates
Komut projeye CodeTemplates/EFCore/DbContext.t4 ve EntityType.t4 dosyalarını ekler. Scaffold bu klasörü görürse kendi gömülü template'leri yerine bunları kullanır. Template paketinin sürümü EF Core sürümüyle aynı olmalı; araç ben kurarken 11.0.0'ın çıktığını da hatırlattı, ama EF Core 10 projesinde 10.0.15'te kaldım. EntityType.t4'te değiştirdiğim satırlar şunlar:
var source = EntityType.GetViewName() != null
? EntityType.GetViewSchema() + "." + EntityType.GetViewName()
: EntityType.GetSchema() + "." + EntityType.GetTableName();
#>
/// <summary>
/// Read model for <#= source #> (owned by the DBA team). Generated code: put changes in a partial class.
/// </summary>
public partial class <#= EntityType.Name #>
public <#= code.Reference(property.ClrType) #><#= needsNullable ? "?" : "" #> <#= property.Name #> { get; init; }<#= needsInitializer ? " = null!;" : "" #>
public ICollection<<#= targetType #>> <#= navigation.Name #> { get; init; } = new List<<#= targetType #>>();
public <#= targetType #><#= needsNullable ? "?" : "" #> <#= navigation.Name #> { get; init; }<#= needsInitializer ? " = null!;" : "" #>
--force ile tekrar scaffold ettikten sonra BookSale.cs:
/// <summary>
/// Read model for reporting.BOOK_SALES (owned by the DBA team). Generated code: put changes in a partial class.
/// </summary>
public partial class BookSale
{
public long SaleId { get; init; }
public string Isbn { get; init; } = null!;
// ...
public decimal? LineTotal { get; init; }
public byte[] RowVer { get; init; } = null!;
public Category Category { get; init; } = null!;
}
EF Core init setter'lı property'leri sorunsuz dolduruyor; yukarıdaki endpoint'ler bu modelle çalıştı. Template'in elinde IEntityType olduğu için tablo adı, kolon yorumu, key bilgisi gibi modeldeki her şeye erişebilirsin. Ekipteki herkes aynı çıktıyı alsın diye CodeTemplates klasörünü repoya koy. Model büyükse üçüncü bir template, EntityTypeConfiguration.t4, her entity'nin konfigürasyonunu 04'teki gibi ayrı bir IEntityTypeConfiguration class'ına yazdırır; ef-templates bu dosyayı varsayılan olarak eklemiyor, kendin oluşturuyorsun.
Komut satırı yerine bir arayüz istersen ErikEJ'in EF Core Power Tools eklentisi Visual Studio içinde aynı işi yapar: tablo seçimini, isimlendirme kurallarını ve seçenekleri bir dosyada saklar, tekrar scaffold'u tek tıka indirir. Arkasında aynı EF Core scaffold altyapısı çalışıyor.
Sık Düşülen Tuzaklar
Birincisi, üretilen dosyayı elle düzeltmek. BookSale.cs'e bir property ya da attribute eklediysen, DBA bir sonraki kolonu eklediğinde yapılan --force scaffold onu sessizce siler. Diff'te fark etmezsen kaybolur. Kural basit: üretilen dosyaya dokunma; partial class, OnModelCreatingPartial ya da template kullan.
İkincisi, connection string'i kodda bırakmak. Scaffold komutuna şifreli bir connection string verdiysen #warning'i görmezden gelme. Name= kullan ya da --no-onconfiguring ile OnConfiguring'i hiç üretme.
Üçüncüsü, database-first şemaya migration uygulamak. Denedim: rapor modelinden migrations add Initial üretip database update çalıştırınca EF Core tabloları baştan kurmaya çalıştı.
Microsoft.Data.SqlClient.SqlException (0x80131904): There is already an object named 'CATEGORY' in the database.
Daha kötüsü, hata gelmeden önce DBA'nın veritabanına __EFMigrationsHistory tablosunu bırakmıştı. Bu şemanın sahibi sen değilsen migration da senin değil. Okuma modeli ile kendi şemanı yönettiğin model aynı projede duruyorsa context'leri ayır; migration komutlarında --context'i hep açıkça ver. 32. yazıda birden fazla context'le migration yönetimine döneceğiz.
Dördüncüsü, --use-database-names'in her şeyi koruduğunu sanmak. Yukarıda gördük: tablo adı yine tekilleşiyor, --no-pluralize ayrıca gerekiyor.
Küçük Bir Deneme
★☆☆ Kendi veritabanında birkaç tablolu bir schema'yı Name= syntax'ı ve --no-onconfiguring ile scaffold et. Sonra aynı komutu --use-database-names, ardından --use-database-names --no-pluralize ile tekrar çalıştır ve üç çıktıdaki class adlarını karşılaştır.
★★☆ Tabloya yeni bir kolon ekle (ALTER TABLE reporting.BOOK_SALES ADD CHANNEL varchar(20) NULL). --force ile tekrar scaffold et ve .Custom.cs dosyalarının yerinde kaldığını, yeni property'nin geldiğini doğrula.
★★★ EntityType.t4'ü değiştir: kolon yorumu (sp_addextendedproperty ile eklenen MS_Description) olan property'lerin üstüne yorumu XML summary olarak yazdırıyor mu kontrol et, yoksa ekle. Sonra keyless entity'ler için class'ın üstüne "Keyless: read only" diye ayrı bir satır ürettir.
Bu yazıda modeli veritabanından ürettik ve sorguları LINQ (Language Integrated Query; C# içinden sorgu yazma özelliği) ile yazdık. Peki yazdığın LINQ'in hangi parçası hangi SQL function'ına dönüşüyor? ToLower, AddDays, Math.Round ya da kendi yazdığın bir SQL function'ı nasıl çevriliyor? Bir sonraki yazıda EF Core'un LINQ'ten SQL'e çeviri kataloğunu SQL Server üzerinde, EF.Functions ve HasDbFunction ile birlikte çıkaracağız.