
Долгое время в метод Contains(), который используется в Entity Framework для фильтрации данных по списку значений, не вносили изменения. До выхода Entity Framework 8 коллекция с этим методом внутри оператора запросов where LINQ транслировалась в значения в виде констант внутри оператора IN для SQL. Если количество элементов равно одному, то преобразование происходило в выражение с оператором ‘=’ на стороне MS SQL Server. Аналогично транслируется и метод расширения Where() LINQ.
Использование в EF8 функции OPENJSON устраняет часть проблем с кэшем планов запросов для SQL Server, но не применимо к старым версиям (compatibility level) баз данных. Да и оптимальность генерируемого ею кода в некоторых случаях вызывает сомнения.
В недавно вышедшем Entity Framework 9 добавили больше настроек для возможности транслирования метода Contains() коллекций как с помощью OPENJSON, так и «по-старому» — в виде констант. Можно включить режим генерации кода совместимый со старыми версиями SQL Server, аналогичный версии EF7.
Преобразование запросов в EF7 и ранее
Несколько значений:
var ids = new[] { 1, 2, 3, 4, };
var employees = await dbcontext.Set<Employee>()
.Where(e => ids.Contains(e.Id));
.ToListAsync();
SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] IN (1, 2, 3, 4)
Одно значение:
var ids = new[] { 1, };
var employees = await dbcontext.Set<Employee>()
.Where(e => ids.Contains(e.Id));
.ToListAsync();
SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] = 1
Аналогично происходит транслирование LINQ операторов и инлайн массива в SQL.
var query =
from e in dbcontext.Set<Employee>()
where new[] { 1, 2, 3, 4, }.Contains(e.Id)
select e;
var employees = await query.ToListAsync();
Можно ещё отметить преобразование запросов с группировкой данных. Так использование метода расширения GroupBy() вместе с Where() может транслироваться в GROUP BY и HAVING для SQL.
var departmentsIds = new[] { 1, 3, };
var query = dbcontext.Set<Employee>()
.GroupBy(g => g.DepartmentId)
.Select(e => new { Id = e.Key, Count = e.Count() })
.Where(x => departmentsIds.Contains(x.Id))
.Select(e => new { e.Id, e.Count });
var employeesInDepartments = await query.ToListAsync();
SELECT [e].[DepartmentId] AS [Id], COUNT(*) AS [Count]
FROM [Employees] AS [e]
GROUP BY [e].[DepartmentId]
HAVING [e].[DepartmentId] IN (1, 3)
Преобразование запросов в EF8 и EF9
Чрезмерное использование констант оказывает пагубное влияние на MS SQL Server. Хэш запроса отличается для каждого набора переданных значений. SQL-серверу необходимо рассчитать план запроса для каждого нового варианта, что занимает процессорное время. Созданным планам запросов отводится запись в кэше планов SQL Server, что может вызывать вытеснение других запросов. Борьба за ресурсы угрожает постоянными пересчётами планов запросов.
Entity Framework имеет свой внутренний кэш для запросов, а использование констант в SQL приводит к дополнительным накладным расходам и делает кэширование неэффективным.
В Entity Framework 8 стали использовать функцию SQL Server OPENJSON для обработки списка значений в виде массива JSON. Этот массив передаётся как простой строковый параметр SQL (nvarchar(4000) или nvarchar(max)). Так решается проблема кэширования SQL.
Но сам процесс транслирования может работать немного медленнее, чем старое преобразование в список IN. К сожалению, функция OPENJSON недоступна в SQL Server 2014 и ниже.
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@__ids_0) WITH ([value] int ''$'') AS [i]
)',N'@__ids_0 nvarchar(4000)',@__ids_0=N'[1,2,3,4]'
EF8 для SQL Server 2014 и ниже
Функция OPENJSON была добавлена в SQL Server 2016. Для её использования уровень совместимости базы данных должен быть не ниже 130. Проверить можно с помощью следующей команды:
SELECT name, compatibility_level FROM sys.databases;
Вызов OPENJSON для SQL Server 2014 и ниже, а также с уровнем совместимости 120 и ниже, закончится ошибкой такого вида:
Msg 102, Level 15, State 1, Line 5
Incorrect syntax near '$'.
Если по какой-то причине нельзя изменить уровень совместимости базы данных, то можно настроить EF для генерации SQL, соответствующего нужной версии.
protected override void OnConfiguring(
DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseSqlServer(@"<CONNECTION STRING>", o => o.UseCompatibilityLevel(120));
Where() и Contains() и EF9
В Entity Framework 9 также возможно настроить работу со старыми версиями SQL Server с помощью UseCompatibilityLevel(). Ещё в EF9 добавили метод TranslateParameterizedCollectionsToConstants(), который настраивает транслирование Contains() для коллекций в константы внутри IN «по старому»:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlServer(
"<CONNECTION STRING>",
o => o.TranslateParameterizedCollectionsToConstants());
Можно заставить EF преобразовать определённую коллекцию в определённом запросе в константы, обернув переменную коллекции в Constant<T>(T). Это переопределяет поведение по умолчанию:
var ids = new[] { 1, 2, 3, 4, };
var employees = await dbcontext.Set<Employee>()
.Where(e => EF.Constant(ids).Contains(e.Id))
.ToListAsync();
SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] IN (1, 2, 3, 4)
Аналогично можно принудительно преобразовать коллекцию в отдельном запросе в один параметр, обернув переменную коллекции в Parameter<T>(T). Это заставит проигнорировать настройку TranslateParameterizedCollectionsToConstants():
var ids = new[] { 1, 2, 3, 4, };
var employees = await dbcontext.Set<Employee>()
.Where(e => EF.Parameter(ids).Contains(e.Id))
.ToListAsync();
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@__ids_0) WITH ([value] int ''$'') AS [i]
)',N'@__ids_0 nvarchar(4000)',@__ids_0=N'[1,2,3,4]'
Планы запросов
Используя функцию OPENJSON удалось добиться прогресса с кэшированием планов запросов SQL. Это важно для повторных вызовов с такими же или другими параметрами.
Но вот сами планы запросов не всегда выглядят оптимальными. В EF8 и EF9 даже одно значение в коллекции преобразуется в SQL с помощью OPENJSON.
var ids = new[] { 1, };
var employees = await dbcontext.Set<Employee>()
.Where(e => ids.Contains(e.Id));
.ToListAsync();
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@__ids_0) WITH ([value] int ''$'') AS [i]
)',N'@__ids_0 nvarchar(4000)',@__ids_0=N'[1]'

