В первой части мы построили рантайм-билдер 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()};
Это работает, но:
-
Нет проверки типов. Если на фронтенде передали
"propName": "Amont"вместо"Amount", ошибка всплывёт только в рантайме. -
Нет поддержки навигаций. Фильтрация по
Customer.Nameпотребует отдельной логики построения null-guard цепочки. -
Нет поддержки подзапросов. Вычисляемые поля (например, последний статус из истории) нужно обрабатывать отдельно через
[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 — это небольшая оптимизация, которая сразу фильтрует классы с нужным атрибутом. Не нужно вручную перебирать все типы в решении.
Для определения имени генерируемого класса генератор использует простую конвенцию:
|
Условие |
Результат |
Пример |
|---|---|---|
|
|
Используется как есть |
|
|
По умолчанию |
|
|
Извлечение пути из лямбды
Ключевая задача — превратить лямбду 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;}
-
string→Contains(поиск по подстроке — самый частый кейс). -
Всё остальное →
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 для навигаций. Если Customer — null, проверка пропускается без 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 параметр Definition-типа, возврат
Expression<Func<TEntity, bool>>?). -
Обходит тело метода, ищет обращения
filter.PropertyName. -
Автоматически добавляет closure-свойства (
ItemId,MinItemCount, …) в сгенерированный POCO-класс. -
В
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 |
Описание |
|---|---|---|
|
|
Warning |
Не удалось извлечь путь из Expression. Поле пропущено. |
|
|
Error |
Expression-поле должно использовать expression-bodied синтаксис ( |
|
|
Warning |
Не удалось распознать метод-предикат. Метод пропущен. |
|
|
Error |
Не удалось разрешить тип сущности из атрибута |
Если вы опечатались в имени свойства (o.Amont вместо o.Amount), получите GFG001 — Warning в IDE и поле будет пропущено. Если используете блочный синтаксис вместо expression-bodied (get { return ...; } вместо => ...), получите GFG002 — Error, компиляция не пройдёт.
Сравнение подходов
|
Критерий |
Runtime-билдер (Часть 1) |
Source Generator (Часть 2) |
|---|---|---|
|
Проверка типов |
Рантайм |
Компиляция |
|
Производительность |
Рефлексия + построение Expression на лету |
Zero-cost: код сгенерирован заранее |
|
Навигации |
Ручная обработка |
Автоматический null-guard |
|
Подзапросы |
|
Expression в свойстве |
|
Multi-column |
Отдельная логика |
|
|
Методы-предикаты |
Такой же [ComputedField] + Expression |
|
|
Добавление поля нового типа |
Новый |
Одна строка в определении |
Тесты
Генератор проверяется на двух уровнях:
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/