Chapter 7: Changing Data & State Management - IndianTechnoEra
Latest update Android YouTube

Chapter 7: Changing Data & State Management

Welcome to Chapter 7! So far, we've focused on querying data. Now it's time to dive deep into how EF Core tracks changes and saves data. Understanding the Change Tracker and entity states is crucial for building robust applications, especially in web scenarios where entities are disconnected. In this chapter, we'll master everything from basic CRUD operations to complex graph handling and concurrency. ---

7.1 Understanding the Change Tracker

The Change Tracker is the brain of EF Core. It monitors every entity that's loaded from or connected to the database, keeping track of what changes were made so that when you call SaveChanges(), it knows exactly what SQL to generate.

What is the Change Tracker?

The Change Tracker is a component of the DbContext that:

  • Maintains references to all entities being tracked
  • Records the original values when entities are loaded
  • Detects changes to property values
  • Manages relationships between entities
  • Determines the appropriate SQL commands (INSERT, UPDATE, DELETE) when saving

Entity States

Every tracked entity has a state that indicates its relationship with the database:

State Description Database Action on SaveChanges()
Detached Entity is not being tracked by the DbContext No action (not tracked)
Unchanged Entity is tracked but has not been modified since it was loaded No SQL generated
Added Entity is tracked and will be inserted into the database INSERT
Modified Entity is tracked and some of its properties have been changed UPDATE
Deleted Entity is tracked and will be removed from the database DELETE

Visualizing State Transitions:

┌─────────────┐
│  Detached   │
└──────┬──────┘
       │ Add() / Attach() / Entry(...).State = ...
       ▼
┌─────────────┐      Modify property      ┌─────────────┐
│   Added     │──────────────────────────▶│  Modified   │
└─────────────┘                           └─────────────┘
       │                                          │
       │ SaveChanges()                            │ SaveChanges()
       ▼                                          ▼
┌─────────────┐                           ┌─────────────┐
│  Unchanged  │◀──────────────────────────│  Unchanged  │
└─────────────┘  After SaveChanges         └─────────────┘
       │
       │ Remove()
       ▼
┌─────────────┐
│   Deleted   │
└─────────────┘
       │
       │ SaveChanges()
       ▼
   (Detached)

7.2 Exploring the Change Tracker

Let's see the Change Tracker in action with some code:

using var db = new AppDbContext();

// Clear and recreate database for clean demo
db.Database.EnsureDeleted();
db.Database.Migrate();

Console.WriteLine("1. Query a book (this will track it)");
var book = db.Books.FirstOrDefault();
if (book == null)
{
    // Add a sample book if none exists
    book = new Book
    {
        Title = "Sample Book",
        Price = 19.99m,
        PublishedOn = DateTime.Now,
        Genre = "Sample"
    };
    db.Books.Add(book);
    db.SaveChanges();
    Console.WriteLine("Created sample book with Id: " + book.Id);
    
    // Re-query to get a tracked entity
    book = db.Books.FirstOrDefault();
}

Console.WriteLine("\n2. Check initial state");
var entry = db.Entry(book);
Console.WriteLine($"Entity State: {entry.State}");
Console.WriteLine($"Is Tracked: {entry.IsKeySet}");

Console.WriteLine("\n3. Modify a property");
book.Price = 24.99m;
entry = db.Entry(book);
Console.WriteLine($"Entity State after modification: {entry.State}");

Console.WriteLine("\n4. Examine property changes");
foreach (var prop in entry.Properties)
{
    if (prop.IsModified)
    {
        Console.WriteLine($"  Property {prop.Metadata.Name}:");
        Console.WriteLine($"    Original: {prop.OriginalValue}");
        Console.WriteLine($"    Current: {prop.CurrentValue}");
    }
}

Console.WriteLine("\n5. Save changes");
int records = db.SaveChanges();
Console.WriteLine($"Records saved: {records}");

entry = db.Entry(book);
Console.WriteLine($"Entity State after save: {entry.State}");

Console.WriteLine("\n6. Mark as Deleted");
db.Books.Remove(book);
entry = db.Entry(book);
Console.WriteLine($"Entity State after Remove: {entry.State}");

Output showing Change Tracker in action:

1. Query a book (this will track it)
Created sample book with Id: 1

2. Check initial state
Entity State: Unchanged
Is Tracked: True

3. Modify a property
Entity State after modification: Modified

4. Examine property changes
  Property Price:
    Original: 19.99
    Current: 24.99

5. Save changes
Records saved: 1
Entity State after save: Unchanged

6. Mark as Deleted
Entity State after Remove: Deleted

7.3 Insert Operations