Есть большая вероятность уйти в INDEX SCAN там, где запросы без OPENJSON стабильно приводят к INDEX SEEK.


Использование INDEX SCAN для OPENJSON в примере для одной записи может оправдать маленькое количество тестовых записей в таблице. Если таблица будет содержать большое число строк, то план запроса поменяется, в том числе и для выборки по нескольким Id.

Преобразование Contain() в условия OR
До сих пор не потеряли актуальность самописные методы, которые транслируют Contains() внутри Where() в набор условий OR для SQL. Упрощенный результат работы такого метода для массива из трёх элементов выглядит так:
exec sp_executesql
N'SELECT *
FROM Employees
WHERE Id = @v1 OR Id = @v2 OR Id = @v3',
N'@v1 int,@v2 int,@v3 int',
@v1=1,@v2=2,@v3=3
Есть варианты принимающие на вход коллекцию, а для уникальных значений лучше сразу передать HashSet.
var ids = new[] { 1, 2, 3, 4, 5, 6, 7, };
var employees = await dbcontext.Set<Employee>().In(
[.. ids], x => x.Id).ToListAsync();
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] = @__v1_0 OR [e].[Id] = @__v2_1 OR [e].[Id] = @__v1_2 OR [e].[Id] = @__v1_3 OR [e].[Id] = @__v2_4 OR [e].[Id] = @__v1_5 OR [e].[Id] = @__v2_6 OR [e].[Id] = @__v1_7 OR [e].[Id] = @__v1_8 OR [e].[Id] = @__v2_9',N'@__v1_0 int,@__v2_1 int,@__v1_2 int,@__v1_3 int,@__v2_4 int,@__v1_5 int,@__v2_6 int,@__v1_7 int,@__v1_8 int,@__v2_9 int',
@__v1_0=1,@__v2_1=2,@__v1_2=3,@__v1_3=4,@__v2_4=5,@__v1_5=6,@__v2_6=7,@__v1_7=7,@__v1_8=7,@__v2_9=7
У этого решения есть хорошие шансы на использование индексов. Создание ограниченного набора корзин (бакетов) для разного количества значений защищает SQL Server от захламления планами запросов.
В примере выше количество элементов попало между размерами корзин 6 и 10. Поэтому для последнего Id были созданы ещё три переменные, чтобы не создавался новый план запроса для семи параметров. Всего для 7 их получилось четыре:
@__v2_6=7,@__v1_7=7,@__v1_8=7,@__v2_9=7
Есть у такого решения минусы. Текст SQL запроса для крупных коллекций значительно больше, чем с OPENJSON или обычным условием IN. В MS SQL существует ограничение на количество параметров в одном запросе — нельзя передавать больше 2100. Но это можно обойти, выкачивая данные несколькими запросами.
var ids = new[] { 1, 2, 3, 4, 5, 6, 7, };
var employees = await dbcontext.Set<Employee>().ToListByPortionsAsync(
[.. ids], x => x.Id, portionSize: 4);
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] = @__v1_0 OR [e].[Id] = @__v2_1 OR [e].[Id] = @__v1_2 OR [e].[Id] = @__v2_3',N'@__v1_0 int,@__v2_1 int,@__v1_2 int,@__v2_3 int',@__v1_0=1,@__v2_1=2,@__v1_2=3,@__v2_3=4
go
exec sp_executesql N'SELECT [e].[Id], [e].[BirthDate], [e].[DepartmentId], [e].[IsActive], [e].[Name]
FROM [Employees] AS [e]
WHERE [e].[Id] = @__v1_0 OR [e].[Id] = @__v2_1 OR [e].[Id] = @__v1_2',N'@__v1_0 int,@__v2_1 int,@__v1_2 int',@__v1_0=5,@__v2_1=6,@__v1_2=7
go
Размеры корзин лучше задавать в соответствии со сценариями использования, например, с количеством строк на странице в UI пользователя.
Исходный код и тесты для примеров этой статьи:
Ссылки на статьи, которые использованы для подготовки данного материала:
Комментарии (9)

