From d14e68962840baf710e3408f283bdce9ce28eccf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:54:24 +0000 Subject: [PATCH 1/3] Initial plan From 33a09deb7c28aed5ef2c80772e67b3c5c32d8350 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:10:18 +0000 Subject: [PATCH 2/3] Fix issue with moving children between parents when using DeleteBehavior.NoAction Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/InternalEntryBase.cs | 7 +- .../ChangeTracking/Internal/ChildMoveTest.cs | 105 ++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index ac2b693833e..7e9996e1a08 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -1441,8 +1441,13 @@ public virtual void HandleNullForeignKey( SetPropertyModified(property, isModified: true, isConceptualNull: true); } + // For NoAction relationships, defer conceptual null validation to allow for entity reparenting + var shouldDeferValidation = property.GetContainingForeignKeys() + .Any(fk => fk.DeleteBehavior == DeleteBehavior.NoAction); + if (!isCascadeDelete - && StateManager.DeleteOrphansTiming == CascadeTiming.Immediate) + && StateManager.DeleteOrphansTiming == CascadeTiming.Immediate + && !shouldDeferValidation) { HandleConceptualNulls( StateManager.SensitiveLoggingEnabled, diff --git a/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs b/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs new file mode 100644 index 00000000000..4010a77090a --- /dev/null +++ b/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.TestUtilities; + +namespace Microsoft.EntityFrameworkCore.ChangeTracking.Internal; + +public class ChildMoveTest +{ + [ConditionalFact] + public async Task Moving_child_between_parents_with_NoAction_should_not_throw() + { + using var context = new TestContext(); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + // Arrange - Create initial data + var fromParent = new Parent { Id = 1, Name = "From" }; + var toParent = new Parent { Id = 2, Name = "To" }; + var child = new Child { Id = 3, Name = "Child" }; + + fromParent.Children.Add(child); + + context.Parents.Add(fromParent); + context.Parents.Add(toParent); + context.Children.Add(child); + + await context.SaveChangesAsync(); + + // Act - Move child from one parent to another + fromParent.Children.Remove(child); + toParent.Children.Add(child); + + // This should not throw an exception + await context.SaveChangesAsync(); + + // Assert - Verify child is now associated with toParent + var updatedChild = await context.Children.FindAsync(3); + var updatedToParent = await context.Parents.Include(p => p.Children).FirstAsync(p => p.Id == 2); + var updatedFromParent = await context.Parents.Include(p => p.Children).FirstAsync(p => p.Id == 1); + + Assert.Contains(updatedChild, updatedToParent.Children); + Assert.DoesNotContain(updatedChild, updatedFromParent.Children); + } + + [ConditionalFact] + public async Task Orphaning_child_with_NoAction_should_still_throw() + { + using var context = new TestContext(); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + // Arrange - Create initial data + var parent = new Parent { Id = 1, Name = "Parent" }; + var child = new Child { Id = 2, Name = "Child" }; + + parent.Children.Add(child); + + context.Parents.Add(parent); + context.Children.Add(child); + + await context.SaveChangesAsync(); + + // Act - Remove child from parent without adding to another parent (orphaning) + parent.Children.Remove(child); + + // This should still throw an exception for truly orphaned children + var exception = await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + + Assert.Contains("association between entity types 'Parent' and 'Child' has been severed", exception.Message); + } + + private class TestContext : DbContext + { + public DbSet Parents { get; set; } + public DbSet Children { get; set; } + + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider) + .UseInMemoryDatabase("ChildMoveTest"); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(p => p.Children) + .WithOne() + .IsRequired() + .OnDelete(DeleteBehavior.NoAction); + } + } + + private class Child + { + public int Id { get; set; } + public string Name { get; set; } + } + + private class Parent + { + public int Id { get; set; } + public required string Name { get; set; } + public ICollection Children { get; set; } = new List(); + } +} \ No newline at end of file From 5b4d2734f582584aa8d75345c75eddbb1e9d1a2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Sep 2025 00:14:20 +0000 Subject: [PATCH 3/3] Add comprehensive tests for child reparenting with DeleteBehavior.NoAction Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../ChangeTracking/Internal/ChildMoveTest.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs b/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs index 4010a77090a..0b299fad959 100644 --- a/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs +++ b/test/EFCore.Tests/ChangeTracking/Internal/ChildMoveTest.cs @@ -70,6 +70,47 @@ public async Task Orphaning_child_with_NoAction_should_still_throw() Assert.Contains("association between entity types 'Parent' and 'Child' has been severed", exception.Message); } + [ConditionalFact] + public async Task Moving_multiple_children_between_parents_with_NoAction_should_work() + { + using var context = new TestContext(); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + // Arrange - Create initial data with multiple children + var fromParent = new Parent { Id = 1, Name = "From" }; + var toParent = new Parent { Id = 2, Name = "To" }; + var child1 = new Child { Id = 3, Name = "Child1" }; + var child2 = new Child { Id = 4, Name = "Child2" }; + + fromParent.Children.Add(child1); + fromParent.Children.Add(child2); + + context.Parents.Add(fromParent); + context.Parents.Add(toParent); + context.Children.AddRange(child1, child2); + + await context.SaveChangesAsync(); + + // Act - Move multiple children from one parent to another + fromParent.Children.Remove(child1); + fromParent.Children.Remove(child2); + toParent.Children.Add(child1); + toParent.Children.Add(child2); + + // This should not throw an exception + await context.SaveChangesAsync(); + + // Assert - Verify both children are now associated with toParent + var updatedToParent = await context.Parents.Include(p => p.Children).FirstAsync(p => p.Id == 2); + var updatedFromParent = await context.Parents.Include(p => p.Children).FirstAsync(p => p.Id == 1); + + Assert.Equal(2, updatedToParent.Children.Count); + Assert.Empty(updatedFromParent.Children); + Assert.Contains(updatedToParent.Children, c => c.Name == "Child1"); + Assert.Contains(updatedToParent.Children, c => c.Name == "Child2"); + } + private class TestContext : DbContext { public DbSet Parents { get; set; }