Использование выражений для фильтрации данных из БД

в 11:43, , рубрики: .net, C#, c#.net, entityframework, expressions, linq, Алгоритмы, Программирование

Статья основана на ответе в StackOverflow. Начну с описания проблемы, с которой я столкнулся. Есть несколько сущностей в базе данных, которые нужно отображать в виде таблиц на UI. Для доступа к базе данных используется Entity Framework. Для этих таблиц есть фильтры, по полям этих сущностей. Нужно написать код для фильтрации сущностей по параметрам.

Например, есть 2 сущности User и Product.

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Допустим, что нам нужно фильтровать пользователей по имени, а продукты по названию. Мы пишем методы для фильтрации каждой сущности.

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return users.Where(user => user.Name.Contains(text));
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return products.Where(product => product.Name.Contains(text));
}

Сразу же замечаем, что эти два метода почти идентичны и отличаются только свойством сущности, по которому фильтруются данные. Если у нас есть десятки сущностей, в каждой из которых есть десятки полей, по которым нужна фильтрация, то это приводит к некоторым трудностям: сложность в поддержке кода, бездумное копирование и как следствие медленная разработка и высокая вероятность ошибок. Перефразируя Фаулера, начинает попахивать. Хотелось бы вместо дублирования кода написать что-то более универсальное. Наример:

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return FilterContainsText(users, user => user.Name, text);
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return FilterContainsText(products, propduct => propduct.Name, text);
}

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities,
 Func<TEntity, string> getProperty, string text)
{
    return entities.Where(entity => getProperty(entity).Contains(text));
}

К сожалению, если мы попытаемся выполнить фильтрацию

public void TestFilter()
{
    using (var context = new Context())
    {
            var filteredProducts = FilterProductsByName(context.Products, "name").ToArray();
    }
}

то словим ошибку «Test method ExpressionTests.ExpressionTest.TestFilter threw exception:
System.NotSupportedException: The LINQ expression node type 'Invoke' is not supported in LINQ to Entities.». Потому что

Использование выражений для фильтрации данных из БД - 1

Выражения

Попробуем разобраться что пошло не так.

Метод Where принимает параметр типа Expression<Func<TEntity, bool>>. Т.е. Linq работает не с делегатами, а с деревьями выражений, по которым строит SQL запросы.

Выражение (Expression) описывает узел синтаксического дерева. Чтобы лучше понять как они устроены, рассмотрим выражение, которое проверяет, что имя равно строке

Expression<Func<Product, bool>> expected = product => product.Name == "target";

При отладке можно увидеть структуру этого выражения (красным отмечены ключевые свойства)

Использование выражений для фильтрации данных из БД - 2

Получается примерно такое дерево

Использование выражений для фильтрации данных из БД - 3

Дело в том, что когда мы передаём делегат как параметр, то формируется другое дерево, в котором вместо обращения к свойству сущности происходит вызов метода Invoke у параметра(делегата). Когда Linq пытается построить SQL запрос по этому дереву, он не знает как интерпретировать метод Invoke и выбрасывает исключение NotSupportedException.

Таким образом нашей задачей является заменить обращение к свойству сущности (часть дерева, которая выделена красным) на выражение, передаваемое через параметр. Попробуем:

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"

Теперь мы видим ошибку «Method name expected» уже на этапе компиляции.

Использование выражений для фильтрации данных из БД - 4

Проблема в том, что выражение — это класс, представляющий узлы синтаксического дерево, а не сам делегат и его нельзя вызвать напрямую. Теперь основной задачей является найти способ сформировать выражение, передавая ему как параметр другое выражение.

The Visitor

После недолгого гугления я обнаружил решение похожей проблемы на StackOverflow.

Для работы с выражениями есть специальный класс ExpressionVisitor, который использует паттерн Visitor. Его суть в том, что он обходит все узлы дерева выражения в порядке разбора синтаксического дерева и позволяет их модифицировать либо вернуть вместо них другой узел. Если ни сам узел ни его дочерние узлы не поменялись, то возвращается исходное выражение.

