Entity framework core: soft delete + workaround


Regardless of your opinion about whether soft delete is evil or is the panacea, the truth is that EF Core allow us to implement soft delete quite straightforwardly using Query Filters.

Just as a reminder, soft delete is when we do not want to delete data from a database (hard delete), instead we mark the rows as “deleted”. For those who advocate for a soft delete solution, they justify their decision saying that in this way you avoid losing any data that perhaps someone can need, for instance if an audit were to happen. On the other hand, there are people who say that implementing soft delete is quite a tedious task because you have to add a WHERE IsDeleted = false condition to every query that you perform against your database; in addition, your database could potentially have a poor performance due to the deleted data that you keep.

I am not going to try to convince you whether soft delete is a good or a bad idea. My opinion about soft delete is “it depends”. You can have your data backed up quite regularly and you will never have to deal with a loss of data. At the same time, I guess it would be quite difficult to find data in those backups if you don’t know which one it is in.

Anyway, EF Core is here to make our lives easier if we decide to implement soft delete, making sure that we do not forget to include the WHERE IsDelete = true to every query.

In order to apply soft delete you must :

  • add a boolean or timestamp column to your entities and then indicate entity framework the existence of this column via Data Annotations or Fluent API
  • change the INSERTS to set the property IsDeleted = false and change the DELETES for an UPDATE with the property IsDeleted = true. You can do that overriding the SaveChanges method of your context
  • finally, you have to change the SELECT in order to bring the rows WHERE IsDeleted = false and here is where we apply the QueryFilters.

You can find an amazing post explaining all of this with great detail here.

However, I found an issue applying what the post suggests. The issue is when you remove a child ENTITY from a collection in a parent ENTITY and the child ENTITY has another relationship with a third ENTITY. Entity Framework Core marks the removed child entity as Modified instead of Deleted (it will mark it as Deleted later, during the execution of the SaveChanges() method, once the foreign key relationships are processed and cascade are applied). Because the ENTITY is not marked as Deleted, the soft delete is not applied, and Entity Framework ends up doing a hard delete of the child ENTITY.

You can see here that this issue has already been reported and it seems that it will be fixed soon. In fact, someone is working on it here. However, until they release it, we will need something to solve this situation. I have applied a workaround and I want to share it.

PROBLEM

Let’s imagine the following pieces of code: A blog that has a collection of post and a post has a feature image.


public class Blog : Entity
{
   public Blog(string title) 
   {
      Title = title;
      posts = new List();
   }

   public string Title { get; private set; }

   private readonly List posts;
   public IReadOnlyCollection Posts => post;

   public void RemovePost(Guid postId) 
   {
      this.posts.Remove(this.posts.First(x => x.Id == postId));
   }
}

public class Post: Entity
{
   public Post(string title, string content, Image featureImage)
   {
      Title = title;
      Content = content;
      FeatureImage = featureImage;
   }
   
   public string Title { get; private set; }
   public string Content { get; private set; }
   public Image FeatureImage { get; private set; }
}

public class Image: Entity
{
   public Image(string url, int width, int height)
   {
      Url = url;
      Width = width;
      Height = height;
   }

   public string Url { get; private set; }
   public int Width { get; private set; }
   public int Height { get; private set; }
}

SOLUTION

In order to fix it, I wrote a couple of extension methods that emulate what Entity Framework Core does when applying cascade deletes. This is a workaround, it should be deleted once they provide us a better solution.


public static void ApplyCascadeDeletes(this IEnumerable<EntityEntry> entities) where T : class
{
   foreach (var entry in entities.Where(
      e => (e.State == EntityState.Modified || e.State == EntityState.Added)
               && e.GetInternalEntityEntry().HasConceptualNull).ToList())
   {
      entry.GetInternalEntityEntry().HandleConceptualNulls();
   }

   foreach (var entry in entities.Where(e => e.State == EntityState.Deleted).ToList())
   {
      entry.GetInternalEntityEntry().CascadeDelete();
   }
}

public static InternalEntityEntry GetInternalEntityEntry(this EntityEntry entityEntry)  where T : class
{
   var internalEntry = (InternalEntityEntry)entityEntry
       .GetType()
       .GetProperty("InternalEntry", BindingFlags.NonPublic  | BindingFlags.Instance)
       .GetValue(entityEntry);

   return internalEntry;
}

Finally, the code exposed here should change just a bit in order to call the method ApplyCascadeDeletes.