7.3.1 Adding a Single Entity

using var db = new AppDbContext();

// Method 1: Add to DbSet
var book1 = new Book
{
    Title = "New Book 1",
    Price = 29.99m,
    PublishedOn = DateTime.Now,
    Genre = "Fiction"
};
db.Books.Add(book1);

// Method 2: Add through DbContext
var book2 = new Book
{
    Title = "New Book 2",
    Price = 19.99m,
    PublishedOn = DateTime.Now,
    Genre = "Fiction"
};
db.Add(book2); // DbContext will figure out the DbSet from the type

// Method 3: Set state directly
var book3 = new Book
{
    Title = "New Book 3",
    Price = 39.99m,
    PublishedOn = DateTime.Now,
    Genre = "Fiction"
};
db.Entry(book3).State = EntityState.Added;

Console.WriteLine("Before SaveChanges:");
Console.WriteLine($"book1 state: {db.Entry(book1).State}");
Console.WriteLine($"book2 state: {db.Entry(book2).State}");
Console.WriteLine($"book3 state: {db.Entry(book3).State}");

int records = db.SaveChanges();
Console.WriteLine($"\n{records} records inserted");

Console.WriteLine("\nAfter SaveChanges:");
Console.WriteLine($"book1 state: {db.Entry(book1).State}");
Console.WriteLine($"book1 Id: {book1.Id}"); // Auto-generated identity value populated

// SQL Generated:
// INSERT INTO [Books] ([Title], [Price], [PublishedOn], [Genre], ...)
// VALUES (@p0, @p1, @p2, @p3, ...);
// SELECT [Id] FROM [Books] WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

7.3.2 Adding Multiple Entities (AddRange)

using var db = new AppDbContext();

var books = new List
{
    new Book { Title = "Batch Book 1", Price = 14.99m, Genre = "Fiction" },
    new Book { Title = "Batch Book 2", Price = 24.99m, Genre = "Fiction" },
    new Book { Title = "Batch Book 3", Price = 34.99m, Genre = "Fiction" }
};

// AddRange adds all entities in one operation
db.Books.AddRange(books);

// Alternative: AddRange on DbContext
db.AddRange(books);

int records = db.SaveChanges();
Console.WriteLine($"Inserted {records} books");

// All SQL statements are batched into a single database round-trip:
/*
INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p0, @p1, ...);
INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p2, @p3, ...);
INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p4, @p5, ...);
SELECT [Id] FROM [Books] WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
...
*/

7.3.3 Adding Related Data (Graph Insert)

using var db = new AppDbContext();

// Method 1: Create entire graph and add
var author = new Author
{
    FirstName = "Brandon",
    LastName = "Sanderson",
    Books = new List
    {
        new Book { Title = "The Way of Kings", Price = 29.99m, Genre = "Fantasy" },
        new Book { Title = "Words of Radiance", Price = 32.99m, Genre = "Fantasy" }
    }
};

db.Authors.Add(author); // Adding the principal adds the entire graph
db.SaveChanges();

Console.WriteLine($"Author Id: {author.Id}");
foreach (var book in author.Books)
{
    Console.WriteLine($"  Book: {book.Title}, Id: {book.Id}, AuthorId: {book.AuthorId}");
}

// SQL Generated:
// INSERT INTO Authors ...
// INSERT INTO Books ... (with AuthorId = new author's Id)
// INSERT INTO Books ... (with AuthorId = new author's Id)

// Method 2: Add dependent first with navigation property
var category = new Category { Name = "Epic Fantasy" };
var book1 = new Book 
{ 
    Title = "Mistborn", 
    Price = 24.99m,
    Categories = new List { category } // Associate
};

db.Books.Add(book1);
db.SaveChanges();

// EF Core handles the join table automatically

7.3.4 Insert with Relationships - Various Approaches

using var db = new AppDbContext();

// Approach 1: Set navigation property
var author1 = new Author { FirstName = "Author", LastName = "One" };
var bookA = new Book { Title = "Book A", Author = author1 };
db.Add(bookA);
db.SaveChanges();
// Author is inserted first, then Book with AuthorId

// Approach 2: Set foreign key (if you know the Id)
var bookB = new Book 
{ 
    Title = "Book B", 
    AuthorId = author1.Id // Using existing author's Id
};
db.Add(bookB);
db.SaveChanges();

// Approach 3: Add to existing author's collection
var author2 = db.Authors.FirstOrDefault(a => a.LastName == "Sanderson");
if (author2 != null)
{
    author2.Books.Add(new Book { Title = "Rhythm of War", Price = 35.99m });
    db.SaveChanges(); // Just the book is inserted, author already exists
}

