Cascade deletions ignore current state of entities resulting in unexpected data loss
See original GitHub issueRegarding this breaking change: https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-3.0/breaking-changes#cascade-deletions-now-happen-immediately-by-default
When the parent entity is removed cascade deletes happen immediately by default but ignores changes already made on child entities. This causes unexpected data loss as entities which are not orphaned are also removed.
Steps to reproduce
Using the following model
class Context : DbContext
{
public DbSet<Parent> Parents { get; set; }
public DbSet<Child> Children { get; set; }
}
class Parent
{
public long Id { get; set; }
}
class Child
{
public long Id { get; set; }
[Required]
public Parent Parent { get; set; }
}
and the following helper
void DumpState()
{
foreach( var entry in context.ChangeTracker.Entries() )
Console.WriteLine($"{entry.Entity.GetType().Name}:{((dynamic)entry).Entity.Id}:{entry.State}");
}
Construct initial state
var parent1 = new Parent() { Id = 1 };
var parent2 = new Parent() { Id = 2 };
var child = new Child() { Id = 3, Parent = parent1 };
context.AddRange(parent1, parent2, child);
context.ChangeTracker.AcceptAllChanges();
DumpState();
// actual output is
// parent:1:unchanged
// parent:2:unchanged
// child:3:unchanged
Retarget the child to a different parent, remove the old parent
child.Parent = parent2;
context.Remove(parent1);
DumpState();
// expected output is
// parent:1:deleted
// parent:2:unchanged
// child:3:modified
// actual output is
// parent:1:deleted
// parent:2:unchanged
// child:3:deleted
The child gets marked for deletion even though it is no longer associated with the old parent.
Possible workarounds
- Use the mitigation as per the docs:
context.ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
This has the drawback of completely reverting the new behavior and all its benefits.
- Call DetectChanges before removing stuff
child.Parent = parent2;
context.DetectChanges();
context.Remove(parent1);
// or
public override EntityEntry Remove(object entity) { this.ChangeTracker.DetectChanges(); return base.Remove(entity); }
public override EntityEntry<TEntity> Remove<TEntity>(TEntity entity) { this.ChangeTracker.DetectChanges(); return base.Remove(entity); }
public override void RemoveRange(IEnumerable<object> entities) { this.ChangeTracker.DetectChanges(); base.RemoveRange(entities); }
public override void RemoveRange(params object[] entities) { this.ChangeTracker.DetectChanges(); base.RemoveRange(entities); }
This is quite tedious and error prone to use at multiple call sites, somewhat better but still a nuisance for multiple contexts. Calling DetectChanges may also have some perf impact.
- Detect changes before cascade by overriding the behavior of the state manager.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ReplaceService<IStateManager, FixedStateManager>();
}
class FixedStateManager : StateManager
{
// Some code omitted for brevity
// Detect changes on all entities, with some perf impact
public override void CascadeDelete(InternalEntityEntry entry, bool force, IEnumerable<IForeignKey> foreignKeys = null)
{
this.Context.ChangeTracker.DetectChanges();
base.CascadeChanges(entry, force, foreignKeys);
}
// Detect changes for affected entities only
public override void CascadeDelete(InternalEntityEntry entry, bool force, IEnumerable<IForeignKey> foreignKeys = null)
{
foreach( var foreignKey in foreignKeys ?? entry.EntityType.GetReferencingForeignKeys() )
{
if( foreignKey.DeleteBehavior != DeleteBehavior.ClientNoAction )
{
foreach( var item in (GetDependentsFromNavigation(entry, foreignKey) ?? GetDependents(entry, foreignKey)).ToList() )
item.ToEntityEntry().DetectChanges();
}
}
base.CascadeDelete(entry, force, foreignKeys);
}
}
This is somewhat more complex but uses internal APIs therefore fragile.
Further technical details
EF Core version: 3.0.0-preview9.19423.6 Database provider: Microsoft.EntityFrameworkCore.SqlServer (not relevant) Target framework: .NET Core 3.0 Operating system: Windows 10 Pro 1903 (18362.356) IDE: Visual Studio 2019 16.3.0 Preview 3.0
Complete runnable code listing to reproduce the issue
ConsoleApp6.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0-preview9.19423.6" />
</ItemGroup>
</Project>
Program.cs
using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace ConsoleApp6
{
class Context : DbContext
{
public DbSet<Parent> Parents { get; set; }
public DbSet<Child> Children { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("data source=dummy");
}
}
class Parent
{
public long Id { get; set; }
}
class Child
{
public long Id { get; set; }
[Required]
public Parent Parent { get; set; }
}
class Program
{
static void Main()
{
using var context = new Context();
void DumpState()
{
foreach( var entry in context.ChangeTracker.Entries() )
Console.WriteLine($"{entry.Entity.GetType().Name}:{((dynamic)entry).Entity.Id}:{entry.State}");
}
// Construct initial state
var parent1 = new Parent() { Id = 1 };
var parent2 = new Parent() { Id = 2 };
var child = new Child() { Id = 3, Parent = parent1 };
context.AddRange(parent1, parent2, child);
context.ChangeTracker.AcceptAllChanges();
DumpState();
// Reproduce issue
child.Parent = parent2;
context.Remove(parent1);
DumpState();
Console.ReadLine();
}
}
}
Issue Analytics
- State:
- Created 4 years ago
- Reactions:1
- Comments:10 (6 by maintainers)
@bachratyg The immediate cascade delete only does a local DetectChanges, that is it doesn’t take into account any changes on the dependents. As another workaround you can call local DetectChanges on every dependent.
See also the slightly different scenario in #19652