Search code examples
c#linqentity-framework-coreexpression-trees

How to select many from string in efcore?


Say there is a palette

+----+--------+
| id |  name  |
+----+--------+
| 1  | pa     |
| 2  | pb     |
+----+--------+

of colors.

+----+------+------------+
| id | name | palette_id |
+----+------+------------+
| 1  | ca   |  1         |
| 2  | cb   |  2         |
+----+------+------------+

To select and filter paletts I can use:

_dbContext.Palettes.Where(p => p.Colors.Any(x => x.Name.Contains("ca"))

However I would like to construct this from a string. Given a string like Colors.Name and ca, how can I create an efcore expression, that returns all palettes where color names match ca?

The use case for this is, that I have a filter efcore extension that takes a string and converts that into an efcore expression.

_dbContext.Palettes.Filter("Colors.Name contains ca")...

Solution

  • I have implemented two methods FilterContains and FilterEquals. I think it will be easy to extend them.

    Function takes care about any nesting level and generates proper filter, but it requires to pass DbContext for using Model information:

    _dbContext.Palettes.FilterContains(_dbContext, "Colors.Name", "ca")
       .ToList();
    _dbContext.Palettes.FilterEquals(_dbContext, "Colors.Name", "ca")
       .ToList();
    

    But it also can handle something like this:

    _dbContext.Users.FilterContains(_dbContext, "Country.Regions.SubRegions.Name", "ca")
       .ToList();
    

    And implementation:

    public static class DynamicQueryableExtensions
    {
        public static IQueryable<T> FilterContains<T>(this IQueryable<T> query, DbContext context, string propPath, string value)
        {
            return FilterQuery(query, context.Model, propPath, propExpression =>
                Expression.Call(EnsureString(propExpression), nameof(string.Contains), Type.EmptyTypes,
                    Expression.Constant(value)));
        }
    
        public static IQueryable<T> FilterEquals<T>(this IQueryable<T> query, DbContext context, string propPath, object? value)
        {
            return FilterQuery(query, context.Model, propPath, propExpression =>
            {
                if (value == null)
                {
                    var propType = propExpression.Type;
                    if (propType.IsValueType)
                    {
                        propExpression = Expression.Convert(propExpression, typeof(Nullable<>).MakeGenericType(propType));
                    }
                }
                else if (propExpression.Type != value.GetType())
                {
                    value = Convert.ChangeType(value, propExpression.Type);
                }
    
                return Expression.Equal(propExpression, Expression.Constant(value, propExpression.Type));
            });
        }
    
        private static IQueryable<T> FilterQuery<T>(IQueryable<T> query, IModel model, string propPath,
            Func<Expression, Expression> filterFactory)
        {
            var propNames = propPath.Split('.');
    
            var entityParameter = Expression.Parameter(typeof(T), "e");
    
            var filter = BuildFilter(entityParameter, model, propNames, 0, filterFactory);
    
            var filterLambda = Expression.Lambda<Func<T, bool>>(filter, entityParameter);
    
            return query.Where(filterLambda);
        }
    
        private static Expression BuildFilter(Expression obj, IModel model, string[] propPath, int currentIndex, Func<Expression, Expression> predicateFactory)
        {
            var entityType = model.FindEntityType(obj.Type);
    
            var propName = propPath[currentIndex];
    
            var prop = entityType.FindProperty(propName);
    
            Expression filter;
    
            if (prop == null)
            {
                var navigation = entityType.GetNavigations().FirstOrDefault(n => n.Name == propName);
    
                if (navigation == null)
                    throw new InvalidOperationException($"Property '{propName}' not found in type '{obj.Type}'");
    
                var navigationAccess = Expression.MakeMemberAccess(obj, navigation.PropertyInfo);
    
                if (navigation.IsCollection)
                {
                    var targetType = navigation.TargetEntityType.ClrType;
                    var nParam = Expression.Parameter(targetType, "n");
                    var anyFilter = BuildFilter(nParam, model, propPath, currentIndex + 1, predicateFactory);
    
                    filter = Expression.Call(typeof(Enumerable), nameof(Enumerable.Any), new[] { targetType }, navigationAccess, Expression.Lambda(anyFilter, nParam));
                }
                else
                {
                    filter = BuildFilter(navigationAccess, model, propPath, currentIndex + 1, predicateFactory);
                }
            }
            else
            {
                var propAccess = Expression.MakeMemberAccess(obj, prop.PropertyInfo);
                filter = predicateFactory(propAccess);
            }
    
            return filter;
        }
    
        // For safe conversion to string
        private static Expression EnsureString(Expression expression)
        {
            if (expression.Type == typeof(string))
                return expression;
    
            if (expression.Type != typeof(object))
                expression = Expression.Convert(expression, typeof(object));
    
            expression = Expression.Call(_toStringMethod, expression);
    
            return expression;
        }
    
        private static MethodInfo _toStringMethod = typeof(Convert).GetMethods()
            .Single(m =>
                m.Name == nameof(Convert.ToString) && m.GetParameters().Length == 1 &&
                m.GetParameters()[0].ParameterType == typeof(object)
            );
    }