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

EF Core ile Veri Kaydetme: SaveChanges, İlişkili Graf ve Disconnected Entity'ler

Mert Özen 5 Kas 2026 11 dk 1 görüntülenme
EF Core ile Veri Kaydetme: SaveChanges, İlişkili Graf ve Disconnected Entity'ler

EF Core'da veri kaydetmenin iki dünyası: connected senaryoda Add/Remove ve yalnızca değişen kolonları yazan SaveChanges, SQLite ve SQL Server'da batching, ilişkili graf kaydetme ve yetim kayıtlar; disconnected senaryoda Update'in bütün kolonları yazması, yükle-değiştir, SetValues, Attach ve IsModified.

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: Update Kaç Kolon Yazar?

Bir API endpoint'i düşün. İstemci 1 numaralı kitabın yeni fiyatını gönderiyor; gelen JSON'dan bir Book nesnesi oluşuyor ve yeni bir context ile kaydediliyor:

var fromClient = new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 13.00m, AuthorId = 1 };

using var db = new BookstoreContext();
db.Books.Update(fromClient);
db.SaveChanges();

Veritabanında bu kitabın bir de ISBN'i var: 9780547773742. İstemci ISBN göndermedi, çünkü ekranında ISBN alanı yoktu. UPDATE komutu hangi kolonları yazar ve kayıttan sonra ISBN ne olur?

UPDATE "Books" SET "AuthorId" = @p0, "Isbn" = @p1, "Price" = @p2, "Title" = @p3
WHERE "Id" = @p4;
SELECT changes();
Isbn after update: NULL

Bütün kolonlar yazıldı ve ISBN silindi. Önceki yazıda EF Core'un yalnızca değişen kolonları güncellediğini görmüştük; o kural, context nesnenin orijinal değerlerini bildiği zaman geçerli. Bu context kitabı hiç görmedi. Elinde karşılaştıracağı bir kopya yok, bu yüzden Update "her şey değişmiş olabilir" diye varsayıp bütün property'leri Modified işaretledi. Isbn property'si de null'dı, null yazıldı.

Bu yazıda veri kaydetmenin iki dünyasını göreceğiz: entity'nin aynı context'te yüklendiği connected senaryo ve bir API'de olduğu gibi context'in nesneyi ilk kez gördüğü disconnected senaryo. Arada SaveChanges'in komutları nasıl gönderdiğine ve ilişkili bir grafı tek seferde kaydetmeye de bakacağız.

Model

Önceki yazının modeline bir Isbn kolonu ekledik ve ilk kitaba bir ISBN verdik:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

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 string? Isbn { 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")
            .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Author>().HasData(
            new Author { Id = 1, Name = "Ursula K. Le Guin" },
            new Author { Id = 2, Name = "Isaac Asimov" });

        modelBuilder.Entity<Book>().HasData(
            new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 12.50m, Isbn = "9780547773742", AuthorId = 1 },
            new Book { Id = 2, Title = "The Left Hand of Darkness", Price = 14.00m, AuthorId = 1 },
            new Book { Id = 3, Title = "Foundation", Price = 11.00m, AuthorId = 2 });
    }
}

Her örnek temiz bir veritabanıyla başlıyor.

Connected Senaryo: Add, Değiştir, Remove

Context'in kendi yüklediği nesnelerle çalışmak en kolay yol. Ne yapmak istediğini söylersin, gerisini change tracker halleder:

using var db = new BookstoreContext();

db.Books.Add(new Book { Title = "The Dispossessed", Price = 15.00m, AuthorId = 1 });

var foundation = db.Books.Single(b => b.Title == "Foundation");
foundation.Price = 10.50m;

var leftHand = db.Books.Single(b => b.Id == 2);
db.Books.Remove(leftHand);

var affected = db.SaveChanges();
Console.WriteLine($"Rows affected: {affected}");

İki SELECT'ten sonra SaveChanges üç komut gönderdi:

DELETE FROM "Books"
WHERE "Id" = @p0;
SELECT changes();

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();

INSERT INTO "Books" ("AuthorId", "Isbn", "Price", "Title")
VALUES (@p2, @p3, @p4, @p5);
SELECT "Id"
FROM "Books"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();
Rows affected: 3

