3

I'm trying to implement a "Soft Delete" using EF7. My Item table has a field named IsDeleted of type bit. All of the examples that I see around SO and elsewhere are using something like this:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Item>().Map(m => m.Requires("IsDeleted").HasValue(false));
}

but Map() is no longer a method of ModelBuilder.

EDIT: Let me clarify. I'm mostly only interested in reading the data right now. I want EF to automatically filter out all records in my Item table where IsDeleted == 1 (or true). I do not want to require an && x.IsDeleted == false at the end of every query.

mai
  • 464
  • 4
  • 23
Homr Zodyssey
  • 738
  • 1
  • 8
  • 19

3 Answers3

6

It's 2021, and it occurred to me to add a more modern, standard, built-in solution that pertains to current versions of EF Core.

With global query filters you can ensure that certain filters are always applied to certain entities. And you can define your soft deletion properties via an interface, which facilitates programmatically adding the filter to all relevant entities. See:


...

public interface ISoftDeletable
{
    public string DeletedBy { get; }
    public DateTime? DeletedAt { get; }
}

...

// Call it from DbContext.OnModelCreating()
private static void ConfigureSoftDeleteFilter(ModelBuilder builder)
{
    foreach (var softDeletableTypeBuilder in builder.Model.GetEntityTypes()
        .Where(x => typeof(ISoftDeletable).IsAssignableFrom(x.ClrType)))
    {
        var parameter = Expression.Parameter(softDeletableTypeBuilder.ClrType, "p");

        softDeletableTypeBuilder.SetQueryFilter(
            Expression.Lambda(
                Expression.Equal(
                    Expression.Property(parameter, nameof(ISoftDeletable.DeletedAt)),
                    Expression.Constant(null)),
                parameter)
        );
    }
}

Then, to make sure this flag is used during deletion instead of hard deletion (alternative to e.g. repositories setting the flag instead of deleting the entity):

public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
    foreach (var entry in ChangeTracker.Entries<ISoftDeletable>())
    {
        switch (entry.State)
        {
            case EntityState.Deleted:
                // Override removal. Unchanged is better than Modified, because the latter flags ALL properties for update.
                // With Unchanged, the change tracker will pick up on the freshly changed properties and save them.
                entry.State = EntityState.Unchanged;
                entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = _currentUser.UserId;
                entry.Property(nameof(ISoftDeletable.DeletedAt)).CurrentValue = _dateTime.Now;
                break;
        }
    }
    return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

Caveat 1: Cascade Delete Timing

One crucial aspect is to take into account the cascade deletion of related entities, and either disable cascade delete, or understand and control the cascade delete timing behavior of EF Core. The default value of the CascadeDeleteTiming setting is CascadeTiming.Immediate, which causes EF Core to immediately flag all navigation properties of the 'deleted' entity as EntityState.Deleted, and reverting the EntityState.Deleted state only on the root entity won't revert it on the navigation properties. So if you have navigation properties which don't use soft deletion, and you want to avoid them being deleted, you must handle their change tracker state too (instead of just handling it for e.g. ISoftDeletable entities), or change the CascadeDeleteTiming setting as shown below.

The same is true for owned types used on the soft-deleted entities. With the default deletion cascade timing EF Core also flags these owned types as 'deleted', and in case they are set as Required/non-nullable, you will encounter SQL update failures when trying to save the soft-deleted entities.

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
    ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
}

Caveat 2: Effect on other root entities

If you define a global query filter this way, EF Core will diligently hide all other entities that reference a soft-deleted entity.

For example if you've soft-deleted a Partner entity, and you have Order entities where each of them references a partner through a (required) navigation property, then, when you retrieve the list of orders and you include the partner, all orders that reference a soft-deleted Partner will be missing from the list.

This behavior is discussed at the bottom of the documentation page.

Sadly, the global query filters as of EF Core 5 don't provide an option to limit them to root entities, or to disable only one of the filters. The only available option is to use the IgnoreQueryFilters() method, which disables ALL filters. And since the IgnoreQueryFilters() method takes an IQueryable and also returns an IQueryable, you cannot use this method to transparently disable the filter inside your DbContext class for an exposed DbSet.

