'EF Core: Soft delete with shadow properties and query filters

I've created an interface to try to do a soft delete, mixing shadow properties and query filters. But it's not working.

public interface IDeletableEntity {}

And then in my model builder

 builder.Model.GetEntityTypes()
                .Where(entityType => typeof(IDeletableEntity).IsAssignableFrom(entityType.ClrType))
                .ToList()
                .ForEach(entityType =>
                {
                    builder.Entity(entityType.ClrType).Property<Boolean>("IsDeleted");
                    builder.Entity(entityType.ClrType).HasQueryFilter(e => EF.Property<Boolean>(e, "IsDeleted") == false);
                });

But the line with the query filter doesn't compile. The error I got is "cannot convert lambda expression to type 'lambda expression' because it is not a delegate type"

If I do this it's working.

builder.Entity<MyEntity>().HasQueryFilter(m => EF.Property<Boolean>(m, "IsDeleted") == false);

there are any way to do this? It's in order to have an Interface with IDeletableEntity and not have to do it, in every Entity that I want to use a soft delete Entity

Many thanks in advance,



Solution 1:[1]

HasQueryFilter of the non generic EntityTypeBuilder (as opposed to the generic EntityTypeBuilder<TEntity>) is almost unusable because there is no easy way to create the expected LambdaExpression.

One solution is to build the lambda expression by hand using the Expression class methods:

.ForEach(entityType =>
{
    builder.Entity(entityType.ClrType).Property<Boolean>("IsDeleted");
    var parameter = Expression.Parameter(entityType.ClrType, "e");
    var body = Expression.Equal(
        Expression.Call(typeof(EF), nameof(EF.Property), new[] { typeof(bool) }, parameter, Expression.Constant("IsDeleted")),
    Expression.Constant(false));
    builder.Entity(entityType.ClrType).HasQueryFilter(Expression.Lambda(body, parameter));
});

Another one is to use a prototype expression

Expression<Func<object, bool>> filter = 
    e => EF.Property<bool>(e, "IsDeleted") == false;

and use a parameter replacer to bind the parameter with actual type:

.ForEach(entityType =>
{
    builder.Entity(entityType.ClrType).Property<Boolean>("IsDeleted");
    var parameter = Expression.Parameter(entityType.ClrType, "e");
    var body = filter.Body.ReplaceParameter(filter.Parameters[0], parameter);
    builder.Entity(entityType.ClrType).HasQueryFilter(Expression.Lambda(body, parameter));
});

where ReplaceParameter is one of the custom helper extension method I'm using for expression tree manipulation:

public static partial class ExpressionUtils
{
    public static Expression ReplaceParameter(this Expression expr, ParameterExpression source, Expression target) =>
        new ParameterReplacer { Source = source, Target = target }.Visit(expr);

    class ParameterReplacer : System.Linq.Expressions.ExpressionVisitor
    {
        public ParameterExpression Source;
        public Expression Target;
        protected override Expression VisitParameter(ParameterExpression node) => node == Source ? Target : node;
    }
}

But most natural solution in my opinion is to move the configuration code in a generic method and call it via reflection. For instance:

static void ConfigureSoftDelete<T>(ModelBuilder builder)
    where T : class, IDeletableEntity
{
    builder.Entity<T>().Property<Boolean>("IsDeleted");
    builder.Entity<T>().HasQueryFilter(e => EF.Property<bool>(e, "IsDeleted") == false);
}

and then

.ForEach(entityType => GetType()
    .GetMethod(nameof(ConfigureSoftDelete), BindingFlags.NonPublic | BindingFlags.Static)
    .MakeGenericMethod(entityType.ClrType)
    .Invoke(null, new object[] { builder })
);

Solution 2:[2]

I've found a simple solution for my answer ;-). Thanks anyway Ivan Stoev

The interface is:

public interface IDeletableEntity
{
    bool IsDeleted { get; }
}

And in your model Builder configuration:

builder.Model.GetEntityTypes()
                       .Where(entityType => typeof(IDeletableEntity).IsAssignableFrom(entityType.ClrType))
                       .ToList()
                       .ForEach(entityType =>
                       {
                           builder.Entity(entityType.ClrType)
                           .HasQueryFilter(ConvertFilterExpression<IDeletableEntity>(e => !e.IsDeleted, entityType.ClrType));
                       });

You need to convertfilterExpression

private static LambdaExpression ConvertFilterExpression<TInterface>(
                            Expression<Func<TInterface, bool>> filterExpression,
                            Type entityType)
                {
                    var newParam = Expression.Parameter(entityType);
                    var newBody = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), newParam, filterExpression.Body);

                    return Expression.Lambda(newBody, newParam);
                }

Solution 3:[3]

A small enhancement to @SamazoOo's answer. You can write an extension method to make it more consistent.