Т.е. наследуясь от класса ExpressionVisitor, мы можем заменить любой узел дерева на выражение, которое мы передаём через параметр. Таким образом, нам надо поместить в дерево какой-то узел-метку, который мы при обходе заменим на параметр. Для этого, напишем метод расширения, который будет имитровать вызов выражения и будет являться меткой.

public static class ExpressionExtension
{
    public static TFunc Call<TFunc>(this Expression<TFunc> expression)
    {
        throw new InvalidOperationException("This method should never be called. It is a marker for replacing.");
    }
}

Теперь мы можем вставить одно выражение в другое

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";

Осталось написать визитор, который заменит в дереве выражений вызов метода Call на его параметр:

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion =
            typeof(ExpressionExtension).GetMethod(nameof(ExpressionExtension.Call)).GetGenericMethodDefinition();
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        if (IsMarker(node))
        {
            return Visit(ExtractExpression(node));
        }
        return base.VisitMethodCall(node);
    }

    private LambdaExpression ExtractExpression(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod && node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Теперь мы можем подменить наш маркер.

public static Expression<TFunc> SubstituteMarker<TFunc>(this Expression<TFunc> expression)
{
    var visitor = new SubstituteExpressionCallVisitor();
    return (Expression<TFunc>)visitor.Visit(expression);
}

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product).Contains("123");
Expression<Func<Product, bool>> finalFilter = filter.SubstituteMarker();

В отладке видим, что выражение получилось не совсем такое как мы ожидали. Фильтр всё еще содержит метод Invoke.

Использование выражений для фильтрации данных из БД - 5

Дело в том, что выражение parameterGetter и выражение finalFilter используют два разных аргумента. Поэтому нам нужно подменить аргумент в parameterGetter на аргумент из finalFilter. Для этого напишем еще один визитор.

Использование выражений для фильтрации данных из БД - 6

В итоге получаем вот такой код:

public class SubstituteParameterVisitor : ExpressionVisitor
{
    private readonly LambdaExpression _expressionToVisit;
    private readonly Dictionary<ParameterExpression, Expression> _substitutionByParameter;

    public SubstituteParameterVisitor(Expression[] parameterSubstitutions, LambdaExpression expressionToVisit)
    {
        _expressionToVisit = expressionToVisit;
        _substitutionByParameter = expressionToVisit
                .Parameters
                .Select((parameter, index) => new {Parameter = parameter, Index = index})
                .ToDictionary(pair => pair.Parameter, pair => parameterSubstitutions[pair.Index]);
    }

    public Expression Replace()
    {
        return Visit(_expressionToVisit.Body);
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        Expression substitution;
        if (_substitutionByParameter.TryGetValue(node, out substitution))
        {
            return Visit(substitution);
        }
        return base.VisitParameter(node);
    }
}

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion = typeof(ExpressionExtensions)
            .GetMethod(nameof(ExpressionExtensions.Call))
            .GetGenericMethodDefinition();
    }

    protected override Expression VisitInvocation(InvocationExpression node)
    {
        var isMarkerCall = node.Expression.NodeType == ExpressionType.Call &&
                           IsMarker((MethodCallExpression) node.Expression);
        if (isMarkerCall)
        {
            var parameterReplacer = new SubstituteParameterVisitor(node.Arguments.ToArray(),
                Unwrap((MethodCallExpression) node.Expression));
            var target = parameterReplacer.Replace();
            return Visit(target);
        }
        return base.VisitInvocation(node);
    }

    private LambdaExpression Unwrap(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod &&
               node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Теперь всё работает так как надо и мы, наконец-то, можем написать наш метод фильтрации

Использование выражений для фильтрации данных из БД - 7

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities, Expression<Func<TEntity, string>> getProperty, string text)
{
    Expression<Func<TEntity, bool>> filter = entity => getProperty.Call()(entity).Contains(text);
    return entities.Where(filter.SubstituteMarker());
}

Послесловие

Подход с подстановкой выражений можно использовать не только для фильтрации, но и для сортировок и вообще для любых запросов к БД.

Также этот метод позволяет хранить выражения вместе с бизнес логикой отдельно от самих запросов к базе.

Полностью код пожно посмотреть на гитхабе.

Автор: pssam

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js