Though, one important detail is that this occurs only if you Include() the given navigation property while querying. And there is an interesting solution for getting a result set that has query filters applied to certain entities but doesn't have them applied to other entities, relying on a lesser known feature of EF, relational fixup. Basically, you load a list of EntityA that has navigation property EntityB (without including EntityB). And then you separately load the list of EntityB, using IgnoreQueryFilters(). What happens is that EF automatically sets the EntityB navigation property on EntityA to the loaded EntityB instances. This way the query filter was applied to EntityA itself, but wasn't applied to the EntityB navigational property, so you can see EntityAs even with soft-deleted EntityBs. See this answer on another question. (Of course this has performance implications, and you still can't encapsulate it in DbContext.)

Leaky
  • 3,088
  • 2
  • 26
  • 35
2

Disclaimer: I'm the owner of the project Entity Framework Plus

As you will see in @Adem link, our library supports query filtering.

You can easily enable/disable a global/instance filter

QueryFilterManager.Filter<Item>(q => q.Where(x => !x.IsDeleted));

Wiki: EF Query Filter

Edit: Answer sub question

Care to explain how this works behind the scene?

Firstly, you can either initialize filter globally or by instance

// Filter by global configuration
QueryFilterManager.Filter<Customer>(q => q.Where(x => x.IsActive));
var ctx = new EntitiesContext();
// TIP: You can also add this line in EntitiesContext constructor instead
QueryFilterManager.InitilizeGlobalFilter(ctx);

// Filter by instance configuration
var ctx = new EntitiesContext();
ctx.Filter<Post>(MyEnum.EnumValue, q => q.Where(x => !x.IsSoftDeleted)).Disable();

Under the hood, the library will loop on every DbSet of the context and checks if a filter can be applied to the generic type.

In this case, the library will filter the original/filtered query from the DbSet using the filter then modify the current internal query for the new filtered query.

In summary, we changed some DbSet internal value to use the filtered query.

The code is FREE & Open Source if you want to learn about how it works.

Edit: Answer sub question

@jonathan will this filter included navigation collections too?

For EF Core, it's not supported yet since Interceptor is not available yet. But starting from EF Core 2.x, the EF Team has implemented Global query filters which should allow this.

Jonathan Magnan
  • 10,874
  • 2
  • 38
  • 60
1

If you can migrate to EF Core 2.0 you can use Model-level query filters https://learn.microsoft.com/en-us/ef/core/what-is-new/index

If you use EF Core 1.0 You can make some trick with available EF Core features:

Inheritance https://learn.microsoft.com/en-us/aspnet/core/data/ef-mvc/inheritance

Shadow properties https://learn.microsoft.com/en-us/ef/core/modeling/shadow-properties

public class Attachment : AttachmentBase
{}

public abstract class AttachmentBase
{
    public const string StatePropertyName = "state";

    public Guid Id { get; set; }
}

public enum AttachmentState
{
    Available,
    Deleted
}

public class AttachmentsDbContext : DbContext
{
    public AttachmentsDbContext(DbContextOptions options)
        : base(options)
    {
    }

    public DbSet<Attachment> Attachments { get; set; }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        IEnumerable<EntityEntry<Attachment>> softDeletedAttachments = ChangeTracker.Entries<Attachment>().Where(entry => entry.State == EntityState.Deleted);

        foreach (EntityEntry<Attachment> softDeletedAttachment in softDeletedAttachments)
        {
            softDeletedAttachment.State = EntityState.Modified;
            softDeletedAttachment.Property<int>(AttachmentBase.StatePropertyName).CurrentValue = (int)AttachmentState.Deleted;
        }
        return base.SaveChangesAsync(cancellationToken);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<AttachmentBase>()
            .HasDiscriminator<int>(AttachmentBase.StatePropertyName)
            .HasValue<Attachment>((int)AttachmentState.Available);

        modelBuilder.Entity<AttachmentBase>().Property<int>(AttachmentBase.StatePropertyName).Metadata.IsReadOnlyAfterSave = false;

        modelBuilder.Entity<Attachment>()
            .ToTable("available_attachment");

        modelBuilder.Entity<AttachmentBase>()
            .ToTable("attachment");

        base.OnModelCreating(modelBuilder);
    }
}