Фильтрация данных в 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# и фильтрации
Автор: yasinovskiye