Именованные фильтры запросов 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 на задачу |
| Генерируемый SQL | WHERE через AND | идентичный WHERE через AND |
IgnoreQueryFilters() (без аргументов) | сбрасывает весь фильтр | сбрасывает все именованные фильтры |
IgnoreQueryFilters(["Name"]) | неприменимо | сбрасывает только этот предикат |
| Смешивать именованные и безымянные | н/п | падает при построении модели; выберите стиль |
| Доступно с | EF Core 2.0 | EF Core 10 |
| Влияние на кеш планов запросов | параметризовано, один план | параметризовано, один план |
| Церемония | наименьшая | константы имён фильтров, вспомогательные методы |
Единственная строка, которая когда-либо меняет исход, — это строка IgnoreQueryFilters(["Name"]). Любое другое различие косметическое или это ограничение, которое вы обходите один раз.
Когда использовать один глобальный фильтр
Прибегайте к простому безымянному фильтру, когда дополнительная адресуемость вам ничего не даёт:
- Одна задача на сущность. Таблице-справочнику только для чтения, которой нужен лишь
!IsArchived, нечего отключать выборочно.HasQueryFilter(x => !x.IsArchived)— вот вся история, а имя было бы мёртвым грузом. - Вам всегда нужно всё-или-ничего. Если единственный момент, когда вы обходите фильтр, — это путь администрирования или миграции, который законно хочет видеть всё, то
IgnoreQueryFilters()без параметров — как раз то, что нужно. Именование позволило бы вам делать меньше, чем вы хотите, а не больше. - Несколько задач, которые никогда не снимаются по отдельности. Два предиката, скомбинированные через
&&, вполне уместны, когда ни один путь кода не требует одного включённым, а другого выключенным. Фильтр!IsDeleted && OrgId == _orgIdво внутреннем инструменте, где вы никогда не открываете “показать удалённые”, проще как одно выражение. - Вы на EF Core 9 или раньше. Именованная перегрузка не существует до EF Core 10, поэтому один фильтр
&&— единственный встроенный вариант. Если вы всё ещё комбинируете задачи таким образом, см. миграция с EF Core 6 на EF Core 11: критические изменения, которые действительно кусаются, прежде чем разделять их.
Преимущество одного фильтра в том, что в нём нет магических строк. Нет опечатки IgnoreQueryFilters(["SoftDeletonFilter"]), поджидающей, чтобы молча ничего не отключить, потому что нет имени, в котором можно ошибиться.
Когда использовать именованные фильтры
Именуйте свои фильтры в тот момент, когда двум задачам нужны разные времена жизни в одном запросе:
- Мягкое удаление плюс мультитенантность на одной таблице. Это канонический случай. Отчёту администратора нужно видеть удалённые счета, но он должен оставаться ограничен текущим tenant. С
IgnoreQueryFilters(["SoftDeletionFilter"])предикат tenant выживает, поэтому вы не можете превратить экран отчётов в утечку данных между tenant. Полная обвязка — в как использовать именованные фильтры запросов для мягкого удаления и мультитенантности в EF Core 11. - “Переключаемая” задача рядом с задачей “никогда не выключается”. Всякий раз, когда один предикат — граница безопасности (tenant, владение, авторизация на уровне строк), а другой — удобство (мягкое удаление, состояние опубликовано/черновик), именование позволяет открыть переключатель удобства, никогда не давая вызывающему коду способ сбросить границу.
- Вам нужен раскрывающий намерение API для обхода. С именами вы можете обернуть ignore за методом расширения, чтобы ни один вызывающий не набирал строку фильтра:
// 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();
- Фильтры живут в отдельных классах конфигурации. Если мягкое удаление приходит из общего соглашения
IEntityTypeConfiguration<T>, а фильтр tenant специфичен для приложения, два именованных вызова держат их развязанными, вместо того чтобы заставлять один класс владеть комбинированным выражением&&.
Почему здесь нет раздела с бенчмарком
Сравнительная статья обычно должна вам цифры. Эта не должна, и стоит сказать об этом прямо, чтобы вы не пустились на поиски различия, которого нет. Оба подхода сводятся к одному и тому же дереву предикатов, поэтому 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, очень даже заметит.
Связанные материалы
- Как использовать именованные фильтры запросов для мягкого удаления и мультитенантности в EF Core 11
- AsNoTracking vs AsNoTrackingWithIdentityResolution в EF Core 11
- Как использовать перехватчики EF Core 11 для аудита
- EF Core ExecuteUpdate vs загрузка сущностей и SaveChanges
- Как обнаружить N+1 запросы в EF Core 11
Источники
- Global Query Filters, документация EF Core (Microsoft Learn)
- What’s New in EF Core 10 (Microsoft Learn)
- Named Query Filters in EF 10: multiple query filters per entity, Milan Jovanovic
- Named global query filters in Entity Framework Core 10, Tim Deschryver
- Add ability to configure multiple query filters on a given DbSet, dotnet/efcore issue #33432
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.