I was recently helping another developer understand the various “OnDelete” behaviors of Entity Framework Core. That is, when a parent entity in a parent/child relationship is deleted, what should happen to the child?
I thought this was actually all fairly straight forward. The way I understood things was :
DeleteBehavior.Cascade – Delete the child when the parent is deleted (e.g. Cascading deletes)
DeleteBehavior.SetNull – Set the FK on the child to just be null (So allow orphans)
DeleteBehavior.Restrict – Don’t allow the parent to be deleted at all
I’m pretty sure if I asked 100 .NET developers what these meant, there is a fairly high chance that all of them would answer the same way. But in reality, DeleteBehavior.Restrict is actually dependant on what you’ve done in that DBContext up until the delete… Let me explain.
Setting Up
Let’s imagine that I have two models in my database, they look like so :
class BlogPost { public int Id { get; set; } public string PostName { get; set; } public ICollection<BlogImage> BlogImages { get; set; } } class BlogImage { public int Id { get; set; } public int? BlogPostId { get; set; } public BlogPost? BlogPost { get; set; } public string ImageUrl { get; set; } }
Then imagine the relationship in EF Core is set up like so :
modelBuilder.Entity<BlogImage>() .HasOne(x => x.BlogPost) .WithMany(x => x.BlogImages) .OnDelete(DeleteBehavior.Restrict);
Any developer looking at this at first glance would say, if I delete a blog post that has images pointing to it, it should stop me from deleting the blog post itself. But is that true?
Testing It Out
Let’s imagine I have a simple set of code that looks like do :
var context = new MyContext(); context.Database.Migrate(); var blogPost = new BlogPost { PostName = "Post 1", BlogImages = new List<BlogImage> { new BlogImage { ImageUrl = "/foo.png" } } }; context.Add(blogPost); context.SaveChanges(); Console.WriteLine("Blog Post Added"); var getBlogPost = context.Find<BlogPost>(blogPost.Id); context.Remove(getBlogPost); context.SaveChanges(); //Does this error here? We are deleting the blog post that has images Console.WriteLine("Blog Post Removed");
Do I receive an exception? The answer is.. No. When this code is run, and I check the database I end up with a BlogImage that looks like so :
So instead of restricting the delete, EF Core has gone ahead and set the BlogPostId to be null, and essentially given me an orphaned record. But why?!
Diving headfirst into the documentation we can see that DeleteBehavior.Restrict has the following description :
For entities being tracked by the DbContext, the values of foreign key properties in dependent entities are set to null when the related principal is deleted. This helps keep the graph of entities in a consistent state while they are being tracked, such that a fully consistent graph can then be written to the database. If a property cannot be set to null because it is not a nullable type, then an exception will be thrown when SaveChanges() is called.
Emphasis mine.
This doesn’t really make that much sense IMO. But I wanted to test it out further. So I used the following test script, which is exactly the same as before, except half way through I recreate the DB Context. Given the documentation, the entity I pull back for deletion will not have the blog images themselves being tracked.
And sure enough given this code :
var context = new MyContext(); context.Database.Migrate(); var blogPost = new BlogPost { PostName = "Post 1", BlogImages = new List<BlogImage> { new BlogImage { ImageUrl = "/foo.png" } } }; context.Add(blogPost); context.SaveChanges(); Console.WriteLine("Blog Post Added"); context = new MyContext(); // <-- Create a NEW DB context var getBlogPost = context.Find<BlogPost>(blogPost.Id); context.Remove(getBlogPost); context.SaveChanges(); Console.WriteLine("Blog Post Removed");
I *do* get the exception I was expecting all along :
SqlException: The DELETE statement conflicted with the REFERENCE constraint “FK_BlogImages_BlogPosts_BlogPostId”.
Still writing this, I’m struggling to understand the logic here. If by some chance you’ve already loaded the child entity (By accident or not), your delete restriction suddenly behaves completely differently. That doesn’t make sense to me.
I’m sure some of you are ready to jump through your screens and tell me that this sort of ambiguity is because I am using a nullable FK on my BlogImage type. Which is true, and does mean that I expect that a BlogImage entity *can* be an orphan. If I set this to be a non-nullable key, then I will always get an exception because it cannot set the FK to null. However, the point I’m trying to make is that if I have a nullable key, but I set the delete behavior to restrict, I should still see some sort of consistent behavior.
What About DeleteBehavior.SetNull?
Another interesting thing to note is that the documentation for DeleteBehavior.SetNull is actually identical to that of Restrict :
For entities being tracked by the DbContext, the values of foreign key properties in dependent entities are set to null when the related principal is deleted. This helps keep the graph of entities in a consistent state while they are being tracked, such that a fully consistent graph can then be written to the database. If a property cannot be set to null because it is not a nullable type, then an exception will be thrown when SaveChanges() is called.
And yet, in my testing, using SetNull does not depend on which entities are being tracked by the DbContext, and works the same every time (Although, I did consider that possibly this is a SQL Server function using the default value rather than EF Core doing the leg work).
I actually spent a long time using Google-Fu to try and find anyone talking about the differences between SetNull and Restrict but, many just go along with what I described in the intro. SetNull sets null when it came, and restrict always stops you from deleting.
Conclusion
Maybe I’m in the minority here, or maybe there is a really good reason for the restrict behavior acting as it does, but I really do think that for the majority of developers, when they use DeleteBehavior.Restrict, they are expecting the parent to be blocked from being deleted in any and all circumstances. I don’t think anyone expects an accidental load of an entity into the DbContext to suddenly change the behavior. Am I alone in that?
Update
I opened an issue on Github asking if all of the above is intended behavior : https://github.com/dotnet/efcore/issues/26857
It’s early days yet but the response is :
EF performs “fixup” to keep the graph of tracked entities consistent when operations are performed on those entities. This includes nulling nullable foreign key properties when the principal that they reference is marked as Deleted. [..]
It is uncommon, but if you don’t want EF to do this fixup to dependent entities when a principal is deleted, then you can set DeleteBehavior.ClientNoAction. Making this change in the code you posted above will result in the database throwing with the message above in both cases, since an attempt is made to delete a principal while a foreign key constraint is still referencing it.
Further on, this is explained more :
Setting Restrict or NoAction in EF Core tells EF Core that the database foreign key constraint is configured this way, and, when using migrations, causes the database foreign key constraint to be created in this way. What it doesn’t do is change the fixup behavior of EF Core; that is, what EF does to keep entities in sync when the graph of tracked entities is changed. This fixup behavior has been the same since legacy EF was released in 2008. For most, it is a major advantage of using an OR/M.
Starting with EF Core, we do allow you to disable this fixup when deleting principal entities by specifying ClientNoAction. The “client” here refers to what EF is doing to tracked entities on the client, as opposed to the behavior of the foreign key constraint in the database. But is is uncommon to do this; most of the time the fixup behavior helps keep changes to entities in sync.
This actually does make a little bit of sense. The “fixup” being disconnected from what is happening in the database. Do I think it’s “intuitive”? Absolutely not. But atleast we have some reasoning for the way it is.
I didn’t try it but what if use .AsNoTracking() instead of creating a new context?
Or just use
context.Entry(blogPost).State = EntityState.Detached;
Interesting read, thanks for sharing.
Yeah, I’m with you. I would file an issue in EF Core and see what they say.
Done : https://github.com/dotnet/efcore/issues/26857
Added an update to the post. Essentially, “Working as intended” and given a work around, but.. I’m still not sure if this thing is intuitive at all.