public class BloggingContext : DbContext
{
    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        OnBeforeSaving();
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
    {
        WorkAround();
        OnBeforeSaving();
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    private void WorkAround()
    {
       var entries = ChangeTracker.Entries().ToList();
       entries.ApplyCascadeDeletes();
    }

    private void OnBeforeSaving()
    {
        foreach (var entry in ChangeTracker.Entries())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.CurrentValues["IsDeleted"] = false;
                    break;

                case EntityState.Deleted:
                    entry.State = EntityState.Modified;
                    entry.CurrentValues["IsDeleted"] = true;
                    break;
            }
        }
    }
}

SOLUTION For .NET Core 2.1.4

As some commenters mentioned, the above solution doesn’t work for the current version of the .NET Core Framework. But with some amendments we can make it work properly.

 


public static InternalEntityEntry GetInternalEntityEntry(this EntityEntry entityEntry)  where T : class
{
   var internalEntry = (InternalEntityEntry)entityEntry
       .GetType()
       .GetProperty("InternalEntry", BindingFlags.NonPublic  | BindingFlags.Instance)
       .GetValue(entityEntry);

   return internalEntry;
}

public static void ApplyCascadeDeletes(this IEnumerable<EntityEntry> entities) where T : class
{
   foreach (var entry in entities.Where(
      e => (e.State == EntityState.Modified
      || e.State == EntityState.Added)
      && e.GetInternalEntityEntry().HasConceptualNull).ToList())
      {
         entry.GetInternalEntityEntry().HandleConceptualNulls(false);
      }

      foreach (var entry in entities.Where(e => e.State == EntityState.Deleted).ToList())
      {
         CascadeDelete(entry.GetInternalEntityEntry());
      }
}

private static void CascadeDelete(InternalEntityEntry entry)
{
   foreach (var fk in entry.EntityType.GetReferencingForeignKeys())
   {
      foreach (var dependent in (entry.StateManager.GetDependentsFromNavigation(entry, fk)
               ?? entry.StateManager.GetDependents(entry, fk)).ToList())
      {
         if (dependent.EntityState != EntityState.Deleted
             && dependent.EntityState != EntityState.Detached)
         {
            if (fk.DeleteBehavior == DeleteBehavior.Cascade)
            {
               var cascadeState = dependent.EntityState == EntityState.Added
                  ? EntityState.Detached
                  : EntityState.Deleted;

               dependent.SetEntityState(cascadeState);

               CascadeDelete(dependent);
            }
            else if (fk.DeleteBehavior != DeleteBehavior.Restrict)
            {
               foreach (var dependentProperty in fk.Properties)
               {
                  dependent[dependentProperty] = null;
               }

               if (dependent.HasConceptualNull)
               {
                  dependent.HandleConceptualNulls(false);
               }
            }
         }
      }
   }
}


About The Author

Jordi Ruiz

I’m extremely passionate about knowledge; wherever I go, you will always find a book inside my trusty rucksack - it could be a technical book, a novel, essays; anything that broadens my mind and understanding.

6 comments

Shelbin Author
2nd September, 2018

Hey, Great solution.

In the current version of ef core HandleConceptualNulls is only available with sensitiveLoggingEnabled flag and CascadeDelete is not available. Can you please have a look and update us.

Ese Author
18th September, 2018

The above workaround returns an error at :

1. entry.GetInternalEntityEntry() : EntryEntity does not contain a definition for GetInternalEntityEntry
2. entries.ApplyCascadeDeletes() : List does not contain a definition for ApplyCascadeDeletes

Please note that I am using .Net Core 2.1.4

jordiruizx Author
18th September, 2018

Hi Shelbin,

thank you for your nice comment.

I have amended to work with the latest version of the framework. Please have a look and let me know.

Kind regards.

jordiruizx Author
18th September, 2018

Hi Ese,

notice that ApplyCascadeDeletes and GetInternalEntityEntry are extension methods, see code above. So you must add them to your solution and reference the namespace into your dbcontext class.

Kind regards

DC Partners Author
8th December, 2018

ApplyCascadeDeletes and GetInternalEntityEntry are extension methods. Do we need to create a class for this? How do reference the namespace into our dbcontext class?

jordiruizx Author
9th December, 2018

Hi DC Partners,

Yes, ApplyCascadeDeletes and GetInternalEntityEntry are extension methods, you can create a public static class to hold these two methods. You can add the reference doing something like using the_namespace_of_my_static_class_with_the_extension_methods;

I hope It helps.

Cheers,
jordi

Post a Comment

Note: Your email address will not be published.
Note: You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Subscribe

Logo

A software company with a simple goal: write good software to help our clients solve their domain problems.

We are people who are passionate and pragmatic with the job that we do.

Our Contacts

Amelia House
Crescent Road
Worthing
West Sussex
BN11 1QR
United Kingdom
Email: info@comment-it.co.uk