Гибкая фильтрация EFCore с помощью Expression

от автора

Фильтрация данных в EntityFramework — это довольно простая задача, которую можно легко решить с помощью метода Where() в LINQ. Для примеров я буду использовать самую популярную доменную область для всех вузовских лабораторных и практических работ, а именно — библиотеку. Например, если нужно отфильтровать книги по году издания, можно сделать это следующим образом:

var filteredBooks = await context.Books.Where(x => x.Year == 2024);

Этот подход работает отлично, пока фильтрация не требует использования нескольких полей, значения которых приходят с фронтенда. Первая идея, которая может прийти в голову — использовать модель книги с nullable-полями, которые будут сигнализировать о том, что по этим полям фильтрация не требуется.

Допустим, наш запрос заключается в том, чтобы показать все книги от издателя X, изданные в год Y и жанра Z. Тогда на фронтенде можно передать следующий JSON:

"filter": {   "publisherId": X,   "year": Y,   "genreId": Z }

При парсинге этого JSON, недостающие поля автоматически будут иметь значение null. В итоге запрос на стороне сервера может выглядеть следующим образом:

var query = context.Books; if (request.Filter.Year != null)     query = query.Where(x => x.Year == request.Filter.Year); if (request.Filter.PublisherId != null)     query = query.Where(x => x.PublisherId == request.Filter.PublisherId); if (request.Filter.GenreId != null)     query = query.Where(x => x.GenreId == request.Filter.GenreId);

Это простое и работающее решение. Однако, по мере роста модели, подобная конструкция будет усложняться. Более того, для каждой новой модели придется повторять этот код с добавлением новых полей.

Однако, интерфейс IQueryable предоставляет возможность передавать в метод Where() выражение типа Expression. Кстати говоря, когда мы передаем лямбду, она автоматически транслируется в выражение, чтобы провайдер базы данных мог корректно интерпретировать его.

Фильтрация с использованием Expression

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

{   "filters": [     { "propName": "Year", "value": "2024" },     { "propName": "PublisherId", "value": "1234" },     { "propName": "GenreId", "value": "1234" }   ] }

На стороне сервера этот запрос преобразуется в выражение, которое затем можно передать в метод Where(). Вот пример кода для генерации выражения:

public Expression<Func<T, bool>> ParseToExpression<T>(IEnumerable<FilterField> filters) {     if (filters?.Any() != true)         return x => true;         var param = Expression.Parameter(typeof(T), "x");     Expression? expressionBody = null;      foreach (var filter in filters)     {         var propertyName = filter.PropertyName.FirstCharToUpper();         var prop = typeof(T).GetProperty(propertyName);         var propType = prop?.PropertyType;         Expression member;          try         {             member = Expression.Property(param, propertyName);         }         catch (ArgumentException)         {             continue;         }          IConstantExpressionHandler handler = propType switch         {             _ when propType == typeof(Guid) => new GuidConstantExpressionHandler(),             _ when propType == typeof(int) => new IntegerConstantExpressionHandler(),             _ when propType == typeof(string) => new StringConstantExpressionHandler(),             _ => throw new ArgumentOutOfRangeException()         };                  var constant = handler.Handle(filter.Value);         var expression = Expression.Equal(member, constant);         expressionBody = expressionBody == null ? expression : Expression.AndAlso(expressionBody, expression);     }         return Expression.Lambda<Func<T, bool>>(expressionBody, param); }

Кроме того, для правильного парсинга разных типов данных необходимо создать вспомогательные классы, реализующие созданный нами интерфейс IConstantExpression Handler, имеющий единственный метод Handle(string value)
, возвращающий ConstantExpression.

Пример обработчика для целочисленных значений:

public class IntegerConstantExpressionHandler : IConstantExpressionHandler {     public ConstantExpression Handle(string value)     {         if (int.TryParse(value, out var number))             return Expression.Constant(number);         else             throw new ArgumentException(nameof(value));     } }

Обработка нескольких значений для одного поля

Что если нужно фильтровать данные по нескольким значениям одного и того же поля, например, по годам 2023 и 2024? Для этого изменим метод, добавив возможность объединять условия через Expression.OrElse:

