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

от автора

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

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

  • Не было никакой проверки на этапе компиляции — опечатка в имени свойства всплывала только при тестировании.

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

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

В этой части мы пойдём дальше и построим кодогенератор, который на этапе компиляции парсит Expression‑свойства фильтра и генерирует отдельный POCO‑класс со свойствами и методом расширения Apply(). Результат — zero‑runtime‑cost фильтрация, проверка типов на этапе сборки, поддержка навигаций, подзапросов и multi‑column поиска.

Проблема: почему 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" вместо "Amount", ошибка всплывёт только в рантайме.

  2. Нет поддержки навигаций. Фильтрация по Customer.Name потребует отдельной логики построения null-guard цепочки.

  3. Нет поддержки подзапросов. Вычисляемые поля (например, последний статус из истории) нужно обрабатывать отдельно через [ComputedField] и статические Expression-свойства.

Альтернатива — перенести всю эту логику из рантайма в этап компиляции.

Roslyn Source Generator: компилятор как помощник

Roslyn это компилятор C# и, что для нас сейчас самое важное, он поддерживает Source Generators — код, который выполняется на этапе компиляции и может анализировать исходный код проекта и генерировать новый.

Идея проста: вместо того чтобы писать if/else для каждого поля фильтра в рантайме, мы описываем фильтр через Expression-свойства, а компилятор сам строит код фильтрации.

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

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

// Точка входа — указывает, для какой сущности генерируется фильтр[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-массива фильтров пользователь описывает определение фильтра как partial class со статическими 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;    // Навигация — автоматический null-guard    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 на этапе компиляции

Генератор — это IIncrementalGenerator. Точка входа:

[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);                    }                }            });    }}

ForAttributeWithMetadataName — это небольшая оптимизация, которая сразу фильтрует классы с нужным атрибутом. Не нужно вручную перебирать все типы в решении.

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

Условие

Результат

Пример

ClassName задан в атрибуте

Используется как есть

[GenerateFilter(..., ClassName = "MyFilter")]MyFilter

По умолчанию

{EntityName}FilterParams

[GenerateFilter(typeof(Order))]OrderFilterParams

Извлечение пути из лямбды

Ключевая задача — превратить лямбду 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}.", "");    }}

Рекурсивный обход MemberAccessExpressionSyntax строит путь по фрагментам: o"", o.Customer"Customer", o.Customer.Name"Customer.Name".

Для подзапросов вида:

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

Рекурсия не справляется — тут InvocationExpressionSyntax с аргументами-лямбдами. В этом случае используем ReconstructChain() — берём исходный текст выражения и заменяем параметр лямбды на e:

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-подзапрос.

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

Генератор не заставляет пользователя указывать оператор для каждого поля. Он выводит его по типу возвращаемого значения Expression:

private static CompareOperator InferDefaultOperator(ITypeSymbol returnType){    if (returnType.SpecialType == SpecialType.System_String)        return CompareOperator.Contains;    return CompareOperator.Equal;}
  • stringContains (поиск по подстроке — самый частый кейс).

  • Всё остальное → Equal.

Пользователь может переопределить через [Compare(...)]:

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

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

На основе распарсенных данных генератор создаёт отдельный POCO-класс и extension-класс с методом 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; }    // ... остальные свойства из Expression-полей и closure}

Extension-класс с методом Apply()

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 &&                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: e.Customer != null добавляется автоматически для навигационных свойств. Если путь — Customer.Name, генератор разбивает его по точкам и строит цепочку проверок:

"Customer.Name"           → "e.Customer != null""Order.Customer.Address"  → "e.Order != null && e.Order.Customer != null"

Шаг 5: Nullable-типы

Генератор корректно обрабатывает nullable value types (int?, decimal?, Guid?, DateTime?):

  • Генерируется проверка filter.X.HasValue.

  • При обращении к значению добавляется .Value.

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

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

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

Шаг 6: Multi-column Search

Частая задача — поиск по нескольким колонкам. В нашем подходе это Expression с типом string[]:

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

Генератор определяет, что возвращаемый тип — string[], извлекает элементы массива и строит OR-условие:

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 для навигаций. Если Customernull, проверка пропускается без NullReferenceException.

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

Шаг 7: Методы-предикаты

Иногда простого Where(x => x.Field == value) недостаточно. Например: «найти заказы, в которых есть товар с определённым Id». Для этого генератор поддерживает методы-предикаты — статические методы с сигнатурой 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 параметр Definition-типа, возврат Expression<Func<TEntity, bool>>?).

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

  3. Автоматически добавляет closure-свойства (ItemId, MinItemCount, …) в сгенерированный POCO-класс.

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

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-паттерн нужен потому, что методы-предикаты определены на Definition-классе и принимают Definition-параметр. Сгенерированный POCO не наследуется от Definition — связь через промежуточный объект.

Это позволяет писать сложные предикаты с Any, All, Count и подзапросами — всё, что выражается через Expression.

Архитектура: Definition vs Generated

OrderFilterDefinition                    OrderFilterParams (генерируется)┌─────────────────────────────┐         ┌─────────────────────────────┐│ static Expression-свойства  │         │ public свойства             ││ static методы-предикаты     │         │   (Expression + closure)    ││ instance closure-свойства   │         │                             │└─────────────────────────────┘         └─────────────────────────────┘        ▲                                          │        │         new Definition()                 │        └──────────────────────────────────────────┘              Apply() создаёт мост: копирует              closure-свойства, вызывает предикаты
  • OrderFilterDefinition — определение фильтра с Expression-свойствами и методами-предикатами

  • OrderFilterParams — standalone POCO со всеми свойствами (можно маппить в DTO)

  • Между ними нет наследования — связь через bridge-паттерн в Apply()

Полный пример

Вот итоговый фильтр, который описывает все возможности:

[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;    // Навигация — null-guard автоматический    public static Expression<Func<Order, string>>? CustomerName { get; } = o => o.Customer.Name;    // Подзапрос — транслируется в SQL    public static Expression<Func<Order, OrderStatus?>>? CurrentStatus { get; } =        o => o.History            .OrderByDescending(h => h.Timestamp)            .Select(h => (OrderStatus?)h.Status)            .FirstOrDefault();    // Multi-column search    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;    // Instance-свойства для closure    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();

Генератор сообщает об ошибках через Roslyn-диагностики:

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 + новый handler

Одна строка в определении

Тесты

Генератор проверяется на двух уровнях:

Unit-тесты генератора — запускают 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);}

Интеграционные тесты — проверяют, что сгенерированный фильтр корректно транслируется в SQL через 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, создаёт таблицы, заполняет тестовыми данными и выполняет запросы. Это гарантирует, что Expression, который мы строим, действительно в конечно итоге транслируется в валидный SQL.

Итог

Мы перешли от runtime-построения Expression к compile-time генерации кода. Это дало нам:

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

  • Поддержку навигаций — null-guard генерируется автоматически.

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

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

Установка

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

dotnet add package GreenNide.FilterGenerator --prerelease

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