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

EF Core Fluent API Kataloğu: Kolon Tipleri, Default ve Computed Değerler, Sequence ve HiLo, Index Seçenekleri ve Toplu Convention'lar

Mert Özen 15 Kas 2026 11 dk 14 görüntülenme
EF Core Fluent API Kataloğu: Kolon Tipleri, Default ve Computed Değerler, Sequence ve HiLo, Index Seçenekleri ve Toplu Convention'lar

EF Core'da kolon tipleri HasMaxLength, IsUnicode, IsFixedLength, HasPrecision ve UseCollation ile, üretilen değerler HasDefaultValueSql, HasComputedColumnSql, HasSequence ve UseHiLo ile, index'ler IsUnique, HasFilter, IncludeProperties ve IsDescending ile ayarlanır; ConfigureConventions bütün string ve decimal'lara tek satırda varsayılan verir. HasDefaultValue, CLR default'unu (0, false) veritabanına göndermez.

Sürüm notu: Bu yazıdaki kodlar EF Core (Entity Framework Core) 10 ve SQL Server 2025 ile yazılıp denenmiştir; EF Core 11 notu EF Core 11.0.0 ile denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kargo ücreti varsayılan olarak 4,99. Bunu veritabanında default değer olarak tanımlıyorsun:

b.Property(x => x.ShippingFee).HasDefaultValue(4.99m);

Pazarlama ekibi "40 TL üzeri kargo bedava" kampanyası başlatıyor. Kod şunu yapıyor:

var order = new Order { Customer = customer, Subtotal = 40m, ShippingFee = 0m };
db.Orders.Add(order);
await db.SaveChangesAsync();

Veritabanına giden ShippingFee kaç? Cevap, Fluent API'nin default değerlerle ilgili bölümünde; çoğu kişinin beklediği 0 değil.

04. yazıda Fluent API ile data annotation'ı karşılaştırmış, IEntityTypeConfiguration ile konfigürasyonu dağıtmıştık. Bu yazı bir katalog: Bookstore modelini SQL Server için ayarlarken günlük işte kullandığın Fluent API çağrılarını, ürettikleri SQL ile birlikte topluyoruz. Model 30. yazıdakinin devamı (Book'a Slug, Order'a sipariş numarası ve tutar kolonları eklendi); bu yazıda EnsureCreated'dan migration'lara geçtik ve SQL'i dotnet ef migrations script ile aldık.

Toplu Ayarlar: ConfigureConventions

İlk iş, modeldeki her property'yi tek tek ayarlamak yerine varsayılanları değiştirmek. EF Core 6'dan beri ConfigureConventions bunun yeri:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<string>().HaveMaxLength(200);
    configurationBuilder.Properties<decimal>().HavePrecision(18, 2);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasSequence<int>("OrderNumbers", "ordering")
        .StartsAt(100001)
        .IncrementsBy(1);

    modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreContext).Assembly);
    // ...
}

Bu iki satırdan sonra modeldeki bütün string'ler nvarchar(200), bütün decimal'lar decimal(18,2) oluyor. Entity'nin kendi konfigürasyonu bu varsayılanın üstüne yazıyor: Author.Name'e HasMaxLength(100), Book.Price'a HasPrecision(10, 2) verdim ve script'te öyle çıktılar. Customer.FullName ise hiçbir şey demediğim için nvarchar(200) oldu. List<string> olan Book.Tags string sayılmıyor; JSON kolonu olarak nvarchar(max) kaldı.

Decimal ayarı süs değil. Onu kaldırıp migration ürettiğimde her decimal property için şu uyarı geldi:

warn: Microsoft.EntityFrameworkCore.Model.Validation[30000]
      No store type was specified for the decimal property 'Subtotal' on entity type 'Order'. This will cause values to be silently truncated if they do not fit in the default precision and scale. Explicitly specify the SQL server column type that can accommodate all the values in 'OnModelCreating' using 'HasColumnType', specify precision and scale using 'HasPrecision', or configure a value converter using 'HasConversion'.

