Например, есть 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)
MonkAlex
03.03.2017 15:22+1Вам бы более реальный пример. Потому что целый метод обёртка ради фильтрации по имени — выглядит оверинжинирингом. Мне быстрее написать Where(e => e.Name == name), чем вспоминать где и как назывался метод WhereProductName(name).
pssam
03.03.2017 15:30Я скорее описывал подход и то, как можно работать с выражениями. Класс ExpressionVisitor есть, а примеров для чего он нужен и как с ним работать в интернете не так уж и много. Вы его можете использовать там, где он вам нужен или не использовать. Реальный продакшн код, где я использую этот подход похож на тот, что я привёл в примере, только больше и немного сложнее. Приводить его я не вижу смысла. Конечно ради одного where это оверинжениринг, но это чисто показательный пример.
denismaster
03.03.2017 15:33+1Также есть очень полезная штука, PredicateBuilder, позволяет объединять выражения с помощью And, Or, Not.
SergeyVoyteshonok
03.03.2017 15:58+1Ваш пример не показывает выгоды вашего решения, вместо 2-х функций написали 3.
gturk
03.03.2017 19:31+4Да, на таких простых примерах выгоды совсем не видно. Статья выиграет, если рассмотрите сложный случай, когда нужно применять динамическую фильтрацию по любому полю, а также сравните сгенерированный SQL с использованием конструктора выражений с написанными вручную выражениями для каждого случая
devlev
04.03.2017 00:27Просто гениально!!! В своё время тоже как то пытался разобраться с этой проблемой стандартными методами но потратив пол дня и не придя к готовому результату я просто забил на это. В итоге пришлось прибегнуть к копипасту. Мне кажется статья может иметь продолжение по этой же теме для разбора например множественной сортировки, по 3м полям и более или обьединение нескольких условий через оператор «или»
hVostt
04.03.2017 15:55+2Люди с язвительными комментариями видимо весьма далеки от разработки крупных приложений, где борьба со сложностью это всегда привнесение некоторой дополнительной сложности, чтобы уменьшить более крупную.
timramone
05.03.2017 00:59+2Возможно многие просто используют, например, хранимые процедуры для работы из приложения с БД. Не говорю, что это плохо, у всех разные архитектурные принципы. Кому-то важна поддерживаемость кода, кому-то — например скорость. Кто-то просто не решает задачи, где может понадобиться переиспользование кода генерации запросов.
Что касается темы статьи, то мы используем нашу же разработку Mindbox Expressions, в которой есть метод ExpandExpressions, делающий то же самое, только интерфейс чуть поприятнее :-)
Так же есть LINQKit, в котором есть AsExpandable, позволяющий примерно то же самое.pssam
05.03.2017 12:36Да, спасибо. Не знал об этих библиотеках. Внутри они используют как раз очень похожие визиторы. Хорошая идея использовать метод ExpandExpressions у IQueryable, а не у самих выражений.
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 )); }
denismaster
04.03.2017 20:56Да, с примером статье не очень повезло. Но прием, описанный в статье, также позволяет реализовать динамическую фильтрацию.
shai_hulud
04.03.2017 21:17Что такое «динамическая фильтрация»? Это текстовые запросы как в OData?
denismaster
05.03.2017 02:27+1Динамическая фильтрация — один из способов фильтрации, который предполагает то, что фильтр мы строим динамически. Можно на основе запросов OData, можно на основе запросов API.
Как пример — есть таблица каких-нибудь студентов, хочется иметь фильтр по ФИО, по группе, по статусу дипломных работ. Причем, поведение фильтров в комбинации зависит от специальности студента. Бизнес-правила могут быть сложные. Вот тут и приходит на помощь динамическая фильтрация.
Мы можем определить несколько выражений, которые соответствуют каждому фильтру по отдельности, потом при помощи ExpressionVisitor и его реализации в PredicateBuilder строить динамически выражение, которое описывает комбинацию этих фильтров. После чего это выражение уже запихивать в тот же Entity Framework, например.
Лично я использую подобную методику на своем проекте, генерируемый SQL-код действительно похож на тот, что я бы написал вручную.
pssam
05.03.2017 12:48А что Вы будете делать, если в какой-то момент захотите, чтобы метод FilterContainsText вместо property.Contains(text) делал, например, property.Replace(" ", "").Contains(text)?
shai_hulud
06.03.2017 09:51Добавлю Expression.Call(.., nameof(string.Replace)), это очевидно. И такой код будет просто поддерживать.
До сих пор не понимаю зачем для такой тривиальной задачи использовать визитор.
Mikluho
05.03.2017 12:38Да уж… На столь хилом примере не показать пользу от ExpressionVisitor, да и мощь ExpressionTrees не раскрыта.
Тем более, что способ использования… не слишком очевиден, попахиает шаманством :)
Я недавно решал в чём-то схожую задачу: делал фильтр для js-грида. В используемом мной гриде был встроенный механизм автофильтров, но работал только на клиентской стороне, а мне очень хотелось не тащить ни клиента лишние данные… Итогом стал универсальный механизм фильтрации на стороне сервера, работающий для любых данных. Со стороны клиента прилетает список фильтров «имя поля/тип фильтра/фильтрующее значение», а коде контроллера получается что-то вроде
IQueryable<T> source = DAL.GetQuery<T>();
IQueryable<T> data = source.ApplyFilter(filters);
Если интересно — могу код выложить и статейку по это набросать.
pawlo16
Такое количество оверхеда и костылей чтобы не писать примитивный SQL запрос… впечатляет. Мне любопытно, вы таки достигли в этом потолка или готовы съесть ещё кактуса сверх того, чтобы только не изучать синтаксис оператора SELECT ?
Ivan22
Ну и да. А потом прибегает DBA и кричит «покажите мне этого… который пишет такие запросы»
pawlo16
"2 сущности User и Product." — без ентитифрэймворка и полллитры можно мозги прям вывихнуть
DBA: WTF???
кодер: это не я, это ентитифрэймворк!
hVostt
Речь не идёт о примитивном SQL, это лишь пример. Кто имел дело с разработкой крупных приложений, тому очевидна проблема, которую решает автор. Так что прошу, не надо говорить про кактусы, если не понимаете о чём идёт речь.
pawlo16
Из вашего комента следует, что ВСЕ крупные проекты используют ентитфреймворк. Вы с луны свалились?
Ок, расскажите чего именно я не понимаю из того, что следовало бы.
lair
Например, вы не понимаете (или делаете вид, что не понимаете) того, что иметь дело с деревом выражений намного удобнее, чем с SQL-в-строке. Например, если вам надо на лету дописывать критерии (бизнес-фильтрация, "логическое удаление", безопасность).
AGhost
а потом это всё тупит беспощадно…
поддерживаем одно такое приложение, которое судя по профайлеру — один и тот же запрос делает 2-3 раза подряд :( да и вообще данные выбирает, в своей массе одиночными запросами, как будто курсором проходится по таблице, для выбора нескольких подряд записей :(
lair
Простите, а какое отношение описанные вами симптомы имеют к той функциональности, о которой я говорю?
pawlo16
если мне нужно нетривиальное генерирование SQL, я использую другой язык программирования, который позволяет делать более мощные абстракции, чем паттерн визитор и дерево выражений Linq Expression. Наример, с провайдерами типов или с синтаксическими макросами. И в котором нет нужды в инструментах наподобие EF
lair
… это если у вас есть возможность взять другой язык программирования. У вас она, возможно, есть. У других людей ее может и не быть.
(ну и да, мне, конечно, интересно, что за абстракцию мощнее, чем AST, вы будете использовать)
pawlo16
В таком случае коль скоро язык и среда не предоставляют других адекватных средств — ado net. Впрочем, я не могу себе представить проект .net, в котором нельзя использовать F#, то есть лично я бы создавал запросы из Quoted Expressions если этого оказалось бы не достаточно.
Вопрос не верный. AST бываю разные. Сабжеваое — полный отстой. Правильный вопрос — где можно использовать наиболее простой алгоритм и структуры данных для генерации SQL, а так же синтаксис в дереве. Ответ — Clojure.
lair
Ну вот видите, вам ado.net, а я предпочту иметь хоть какой-нибудь работающий AST вместо строк. Каждому свое.
Корпоративный стандарт на C#?
И что, такая большая разница с
System.Expressions
?Паттерн-то один и тот же.
Аргументы (отличные от "мне неудобно") в студию.
pawlo16
ado net тоже таки работает. Откуда следует, что этот подход чем-то хуже?
Корпоративный стандарт не законы шариата, а менеджмент не упоротые гашишные имамы — обычно идут на встречу, если от ноухау проект технически выигрывает.
Да. Простая декомпозиция произвольного кода, паттернматчинг вместо визитора, синтаксис F# в дереве, никаких проблем с пониманием вызовов функций, не надо использовать рефлексию.
это абсолютно ни о чём не говорит
lair
Оттуда, что ручной парсинг SQL для его изменения более хрупок, чем работа с деревом.
Обучать остальных 50+ программистов F# кто будет?
pawlo16
А при чём здесь ручной парсинг SQL?
Как правило острой необходимости обучать всех нет, поскольку бинарная совместимость сборок, общие инструменты, структуры данных и экосистема. Но если надо — обучатся сами, или вон из профессии.
lair
При описанной выше задаче: "Например, если вам надо на лету дописывать критерии (бизнес-фильтрация, "логическое удаление", безопасность)."
Бизнес с вами не согласен. По крайней мере, в моем опыте.
pawlo16
Это не ответ. Вопрос остаётся — зачем при для создания SQL запросов ado net для взаимодействия с SQL сервером парсить стороку с SQL запросом только потому, что я не хочу Linq Expressions.
Не всегда есть человек, который способен внятно объяснить менеджменту выгоды от применения F#.
lair
Потому что у вас есть один компонент, который создает запрос в БД по каким-то своим правилам, а потом есть другой, который хочет этот запрос поменять. И если у вас первый компонент будет создавать сразу SQL-строку, второму придется ее парсить.
pawlo16
Нет. У меня есть функция, у которой на входе бизнес требования, на выходе данные. Строка запроса, по которому она получает эти данные, является внутренyей деталью работы этой функции. Эта строка либо кэшируется, либо отдаётся сборщику мусора.
lair
Вот я и говорю: вы не понимаете (точнее, делаете вид, что не понимаете), что у других людей может быть иначе, и они для решения своей задачи вполне могут взять какой-нибудь готовый AST (существующий для их платформы).