Фильтрация данных в 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/
Добавить комментарий