Sıra senin yazdığın sıra değil; EF Core komutları foreign key bağımlılıklarına göre kendisi sıralar. UPDATE'te yalnızca Price var, çünkü foundation bu context'te yüklendi ve orijinal değerleri biliniyor. INSERT'ten sonraki SELECT "Id" da veritabanının ürettiği key'i geri okuyup nesneye yazıyor. EF Core 10 aynı işi RETURNING "Id" ile yapıyor. SaveChanges'in dönüş değeri etkilenen satır sayısı.

Bir şey daha: bu üç komut tek bir transaction içinde çalıştı. Biri başarısız olursa hiçbiri kalıcı olmaz. Transaction'ları ayrı bir yazıda ayrıntılı göreceğiz.

SaveChanges ve Batching

Üç kitabı birden ekleyelim:

using var db = new BookstoreContext();
db.Books.AddRange(
    new Book { Title = "I, Robot", Price = 10.00m, AuthorId = 2 },
    new Book { Title = "The Caves of Steel", Price = 11.50m, AuthorId = 2 },
    new Book { Title = "The End of Eternity", Price = 12.00m, AuthorId = 2 });
Console.WriteLine($"Rows affected: {db.SaveChanges()}");

Logda üç ayrı Executed DbCommand satırı var; her INSERT kendi komutuyla gitti. SQLite provider'ı EF Core 6'da da EF Core 10'da da komutları birleştirmiyor. SQLite aynı süreç içinde çalışan bir dosya veritabanı olduğu için ayrı komutların maliyeti düşük.

SQL Server'da durum farklı. Orada her komut ağ üzerinden bir gidiş dönüş demek ve EF Core birden fazla INSERT, UPDATE ve DELETE'i tek bir komutta birleştirerek gönderir. Buna batching denir. EF Core 5'ten beri SQL Server için varsayılan en büyük batch boyutu 42 komut; UseSqlServer(connectionString, o => o.MaxBatchSize(100)) ile değiştirebilirsin. Senin tarafında yapman gereken tek şey değişiklikleri biriktirip tek bir SaveChanges çağırmak. Döngünün içinde her kayıtta SaveChanges çağırırsan batching'den hiç faydalanamazsın.

İlişkili Graf Kaydetmek

Yeni bir yazarı kitaplarıyla birlikte eklemek için grafı kurman ve kökünü Add etmen yeterli:

using var db = new BookstoreContext();
var lem = new Author
{
    Name = "Stanislaw Lem",
    Books =
    {
        new Book { Title = "Solaris", Price = 13.00m },
        new Book { Title = "The Cyberiad", Price = 12.00m }
    }
};

db.Authors.Add(lem);
Console.WriteLine($"Rows affected: {db.SaveChanges()}");
Console.WriteLine($"Author Id: {lem.Id}, book AuthorIds: {string.Join(", ", lem.Books.Select(b => b.AuthorId))}");
Rows affected: 3
Author Id: 3, book AuthorIds: 3, 3

AuthorId vermedik. Add, grafta ulaşabildiği her yeni nesneyi Added olarak işaretledi. EF Core önce yazarı ekledi, üretilen Id'yi okudu, sonra onu iki kitabın foreign key'ine yazıp kitapları ekledi.

Mevcut bir yazarın koleksiyonunu değiştirmek de aynı şekilde çalışır:

using var db = new BookstoreContext();
var asimov = db.Authors.Include(a => a.Books).Single(a => a.Id == 2);

asimov.Books.Add(new Book { Title = "I, Robot", Price = 10.00m });

var foundation = asimov.Books.Single(b => b.Title == "Foundation");
asimov.Books.Remove(foundation);

db.ChangeTracker.DetectChanges();
Console.WriteLine(db.ChangeTracker.DebugView.ShortView);
Console.WriteLine($"Rows affected: {db.SaveChanges()}");
Author {Id: 2} Unchanged
Book {Id: -2147482641} Added FK {AuthorId: 2}
Book {Id: 3} Deleted FK {AuthorId: 2}

Rows affected: 2

Listeye eklenen kitap Added oldu. Listeden çıkarılan Foundation ise Deleted oldu ve bir DELETE gönderildi. Sebep ilişkinin required olması: AuthorId bir int, yazarsız bir kitap olamaz. Yazarından koparılan kitap yetim kalır ve EF Core onu siler. İlişki optional olsaydı (int? AuthorId) aynı işlem kitabı silmez, AuthorId'yi NULL yapardı. Bu davranışların ayrıntısı ilişkiler yazısındaki cascade delete bölümünde.

