В первой части мы построили рантайм-билдер 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

Комментарии (0)