Start Debugging

Именованные фильтры запросов vs один глобальный фильтр запросов в EF Core 11: что выбрать?

Оба варианта генерируют одинаковый SQL. Прибегайте к именованным фильтрам только тогда, когда нужно отключить один предикат независимо; один комбинированный фильтр проще для единственной задачи в EF Core 11.

Если у вас одна задача фильтрации на сущности, используйте один безымянный HasQueryFilter(e => ...). Если у вас две или более задач, которые должны сниматься независимо во время выполнения запроса, например фильтр мягкого удаления и фильтр tenant, где экран администратора должен видеть удалённые строки, но никогда строки другого tenant, используйте именованные фильтры. Это всё решение целиком. Генерируемый SQL идентичен в обоих случаях; меняется лишь то, может ли IgnoreQueryFilters отключить половину вашего предиката. Именованные фильтры запросов появились в EF Core 10 и являются стандартным механизмом для нескольких фильтров в EF Core 11 (Microsoft.EntityFrameworkCore 11.0, .NET 11, C# 14).

Всё дальнейшее посвящено этой единственной оси: гранулярности отключения. Поскольку оба подхода транслируются в одну и ту же конструкцию WHERE, здесь нет никакой истории о производительности и никакой истории об экосистеме. Сравнение целиком про то, сколько контроля над отключением фильтров вам нужно.

Две формы бок о бок

Глобальный фильтр запроса — это предикат, который EF Core внедряет в каждый LINQ-запрос для типа сущности. До EF Core 10 у вас был ровно один на сущность, поэтому вторая задача означала &&:

// EF Core 9 style -- one unnamed filter, two concerns welded with &&
// .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
modelBuilder.Entity<Invoice>()
    .HasQueryFilter(i => !i.IsDeleted && i.TenantId == _tenantId);

Именованная перегрузка принимает первым аргументом строковый ключ, и повторные вызовы компонуются, а не перезаписывают друг друга:

// EF Core 11 -- two named filters on the same entity
modelBuilder.Entity<Invoice>()
    .HasQueryFilter("SoftDeletionFilter", i => !i.IsDeleted)
    .HasQueryFilter("TenantFilter",       i => i.TenantId == _tenantId);

Выполните простой запрос к любой из двух конфигураций, и вы получите одни и те же строки и один и тот же SQL:

-- Identical for both the && filter and the two named filters
SELECT ... FROM Invoices WHERE NOT IsDeleted AND TenantId = @__tenantId

Вот что важно усвоить, прежде чем выбирать: именованные фильтры — это не “более мощная фильтрация”. Они фильтруют абсолютно так же. То, что вы покупаете за дополнительную церемонию, — это именованная ручка, за которую вы сможете взяться позже.

Матрица возможностей

ВозможностьОдин глобальный фильтрИменованные фильтры
Фильтров на сущность (EF Core 11)один безымянный предикатмного, каждый со своим именем
Комбинировать несколько задач&& в одно выражениеодин вызов HasQueryFilter на задачу
Генерируемый SQLWHERE через ANDидентичный WHERE через AND
IgnoreQueryFilters() (без аргументов)сбрасывает весь фильтрсбрасывает все именованные фильтры
IgnoreQueryFilters(["Name"])неприменимосбрасывает только этот предикат
Смешивать именованные и безымянныен/ппадает при построении модели; выберите стиль
Доступно сEF Core 2.0EF Core 10
Влияние на кеш планов запросовпараметризовано, один планпараметризовано, один план
Церемониянаименьшаяконстанты имён фильтров, вспомогательные методы

Единственная строка, которая когда-либо меняет исход, — это строка IgnoreQueryFilters(["Name"]). Любое другое различие косметическое или это ограничение, которое вы обходите один раз.

Когда использовать один глобальный фильтр

Прибегайте к простому безымянному фильтру, когда дополнительная адресуемость вам ничего не даёт:

Преимущество одного фильтра в том, что в нём нет магических строк. Нет опечатки IgnoreQueryFilters(["SoftDeletonFilter"]), поджидающей, чтобы молча ничего не отключить, потому что нет имени, в котором можно ошибиться.

Когда использовать именованные фильтры

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

// EF Core 11 -- filter name hidden behind a readable call site
public static class InvoiceFilters
{
    public const string SoftDelete = nameof(SoftDelete);
    public const string Tenant     = nameof(Tenant);
}

public static IQueryable<Invoice> IncludeDeleted(this IQueryable<Invoice> q)
    => q.IgnoreQueryFilters([InvoiceFilters.SoftDelete]); // tenant filter stays on

// Reads like English, cannot leak tenants
var report = await context.Invoices.IncludeDeleted().ToListAsync();

Почему здесь нет раздела с бенчмарком

Сравнительная статья обычно должна вам цифры. Эта не должна, и стоит сказать об этом прямо, чтобы вы не пустились на поиски различия, которого нет. Оба подхода сводятся к одному и тому же дереву предикатов, поэтому EF Core выдаёт одну и ту же конструкцию WHERE, и планировщик запросов видит один и тот же SQL. Значение tenant становится параметром запроса в обоих случаях, а значит ни один из подходов не фрагментирует ваш кеш планов так, как это сделала бы встроенная константа. Нет затрат на запрос при именовании фильтра; имя существует только в метаданных модели, а не в транслированном запросе. Если вы надеялись, что именованные фильтры — это ещё и рычаг производительности, то нет. Выбирайте исключительно по оси гранулярности отключения. Если вы измеряете число запросов, добавляя любой фильтр, то по-настоящему стрелку двигают именно join’ы, что разобрано в как обнаружить N+1 запросы в EF Core 11.

Детали, которые решают за вас

Три ограничения могут навязать решение независимо от того, что вы предпочли бы из соображений стиля.

Нельзя смешивать именованные и безымянные фильтры на одной сущности. Как только любой фильтр на Invoice получил имя, имя должны иметь все. Добавление безымянного HasQueryFilter(i => ...) к сущности, у которой уже есть именованный фильтр, падает при построении модели. Так что выбор делается на уровне сущности, а не фильтра: решите стиль один раз, и если вы вообще ожидаете вторую независимо переключаемую задачу, начинайте с именованных.

IgnoreQueryFilters() без параметров по-прежнему сносит всё. Именование ваших фильтров не делает старый вызов без параметров безопасным. context.Invoices.IgnoreQueryFilters().ToListAsync() сбрасывает и фильтр tenant тоже. На любой таблице, несущей колонку tenant, относитесь к перегрузке без аргументов как к запаху кода и всегда передавайте явный список имён, которые собираетесь отключить. Эта одна привычка — разница между переключателем мягкого удаления и случайным чтением между tenant.

Имена фильтров как магические строки падают молча. IgnoreQueryFilters(["SoftDeletonFilter"]) с опечаткой не отключает ничего и не выбрасывает ошибку, потому что EF никак не может знать, что вы имели в виду настоящий фильтр. Если идёте по пути имён, закрепите имена в полях const (как в фрагменте выше), чтобы опечатка была ошибкой компиляции, а не утечкой в продакшене. У одного безымянного фильтра такой ловушки нет, и это настоящий довод в его пользу, когда у вас всего одна задача.

Одна деталь применима к обоим одинаково и не является решающим доводом: фильтр на обязательной стороне навигации превращает Include в INNER JOIN, поэтому отфильтрованный родитель молча отбрасывает своих детей. Это поведение идентично, названо ли предикат или скомбинирован через &&. Исправление — LEFT JOIN через необязательную навигацию или совпадающий фильтр на обоих концах — одинаково в обоих мирах и пошагово разобрано в руководстве по именованным фильтрам запросов.

Миграция с комбинированного фильтра на именованные фильтры

Если вы уже поставляете фильтр !IsDeleted && TenantId == x и теперь вам нужно открыть “показать удалённые”, не сбрасывая ограничение по tenant, миграция механическая. Разбейте одно выражение на два именованных вызова:

// Before -- one filter, cannot disable half of it
modelBuilder.Entity<Invoice>()
    .HasQueryFilter(i => !i.IsDeleted && i.TenantId == _tenantId);

// After -- two named filters, soft delete now independently ignorable
modelBuilder.Entity<Invoice>()
    .HasQueryFilter(InvoiceFilters.SoftDelete, i => !i.IsDeleted)
    .HasQueryFilter(InvoiceFilters.Tenant,     i => i.TenantId == _tenantId);

Ничто в поведении запроса по умолчанию не меняется; возвращаются те же строки и генерируется тот же SQL. Единственная новая возможность в том, что IgnoreQueryFilters([InvoiceFilters.SoftDelete]) теперь существует как точечный обход. Поскольку смешивать стили нельзя, сделайте это разбиение для всей сущности в одной правке и проверьте каждый существующий вызов IgnoreQueryFilters() на этой сущности: любой вызов без параметров, который раньше означал “административный вид с удалёнными”, уже сбрасывал фильтр tenant, и разбиение фильтра — подходящий момент сделать эти вызовы явными.

Итоговый выбор

По умолчанию — один безымянный глобальный фильтр запроса. Это вариант с наименьшей церемонией, в нём нет магических строк, и для распространённого случая одной задачи на сущность он строго правильный выбор. Переходите на именованные фильтры в тот миг, когда вторую задачу нужно переключать независимо от первой, что на практике почти всегда означает “мягкое удаление рядом с границей tenant или владения”. Правило большого пальца: если вы можете представить запрос, которому нужен один предикат выключенным, а другой включённым, именуйте их сейчас; иначе комбинируйте через && и двигайтесь дальше. SQL не заметит разницы, а вот ваше будущее “я”, отлаживающее, почему административный отчёт слил счета другого tenant, очень даже заметит.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад