Долгое время в метод 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 пользователя.
Исходный код и тесты для примеров этой статьи:
Ссылки на статьи, которые использованы для подготовки данного материала:
Комментарии (4)
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
troepolik
У меня была идея вместо того чтобы заворачивать переданный список в
OPENJSON
завернуть его в табличный параметр с юзер типом, скорее всего такое бы работало быстрее чем парсить json на стороне sql server. EF поддерживает такое если писать запрос через интерполированную скл строку (стрингом) если список преобразовать в дататейбл. но вот как добавить поддержку такого в linq я так и не разобрался... EF имеет точки расширения и внутренние тайп провайдеры, но что-то так и не вышло.