DetectChanges() satırına da dikkat et. DebugView kendi başına değişiklikleri algılamaz; onu çağırmasaydık iki kitap da hâlâ Unchanged görünürdü. SaveChanges ise bunu kendisi çağırıyor.

Disconnected Senaryo: API'den Gelen Veri

Bir web API'de her istek yeni bir context ile çalışır. İstemciden gelen veri, context'in hiç görmediği bir nesnedir. Bu durumda en güvenli ve en okunur yol, kaydı yükleyip değiştirmek:

using Microsoft.EntityFrameworkCore;

namespace Bookstore;

public record BookPriceUpdate(int Id, decimal Price);

public static class BookService
{
    public static async Task<bool> UpdatePriceAsync(BookPriceUpdate request)
    {
        await using var db = new BookstoreContext();
        var book = await db.Books.SingleOrDefaultAsync(b => b.Id == request.Id);
        if (book is null)
        {
            return false;
        }

        book.Price = request.Price;
        await db.SaveChangesAsync();
        return true;
    }
}
SELECT "b"."Id", "b"."AuthorId", "b"."Isbn", "b"."Price", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Id" = @__request_Id_0
LIMIT 2

UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();

Bir SELECT fazladan, ama karşılığında üç şey kazandın: kayıt yoksa bunu biliyorsun (false dönüp 404 verebilirsin), yalnızca değişen kolon yazılıyor ve istemcinin göndermediği ISBN'e kimse dokunmuyor. İstemciden entity yerine yalnızca izin verdiğin alanları taşıyan bir DTO almak da bu yolun doğal parçası.

DTO'da çok alan varsa tek tek atamak yerine SetValues kullanabilirsin. db.Entry(book).CurrentValues.SetValues(request) çağrısı, request nesnesindeki adı eşleşen property'leri entity'ye kopyalar. BookPriceUpdate yalnızca Id ve Price taşıdığı için yine yalnızca Price güncellendi ve ISBN yerinde kaldı. Aynı çağrıya bir DTO yerine istemciden gelen tam bir Book verirsen, onun null ISBN'i de kopyalanır ve yazının başındaki kayıp bu kez daha kısa bir UPDATE ile tekrarlanır.

ASP.NET Core Web API serisinde bu kalıbı controller'lar ve dependency injection ile birlikte, gerçek endpoint'ler üzerinden kuracağız.

Attach ve State'i Elle Ayarlamak

Bazen SELECT'i atlamak istersin. Kaydın var olduğundan eminsen ve hangi kolonun değiştiğini biliyorsan, nesneyi Attach ile bağlayıp state'i kendin ayarlayabilirsin:

var book = new Book { Id = 1, Price = 13.00m };

using var db = new BookstoreContext();
db.Books.Attach(book);
Console.WriteLine(db.Entry(book).State);

db.Entry(book).Property(b => b.Price).IsModified = true;
Console.WriteLine(db.Entry(book).State);

db.SaveChanges();
Unchanged
Modified
UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();

Attach nesneyi Unchanged olarak takibe aldı: "bu kayıt veritabanında olduğu gibi duruyor" dedik. Ardından yalnızca Price'ı değişmiş olarak işaretledik ve state kendiliğinden Modified oldu. Tek bir UPDATE, tek bir kolon, hiç SELECT yok. Title'ın null olması önemli değil; değişmiş olarak işaretlenmediği için SQL'e girmedi.

Silme için de aynı numara geçerli. Yalnızca key'i dolu bir stub nesne yeter: db.Books.Remove(new Book { Id = 3 }) tek bir DELETE gönderir. Ama "kayıt var" varsayımın yanlışsa EF Core bunu fark eder:

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

99 numaralı kitap yoktu, DELETE 0 satır etkiledi ve EF Core bunu bir concurrency hatası olarak raporladı. Bu exception'ı concurrency yazısında asıl amacı için kullanacağız.

Update bir graf üzerinde çağrıldığında her nesne için key'e bakar. Key doluysa Modified, key'in tipi için varsayılan değerdeyse (int için 0) Added:

var author = new Author
{
    Id = 1,
    Name = "Ursula K. Le Guin",
    Books =
    {
        new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 13.00m, AuthorId = 1 },
        new Book { Title = "The Lathe of Heaven", Price = 12.00m, AuthorId = 1 }
    }
};

using var db = new BookstoreContext();
db.Authors.Update(author);
Console.WriteLine(db.ChangeTracker.DebugView.ShortView);
Console.WriteLine($"Rows affected: {db.SaveChanges()}");
Author {Id: 1} Modified
Book {Id: -2147482640} Added FK {AuthorId: 1}
Book {Id: 1} Modified FK {AuthorId: 1}