// Approach 4: Mixed - new book with existing category
var existingCategory = db.Categories.FirstOrDefault(c => c.Name == "Fantasy");
if (existingCategory != null)
{
    var bookC = new Book 
    { 
        Title = "Oathbringer",
        Categories = new List { existingCategory }
    };
    db.Books.Add(bookC);
    db.SaveChanges();
    // Book inserted, join table record created
}

7.4 Update Operations

7.4.1 Tracking Scenario (Entity loaded from same DbContext)

using var db = new AppDbContext();

// Load an entity (tracked)
var book = db.Books.FirstOrDefault(b => b.Title.Contains("Way of Kings"));

if (book != null)
{
    // Modify properties
    book.Price = 31.99m;
    book.Genre = "Epic Fantasy";
    
    // No need to call Update() - Change Tracker knows it's modified
    int records = db.SaveChanges();
    Console.WriteLine($"Updated {records} record(s)");
    
    // SQL Generated:
    // UPDATE [Books] SET [Price] = @p0, [Genre] = @p1
    // WHERE [Id] = @p2;
    // Only modified columns are included!
}

7.4.2 Disconnected Scenario (The Web API Challenge)

In web applications, entities are "disconnected" - they come from the client and aren't tracked by the DbContext that receives them.

// Imagine this is a PUT endpoint in your API
public IActionResult UpdateBook(int id, Book updatedBook)
{
    using var db = new AppDbContext();
    
    // updatedBook came from the client - it's DETACHED
    Console.WriteLine($"State before: {db.Entry(updatedBook).State}"); // Detached
    
    // Method 1: Update() - marks entire entity as Modified
    db.Books.Update(updatedBook);
    Console.WriteLine($"State after Update: {db.Entry(updatedBook).State}"); // Modified
    
    // PROBLEM: This will update ALL columns, even if they weren't changed!
    // If the client didn't send some properties, they might be overwritten with defaults
    
    db.SaveChanges();
    
    // Method 2: Attach and set specific properties (better)
    var existingBook = db.Books.Find(id);
    if (existingBook == null) return NotFound();
    
    // Update only the properties that actually changed
    existingBook.Title = updatedBook.Title;
    existingBook.Price = updatedBook.Price;
    // Leave other properties untouched
    
    db.SaveChanges();
    
    return Ok();
}

7.4.3 Different Update Strategies

using var db = new AppDbContext();

// Strategy 1: Update entire entity (simple but dangerous)
var bookFromClient = new Book
{
    Id = 1, // Must include the Id
    Title = "Updated Title",
    Price = 25.99m
    // Genre, PublishedOn, etc. are null/default!
};

db.Books.Update(bookFromClient);
// SQL: UPDATE Books SET Title = '...', Price = 25.99, Genre = NULL, PublishedOn = NULL, ...
// This will wipe out any properties not set in bookFromClient!

// Strategy 2: Update with DTO (Data Transfer Object)
public class UpdateBookDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal? Price { get; set; } // Nullable means only update if provided
}

public void UpdateBookPartial(UpdateBookDto dto)
{
    using var db = new AppDbContext();
    var book = db.Books.Find(dto.Id);
    if (book == null) return;
    
    if (dto.Title != null)
        book.Title = dto.Title;
    
    if (dto.Price.HasValue)
        book.Price = dto.Price.Value;
    
    db.SaveChanges(); // Only modified properties are updated
}

// Strategy 3: Attach and set Modified for specific properties
var book2 = new Book { Id = 1 };
db.Books.Attach(book2); // Attached as Unchanged
book2.Title = "New Title";
db.Entry(book2).Property(b => b.Title).IsModified = true;
db.SaveChanges(); // Only Title is updated

// Strategy 4: Use original values
var book3 = new Book { Id = 1, Title = "Original", Price = 10 };
var entry3 = db.Entry(book3);
entry3.State = EntityState.Unchanged; // Mark as unchanged
entry3.Property(b => b.Title).CurrentValue = "Modified";
entry3.Property(b => b.Title).IsModified = true;
db.SaveChanges(); // Only Title is updated

7.4.4 Updating Related Data

using var db = new AppDbContext();

// Update one-to-many relationship
var book = db.Books
    .Include(b => b.Author)
    .FirstOrDefault(b => b.Id == 1);

if (book != null)
{
    // Change author
    var newAuthor = db.Authors.FirstOrDefault(a => a.LastName == "Sanderson");
    book.Author = newAuthor; // Or book.AuthorId = newAuthor.Id
    db.SaveChanges();
}