commanderkid
25.12.2024 12:12Здорово! А можно ли сделать так, чтобы Contains или иной способ, например по Id и допустим, какому-нибудь OtherId искал? Что-то типа WHERE ID = id AND OTHER_ID = OtherId?

ioserg Автор
25.12.2024 12:12Если надо так:
var filters = new[] { new { CompanyId = 2, Number = 40 }, new { CompanyId = 3, Number = 80 }, }; var departments = await dbcontext.Set<Department>().FilterByItems( filters, (dep, fr) => dep.CompanyId == fr.CompanyId && dep.Number == fr.Number, true).ToListAsync();exec sp_executesql N'SELECT [d].[Id], [d].[CompanyId], [d].[Description], [d].[Name], [d].[Number] FROM [Departments] AS [d] WHERE (([d].[CompanyId] = @__CompanyId_0) AND ([d].[Number] = @__Number_1)) OR (([d].[CompanyId] = @__CompanyId_2) AND ([d].[Number] = @__Number_3))',N'@__CompanyId_0 int,@__Number_1 int,@__CompanyId_2 int,@__Number_3 int', @__CompanyId_0=2,@__Number_1=40,@__CompanyId_2=3,@__Number_3=80
ioserg Автор
25.12.2024 12:12то можно взять такой вариант
FilterByItems():public static IQueryable<T> FilterByItems<T, TItem>( this IQueryable<T> query, IEnumerable<TItem> items, Expression<Func<T, TItem, bool>> filterPattern, bool isOr) { Expression? predicate = null; foreach (var item in items) { var itemExpr = Expression.Constant(item); var itemCondition = ExpressionReplacer.Replace(filterPattern.Body, filterPattern.Parameters[1], itemExpr); predicate = predicate == null ? itemCondition : Expression.MakeBinary(isOr ? ExpressionType.OrElse : ExpressionType.AndAlso, predicate, itemCondition); } predicate ??= Expression.Constant(false); var filterLambda = Expression.Lambda<Func<T, bool>>(predicate, filterPattern.Parameters[0]); return query.Where(filterLambda); } class ExpressionReplacer(IDictionary<Expression, Expression> replaceMap) : ExpressionVisitor { readonly IDictionary<Expression, Expression> _replaceMap = replaceMap ?? throw new ArgumentNullException(nameof(replaceMap)); [return: NotNullIfNotNull(nameof(node))] public override Expression? Visit(Expression? node) { if (node != null && _replaceMap.TryGetValue(node, out var replacement)) { return replacement; } return base.Visit(node); } public static Expression Replace(Expression expr, Expression toReplace, Expression toExpr) => new ExpressionReplacer(new Dictionary<Expression, Expression> { { toReplace, toExpr } }).Visit(expr); public static Expression Replace(Expression expr, IDictionary<Expression, Expression> replaceMap) => new ExpressionReplacer(replaceMap).Visit(expr); public static Expression GetBody(LambdaExpression lambda, params Expression[] toReplace) { if (lambda.Parameters.Count != toReplace.Length) { throw new InvalidOperationException(); } return new ExpressionReplacer(Enumerable.Range(0, lambda.Parameters.Count). ToDictionary(i => (Expression)lambda.Parameters[i], i => toReplace[i])).Visit(lambda.Body); } }FilterByItems()отсюда, с небольшими изменениями https://stackoverflow.com/questions/67666649/lambda-linq-with-contains-criteria-for-multiple-keywords/67666993#67666993
commanderkid
25.12.2024 12:12Спасибо! Жалко, что нет возможности из коробки искать сразу по нескольким параметрам в коллекциях и приходится что-то свое делать.

