Статья основана на ответе в 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.». Потому что



Выражения


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

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

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

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

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



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



Дело в том, что когда мы передаём делегат как параметр, то формируется другое дерево, в котором вместо обращения к свойству сущности происходит вызов метода 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» уже на этапе компиляции.



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

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.



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



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

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;
    }
}

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



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());
}

Послесловие


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

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

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

Комментарии (38)


  1. pawlo16
    03.03.2017 15:15
    -2

    Такое количество оверхеда и костылей чтобы не писать примитивный SQL запрос… впечатляет. Мне любопытно, вы таки достигли в этом потолка или готовы съесть ещё кактуса сверх того, чтобы только не изучать синтаксис оператора SELECT ?


    1. Ivan22
      03.03.2017 15:21
      -2

      Ну и да. А потом прибегает DBA и кричит «покажите мне этого… который пишет такие запросы»


      1. pawlo16
        03.03.2017 16:17
        -3

        "2 сущности User и Product." — без ентитифрэймворка и полллитры можно мозги прям вывихнуть


        DBA: WTF???
        кодер: это не я, это ентитифрэймворк!


    1. hVostt
      04.03.2017 15:57
      +3

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


      1. pawlo16
        04.03.2017 18:18
        -2

        Из вашего комента следует, что ВСЕ крупные проекты используют ентитфреймворк. Вы с луны свалились?


        Ок, расскажите чего именно я не понимаю из того, что следовало бы.


        1. lair
          04.03.2017 21:14
          +4

          Ок, расскажите чего именно я не понимаю из того, что следовало бы.

          Например, вы не понимаете (или делаете вид, что не понимаете) того, что иметь дело с деревом выражений намного удобнее, чем с SQL-в-строке. Например, если вам надо на лету дописывать критерии (бизнес-фильтрация, "логическое удаление", безопасность).


          1. AGhost
            06.03.2017 09:40

            а потом это всё тупит беспощадно…

            поддерживаем одно такое приложение, которое судя по профайлеру — один и тот же запрос делает 2-3 раза подряд :( да и вообще данные выбирает, в своей массе одиночными запросами, как будто курсором проходится по таблице, для выбора нескольких подряд записей :(


            1. lair
              06.03.2017 11:21

              Простите, а какое отношение описанные вами симптомы имеют к той функциональности, о которой я говорю?


          1. pawlo16
            06.03.2017 16:33

            если мне нужно нетривиальное генерирование SQL, я использую другой язык программирования, который позволяет делать более мощные абстракции, чем паттерн визитор и дерево выражений Linq Expression. Наример, с провайдерами типов или с синтаксическими макросами. И в котором нет нужды в инструментах наподобие EF


            1. lair
              06.03.2017 16:43

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


              (ну и да, мне, конечно, интересно, что за абстракцию мощнее, чем AST, вы будете использовать)


              1. pawlo16
                06.03.2017 20:07

                У других людей ее может и не быть.

                В таком случае коль скоро язык и среда не предоставляют других адекватных средств — ado net. Впрочем, я не могу себе представить проект .net, в котором нельзя использовать F#, то есть лично я бы создавал запросы из Quoted Expressions если этого оказалось бы не достаточно.


                что за абстракцию мощнее, чем AST, вы будете использовать

                Вопрос не верный. AST бываю разные. Сабжеваое — полный отстой. Правильный вопрос — где можно использовать наиболее простой алгоритм и структуры данных для генерации SQL, а так же синтаксис в дереве. Ответ — Clojure.


                1. lair
                  06.03.2017 22:29

                  В таком случае коль скоро язык и среда не предоставляют других адекватных средств — ado net.

                  Ну вот видите, вам ado.net, а я предпочту иметь хоть какой-нибудь работающий AST вместо строк. Каждому свое.


                  Впрочем, я не могу себе представить проект .net, в котором нельзя использовать F#

                  Корпоративный стандарт на C#?


                  лично я бы создавал запросы из Quoted Expressions

                  И что, такая большая разница с System.Expressions?


                  Вопрос не верный. AST бываю разные.

                  Паттерн-то один и тот же.


                  Сабжеваое — полный отстой.

                  Аргументы (отличные от "мне неудобно") в студию.


                  1. pawlo16
                    07.03.2017 09:53

                    хоть какой-нибудь работающий AST

                    ado net тоже таки работает. Откуда следует, что этот подход чем-то хуже?


                    Корпоративный стандарт на C#?

                    Корпоративный стандарт не законы шариата, а менеджмент не упоротые гашишные имамы — обычно идут на встречу, если от ноухау проект технически выигрывает.


                    И что, такая большая разница с System.Expressions?

                    Да. Простая декомпозиция произвольного кода, паттернматчинг вместо визитора, синтаксис F# в дереве, никаких проблем с пониманием вызовов функций, не надо использовать рефлексию.


                    Паттерн-то один и тот же.

                    это абсолютно ни о чём не говорит


                    1. lair
                      07.03.2017 09:59

                      Откуда следует, что этот подход чем-то хуже?

                      Оттуда, что ручной парсинг SQL для его изменения более хрупок, чем работа с деревом.


                      обычно идут на встречу, если от ноухау проект технически выигрывает.

                      Обучать остальных 50+ программистов F# кто будет?


                      1. pawlo16
                        07.03.2017 10:59

                        А при чём здесь ручной парсинг SQL?


                        Как правило острой необходимости обучать всех нет, поскольку бинарная совместимость сборок, общие инструменты, структуры данных и экосистема. Но если надо — обучатся сами, или вон из профессии.


                        1. lair
                          07.03.2017 11:12

                          А при чём здесь ручной парсинг SQL?

                          При описанной выше задаче: "Например, если вам надо на лету дописывать критерии (бизнес-фильтрация, "логическое удаление", безопасность)."


                          Но если надо — обучатся сами, или вон из профессии.

                          Бизнес с вами не согласен. По крайней мере, в моем опыте.


                          1. pawlo16
                            07.03.2017 13:16

                            Это не ответ. Вопрос остаётся — зачем при для создания SQL запросов ado net для взаимодействия с SQL сервером парсить стороку с SQL запросом только потому, что я не хочу Linq Expressions.


                            Не всегда есть человек, который способен внятно объяснить менеджменту выгоды от применения F#.


                            1. lair
                              07.03.2017 13:19

                              Вопрос остаётся — зачем при для создания SQL запросов ado net для взаимодействия с SQL сервером парсить стороку с SQL запросом только потому, что я не хочу Linq Expressions.

                              Потому что у вас есть один компонент, который создает запрос в БД по каким-то своим правилам, а потом есть другой, который хочет этот запрос поменять. И если у вас первый компонент будет создавать сразу SQL-строку, второму придется ее парсить.


                              1. pawlo16
                                07.03.2017 14:30

                                Нет. У меня есть функция, у которой на входе бизнес требования, на выходе данные. Строка запроса, по которому она получает эти данные, является внутренyей деталью работы этой функции. Эта строка либо кэшируется, либо отдаётся сборщику мусора.


                                1. lair
                                  07.03.2017 14:31

                                  У меня есть функция, у которой на входе бизнес требования, на выходе данные.

                                  Вот я и говорю: вы не понимаете (точнее, делаете вид, что не понимаете), что у других людей может быть иначе, и они для решения своей задачи вполне могут взять какой-нибудь готовый AST (существующий для их платформы).


  1. MonkAlex
    03.03.2017 15:22
    +1

    Вам бы более реальный пример. Потому что целый метод обёртка ради фильтрации по имени — выглядит оверинжинирингом. Мне быстрее написать Where(e => e.Name == name), чем вспоминать где и как назывался метод WhereProductName(name).


    1. pssam
      03.03.2017 15:30

      Я скорее описывал подход и то, как можно работать с выражениями. Класс ExpressionVisitor есть, а примеров для чего он нужен и как с ним работать в интернете не так уж и много. Вы его можете использовать там, где он вам нужен или не использовать. Реальный продакшн код, где я использую этот подход похож на тот, что я привёл в примере, только больше и немного сложнее. Приводить его я не вижу смысла. Конечно ради одного where это оверинжениринг, но это чисто показательный пример.


  1. denismaster
    03.03.2017 15:33
    +1

    Также есть очень полезная штука, PredicateBuilder, позволяет объединять выражения с помощью And, Or, Not.


  1. SergeyVoyteshonok
    03.03.2017 15:58
    +1

    Ваш пример не показывает выгоды вашего решения, вместо 2-х функций написали 3.


  1. dezconnect
    03.03.2017 17:08
    -2

    надо убирать такое с главной.


  1. AxisPod
    03.03.2017 19:02
    -2

    Интерфейс не судьба была использовать?


  1. gturk
    03.03.2017 19:31
    +4

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


  1. devlev
    04.03.2017 00:27

    Просто гениально!!! В своё время тоже как то пытался разобраться с этой проблемой стандартными методами но потратив пол дня и не придя к готовому результату я просто забил на это. В итоге пришлось прибегнуть к копипасту. Мне кажется статья может иметь продолжение по этой же теме для разбора например множественной сортировки, по 3м полям и более или обьединение нескольких условий через оператор «или»


  1. hVostt
    04.03.2017 15:55
    +2

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


    1. timramone
      05.03.2017 00:59
      +2

      Возможно многие просто используют, например, хранимые процедуры для работы из приложения с БД. Не говорю, что это плохо, у всех разные архитектурные принципы. Кому-то важна поддерживаемость кода, кому-то — например скорость. Кто-то просто не решает задачи, где может понадобиться переиспользование кода генерации запросов.

      Что касается темы статьи, то мы используем нашу же разработку Mindbox Expressions, в которой есть метод ExpandExpressions, делающий то же самое, только интерфейс чуть поприятнее :-)

      Так же есть LINQKit, в котором есть AsExpandable, позволяющий примерно то же самое.


      1. pssam
        05.03.2017 12:36

        Да, спасибо. Не знал об этих библиотеках. Внутри они используют как раз очень похожие визиторы. Хорошая идея использовать метод ExpandExpressions у IQueryable, а не у самих выражений.


  1. shai_hulud
    04.03.2017 19:52
    +2

    Я так понял, что вся статья о том как сделать работающим:

    FilterContainsText<TEntity>(entities, getProperty, text)
    

    Сделать это просто, достаточно поменять сигнатуру у метода и внутри собрать новый LambdaExpression:
    public IQueryable<TEntity> FilterContainsText<TEntity>(
        IQueryable<TEntity> entities, 
        Expression<Func<TEntity, string>> getProperty, // компилятор C# так умеет
        string text)
    {
    	return entities.Where(Expression.Lambda<Func<TEntity, bool>>
    	(
    		body: Expression.Call
    		(
    			getProperty.Body,
    			nameof(string.Contains),
    			Type.EmptyTypes,
    			Expression.Constant(text)
    		),
    		parameters: getProperty.Parameters
    	));
    }
    



    1. denismaster
      04.03.2017 20:56

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


      1. shai_hulud
        04.03.2017 21:17

        Что такое «динамическая фильтрация»? Это текстовые запросы как в OData?


        1. denismaster
          05.03.2017 02:27
          +1

          Динамическая фильтрация — один из способов фильтрации, который предполагает то, что фильтр мы строим динамически. Можно на основе запросов OData, можно на основе запросов API.
          Как пример — есть таблица каких-нибудь студентов, хочется иметь фильтр по ФИО, по группе, по статусу дипломных работ. Причем, поведение фильтров в комбинации зависит от специальности студента. Бизнес-правила могут быть сложные. Вот тут и приходит на помощь динамическая фильтрация.
          Мы можем определить несколько выражений, которые соответствуют каждому фильтру по отдельности, потом при помощи ExpressionVisitor и его реализации в PredicateBuilder строить динамически выражение, которое описывает комбинацию этих фильтров. После чего это выражение уже запихивать в тот же Entity Framework, например.
          Лично я использую подобную методику на своем проекте, генерируемый SQL-код действительно похож на тот, что я бы написал вручную.


    1. pssam
      05.03.2017 12:48

      А что Вы будете делать, если в какой-то момент захотите, чтобы метод FilterContainsText вместо property.Contains(text) делал, например, property.Replace(" ", "").Contains(text)?


      1. shai_hulud
        06.03.2017 09:51

        Добавлю Expression.Call(.., nameof(string.Replace)), это очевидно. И такой код будет просто поддерживать.
        До сих пор не понимаю зачем для такой тривиальной задачи использовать визитор.


  1. Mikluho
    05.03.2017 12:38

    Да уж… На столь хилом примере не показать пользу от ExpressionVisitor, да и мощь ExpressionTrees не раскрыта.
    Тем более, что способ использования… не слишком очевиден, попахиает шаманством :)
    Я недавно решал в чём-то схожую задачу: делал фильтр для js-грида. В используемом мной гриде был встроенный механизм автофильтров, но работал только на клиентской стороне, а мне очень хотелось не тащить ни клиента лишние данные… Итогом стал универсальный механизм фильтрации на стороне сервера, работающий для любых данных. Со стороны клиента прилетает список фильтров «имя поля/тип фильтра/фильтрующее значение», а коде контроллера получается что-то вроде
    IQueryable<T> source = DAL.GetQuery<T>();
    IQueryable<T> data = source.ApplyFilter(filters);
    Если интересно — могу код выложить и статейку по это набросать.