question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Cascade deletions ignore current state of entities resulting in unexpected data loss

See original GitHub issue

Regarding 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

  1. 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.

  1. 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.

  1. 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:closed
  • Created 4 years ago
  • Reactions:1
  • Comments:10 (6 by maintainers)

github_iconTop GitHub Comments

1reaction
AndriySvyrydcommented, Sep 17, 2019

@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.

context.Entry(child).DetectChanges();
0reactions
ajcvickerscommented, Jan 24, 2020

See also the slightly different scenario in #19652

Read more comments on GitHub >

github_iconTop Results From Across the Web

Cascade Delete - EF Core
Cascading deletes are needed when a dependent/child entity can no longer be associated with its current principal/parent.
Read more >
postgresql - CASCADE DELETE just once
This command will delete all data from all tables that have a foreign key to the specified table, plus everything that foreign keys...
Read more >
Removing cascade deletion in the production environment ...
If a cascade-delete occurs in the production environment, any ORDERITEMS record that is associated with the deleted record in the OFFER table is...
Read more >
Why you should avoid CascadeType.REMOVE for to-many ...
Most developers worry about deleting too many database records when they use CascadeType.REMOVE. And that's definitely an issue. But it's not the only...
Read more >
Correct way to disable Cascade Delete
Set up a basic Radzen Blazor App with "Enable cascade delete" unchecked during the data import.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found