// Update many-to-many (simplified approach)
var book2 = db.Books
    .Include(b => b.Categories)
    .FirstOrDefault(b => b.Id == 2);

if (book2 != null)
{
    // Clear all categories
    book2.Categories.Clear();
    
    // Add new categories
    var categories = db.Categories
        .Where(c => new[] { "Fantasy", "Epic Fantasy" }.Contains(c.Name))
        .ToList();
    
    foreach (var cat in categories)
    {
        book2.Categories.Add(cat);
    }
    
    db.SaveChanges();
    // EF Core handles the join table - removes old, inserts new
}

// Update with explicit join entity
var bookCategory = db.Set()
    .FirstOrDefault(bc => bc.BookId == 1 && bc.CategoryId == 1);

if (bookCategory != null)
{
    bookCategory.IsPrimary = true;
    db.SaveChanges();
}

7.5 Delete Operations

7.5.1 Deleting Tracked Entities

using var db = new AppDbContext();

// Load the entity first (tracked)
var book = db.Books.FirstOrDefault(b => b.Id == 1);

if (book != null)
{
    // Method 1: Remove from DbSet
    db.Books.Remove(book);
    
    // Method 2: Remove through DbContext
    db.Remove(book);
    
    // Method 3: Set state directly
    db.Entry(book).State = EntityState.Deleted;
    
    Console.WriteLine($"State before save: {db.Entry(book).State}"); // Deleted
    
    int records = db.SaveChanges();
    Console.WriteLine($"Deleted {records} record(s)");
    
    // SQL Generated:
    // DELETE FROM [Books] WHERE [Id] = @p0;
}

7.5.2 Deleting Disconnected Entities

using var db = new AppDbContext();

// You have an entity from client with just the Id
var bookFromClient = new Book { Id = 5 };

// Method 1: Attach and Remove
db.Books.Attach(bookFromClient); // Attach as Unchanged
db.Books.Remove(bookFromClient); // Mark as Deleted
db.SaveChanges();

// Method 2: Remove directly (EF Core will attach then remove)
db.Books.Remove(bookFromClient);
db.SaveChanges();

// Method 3: Set state directly
db.Entry(bookFromClient).State = EntityState.Deleted;
db.SaveChanges();

// All methods result in the same SQL: DELETE FROM Books WHERE Id = 5

7.5.3 Deleting Multiple Entities

using var db = new AppDbContext();

// Load entities first
var oldBooks = db.Books
    .Where(b => b.PublishedOn < new DateTime(2000, 1, 1))
    .ToList();

db.Books.RemoveRange(oldBooks);
db.SaveChanges();

// More efficient: ExecuteDelete (EF Core 7+)
int deleted = db.Books
    .Where(b => b.PublishedOn < new DateTime(2000, 1, 1))
    .ExecuteDelete();

Console.WriteLine($"Deleted {deleted} books directly in database");

// SQL Generated:
// DELETE FROM [Books]
// WHERE [PublishedOn] < '2000-01-01'

7.5.4 Delete with Relationships and Cascade Delete

using var db = new AppDbContext();

// This behavior depends on your DeleteBehavior configuration
var author = db.Authors
    .Include(a => a.Books)
    .FirstOrDefault(a => a.Id == 1);

if (author != null)
{
    db.Authors.Remove(author);
    
    // What happens to the books?
    // - If DeleteBehavior.Cascade: All books are also deleted
    // - If DeleteBehavior.SetNull: Books remain, AuthorId becomes NULL
    // - If DeleteBehavior.Restrict: Exception if books exist
    
    try
    {
        db.SaveChanges();
    }
    catch (DbUpdateException ex)
    {
        Console.WriteLine($"Cannot delete: {ex.Message}");
    }
}

// Delete principal without loading dependents (EF Core 7+)
db.Authors
    .Where(a => a.Id == 1)
    .ExecuteDelete(); // Will fail if books exist and not cascade

7.6 Deep Dive into Change Tracker

7.6.1 Tracking Multiple Entities

using var db = new AppDbContext();

// Load some entities
var books = db.Books.Take(3).ToList();
var authors = db.Authors.Take(2).ToList();
var categories = db.Categories.Take(2).ToList();

// See what's being tracked
var trackedEntities = db.ChangeTracker.Entries().ToList();

Console.WriteLine($"Tracking {trackedEntities.Count} entities:");
foreach (var entry in trackedEntities)
{
    Console.WriteLine($"  {entry.Entity.GetType().Name} Id: {entry.Property("Id").CurrentValue}, State: {entry.State}");
}

