diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs index 9af4a68e45a..2482785482b 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs @@ -204,6 +204,18 @@ protected override void OnStateChanging(EntityState newState) if (EntityState == EntityState.Detached) { + // Check if this entity is already being tracked by a different entry. + // This can happen when the entity was detached and then re-tracked through + // navigation fixup (via DetectChanges) before the user manually sets its state. + var existingEntry = StateManager.TryGetEntry(Entity, EntityType); + if (existingEntry != null && existingEntry != this) + { + // The entity is already tracked by a different entry. + // Update the state of the existing entry instead of trying to track this stale entry. + existingEntry.SetEntityState(newState); + return; + } + StateManager.StartTracking(this); } } diff --git a/test/EFCore.Tests/ChangeTracking/GraphTrackingTest.cs b/test/EFCore.Tests/ChangeTracking/GraphTrackingTest.cs index 42d82cb22f1..c546577ca51 100644 --- a/test/EFCore.Tests/ChangeTracking/GraphTrackingTest.cs +++ b/test/EFCore.Tests/ChangeTracking/GraphTrackingTest.cs @@ -384,6 +384,70 @@ public void Can_add_two_aggregates_linked_down_the_tree() Assert.Equal(EntityState.Added, context.Entry(reminders[1]).State); } + [ConditionalFact] + public void Can_reattach_graph_with_client_generated_keys() + { + using var context = new GuidKeyContext(); + var foo = new FooGuid { Name = "Foo1" }; + var bar1 = new BarGuid { Name = "Bar1", Foo = foo }; + var bar2 = new BarGuid { Name = "Bar2", Foo = foo, RelatedBar = bar1 }; + + var fooEntry = context.Foos.Add(foo); + var bar1Entry = context.Bars.Add(bar1); + var bar2Entry = context.Bars.Add(bar2); + + fooEntry.State = EntityState.Detached; + bar1Entry.State = EntityState.Detached; + bar2Entry.State = EntityState.Detached; + + // When detaching entities in Added state, EF Core automatically nulls out navigations + // to maintain consistency (since Added entities don't exist in the database yet). + // This means bar2.RelatedBar is now null and needs to be manually restored. + + fooEntry.State = EntityState.Added; + fooEntry.DetectChanges(); + + bar2Entry.State = EntityState.Added; + bar2.RelatedBar = bar1; // Manually restore the navigation + bar2Entry.DetectChanges(); + + // This should not throw + bar1Entry.State = EntityState.Added; + bar1Entry.DetectChanges(); + + Assert.Equal(EntityState.Added, context.Entry(foo).State); + Assert.Equal(EntityState.Added, context.Entry(bar1).State); + Assert.Equal(EntityState.Added, context.Entry(bar2).State); + } + + private class GuidKeyContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInMemoryDatabase(nameof(GuidKeyContext)) + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider); + + public DbSet Foos => Set(); + public DbSet Bars => Set(); + } + + private class FooGuid + { + public Guid Id { get; set; } + public string Name { get; set; } + public ICollection Bars { get; set; } = new HashSet(); + } + + private class BarGuid + { + public Guid Id { get; set; } + public string Name { get; set; } + public FooGuid Foo { get; set; } + public Guid FooId { get; set; } + public BarGuid RelatedBar { get; set; } + public Guid? RelatedBarId { get; set; } + } + private class AggregateContext : DbContext { protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)