public static EntityTypeBuilder HasQueryFilter<T>(this EntityTypeBuilder entityTypeBuilder, Expression<Func<T, bool>> filterExpression)
    {
        var param = Expression.Parameter(entityTypeBuilder.Metadata.ClrType);
        var body = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), param, filterExpression.Body);

        var lambdaExp = Expression.Lambda(body, param);

        return entityTypeBuilder.HasQueryFilter(lambdaExp);
    }

Solution 4:[4]

That does not work for me, .net core 3.1, so I tried the following approach which is kinda similar :

// fetch entity types by reflection then: 

 softDeletedEntityTypes.ForEach(entityType =>
            {
                modelBuilder.Entity(entityType, builder =>
                {
                    builder.Property<bool>("IsDeleted");
                    builder.HasQueryFilter(GenerateQueryFilterExpression(entityType));
                });
            });


 private static LambdaExpression GenerateQueryFilterExpression(Type entityType)
        {            
             // the following lambda expression should be generated
             // e => !EF.Property<bool>(e, "IsDeleted")); 

            var parameter = Expression.Parameter(entityType, "e"); // e =>

            var fieldName = Expression.Constant("IsDeleted", typeof(string)); // "IsDeleted"

            // EF.Property<bool>(e, "IsDeleted")
            var genericMethodCall = Expression.Call(typeof(EF), "Property", new[] {typeof(bool)}, parameter, fieldName);

            // !EF.Property<bool>(e, "IsDeleted"))
            var not = Expression.Not(genericMethodCall);

            // e => !EF.Property<bool>(e, "IsDeleted"));
            var lambda = Expression.Lambda(not, parameter);
        }

Solution 5:[5]

What I did was

builder.Model.GetEntityTypes()
           .Where(p => typeof(IDeletableEntity).IsAssignableFrom(p.ClrType))
           .ToList()
            .ForEach(entityType =>
            {
                builder.Entity(entityType.ClrType)
                .HasQueryFilter(ConvertFilterExpression<IDeletableEntity>(e => !e.IsDeleted, entityType.ClrType));
            });

and

 private static LambdaExpression ConvertFilterExpression<TInterface>(
                Expression<Func<TInterface, bool>> filterExpression,
                Type entityType)
    {
        var newParam = Expression.Parameter(entityType);
        var newBody = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), newParam, filterExpression.Body);

        return Expression.Lambda(newBody, newParam);
    }

Solution 6:[6]

For EF core version 6.0 here is an extension function that applies "soft deleting" filter query to all entities that extends to IDeletableEntity interface.

  1. nullable version (bool? IsDeleted)
    public interface IDeletableEntity
    {
      public bool? IsDeleted { get; set; }
    }
      
      public static void UseSoftDelete(this ModelBuilder modelBuilder)
      {
        var softDeleteEntities = modelBuilder.Model
        .GetEntityTypes()
        .Where(t => t.ClrType.IsAssignableTo(typeof(IDeletableEntity)))
        .ToArray();
        foreach (var softDeleteEntity in softDeleteEntities)
        {
            var entityBuilder = modelBuilder.Entity(softDeleteEntity.ClrType);
            var parameter = Expression.Parameter(entityType, "e");
            var methodInfo = typeof(EF).GetMethod(nameof(EF.Property))!.MakeGenericMethod(typeof(bool?))!;
            var efPropertyCall = Expression.Call(null, methodInfo, parameter, Expression.Constant(nameof(IDeletableEntity.IsDeleted)));
            var converted = Expression.MakeBinary(ExpressionType.Coalesce, efPropertyCall, Expression.Constant(false));
            var body = Expression.MakeBinary(ExpressionType.Equal, converted, Expression.Constant(false));
            var expression = Expression.Lambda(body, parameter);
            entityBuilder.HasQueryFilter(expression);
        }
      }
  1. non nullable version (bool IsDeleted)
    public interface IDeletableEntity
    {
      public bool IsDeleted { get; set; }
    }
      
      public static void UseSoftDelete(this ModelBuilder modelBuilder)
      {
        var softDeleteEntities = modelBuilder.Model
        .GetEntityTypes()
        .Where(t => t.ClrType.IsAssignableTo(typeof(IDeletableEntity)))
        .ToArray();
        foreach (var softDeleteEntity in softDeleteEntities)
        {
            var entityBuilder = modelBuilder.Entity(softDeleteEntity.ClrType);
            var parameter = Expression.Parameter(entityType, "e");
            var methodInfo = typeof(EF).GetMethod(nameof(EF.Property))!.MakeGenericMethod(typeof(bool))!;
            var efPropertyCall = Expression.Call(null, methodInfo, parameter, Expression.Constant(nameof(IDeletableEntity.IsDeleted)));
            var body = Expression.MakeBinary(ExpressionType.Equal, efPropertyCall, Expression.Constant(false));
            var expression = Expression.Lambda(body, parameter);
            entityBuilder.HasQueryFilter(expression);
        }
      }

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 SamazoOo
Solution 3 Chamika Goonetilaka
Solution 4
Solution 5 SamazoOo
Solution 6