// Check specific entity
var firstBook = books.First();
var entry1 = db.Entry(firstBook);
Console.WriteLine($"\nDetails for {firstBook.Title}:");
foreach (var prop in entry1.Properties)
{
    Console.WriteLine($"  {prop.Metadata.Name}: Original={prop.OriginalValue}, Current={prop.CurrentValue}, Modified={prop.IsModified}");
}

// Get all modified entities
var modified = db.ChangeTracker.Entries()
    .Where(e => e.State == EntityState.Modified || e.State == EntityState.Added || e.State == EntityState.Deleted)
    .ToList();

Console.WriteLine($"\nModified entities count: {modified.Count}");

7.6.2 Original vs. Current Values

using var db = new AppDbContext();

var book = db.Books.FirstOrDefault();
if (book != null)
{
    var entry = db.Entry(book);
    
    Console.WriteLine("Original Values:");
    foreach (var prop in entry.Properties)
    {
        Console.WriteLine($"  {prop.Metadata.Name}: {prop.OriginalValue}");
    }
    
    // Change some values
    book.Price += 5;
    book.Title = book.Title + " (Updated)";
    
    Console.WriteLine("\nCurrent Values:");
    foreach (var prop in entry.Properties)
    {
        Console.WriteLine($"  {prop.Metadata.Name}: {prop.CurrentValue} (Modified: {prop.IsModified})");
    }
    
    // Get database values (what's actually in the database right now)
    var databaseValues = entry.GetDatabaseValues();
    
    Console.WriteLine("\nDatabase Values:");
    foreach (var prop in databaseValues.Properties)
    {
        Console.WriteLine($"  {prop.Name}: {databaseValues[prop]}");
    }
    
    // Reload from database (discard changes)
    entry.Reload();
    Console.WriteLine($"\nAfter Reload - Price: {book.Price}");
}

7.6.3 Detecting Changes

using var db = new AppDbContext();

var book = db.Books.FirstOrDefault();

// Change tracker automatically detects changes on SaveChanges
// But you can also detect manually
book.Price = 999.99m;

var hasChanges = db.ChangeTracker.HasChanges();
Console.WriteLine($"Has changes: {hasChanges}");

// Manually detect changes (usually automatic, but useful for performance)
db.ChangeTracker.DetectChanges();

// You can also accept all changes (reset original values to current)
// db.ChangeTracker.AcceptAllChanges();

// Or clear the tracker
// db.ChangeTracker.Clear();

7.6.4 Working with Shadow Properties

// Configure a shadow property in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity()
        .Property("LastUpdated");
    
    modelBuilder.Entity()
        .Property("CreatedBy");
}

// Using shadow properties
using var db = new AppDbContext();

var book = new Book { Title = "Shadow Book", Price = 19.99m };
db.Books.Add(book);

// Set shadow property values
db.Entry(book).Property("LastUpdated").CurrentValue = DateTime.Now;
db.Entry(book).Property("CreatedBy").CurrentValue = "Admin";

db.SaveChanges();

// Query using shadow properties
var recentBooks = db.Books
    .Where(b => EF.Property(b, "LastUpdated") > DateTime.Now.AddDays(-1))
    .ToList();

// Read shadow property
foreach (var b in recentBooks)
{
    var lastUpdated = db.Entry(b).Property("LastUpdated").CurrentValue;
    var createdBy = db.Entry(b).Property("CreatedBy").CurrentValue;
    Console.WriteLine($"{b.Title} - Updated: {lastUpdated}, By: {createdBy}");
}

7.7 Disconnected Scenarios (Web API Focus)

This is one of the most important sections for web developers. In web applications, the DbContext is typically created per request, and entities received from the client are "disconnected" - they were created in a different context instance.

7.7.1 The Problem Illustrated

// Client sends this JSON to your API
// PUT /api/books/1
// {
//   "id": 1,
//   "title": "Updated Title",
//   "price": 29.99
// }

[HttpPut("{id}")]
public IActionResult UpdateBook(int id, Book book)
{
    if (id != book.Id)
        return BadRequest();
    
    using var db = new AppDbContext();
    
    // book is DETACHED - created by JSON serializer, not tracked by this DbContext
    Console.WriteLine($"State: {db.Entry(book).State}"); // Detached
    
    // If you simply update, you'll have problems
    db.Books.Update(book); // Marks ALL properties as modified
    db.SaveChanges();
    
    return Ok();
}

7.7.2 Solution 1: Load and Apply Changes