"Silently truncated" kısmı önemli: kuruşun altındaki bir değer ya da sığmayan büyük bir tutar hata vermeden kesilir. Para tutan her decimal'a precision ver; tek tek unutmamak için de toplu ayarı kullan.

Kolon Tipleri

Book'un konfigürasyonu, kolon tipini belirleyen çağrıların çoğunu içeriyor:

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> b)
    {
        b.ToTable("Books", "catalog", t => t.HasCheckConstraint("CK_Books_Price", "[Price] >= 0"));

        b.Property(x => x.Title).UseCollation("Turkish_CI_AS");
        b.Property(x => x.Slug).HasMaxLength(220).IsUnicode(false);
        b.Property(x => x.Isbn)
            .HasMaxLength(13)
            .IsFixedLength()
            .IsUnicode(false)
            .HasComment("ISBN-13 without dashes");
        b.Property(x => x.Price).HasPrecision(10, 2);

        b.HasAlternateKey(x => x.Isbn);
        b.HasIndex(x => x.Title);
        b.HasIndex(x => x.Slug).IsUnique();
        b.HasIndex(x => x.PublishedOn)
            .IsDescending()
            .IncludeProperties(x => new { x.Title, x.Price })
            .HasDatabaseName("IX_Books_PublishedOn_Newest");
    }
}
CREATE TABLE [catalog].[Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(200) COLLATE Turkish_CI_AS NOT NULL,
    [Slug] varchar(220) NULL,
    [Isbn] char(13) NOT NULL,
    [Price] decimal(10,2) NOT NULL,
    [PublishedOn] date NOT NULL,
    [Tags] nvarchar(max) NOT NULL,
    [AuthorId] int NOT NULL,
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id]),
    CONSTRAINT [AK_Books_Isbn] UNIQUE ([Isbn]),
    CONSTRAINT [CK_Books_Price] CHECK ([Price] >= 0),
    CONSTRAINT [FK_Books_Authors_AuthorId] FOREIGN KEY ([AuthorId]) REFERENCES [catalog].[Authors] ([Id]) ON DELETE CASCADE
);
DECLARE @description AS sql_variant;
SET @description = N'ISBN-13 without dashes';
EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', N'catalog', 'TABLE', N'Books', 'COLUMN', N'Isbn';

Satır satır: HasMaxLength uzunluğu, IsUnicode(false) n'siz tipi (varchar), IsFixedLength sabit uzunluğu (char) verdi. ISBN (International Standard Book Number; kitapların uluslararası kimlik numarası) hep 13 rakam olduğu için char(13) doğru tip; slug da URL'de geçtiği için ASCII (yalnız temel Latin karakterleri içeren karakter seti). HasPrecision(10, 2) toplu ayarı ezdi. HasComment, 29. yazıda scaffold'un okuduğu MS_Description'ı yazdı; şemayı başkası okuyacaksa ucuz bir belge. Bunların hiçbiri yetmezse HasColumnType("money") gibi ham tip de verebilirsin, ama o zaman provider'dan bağımsızlığı kaybedersin.

UseCollation("Turkish_CI_AS") 30. yazıdaki sözü yerine getiriyor. Kolon artık Türkçe kurallarla karşılaştırılıyor; EF.Functions.Collate olmadan, düz StartsWith("ince") sorgusu "İnce Memed"i buldu ve IX_Books_Title index'i de aynı collation ile kurulduğu için arama sargable kaldı. Değeri saklama biçimini değiştiren dönüşümler (enum'ı string olarak saklamak, strongly typed id) için 20. yazıdaki HasConversion'a bak; o da bu kataloğun parçası.

