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

в 9:15, , рубрики: C#, efcore, entityframework, expression, expression trees, Expressiontree, фильтрация

Фильтрация данных в 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js