[HttpPut("{id}")]
public IActionResult UpdateBook(int id, Book book)
{
    using var db = new AppDbContext();
    
    // Load the existing entity from database
    var existingBook = db.Books.Find(id);
    if (existingBook == null)
        return NotFound();
    
    // Update only the properties that should change
    existingBook.Title = book.Title;
    existingBook.Price = book.Price;
    // Leave other properties (like Genre, PublishedOn) untouched
    
    db.SaveChanges();
    
    return Ok(existingBook);
}

7.7.3 Solution 2: Use DTOs and AutoMapper

public class UpdateBookDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal? Price { get; set; } // Nullable means optional
    public string Genre { get; set; }
}

[HttpPut("{id}")]
public IActionResult UpdateBook(int id, UpdateBookDto dto)
{
    using var db = new AppDbContext();
    
    var book = db.Books.Find(id);
    if (book == null)
        return NotFound();
    
    // Update only properties that are provided
    if (dto.Title != null)
        book.Title = dto.Title;
    
    if (dto.Price.HasValue)
        book.Price = dto.Price.Value;
    
    if (dto.Genre != null)
        book.Genre = dto.Genre;
    
    db.SaveChanges();
    
    return Ok();
}

7.7.4 Solution 3: Track Graph with Custom Logic

public void UpdateBookGraph(Book bookFromClient)
{
    using var db = new AppDbContext();
    
    // Attach the entity as Unchanged
    db.Books.Attach(bookFromClient);
    
    // Explicitly mark properties as modified based on what changed
    // This requires knowing what changed - maybe from client
    var entry = db.Entry(bookFromClient);
    
    // Assuming client tells us what changed (e.g., in a separate array)
    var changedProperties = new[] { "Title", "Price" };
    
    foreach (var propName in changedProperties)
    {
        entry.Property(propName).IsModified = true;
    }
    
    db.SaveChanges();
}

// More sophisticated - track original values
public void UpdateWithOriginalValues(Book bookFromClient, Book originalBook)
{
    using var db = new AppDbContext();
    
    // Attach the original version
    db.Books.Attach(originalBook);
    
    // Copy changed values from client
    var entry = db.Entry(originalBook);
    entry.CurrentValues.SetValues(bookFromClient);
    
    // EF will automatically mark properties that are different as Modified
    db.SaveChanges();
}

7.7.5 Handling Graphs of Entities

// Client sends an author with its books
public IActionResult UpdateAuthor(Author authorFromClient)
{
    using var db = new AppDbContext();
    
    // This is complex - need to handle:
    // - Existing author? (Update)
    // - New books? (Insert)
    // - Removed books? (Delete)
    // - Modified books? (Update)
    
    // Option 1: Update the graph manually
    var existingAuthor = db.Authors
        .Include(a => a.Books)
        .FirstOrDefault(a => a.Id == authorFromClient.Id);
    
    if (existingAuthor == null)
        return NotFound();
    
    // Update author properties
    existingAuthor.FirstName = authorFromClient.FirstName;
    existingAuthor.LastName = authorFromClient.LastName;
    
    // Handle books - simplistic approach: replace all
    existingAuthor.Books.Clear();
    
    foreach (var book in authorFromClient.Books)
    {
        book.AuthorId = existingAuthor.Id;
        existingAuthor.Books.Add(book);
    }
    
    db.SaveChanges();
    
    // Option 2: Use TrackGraph for complex graphs
    db.ChangeTracker.TrackGraph(authorFromClient, e =>
    {
        if (e.Entry.IsKeySet)
        {
            e.Entry.State = EntityState.Modified;
        }
        else
        {
            e.Entry.State = EntityState.Added;
        }
    });
    
    db.SaveChanges();
    
    return Ok();
}

7.7.6 The TrackGraph Method

using var db = new AppDbContext();

// Complex graph with multiple entities
var author = new Author
{
    Id = 1, // Existing author
    FirstName = "Updated",
    LastName = "Name",
    Books = new List
    {
        new Book { Id = 10, Title = "Existing Book Modified", Price = 19.99m }, // Existing
        new Book { Title = "New Book", Price = 24.99m } // New
    }
};

// TrackGraph visits every entity in the graph
db.ChangeTracker.TrackGraph(author, node =>
{
    var entry = node.Entry;
    var entity = entry.Entity;
    
    // Decide state based on whether Id is set
    if (entry.IsKeySet)
    {
        // If key is set, assume it's existing
        entry.State = EntityState.Modified;
        
        // You could be more sophisticated
        if (entity is Book book && book.Title.StartsWith("New"))
        {
            entry.State = EntityState.Added; // Actually it's new
        }
    }
    else
    {
        entry.State = EntityState.Added;
    }
});

db.SaveChanges();