Rows affected: 3

Yazar ve mevcut kitap bütün kolonlarıyla güncellendi, yeni kitap eklendi. Pratik, ama yazının başındaki uyarı bütün graf için geçerli: Modified işaretlenen her nesnenin her kolonu yazılır. Attach de aynı kuralı izler; key'i 0 olan yeni bir kitabı Attach edersen state Unchanged değil Added olur.

Sık Düşülen Tuzaklar

Eksik dolu entity ile Update. İstemci yalnızca fiyatı ve yazarı gönderdiyse (new Book { Id = 1, Price = 13.00m, AuthorId = 1 }), Update başlığı da null olarak yazmaya çalışır. Şanslıysan veritabanı seni durdurur:

Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
  inner: SQLite Error 19: 'NOT NULL constraint failed: Books.Title'.

Şanssızsan kolon nullable'dır ve ISBN örneğindeki gibi veri sessizce silinir. Kısmi güncellemelerde yükle-değiştir ya da Attach ve IsModified kullan.

"Already being tracked" hatası. Önceki yazıda gördüğümüz bu hata, aynı context'te önce kaydı yükleyip sonra istemciden gelen nesneyi Update etmeye çalışınca çıkar. İki temiz çözüm var. Birincisi, yüklediğin nesneyi değiştirmek (yukarıdaki UpdatePriceAsync). İkincisi, varlık kontrolünü entity yüklemeyen bir sorguyla yapmak:

public static async Task<bool> ReplaceAsync(Book incoming)
{
    await using var db = new BookstoreContext();
    if (!await db.Books.AnyAsync(b => b.Id == incoming.Id))
    {
        return false;
    }

    db.Books.Update(incoming);
    await db.SaveChangesAsync();
    return true;
}

AnyAsync bir SELECT EXISTS çalıştırır ve hiçbir nesneyi takibe almaz, bu yüzden ardından gelen Update çakışmaz. Bu method'u bilinçli olarak bütün kaydı değiştiren bir "replace" işlemi için yazdık; kısmi güncelleme için değil.

Döngüde SaveChanges. Her kayıttan sonra SaveChanges çağırmak, her biri ayrı bir transaction ve ayrı gidiş dönüş demek. Değişiklikleri biriktir, sonda bir kez kaydet. Çok büyük toplu işlerde context'i belirli aralıklarla yenilemek de change tracker'ın şişmesini önler.

Toplu güncelleme ve silme için entity yüklemek. "Fiyatı 10'un altındaki bütün kitapları sil" gibi bir işi entity'leri yükleyip Remove ederek yapmak çalışır ama verimsizdir. EF Core 7 bunun için tek SQL komutu üreten ExecuteUpdate ve ExecuteDelete'i getirdi; sürüm yazılarında göreceğiz. EF Core 6'da bu tür işler için raw SQL yazısında göreceğimiz ExecuteSqlInterpolated var.

Küçük Bir Deneme

★☆☆ Yeni bir context'te 2 numaralı kitabı yükle, başlığını değiştir ve SaveChanges'in döndürdüğü sayıyı yazdır. Sonra aynı işi Update ile, yüklemeden, tam dolu bir Book nesnesiyle yap ve iki UPDATE komutunu loglarda karşılaştır.

★★☆ Bir yazarı yükle ve kitaplarından birini başka bir yazarın Books listesine taşı. Kaydetmeden önce DetectChanges() ve DebugView.LongView ile kitabın AuthorId'sinin nasıl değiştiğini gör, sonra üretilen SQL'i kontrol et.

★★★ PATCH mantığıyla çalışan bir UpdateBookAsync(int id, string? title, decimal? price, string? isbn) method'u yaz: yalnızca null olmayan parametreler güncellensin. Önce yükle-değiştir ile, sonra SELECT atmadan Attach ve IsModified ile iki versiyon yaz ve olmayan bir id için ikisinin nasıl davrandığını karşılaştır.

SaveChanges'in komutlarını tek bir transaction içinde gönderdiğini söyledik ama içini açmadık. Birden fazla SaveChanges'i birlikte geri almak, bir kısmını savepoint ile korumak ya da kendi transaction'ını başlatmak istersen ne yaparsın? Bir sonraki yazıda transaction'ları konuşacağız.