Contains коллекций в Entity Framework для SQL Server
Contains коллекций в Entity Framework для SQL Server

Долгое время в метод 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]'
План запроса для OPENJSON
План запроса для OPENJSON

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

План запроса для EF7
План запроса для EF7
План запроса для одного параметра
План запроса для одного параметра

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

План запроса для OPENJSON большой таблицы
План запроса для OPENJSON большой таблицы

Преобразование 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)


  1. troepolik
    25.12.2024 12:12

    У меня была идея вместо того чтобы заворачивать переданный список в OPENJSON  завернуть его в табличный параметр с юзер типом, скорее всего такое бы работало быстрее чем парсить json на стороне sql server. EF поддерживает такое если писать запрос через интерполированную скл строку (стрингом) если список преобразовать в дататейбл. но вот как добавить поддержку такого в linq я так и не разобрался... EF имеет точки расширения и внутренние тайп провайдеры, но что-то так и не вышло.


  1. commanderkid
    25.12.2024 12:12

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


    1. 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


      1. 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