// You can also use a dictionary to track changes
var states = new Dictionary();
states[author] = EntityState.Modified;
foreach (var book in author.Books)
{
    states[book] = book.Id == 0 ? EntityState.Added : EntityState.Modified;
}

db.ChangeTracker.TrackGraph(author, e => e.Entry.State = states[e.Entry.Entity]);

7.8 Concurrency Control

Concurrency issues occur when two users try to modify the same data simultaneously.

7.8.1 The Problem

// User A and User B load the same book at the same time
// User A updates and saves
// User B updates and saves - overwriting User A's changes without knowing

// In code:
using var db1 = new AppDbContext();
using var db2 = new AppDbContext();

var book1 = db1.Books.Find(1);
var book2 = db2.Books.Find(1);

// User A updates
book1.Price = 30m;
db1.SaveChanges(); // Succeeds

// User B updates based on old data
book2.Price = 25m;
db2.SaveChanges(); // Succeeds and overwrites User A's change!
// Last write wins - data loss!

7.8.2 Solution: Concurrency Tokens

// Add a concurrency token to your entity
public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal Price { get; set; }
    
    // Concurrency token - SQL Server: rowversion/timestamp
    [Timestamp]
    public byte[] RowVersion { get; set; }
}

// Or configure with Fluent API
modelBuilder.Entity()
    .Property(b => b.RowVersion)
    .IsRowVersion();

// Now when updating:
using var db1 = new AppDbContext();
using var db2 = new AppDbContext();

var book1 = db1.Books.Find(1);
var book2 = db2.Books.Find(1);

// User A updates
book1.Price = 30m;
db1.SaveChanges(); // Succeeds, RowVersion changes

// User B updates based on old RowVersion
book2.Price = 25m;

try
{
    db2.SaveChanges(); // Throws DbUpdateConcurrencyException
    // RowVersion in database doesn't match the one book2 was loaded with
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine("Concurrency conflict detected!");
    
    // Handle the conflict
    var entry = ex.Entries.Single();
    var databaseValues = entry.GetDatabaseValues();
    
    Console.WriteLine("Your values:");
    foreach (var prop in entry.Properties)
    {
        Console.WriteLine($"  {prop.Metadata.Name}: {prop.CurrentValue}");
    }
    
    Console.WriteLine("Database values:");
    foreach (var prop in databaseValues.Properties)
    {
        Console.WriteLine($"  {prop.Name}: {databaseValues[prop]}");
    }
}

7.8.3 Handling Concurrency Conflicts

public IActionResult UpdateBook(Book book)
{
    using var db = new AppDbContext();
    
    try
    {
        db.Books.Update(book);
        db.SaveChanges();
        return Ok();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var entry = ex.Entries.Single();
        var databaseValues = entry.GetDatabaseValues();
        
        if (databaseValues == null)
        {
            // The record was deleted by someone else
            return NotFound("Book was deleted by another user");
        }
        
        var databaseBook = (Book)databaseValues.ToObject();
        
        // Option 1: Client wins - use client values
        // entry.OriginalValues.SetValues(databaseValues);
        // entry.CurrentValues.SetValues(book);
        // db.SaveChanges();
        
        // Option 2: Database wins - reload and show user
        entry.Reload();
        var currentBook = entry.Entity;
        
        // Option 3: Merge - let user decide
        return Conflict(new
        {
            message = "Concurrency conflict",
            yourValues = book,
            currentValues = currentBook
        });
    }
}

7.8.4 Alternative: Row Version with Custom Property

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal Price { get; set; }
    
    // Custom concurrency token
    [ConcurrencyCheck]
    public string Version { get; set; } = Guid.NewGuid().ToString();
}

// Or with Fluent API
modelBuilder.Entity()
    .Property(b => b.Version)
    .IsConcurrencyToken();

// When updating, EF Core will include Version in WHERE clause
// UPDATE Books SET Title = @p0 WHERE Id = @p1 AND Version = @p2
// If no rows updated, concurrency exception thrown

7.9 Transactions and SaveChanges

7.9.1 Default Transaction Behavior

using var db = new AppDbContext();

// By default, each SaveChanges call is wrapped in a transaction
// All changes in this call are committed or rolled back together

db.Books.Add(new Book { Title = "Book 1", Price = 10 });
db.Books.Add(new Book { Title = "Book 2", Price = 20 });
db.SaveChanges(); // Single transaction, both inserts succeed or fail together

// If you need to, you can manually control transactions
using var transaction = await db.Database.BeginTransactionAsync();