public Expression<Func<T, bool>> ParseToExpression<T>(IEnumerable<FilterField> filters) {     if (filters?.Any() != true)         return x => true;      var filtersMap = new Dictionary<string, List<Expression>>();     var param = Expression.Parameter(typeof(T), "x");     Expression? expressionBody = null;      foreach (var filter in filters)     {         var propertyName = filter.PropertyName.FirstCharToUpper();                  if (!filtersMap.ContainsKey(propertyName))             filtersMap.Add(propertyName, new List<Expression>());          var prop = typeof(T).GetProperty(propertyName);         var propType = prop?.PropertyType;         Expression member;          try         {             member = Expression.Property(param, propertyName);         }         catch (ArgumentException)         {             continue;         }          var handler = propType switch         {             _ when propType == typeof(Guid) => new GuidConstantExpressionHandler(),             _ when propType == typeof(int) => new IntegerConstantExpressionHandler(),             _ when propType == typeof(string) => new StringConstantExpressionHandler(),             _ => throw new ArgumentOutOfRangeException()         };          var constant = handler.Handle(filter.Value);         var expression = Expression.Equal(member, constant);         filtersMap[propertyName].Add(expression);     }      foreach (var prop in filtersMap)     {         var expression = prop.Value.Aggregate((acc, x) => Expression.OrElse(acc, x));         expressionBody = expressionBody == null ? expression : Expression.AndAlso(expressionBody, expression);     }      return Expression.Lambda<Func<T, bool>>(expressionBody, param); } 

Фильтрация по вычисляемым полям

Та часть, из-за которой и была написана эта статья. Многим и так знакома концепция фильтрации через Expression, однако большинство статей и примеров в интернете (если не все) ничего не говорят про фильтрацию вычисляемых полей, вычисление которых происходит не на стороне БД, а непосредственно перед отправкой DTO. Допустим, что примером такого поля в нашем домене будет состояние книги, которое мы вычисляем по последней записи в истории этой книги. Состояния у книги представим следующими

  • Новая — 1

  • Выдана читателю — 2

  • Возвращена читателем — 3

  • Утеряна — 4

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

[ComputedField] public static Expression<Func<Book, int>> StateId => x =>     x.History != null && x.History.Any()         ? x.History.OrderByDescending(h => h.WhenChanged).FirstOrDefault().StateId         : 1;

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

public Expression<Func<T, bool>> ParseToExpression<T>(IEnumerable<FilterField> filters) {     if (filters?.Any() != true)         return x => true;      var filtersMap = new Dictionary<string, List<Expression>>();     var param = Expression.Parameter(typeof(T), "x");     Expression? expressionBody = null;      foreach (var filter in filters)     {         var propertyName = filter.PropertyName.FirstCharToUpper();                  if (!filtersMap.ContainsKey(propertyName))             filtersMap.Add(propertyName, new List<Expression>());          var prop = typeof(T).GetProperty(propertyName);         var propType = prop?.PropertyType;         Expression member;          try         {             if (Attribute.IsDefined(prop, typeof(ComputedFieldAttribute)))             {                 var computedExpression = prop.GetValue(null) as LambdaExpression;                 member = Expression.Invoke(computedExpression, param);             }             else             {                 member = Expression.Property(param, propertyName);             }         }         catch (ArgumentException)         {             continue;         }          AbstractConstantExpressionHandler handler = propType switch         {             _ when propType == typeof(Guid) => new Base64ConstantExpressionHandler(),             _ when propType == typeof(int) => new IntegerConstantExpressionHandler(),             _ when propType == typeof(string) => new StringConstantExpressionHandler(),             _ => throw new ArgumentOutOfRangeException()         };          var constant = handler.Handle(filter.Value);         var expression = Expression.Equal(member, constant);         filtersMap[propertyName].Add(expression);     }      foreach (var prop in filtersMap)     {         var expression = prop.Value.Aggregate((acc, x) => Expression.OrElse(acc, x));         expressionBody = expressionBody == null ? expression : Expression.AndAlso(expressionBody, expression);     }      return Expression.Lambda<Func<T, bool>>(expressionBody, param); }

Таким образом мы построили мощный и гибкий механизм фильтрации данных в EFCore, который поддерживает как обычные поля, так и вычисляемые, а еще сделали его универсальным за счет использования Generic методов.
Также, если вас заинтересовала данная статья рекомендую к прочтению статьи на тему Expression в C# и фильтрации


ссылка на оригинал статьи https://habr.com/ru/articles/851874/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *