Skip to content

Comments

Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced#37787

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/fix-in-memory-corruption
Draft

Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced#37787
Copilot wants to merge 3 commits intomainfrom
copilot/fix-in-memory-corruption

Conversation

Copy link
Contributor

Copilot AI commented Feb 24, 2026

When a JSON-mapped owned entity navigation is replaced with a new instance sharing nested child references (e.g., via C# record with expression), nested owned entities become null in memory after SaveChanges, despite being correctly persisted to the database.

product.Metadata = product.Metadata with { Color = "red" };
await context.SaveChangesAsync();
// product.Metadata.Weight is now null — NullReferenceException

Root cause: Three-step failure during the replace-and-save cycle:

  1. The old owned entity tree is cascade-deleted, marking shared child entities as Deleted
  2. EntityGraphAttacher.PaintAction only processes Detached entities, so shared children stuck in Deleted state are skipped during attachment of the new parent graph
  3. CascadeDelete in GetEntriesToSave re-cascades the old parent's deletion to the shared child, and during AcceptChanges, DeleteFixup resolves the replacement principal via identity map and nulls its navigation

Changes:

  • EntityGraphAttacher.PaintAction/PaintActionAsync: Process entities in Deleted state (not just Detached) during graph traversal, allowing shared child entities to be re-attached to the new parent
  • StateManager.CascadeDelete: Skip cascade when FindPrincipal returns a different entry than the one being cascaded from — this means the dependent has been re-associated with a replacement principal via shared identity
  • JsonUpdateTestBase: Add Replace_json_reference_root_preserves_nested_owned_entities_in_memory covering the replace-with-shared-children scenario
Original prompt

This section details on the original issue you should resolve

<issue_title>In-Memory Corruption of Nested Owned Entities After SaveChanges with Record 'with' Expression</issue_title>
<issue_description>### Bug description

When using C# record's with expression on an owned entity stored as JSON, EF Core corrupts the in-memory representation of nested owned entities after calling SaveChanges(). The nested properties become null in memory, causing NullReferenceException when accessed, even though the data is correctly persisted to the database.

Steps to Reproduce

  1. Create an entity with an owned entity (C# record) stored as JSON that contains nested owned entities
  2. Add the entity to the context and call SaveChanges() (first save)
  3. Use the with expression to create a modified copy of the owned entity
  4. Call SaveChanges() again (second save)
  5. Access nested properties of the owned entity

Expected Behavior

The in-memory entity graph should remain intact after SaveChanges(). All nested properties of the owned entity should be accessible with their correct values.

In my reproduction code, product.Metadata.Weight.Value should remain 5 after the second SaveChanges().

Actual Behavior

After the second SaveChanges(), nested owned entities become null in memory. Accessing product.Metadata.Weight.Value throws NullReferenceException because Weight is now null.

Important: The data IS correctly persisted to the database. This is purely an in-memory corruption of the tracked entity.

Your code

Full reproduction code including failing test can be found here: https://github.com/gustavlarson/efcore-record-bug

using Microsoft.EntityFrameworkCore;

// Entity model
public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public ProductMetadata? Metadata { get; set; }
}

public record ProductMetadata
{
    public Weight? Weight { get; init; }
    public string? Color { get; init; }
}

public record Weight
{
    public int Value { get; init; }
    public string Unit { get; init; }
}

// DbContext configuration
public class TestDbContext : DbContext
{
    public TestDbContext(DbContextOptions<TestDbContext> options) : base(options) { }
    
    public DbSet<Product> Products => Set<Product>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
            entity.OwnsOne(e => e.Metadata, metadata =>
            {
                metadata.ToJson();  // Key: stored as JSON
                metadata.OwnsOne(e => e.Weight);
            });
        });
    }
}

// Reproduction
var product = new Product 
{ 
    Name = "Test Product", 
    Metadata = new() 
    { 
        Weight = new() { Value = 5 } 
    }
};

context.Products.Add(product);
await context.SaveChangesAsync();  // First save - works fine

// Use 'with' expression to create modified copy
product.Metadata = product.Metadata with { Color = product.Metadata.Color };

// At this point, product.Metadata.Weight.Value is still 5
Console.WriteLine($"Before SaveChanges: {product.Metadata.Weight.Value}"); // Prints: 5

await context.SaveChangesAsync();  // Second save - triggers bug

// BUG: Weight is now null!
Console.WriteLine($"After SaveChanges: {product.Metadata.Weight.Value}"); // NullReferenceException!

Stack traces


Verbose output


EF Core version

10.0.2

Database provider

No response

Target framework

.NET 10

Operating system

Windows 11

IDE

No response</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits February 24, 2026 06:41
…ith record with expression

When an owned entity stored as JSON is replaced with a new instance that shares
nested owned entity references (e.g., via C# record 'with' expression), the
change tracker now properly handles the shared child entities:

1. EntityGraphAttacher.PaintAction: Allow entities in Deleted state to be
   re-attached when encountered during graph traversal of a new parent entity.
2. StateManager.CascadeDelete: Skip cascade deletion for dependents whose
   current principal (from identity map) has been replaced by a new entity
   via shared identity.

Fixes #36017

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix in-memory corruption of nested owned entities after SaveChanges Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In-Memory Corruption of Nested Owned Entities After SaveChanges with Record 'with' Expression

2 participants