Default ve Computed Değerler

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> b)
    {
        b.Property(x => x.OrderNumber)
            .HasDefaultValueSql("NEXT VALUE FOR ordering.OrderNumbers");
        b.HasIndex(x => x.OrderNumber).IsUnique();

        b.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
        b.Property(x => x.ShippingFee)
            .HasDefaultValue(4.99m)
            .ValueGeneratedNever();
        b.Property(x => x.TotalAmount)
            .HasComputedColumnSql("[Subtotal] - [DiscountAmount] + [ShippingFee]", stored: true);
    }
}
CREATE TABLE [Orders] (
    [Id] int NOT NULL IDENTITY,
    [OrderNumber] int NOT NULL DEFAULT (NEXT VALUE FOR ordering.OrderNumbers),
    [CreatedAt] datetime2 NOT NULL DEFAULT (SYSUTCDATETIME()),
    [Subtotal] decimal(18,2) NOT NULL,
    [DiscountAmount] decimal(18,2) NOT NULL,
    [ShippingFee] decimal(18,2) NOT NULL DEFAULT 4.99,
    [TotalAmount] AS [Subtotal] - [DiscountAmount] + [ShippingFee] PERSISTED,
    [CustomerId] int NOT NULL,
    CONSTRAINT [PK_Orders] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Orders_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

HasDefaultValueSql bir SQL ifadesi, HasDefaultValue sabit bir değer alır. HasComputedColumnSql'in stored: true parametresi kolonu PERSISTED yapıyor: değer satır yazılırken hesaplanıp diske konuyor, okurken tekrar hesaplanmıyor ve üzerine index kurulabiliyor. stored vermezsen kolon her okunuşta hesaplanır. TotalAmount'ın setter'ını private yaptım; computed bir kolona uygulamanın değer yazması anlamsız. Computed kolon yalnız aynı satırdaki kolonlara bakabilir; sipariş kalemlerinin toplamını hesaplayamaz, bu yüzden Subtotal ayrı bir kolon.

Şimdi açılıştaki sorunun cevabı. İlk denemede ShippingFee'de yalnız HasDefaultValue(4.99m) vardı ve kampanya siparişinin INSERT'i şöyle gitti:

INSERT INTO [Orders] ([CustomerId], [DiscountAmount], [Subtotal])
OUTPUT INSERTED.[Id], INSERTED.[CreatedAt], INSERTED.[OrderNumber], INSERTED.[ShippingFee], INSERTED.[TotalAmount]
VALUES (@p0, @p1, @p2);
OrderNumber=100007 ShippingFee=4.99 TotalAmount=44.99

ShippingFee kolon listesinde yok. EF Core, database default'u olan bir property'de CLR (Common Language Runtime; .NET'in çalışma ortamı) default değerini (decimal için 0) "değer verilmedi" diye yorumluyor ve kolonu INSERT'ten çıkarıyor; veritabanı da 4,99'u yazıyor. Müşteri bedava dediğin kargoya para ödüyor. EF Core 8, bu sorunun en bariz hâlini, default'u true olan bool'ları kendisi düzeltti; diğer tiplerde sorun hâlâ duruyor.

İki çıkış yolu var. Birincisi HasSentinel: "değer verilmedi" anlamına gelecek değeri değiştirirsin (ör. HasSentinel(-1m) ve property'nin başlangıç değeri -1). İkincisi, benim seçtiğim: ValueGeneratedNever() ile EF Core'a değeri her zaman göndermesini söylemek ve varsayılanı C#'ta vermek (public decimal ShippingFee { get; set; } = 4.99m;). Database default'u yerinde kalıyor; migration'la eklenen yeni kolonu doldurmak ya da başka uygulamaların INSERT'leri için hâlâ işe yarıyor. Aynı sipariş bu kez:

INSERT INTO [Orders] ([CustomerId], [DiscountAmount], [ShippingFee], [Subtotal])
OUTPUT INSERTED.[Id], INSERTED.[CreatedAt], INSERTED.[OrderNumber], INSERTED.[TotalAmount]
VALUES (@p0, @p1, @p2, @p3);
OrderNumber=100007 ShippingFee=0 TotalAmount=40.00

OUTPUT INSERTED satırına da bak: EF Core, veritabanının ürettiği her değeri (Id, CreatedAt, OrderNumber, TotalAmount) aynı komutta geri okuyor ve entity'ye yazıyor. ValueGeneratedNever() şemayı değiştirmediği için migration da gerektirmedi; denediğimde EF Core boş bir migration üretti, onu sildim. Tersine, veritabanında değeri trigger gibi senin görmediğin bir şey üretiyorsa ValueGeneratedOnAdd() ya da ValueGeneratedOnAddOrUpdate() ile EF Core'a bunu söylersin; o zaman değeri her kayıttan sonra geri okur.

Sequence, HiLo ve Alternate Key

Sipariş numarası müşteriye gösteriliyor, bu yüzden Id'den ayrı ve 100001'den başlayan bir sayı olsun istiyorum. HasSequence veritabanında bir sequence tanımlıyor, OrderNumber'ın default'u da o sequence'ten bir sonraki değeri alıyor:

CREATE SEQUENCE [catalog].[AuthorIds] START WITH 1 INCREMENT BY 10 NO CYCLE;

CREATE SEQUENCE [ordering].[OrderNumbers] AS int START WITH 100001 INCREMENT BY 1 NO CYCLE;

Identity'den farkı, sequence'in tabloya bağlı olmaması: aynı sequence'i birden fazla tablo paylaşabilir, değeri INSERT'ten önce de alabilirsin. Seed'deki altı siparişten sonra yeni sipariş 100007 numarasını aldı.

İlk sequence Author.Id için ve HiLo'ya ait: b.Property(x => x.Id).UseHiLo("AuthorIds", "catalog");. HiLo, key'leri veritabanından tek tek almak yerine bloklar hâlinde ayırıyor. Yeni bir yazar eklediğimde iki komut gitti:

SELECT NEXT VALUE FOR [catalog].[AuthorIds]

INSERT INTO [catalog].[Authors] ([Id], [Name])
VALUES (@p0, @p1);

EF Core sequence'ten bir değer aldı ve ondan sonraki dokuz Id'yi kendi verecek; INCREMENT BY 10 blok büyüklüğü. INSERT'te Id artık uygulamadan geliyor, OUTPUT INSERTED.[Id] yok. Avantajı, entity'nin key'inin SaveChanges'tan önce belli olması ve çok sayıda INSERT'in tek batch'te gidebilmesi. Bedeli, uygulama her yeniden başladığında elindeki bloğun kullanılmayan kısmının atlanması; Id'lerde boşluklar olur. Id'lerin ardışık olması gerekiyorsa HiLo kullanma; sipariş numarası için zaten ayrı bir sequence seçtik.

HasAlternateKey(x => x.Isbn) ise AK_Books_Isbn adlı bir unique constraint üretti. Unique index'ten farkı, EF Core'un onu bir key olarak görmesi: başka bir entity'nin foreign key'i Id yerine ISBN'i gösterebilir (HasPrincipalKey). Yalnız tekilliği garanti etmek istiyorsan unique index yeterli.

Index Seçenekleri ve Constraint'ler

CREATE INDEX [IX_Books_PublishedOn_Newest] ON [catalog].[Books] ([PublishedOn] DESC) INCLUDE ([Title], [Price]);

CREATE UNIQUE INDEX [IX_Books_Slug] ON [catalog].[Books] ([Slug]) WHERE [Slug] IS NOT NULL;

CREATE INDEX [IX_Books_Title] ON [catalog].[Books] ([Title]);

"Yeni çıkanlar" sayfası kitapları yayın tarihine göre tersten sıralayıp başlık ve fiyat gösteriyor. IsDescending() (EF Core 7) index'i o sırada kuruyor, IncludeProperties başlık ve fiyatı index'in yaprak seviyesine ekliyor; sorgu tabloya hiç gitmeden index'ten cevaplanabiliyor. HasDatabaseName convention'ın verdiği IX_Books_PublishedOn adını değiştirdi.

IX_Books_Slug'daki WHERE [Slug] IS NOT NULL filtresini ben yazmadım. Slug nullable olduğu için EF Core, SQL Server'da unique index'e bu filtreyi kendisi ekliyor; aksi hâlde SQL Server iki NULL'u da çakışma sayardı ve slug'ı olmayan ikinci kitap eklenemezdi. Farklı bir koşul gerekiyorsa HasFilter("...") ile kendin verirsin, HasFilter(null) ile filtreyi kaldırırsın. Aynı slug'la ikinci bir kitap eklemeye çalıştığımda:

Cannot insert duplicate key row in object 'catalog.Books' with unique index 'IX_Books_Slug'. The duplicate key value is (dune).

ToTable("Books", "catalog", t => t.HasCheckConstraint(...)) hem schema'yı hem check constraint'i aynı çağrıda verdi. Authors ve Books artık catalog schema'sında, sipariş tabloları dbo'da. Negatif fiyatlı bir kitap uygulamadaki doğrulamayı atlasa bile veritabanında duruyor:

The INSERT statement conflicted with the CHECK constraint "CK_Books_Price". The conflict occurred in database "efseries_ext_31", table "catalog.Books", column 'Price'.

Script'te default constraint'lerin adı yok; SQL Server onlara rastgele ad veriyor. EF Core 10 ile HasDefaultValue(4.99m, "DF_Orders_ShippingFee") gibi ad verebiliyor ya da modelBuilder.UseNamedDefaultConstraints() ile hepsine otomatik ad verdirebiliyorsun. Var olan migration'ların varsa bir sonraki migration bütün default constraint'leri yeniden adlandırır; bunu baştan karar ver.

ToSqlQuery ve ToView

Raporlar için kitap başına satılan adedi veren bir okuma modeli istiyorum, ama bunun için bir tablo ya da view açmak istemiyorum. ToSqlQuery, keyless bir entity'yi bir SQL sorgusuna eşliyor:

modelBuilder.Entity<BookSalesSummary>()
    .HasNoKey()
    .ToTable((string?)null)
    .ToSqlQuery("""
        SELECT b.Id AS BookId, b.Title, SUM(oi.Quantity) AS Units
        FROM catalog.Books AS b
        JOIN dbo.OrderItems AS oi ON oi.BookId = b.Id
        GROUP BY b.Id, b.Title
        """);
SELECT [b].[BookId], [b].[Title], [b].[Units]
FROM (
    SELECT b.Id AS BookId, b.Title, SUM(oi.Quantity) AS Units
    FROM catalog.Books AS b
    JOIN dbo.OrderItems AS oi ON oi.BookId = b.Id
    GROUP BY b.Id, b.Title
) AS [b]
WHERE [b].[Units] > 2
ORDER BY [b].[Units] DESC
  { Title = The Hobbit, Units = 7 }
  { Title = Emma, Units = 6 }
  { Title = Dune, Units = 3 }

Sorgu alt sorgu olarak yerleşiyor, Where ve OrderBy dışarıda kalıyor. ToTable((string?)null) satırı yine sonradan eklendi: onsuz ilk migration'da boş bir BookSalesSummaries tablosu vardı. ToSqlQuery okuma tarafını eşliyor, ama EF Core entity'yi ayrıca varsayılan bir tabloya da eşliyor ve migration o tabloyu kuruyor. Aynı modeli veritabanındaki bir view'dan okumak istersen ToView("vw_BookSales") yazarsın; o durumda migration view'ı oluşturmaz, view'ı senin kurman gerekir. Bunu bir sonraki yazıda migration içinde yapacağız.

EF Core 11'de Complex Type Ayarları

EF Core 11 notu: 26. ve 28. yazılarda gördüğümüz complex type'ların property'lerini ayarlamak için şimdiye kadar önce ComplexProperty builder'ına inmen gerekiyordu. EF Core 11'de lambda zinciri doğrudan çalışıyor ve complex type property'si üzerinde index de kurulabiliyor:

modelBuilder.Entity<Customer>(b =>
{
    b.ComplexProperty(c => c.Address);
    b.Property(c => c.Address.City).HasMaxLength(100);
    b.Property(c => c.Address.PostalCode).HasMaxLength(10).IsUnicode(false);
    b.HasIndex(c => c.Address.PostalCode);
});
CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [FullName] nvarchar(max) NOT NULL,
    [Address_City] nvarchar(100) NOT NULL,
    [Address_PostalCode] varchar(10) NOT NULL,
    [Address_Street] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);

CREATE INDEX [IX_Customers_Address_PostalCode] ON [Customers] ([Address_PostalCode]);

Bu çıktıyı net11.0 hedefleyen ayrı bir projede EF Core 11.0.0 ile GenerateCreateScript() kullanarak aldım; Bookstore'un kendisi EF Core 10'da kalıyor. Aynı kodu EF Core 10'a çevirip denedim: derleniyor, ama model kurulurken ArgumentException veriyor: "The expression 'c => c.Address.City' is not a valid member access expression." Property'leri ComplexProperty(c => c.Address, a => ...) builder'ına taşıyınca bu kez aynı hata HasIndex(c => c.Address.PostalCode) satırından geldi. EF Core 10'da posta kodu üzerinde index istiyorsan adresi owned type ile modellemen gerekiyor.

Sık Düşülen Tuzaklar

Birincisi, HasDefaultValue'nun CLR default'uyla çakışması. Yukarıdaki kargo ücreti gibi, 0 ya da false anlamlı bir değerse ve database default'u farklıysa EF Core o değeri hiç göndermez. Default tanımladığın her property'de "CLR default'u bilerek yazarsam ne olur?" diye sor; cevabı HasSentinel ya da ValueGeneratedNever.

İkincisi, decimal'ı precision vermeden map etmek. Uyarı build çıktısında kaybolur, sonuç da sessizce kesilen tutarlardır. ConfigureConventions'a bir satır yazmak yeterli.

Üçüncüsü, ToSqlQuery ya da function satır tipi gibi keyless modellerin migration'da tabloya dönüşmesi. Migration ürettikten sonra script'i oku; beklemediğin bir CREATE TABLE görürsen ToTable((string?)null) eksik demektir.

Dördüncüsü, HiLo'dan ardışık Id beklemek. Uygulama yeniden başladıkça bloklar atlanır; Id'yi müşteriye gösterilen bir numara olarak kullanma.

Küçük Bir Deneme

★☆☆ Customer.Email'e IsUnicode(false) ekle, migration üret ve migration dosyasında AlterColumn'un eski ve yeni tipini oku.

★★☆ Order'a Status property'si ekle, default'u "Pending" olsun. Status'u hiç vermeden bir sipariş, "Shipped" vererek bir sipariş daha ekle; INSERT'lerin kolon listesine bakarak hangisinde default'un çalıştığını açıkla.

★★★ Author.Id'deki HiLo'yu UseSequence ile değiştir. İki yöntemin INSERT'lerini LogTo ile karşılaştır ve 100 yazar eklerken veritabanına kaç komut gittiğini say.

Bu yazıda modeli değiştirdik ve migration'ı hep sıfırdan, boş bir veritabanına uyguladık. Gerçek hayatta migration'lar dolu bir veritabanına gider: kolon adı değişir, bir kolon ikiye bölünür, aynı anda iki branch'te iki migration açılır. Bir sonraki yazıda migration'ları pratikte konuşacağız: rename mi drop mu, veri taşıyan migration'lar, view ve function'ları migration'la yönetmek, ekipteki çakışmalar ve squash.