Гибкая фильтрация EF Core с помощью Expression. Часть 2: Roslyn Source Generator

от автора

В первой части статьи о гибкой фильтрации в EF Core мы построили рантайм-билдер Expression-выражений для фильтрации данных в Entity Framework. Решение работало, но имело ряд ограничений:

  • Каждый тип данных требовал отдельного обработчика (IConstantExpressionHandler). Нужно было создавать методы парсинга новых данных из строки и добавлять их в эти обработчики.

  • Вычисляемые поля требовали дополнительной обработки аттрибута [ComputedField], работали через рефлексию, это вызывало хоть и не сильное, но все таки замедления в производительности.

Плюс, как заметили в комментариях, фронтенд не имел четкого контракта, по которому можно было бы понять тип поля или его название, какие вообще поля разрешено передавать на фильтрацию.

Текущая версия этих фильтров должна исправить проблемы, описанные выше. Для этого можно воспользоваться прекрасным механизмом .NET и компилятора Roslyn — кодогенерацией. Нам необходимо будет создать шаблон для фильтра, а программа-кодогенератор сама напишет для нас POCO-класс с полями фильтра, который легко сериализуется и передается на сторону клиента в качестве контракта, а также легко принимается самим приложением в качестве запроса в url\body. В результате мы получим фильтрацию, гибкость которой будет бесплатной в рантайме, но займет немного времени на этапе компиляции. Это фильтрация будет поддерживать проверку типов на этапе сборки, поддержку навигаций, подзапросов и поиска по нескольким колонкам.

Почему runtime-билдер — не финальное решение ?

Вернёмся к примеру из первой части. У нас есть фильтр для книг:

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

И код, который строит Expression через рефлексию:

var prop = typeof(T).GetProperty(propertyName);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()};// Дальнейшая обработка полученного значения из первой части

Это способ работает, и даже неплохо справляется на реальном проекте в данный момент, но все таки имеет ряд недостатков, которые хотелось бы исправить.

  1. Нет четкого контракта. Если на фронтенде передали "propName": "Amont" вместо "propName":"Amount", ошибка всплывёт только в рантайме, отдав нам 500 на запрос от пользователя с фильтром.

  2. Нет защиты от null при переходе по связанным сущностям. Фильтрация по Customer.Name потребует отдельной логики построения null-guard цепочки, чтобы убедиться, что Customer != null. Сам EFCore при трансляции в SQL конечно не выбросит на NullReferenceException, однако если мы вызовем этот метод фильтрации на какой-нибудь другой IQueriable коллекции — получим выстрел в ногу.

  3. Для каждого нового поля в фильтрации, если оно вычисляемое нужно не забыть добавить аттрибут [ComputedField].

Roslyn Source Generator как компилятор, который сам пишет код

Roslyn это компилятор C# и, что для нас сейчас самое важное, он поддерживает Source Generators — код, который выполняется на этапе компиляции и может анализировать исходный код проекта(выкидывая ворнинги и ошибки), генерировать новый код основываясь на существующих файлах. Имеено возможность генерации нового кода мы и будем использовать.

Концепция сама по себе простая. Вместо того чтобы заставлять фронтенд разработчика запоминать какие у нас есть поля для фильтрации, а сущности хранить внутри себя статические выражения для получения вычисляемых полей, мы опишем поля для фильтрации в отдельном классе, в том числе и вычисляемые, через все те-же Expression-свойства, а компилятор сам построит код фильтрации.

Шаг 1: Определяем атрибуты

Начнём с двух атрибутов:

// Аттрибут для указания Roslyn,что класс с этим атрибутом - скелет для генерации нашего фильтра [AttributeUsage(AttributeTargets.Class, Inherited = false)]public class GenerateFilterAttribute(Type entityType) : Attribute{// Тип сущности для которой создается фильтрType EntityType = entityType;            // Явное имя сгенерированного класса    // Если не задано — генератор использует {EntityName}FilterParams    public string? ClassName { get; set; }}// Аттрибут для переопределения оператора сравнения при фильтрации[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]public class CompareAttribute : Attribute{    public CompareOperator Operator { get; }    public CompareAttribute(CompareOperator op) => Operator = op;}