try
{
    db.Books.Add(new Book { Title = "Book 3", Price = 30 });
    db.SaveChanges();
    
    db.Authors.Add(new Author { FirstName = "New", LastName = "Author" });
    db.SaveChanges();
    
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

7.9.2 Multiple SaveChanges in One Transaction

using var db = new AppDbContext();

using var transaction = db.Database.BeginTransaction();

try
{
    // Operation 1
    var book = new Book { Title = "New Book", Price = 25 };
    db.Books.Add(book);
    db.SaveChanges();
    
    // Operation 2 - uses the book's ID
    var review = new Review
    {
        BookId = book.Id,
        Rating = 5,
        ReviewerName = "User",
        ReviewDate = DateTime.Now
    };
    db.Reviews.Add(review);
    db.SaveChanges();
    
    // Both operations succeed or fail together
    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

7.9.3 SaveChangesOptions

using var db = new AppDbContext();

// AcceptAllChangesOnSave - Default behavior
// After SaveChanges, entities become Unchanged
db.SaveChanges();

// AcceptAllChangesAfterSave - Equivalent to default

// DetectChangesBeforeSave - Manually detect changes before saving
db.SaveChanges(SaveChangesOptions.DetectChangesBeforeSave);

// AutoDetectChangesEnabled (configured on ChangeTracker)
db.ChangeTracker.AutoDetectChangesEnabled = false; // For performance

// Don't forget to enable it again for normal operations
db.ChangeTracker.AutoDetectChangesEnabled = true;

7.10 Best Practices and Performance Tips

DO: Use Add/Update for simple scenarios

db.Books.Add(newBook);      // Good for inserts
db.Books.Update(existing);   // Good for disconnected updates

DO: Use Find() for single entity by key

var book = db.Books.Find(id); // Checks local cache first

DO: Use ExecuteDelete/ExecuteUpdate for bulk operations (EF Core 7+)

// Instead of loading and deleting
db.Books.Where(b => b.Price > 100).ExecuteDelete();

// Instead of loading and updating
db.Books.Where(b => b.Genre == "Fantasy")
    .ExecuteUpdate(s => s.SetProperty(b => b.Price, b => b.Price * 1.1m));

DON'T: Use Add/Update for disconnected graphs

// Bad - will try to insert everything as new
db.Books.Add(authorWithBooks); // Will cause duplicates if author exists

// Good - handle each entity state individually

DON'T: Call SaveChanges multiple times unnecessarily

// Bad - multiple database round trips
foreach (var item in items)
{
    db.Books.Add(item);
    db.SaveChanges(); // Don't do this!
}

// Good - single SaveChanges
db.Books.AddRange(items);
db.SaveChanges();

DON'T: Ignore concurrency exceptions

try { db.SaveChanges(); }
catch (DbUpdateConcurrencyException)
{
    // Always handle concurrency appropriately
}

Chapter Summary

Concept Key Points
Entity States Detached, Unchanged, Added, Modified, Deleted. Each determines SaveChanges behavior.
Change Tracker Tracks entities, original values, changes. Use db.Entry() to access.
Insert Add(), AddRange(). Related entities are inserted automatically.
Update (Tracked) Just modify properties, Change Tracker detects changes.
Update (Disconnected) Load first, then modify. Or use Update() but be careful.
Delete Remove(), RemoveRange(). ExecuteDelete() for bulk.
Concurrency Use [Timestamp] or [ConcurrencyCheck] to prevent lost updates.
Transactions SaveChanges is transactional. Use BeginTransaction() for multiple SaveChanges.
Disconnected Graphs Use TrackGraph() or manual logic to set states correctly.
Bulk Operations ExecuteDelete() and ExecuteUpdate() for performance-critical scenarios.

What's Next?

Congratulations! You've mastered data modification and state management in EF Core. You now understand how to handle both connected and disconnected scenarios, which is essential for building web applications.

In Chapter 8: Advanced Mapping and Inheritance, we will:

  • Explore Value Conversions (store complex types as simple columns)
  • Learn about Owned Types (value objects)
  • Master Inheritance Mapping strategies (TPH, TPT, TPC)
  • Work with JSON columns (EF Core 7+)
  • Handle Spatial Data and other advanced types

Ready to take your EF Core skills to the next level?


إرسال تعليق

Feel free to ask your query...
Cookie Consent
We serve cookies on this site to analyze traffic, remember your preferences, and optimize your experience.
Oops!
It seems there is something wrong with your internet connection. Please connect to the internet and start browsing again.
AdBlock Detected!
We have detected that you are using adblocking plugin in your browser.
The revenue we earn by the advertisements is used to manage this website, we request you to whitelist our website in your adblocking plugin.
Site is Blocked
Sorry! This site is not available in your country.