ioserg Автор
25.12.2024 12:12Пожалуйста! Но надо учитывать, что это "сырой" вариант. В нем нет бакетирования (bucketing, padding) переменных. А для MS SQL будет еще ограничение на 2100 параметров в запросе. Так как фильтр по двум ИД сразу, то максимальное количество фильтров 1050. Это если в этом же запросе не будет еще других условий, которые транслируются в параметры

kemsky
25.12.2024 12:12То есть or или in с параметрами он так и не научился генерить сам? Я теряюсь в догадках, что им мешает это сделать столько лет.

ioserg Автор
25.12.2024 12:12Не научился, добавили больше способов отключить
OPENJSON. Заняты с их точки зрения более приоритетными задачами. Хотя странно, что для своего же продукта оптимизацию не сделали.При небольшом отношении
(количество ИД) / (всего записей)быстрее работаетIN с параметрами. По мере увеличения этого соотношенияOPENJSONвыравнивается с ним по скорости, а затем бывает даже чуть быстрее.В идеале нужен гибридный подход, использующий заполнение параметров при небольших значениях, а
OPENJSONдля большого количества параметров. Но разработчики EFCORE считают, что архитектура запросов и кэширования EF делает это нетривиальной задачей.Как только все текущие баги исправят, так обязательно запилят)

kemsky
25.12.2024 12:12OPENJSON имеет нюансы по сериализации и вытекающие из них приведения типов (в джсоне их по сути три строка, число, булев тип) и тп. Без парсинга сервер не может оценить число параметров и выбрать лучший план, тоже не супер. Решает проблему захламления планов и кеширования запросов, но не оптимально. Если запросов с большим числом параметров не много, то может лучше и не делать ничего, чем юзать OPENJSON, пусть генерит константы. Ради интереса прикрутил к EF6, но там генерируется exists/join вместо in и все еще хуже становится.
troepolik
У меня была идея вместо того чтобы заворачивать переданный список в
OPENJSONзавернуть его в табличный параметр с юзер типом, скорее всего такое бы работало быстрее чем парсить json на стороне sql server. EF поддерживает такое если писать запрос через интерполированную скл строку (стрингом) если список преобразовать в дататейбл. но вот как добавить поддержку такого в linq я так и не разобрался... EF имеет точки расширения и внутренние тайп провайдеры, но что-то так и не вышло.