И enum операторов:

public enum CompareOperator{    Equal,    NotEqual,    GreaterThan,    GreaterThanOrEqual,    LessThan,    LessThanOrEqual,    Contains,    StartsWith,    EndsWith,}

Шаг 2: Описываем определение фильтра

Вместо JSON-массива фильтров пользователь описывает определение фильтра как класс со статическими Expression-свойствами и методами предикатами:

[GenerateFilter(typeof(Order))]public partial class OrderFilterDefinition{    // Equal по умолчанию для Guid    public static Expression<Func<Order, Guid?>>? CustomerId { get; } = o => o.CustomerId;        // Contains по умолчанию для string    public static Expression<Func<Order, string>>? Description { get; } = o => o.Description;    // GreaterThanOrEqual - через атрибут    [Compare(CompareOperator.GreaterThanOrEqual)]    public static Expression<Func<Order, decimal?>>? MinAmount { get; } = o => o.Amount;    // LessThanOrEqual    [Compare(CompareOperator.LessThanOrEqual)]    public static Expression<Func<Order, decimal?>>? MaxAmount { get; } = o => o.Amount;    // Навигация через связанную сущность    public static Expression<Func<Order, string>>? CustomerName { get; } = o => o.Customer.Name;    // Метод-предикат    public static Expression<Func<Order, bool>>? HasItemWithCategory(OrderFilterDefinition filter) =>        filter.ItemId.HasValue            ? o => o.OrderItems.Any(i => i.CategoryId == filter.CategoryId.Value)            : null;    // Instance-свойство для замыканий методов-предикатов    public Guid? CategoryId { get; set; }}

Каждое Expression-свойство — это лямбда, которая описывает путь к полю сущности. Компилятор видит это выражение как syntax tree и может его разобрать.

Шаг 3: Парсинг класса фильтра и внутренних Expression

Точка входа в наш генератор выглядит так:

[Generator]public sealed partial class FilterGenerator : IIncrementalGenerator{    private const string GenerateFilterAttrFqn =        "GreenNide.ExpressionFilter.GenerateFilterAttribute";            public void Initialize(IncrementalGeneratorInitializationContext context)    {        var results = context.SyntaxProvider            .ForAttributeWithMetadataName(                GenerateFilterAttrFqn,                predicate: static (node, ct) => node is ClassDeclarationSyntax,                transform: static (ctx, ct) => ParseFilterClass(ctx, ct))            .Where(static r => r is not null);        context.RegisterSourceOutput(            results.Collect(),            static (spc, results) =>            {                foreach (var result in results)                {                    if (result is null) continue;                    foreach (var diag in result.Diagnostics)                        spc.ReportDiagnostic(diag);                    if (result.Definition is not null)                    {                        var code = GenerateCode(result.Definition);                        spc.AddSource(                            $"{result.Definition.GeneratedClassName}.Filter.g.cs", code);                    }                }            });    }}

IIncrementalGenerator это вторая версия интерфейса, который мы должны реализовать для создания генератора. Первой версией был ISourceGenerator, но в данный момент он является устаревшим из-за более низкой производительности. В целом о кодогенерации на хабре уже есть много статей, поэтому подробно на ней останавливаться я не буду. Скажу только, что метод ForAttributeWithMetadataName здесь — это еще одна небольшая оптимизация, которая сразу фильтрует классы с нужным атрибутом. Генератору не придется перебирать и пытаться распарсить все типы в синтаксическом дереве\лесу.

Собственно, все найденные классы с атрибутом попадают в метод парсинга этих классов ParseFilterClass, который возвращает достаточно простой объект

internal sealed class FilterClassDefinition{    /// <summary>Пространство имён класса фильтра</summary>    public string Namespace { get; set; } = "";    /// <summary>Имя исходного класса (например, "OrderFilterDefinition")</summary>    public string ClassName { get; set; } = "";    /// <summary>    ///     Имя сгенерированного класса со свойствами (например, "OrderFilterParams").    ///     Вычисляется по конвенции: {EntityName}FilterParams, или из атрибута ClassName.    /// </summary>    public string GeneratedClassName { get; set; } = "";    /// <summary>Короткое имя сущности: (например "Order")</summary>    public string EntityName { get; set; } = "";    /// <summary>Полное имя сущности: (например "MyApp.Models.Order")</summary>    public string EntityFullName { get; set; } = "";    /// <summary>Поля-фильтры из Expression&lt;Func&lt;TEntity, TValue&gt;&gt;</summary>    public List<FilterFieldDefinition> Fields { get; set; } = new();    /// <summary>Методы-предикаты из static Expression&lt;Func&lt;TEntity, bool&gt;&gt;?(Filter)</summary>    public List<MethodDefinition> Methods { get; set; } = new();}

Парсинг выражений

Нам нужно, чтобы лямбда вида o => o.Customer.Name превратилась в строку "Customer.Name", и этой строкой мы потом дополним наш файл исходного кода. Для этого нужно обойти syntax tree лямбды, и делаем мы это следующим методом:

private static string WalkExpression(ExpressionSyntax expr, string paramName){    switch (expr)    {        case IdentifierNameSyntax id when id.Identifier.Text == paramName:            return "";        case MemberAccessExpressionSyntax member:            var left = WalkExpression(member.Expression, paramName);            var right = member.Name.Identifier.Text;            return string.IsNullOrEmpty(left) ? right : $"{left}.{right}";        case InvocationExpressionSyntax inv:            var invoked = WalkExpression(inv.Expression, paramName);            // Для сложных цепочек (LINQ-методов) —            // заменяем параметр лямбды на "e" и возвращаем исходный текст            return ReconstructChain(expr, paramName);        case CastExpressionSyntax cast:            return WalkExpression(cast.Expression, paramName);        default:            return expr.ToString().Replace($"{paramName}.", "");    }}

Таким образом, если лямбда — это просто вызов свойств объекта ([MemberAccessExression]), то пусть строится по фрагментам дерева с помощью рекурсивного обхода. Получаем следующее: o → “”, o.Customer → “Customer”, o.Customer.Name → “Customer.Name”.

Для подзапросов типа такого

o => o.History    .OrderByDescending(h => h.Timestamp)    .Select(h => (OrderStatus?)h.Status)    .FirstOrDefault()

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

private static string ReconstructChain(ExpressionSyntax expr, string paramName){    return expr.ToString().Replace($"{paramName}.", "");}

Получаем строку:

History.OrderByDescending(h => h.Timestamp)    .Select(h => (OrderStatus?)h.Status)    .FirstOrDefault()

Эта строка будет вставлена в сгенерированный Where() как есть и EF Core с нужным адаптером переведет её в SQL-подзапрос на необходимом SQL диалекте.

Автоопределение оператора сравнения

Генератор, если ему не уточнили оператор сравнения, сам определит нужный. По типу возвращаемого значения Expression выставляется один из двух стандартных операторов:

string -> Contains (поиск по подстроке — самый частый кейс). Всё остальное -> Equal.

private static CompareOperator InferDefaultOperator(ITypeSymbol returnType){    if (returnType.SpecialType == SpecialType.System_String)        return CompareOperator.Contains;    return CompareOperator.Equal;}

А переопределить этот оператор можно через аттрибут [Compare(...)]

[Compare(CompareOperator.GreaterThanOrEqual)]public static Expression<Func<Order, decimal?>>? MinAmount { get; } = o => o.Amount;

Шаг 4: Генерация кода

На основе распарсенных данных генератор создаёт 2 отдельных класса: простой POCO-класс со свойствами для значений по которым необходимо фильтровать данные и класс с exstension методом Apply():

public class OrderFilterParams{    public Guid? CustomerId { get; set; }    public string? Description { get; set; }    public decimal? MinAmount { get; set; }    public decimal? MaxAmount { get; set; }    public string? CustomerName { get; set; }    public Guid? CategoryId { get; set; }}public static class OrderFilterParamsExtensions{    public static IQueryable<Order> Apply(        this IQueryable<Order> query,        OrderFilterParams filter)    {        // Не выбрасываем ошибку, если фильтр не передали        if (filter is null) return query;        // Проверяем что значения для фильтруемого поля есть        if (filter.CustomerId.HasValue)            query = query.Where(e => e.CustomerId == filter.CustomerId.Value);        if (!string.IsNullOrWhiteSpace(filter.Description))            query = query.Where(e => e.Description.Contains(filter.Description));        if (filter.MinAmount.HasValue)            query = query.Where(e => e.Amount >= filter.MinAmount.Value);        if (filter.MaxAmount.HasValue)            query = query.Where(e => e.Amount <= filter.MaxAmount.Value);        if (!string.IsNullOrWhiteSpace(filter.CustomerName))            query = query.Where(e =>                e.Customer != null && // Проверяем что навигационное свойство не null                e.Customer.Name.Contains(filter.CustomerName));        var __def = new OrderFilterDefinition();        __def.ItemId = filter.ItemId;        var __pred_HasItem = OrderFilterDefinition.HasItemWithCaategory(__def);        if (__pred_HasItem != null)            query = query.Where(__pred_HasItem);        return query;    }}

Null-Guard

В коде классов можно увидеть ту самую проверку на null, о которой я говорил выше. Null-guard добавляется генератором автоматически. Причем что для входных параметров (мы не будем фильтровать по ним, если они не переданы, что для навигационных свойств)

Также видно, что генератор корректно обрабатывает nullable типы данных (int?, decimal?, Guid?, DateTime?):

Добавляется проверка filter.X.HasValue пере использованием, а при самом обращении к значению добавляется .Value

Для string? используется string.IsNullOrWhiteSpace(). Для ссылочных типов — != null.

Это определяется автоматически по типу Expression:

var isNullableValueType = returnType.IsValueType    && returnType.NullableAnnotation == NullableAnnotation.Annotated;

Multi-column поиск

Поиск по нескольким колонкам — достаточно частая задача. В данном генераторе такой поиск реализуется с помощью фильтра (Expression-свойства) с типом string[]:

public static Expression<Func<Order, string[]>>? Search { get; } =    o => new[] { o.Description, o.Customer.Name, o.Customer.Email };

Генератор определяет, что возвращаемый тип это массив строк и извлекает элементы массива объединяя их через логическое ИЛИ

if (!string.IsNullOrWhiteSpace(filter.Search)){    query = query.Where(e =>        e.Description.Contains(filter.Search) ||        (e.Customer != null && e.Customer.Name.Contains(filter.Search)) ||        (e.Customer != null && e.Customer.Email.Contains(filter.Search)));}

Каждое колонка поиска также оборачивается null-guard для свойств навигации.

Я понимаю, что для такого кейса обычно используется полнотекстовый поиск, в случае с PG это tsvector по этим полям. Но в нашем случае, учитывая то, что поля находятся в разных сущностях — нам придется самим перестраивать этот вектор и обновлять его. В итоговое решение я добавил этот фильтр для своих задач, так как мне вполне подходит такой поиск, ибо он будет использоваться достаточно редко, и не будет сильно нагружать БД. Но, в целом, сама задача достаточно часто встречается, поэтому решил оставить его.

Методы-предикаты

Иногда простого сравнения полей недостаточно для фильтрации. Например: «найти заказы, в которых есть товар из определенной категории». Для этого генератор поддерживает методы-предикаты, которые объявляются как статические методы с сигнатурой Expression<Func<TEntity, bool>>?:

// В OrderFilterDefinition (определение фильтра)public static Expression<Func<Order, bool>>? HasItemWithCategory(OrderFilterDefinition filter) =>    filter.ItemId.HasValue        ? o => o.OrderItems.Any(i => i.CategoryId == filter.CategoryId.Value)        : null;

Для генерации фильтрации по таким предикатам генератор делает следующее:

  1. Проверяет сигнатуру метода (1 параметр типа FilterDefinition, возврат типа Expression<Func<TEntity, bool>>?).

  2. Обходит тело метода, ищет обращения filter.PropertyName.

  3. Автоматически добавляет closure-свойства, то есть свойства, которые мы использовали из фильтра в самом предикате в сгенерированный POCO-класс.

  4. В методе Apply() использует bridge-паттерн — создаёт временный объект типа FilterDefinition, копирует свойства из параметров фильтра и передает готовый предикат с установленным параметром в Where

var __def = new OrderFilterDefinition();__def.ItemId = filter.ItemId;__def.MinItemCount = filter.MinItemCount;var __pred_HasItem = OrderFilterDefinition.HasItem(__def);if (__pred_HasItem != null)    query = query.Where(__pred_HasItem);

Bridge-паттерн нужен потому, что методы-предикаты определены на уровне FilterDefinition класса и принимают этот же самый FilterDefinition в качестве параметра, ибо ничего не знают о классе с параметрами. А сгенерированный класс параметров не наследуется от FilterDefinition, для упрощения сериализации и передачи, поэтому делаем связь связь через промежуточный объект.

Это позволяет писать сложные предикаты с Any, All, Count, и прочими методами LINQ, которые смогут транслироваться в SQL, чтобы гибко отфильтровать например заказы с товарами для животных, или, если брать предметную область из первой части, книги, у которых были просрочки в возвратах.

Итоговый код определения фильтра и его использование

Определение фильтра:

[GenerateFilter(typeof(Order))]public partial class OrderFilterDefinition{    // Простые поля    public static Expression<Func<Order, Guid?>>? CustomerId { get; } = o => o.CustomerId;    public static Expression<Func<Order, string>>? Description { get; } = o => o.Description;    // Диапазон значений    [Compare(CompareOperator.GreaterThanOrEqual)]    public static Expression<Func<Order, decimal?>>? MinAmount { get; } = o => o.Amount;    [Compare(CompareOperator.LessThanOrEqual)]    public static Expression<Func<Order, decimal?>>? MaxAmount { get; } = o => o.Amount;    // Диапазон дат    [Compare(CompareOperator.GreaterThanOrEqual)]    public static Expression<Func<Order, DateTime?>>? FromDate { get; } = o => o.CreatedAt;    [Compare(CompareOperator.LessThanOrEqual)]    public static Expression<Func<Order, DateTime?>>? ToDate { get; } = o => o.CreatedAt;    // Поля вложенных сущностей    public static Expression<Func<Order, string>>? CustomerName { get; } = o => o.Customer.Name;    // Подзапрос    public static Expression<Func<Order, OrderStatus?>>? CurrentStatus { get; } =        o => o.History            .OrderByDescending(h => h.Timestamp)            .Select(h => (OrderStatus?)h.Status)            .FirstOrDefault();    // Поиск по нескольким колонкас    public static Expression<Func<Order, string[]>>? Search { get; } =        o => new[] { o.Description, o.Customer.Name, o.Customer.Email };    // Предикаты    public static Expression<Func<Order, bool>>? HasItemWithCategory(OrderFilterDefinition filter) =>        filter.ItemId.HasValue            ? o => o.OrderItems.Any(i => i.CategoryId == filter.CategoryId.Value)            : null;    public static Expression<Func<Order, bool>>? HasMinItemCount(OrderFilterDefinition filter) =>        filter.MinItemCount.HasValue            ? o => o.OrderItems.Count >= filter.MinItemCount.Value            : null;    public static Expression<Func<Order, bool>>? AllItemsExpensive(OrderFilterDefinition filter) =>        filter.MinItemPrice.HasValue            ? o => o.OrderItems.All(i => i.Price >= filter.MinItemPrice.Value)            : null;    // Свойства для предикатов    public Guid? CaregoryId { get; set; }    public int? MinItemCount { get; set; }    public decimal? MinItemPrice { get; set; }}

Использование:

var filter = new OrderFilterParams{    MinAmount = 100m,    CustomerName = "Alice",    ItemId = itemId}; // также может быть параметром метода в конроллере и быть распаршеным из body запросаvar results = await dbContext.Orders    .Apply(filter)    .ToListAsync();

Или получаем из запроса прямо внутри конроллера

[HttpGet("api/orders")]//localhost:5000/api/order?minAmount=500&maxAmount=1000&CategoryId=42public async Task<IActionResult<OrdersDto>> GetOrders([FromQuery]OrderFilterParams filter){return dbContext.Products.Apply(filter).ToListAsync();}

Также в генератор были добавлены предупреждения и ошибки, чтобы можно было увидеть проблемы на этапе компиляции:

ID

Severity

Описание

GFG001

Warning

Не удалось извлечь путь из Expression. Поле пропущено.

GFG002

Error

Expression-поле должно использовать expression-bodied синтаксис (=>).

GFG003

Warning

Не удалось распознать метод-предикат. Метод пропущен.

GFG004

Error

Не удалось разрешить тип сущности из атрибута [GenerateFilter].

Если вы опечатались в имени свойства (o.Amont вместо o.Amount), получите GFG001 — Warning в IDE и поле будет пропущено. Если используете блочный синтаксис вместо expression-bodied (get { return ...; } вместо => ...), получите GFG002 — Error, компиляция не пройдёт.

Сравнение первой и второй версии гибкой фильтрации

Критерий

Runtime-билдер (Часть 1)

Source Generator (Часть 2)

Проверка типов

Рантайм

Компиляция

Производительность

Рефлексия + построение Expression на лету

Zero-cost: код сгенерирован заранее

Навигации

Ручная обработка

Автоматический null-guard

Подзапросы

[ComputedField] + статический Expression

Expression в свойстве

Multi-column

Отдельная логика

string[] в Expression

Методы-предикаты

Такой же [ComputedField] + Expression

Expression<Func<T, bool>> методы

Добавление нового типа

Новый if + новый IConstanctHandler

Автоматический резолв

Конкракт для фронтенда

Нет

Генерируется и обновляется автоматически вместе с набором фильтров

Тесты

Для библиотеки генератора были написаны 2 типа тестов: юнит и интеграционные.

Юнит тесты генератора запускают Source Generator через CSharpGeneratorDriver в процессе и проверяют, что сгенерированный код соответствует ожиданиям:

[Fact]public void Generator_ShouldProduceOutput_WhenFilterClassHasGenerateFilterAttribute(){    var source = @"        using GreenNide.ExpressionFilter;        namespace TestNamespace;        public class Order { public int Id { get; set; } }        [GenerateFilter(typeof(Order))]        public partial class OrderFilterDefinition        {            public static Expression<Func<Order, int>>? Id { get; } = o => o.Id;        }";    var (_, runResult) = GeneratorTestHelper.RunGenerator(source);    Assert.Single(runResult.GeneratedTrees);}

Интеграционные тесты проверяют, что EFCore не выбрасывает исключений на сгенерированный фильтр, а сама фильтрация возвращает те данные, которые мы ожидаем. Здесь я использовал Testcontainers с реальным PostgreSQL:

[Fact]public async Task Subquery_Filter_ShouldTranslateToSql(){    var result = await _ctx.Orders        .Where(o => o.History            .OrderByDescending(h => h.Timestamp)            .Select(h => (OrderStatus?)h.Status)            .FirstOrDefault() == OrderStatus.Shipped)        .ToListAsync();    Assert.Single(result);    Assert.Equal("Order 2 - Premium", result[0].Description);}

Testcontainers поднимает postgres:15-alpine в Docker, создаёт таблицы, заполняет тестовыми данными и выполняет запросы. Это гарантирует, что мы получаем из генератора не только компилируемые, но и реально работающий на целевых задачах код.

Итог

Мы перешли от runtime-построения Expression-выражений к генерации кода и вот что это нам дало:

  • Zero-cost абстракцию: никакой рефлексии в рантайме.

  • Поддержку безопасных навигаций: null-guard генерируется автоматически.

  • Отдельный класс с параметрами фильтрации: можно передать на фронтенд как контракт.

Сурцы

Весь код доступен на GitHub.

Установка

Пакет доступен на NuGet:

или из консоли устанавливается через

dotnet add package GreenNide.FilterGenerator --prerelease

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