Start Debugging

Исправление: EF Core `Contains` для static readonly `IList<T>` или `ISet<T>` не удаётся транслировать

EF Core 8, 9 и 10 не могут транслировать Contains, если список является static readonly полем типа IList, ICollection, ISet или IReadOnlySet. Вызовите Enumerable.Contains явно или перейдите на EF Core 11.

Если Where(x => AllowedCodes.Contains(x.Code)) выбрасывает The LINQ expression ... could not be translated с сообщением Translation of method 'System.Linq.Enumerable.Contains' failed, посмотрите, как объявлено AllowedCodes. Почти наверняка это поле static readonly типа IList<T>, ICollection<T>, ISet<T>, IReadOnlySet<T>, IImmutableSet<T> или FrozenSet<T>. Самое быстрое исправление, которое сохраняет тот же SQL, это явно вызвать оператор LINQ: Enumerable.Contains(AllowedCodes, x.Code). Настоящее исправление это EF Core 11, где dotnet/efcore#36757 поправил проверку корня запроса, отклоняющую такие формы. Я проверил каждый вариант ниже на Microsoft.EntityFrameworkCore.Sqlite 8.0.21, 9.0.19, 10.0.12 и 11.0.0-rc.1.26425.128. Первые три версии падают одинаково. EF Core 11 RC 1 транслирует все варианты.

Ошибка в контексте

Вот исключение из EF Core 10.0.12 на .NET 10 для static readonly IList<string>:

System.InvalidOperationException: The LINQ expression 'DbSet<Order>()
    .Where(o => (IList<string>)List<string> { "Open", "Pending" }
        .Contains(o.Status))' could not be translated. Additional information: Translation of method 'System.Linq.Enumerable.Contains' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)
   at Microsoft.EntityFrameworkCore.Query.QueryCompilationContext.CreateQueryExecutorExpression[TResult](Expression query)

В этом сообщении есть две подсказки. Первая это приведение типа. Коллекция выглядит как (IList<string>)List<string> { "Open", "Pending" }, то есть EF уже вычислил ваше поле до значения, константы, и обернул её в приведение обратно к объявленному типу. Вторая подсказка это имя метода. Вы написали IList<T>.Contains, метод экземпляра, но в сообщении указан Enumerable.Contains. EF переписывает вызовы ICollection<T>.Contains в оператор LINQ перед трансляцией. Так что дело не в методе. Проблема в аргументе, который EF ему передаёт.

Когда поле имеет тип IReadOnlySet<T> или IImmutableSet<T>, имя метода в сообщении меняется на System.Collections.Generic.IReadOnlySet<string>.Contains или System.Collections.Immutable.IImmutableSet<string>.Contains. Эти интерфейсы не наследуют ICollection<T>, поэтому EF никогда не переписывает вызов. Это тот же баг с другим сообщением.

Почему static readonly поле ломается, а локальная переменная нет

Здесь два шага, и баг проявляется, только когда происходят оба.

Шаг 1: EF подставляет static readonly поля как константы. Перед трансляцией funcletizer в EF обходит запрос и вычисляет всё, что не зависит от базы данных. Захваченные локальные переменные, поля экземпляра и статические свойства становятся параметрами запроса. Статическое поле с readonly (FieldInfo.IsInitOnly) считается значением, которое не может измениться, поэтому EF вычисляет его один раз и подставляет как константу. В EF Core 10 это видно в ExpressionTreeFuncletizer.VisitMember: статический член помечается как захваченная переменная “unless the captured variable is init-only”. Когда EF строит эту константу, он типизирует её по типу значения времени выполнения (List<string>), а затем добавляет узел Convert обратно к объявленному типу (IList<string>), если два типа различаются.

Шаг 2: проверка корня запроса снимает только один вид приведения. Чтобы транслировать Contains по коллекции в памяти, EF превращает коллекцию во встроенный корень запроса, а затем в IN (...). В EF Core 8, 9 и 10 QueryRootProcessor.VisitQueryRootCandidate снимает Convert только тогда, когда целевой тип в точности IEnumerable<T>:

// EF Core 10.0.x, src/EFCore/Query/QueryRootProcessor.cs
if (expression is UnaryExpression { NodeType: ExpressionType.Convert } convertExpression
    && convertExpression.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
    candidateExpression = convertExpression.Operand;
}

Convert к IList<string> не подходит, поэтому коллекция не распознаётся как корень запроса, и Contains доходит до “could not be translated”.

С этим понятна и картина того, что работает, а что нет:

Это не регрессия. Первоначальный отчёт о варианте с IReadOnlySet<T> восходит к EF Core 7, а dotnet/efcore#38839 воспроизводит его на версиях с 7.0.20 по 10.0.11.

Минимальный пример воспроизведения

// .NET 10, C# 14, Microsoft.EntityFrameworkCore.Sqlite 10.0.12
using Microsoft.EntityFrameworkCore;

using var db = new ShopContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Orders.AddRange(
    new Order { Status = "Open" },
    new Order { Status = "Pending" },
    new Order { Status = "Shipped" });
db.SaveChanges();

// Throws InvalidOperationException on EF Core 8, 9 and 10
var active = db.Orders
    .Where(o => OrderRules.ActiveStatuses.Contains(o.Status))
    .ToList();

Console.WriteLine(active.Count);

public static class OrderRules
{
    public static readonly IList<string> ActiveStatuses = new List<string> { "Open", "Pending" };
}

public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "";
}

public class ShopContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=shop.db");
}

Замените IList<string> на List<string>, уберите readonly или превратите поле в свойство { get; }, и тот же запрос заработает. Обычно баг находят именно так: на ревью кода предлагают “открывать интерфейс, а не конкретный тип” или “сделать поле readonly”, и запрос, который месяцами работал, ломается.

Что делает каждое объявление в EF Core 8, 9, 10 и 11

Я запустил по одной пробе для каждой формы на SQLite в каждой версии EF. Пакеты EF Core 8 и 9 работали на среде выполнения .NET 10. EF Core 11 RC 1 работал на .NET 11 RC 1. “Constant” означает, что EF подставил значения прямо в SQL. “Parameter” означает, что он передал их как параметры.

ОбъявлениеEF 8.0.21EF 9.0.19EF 10.0.12EF 11 RC 1
static readonly IList<T>failsfailsfailsconstant
static readonly ICollection<T>failsfailsfailsconstant
static readonly ISet<T>failsfailsfailsconstant
static readonly IReadOnlySet<T>failsfailsfailsconstant
static readonly IImmutableSet<T>failsfailsfailsconstant
static readonly FrozenSet<T>failsfailsfailsconstant
static readonly IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T>constantconstantconstantconstant
static readonly List<T>, HashSet<T>, T[]constantconstantconstantconstant
static IList<T> (не readonly) или статическое свойствоparameterparameterparameterparameter
захваченная локальная переменная IList<T>parameterparameterparameterparameter
EF.Constant(localIList).Contains(...)failsconstantconstantconstant

Последняя строка это похожий случай, о котором полезно знать. В EF Core 8 принудительное превращение локального IList<T> в константу через EF.Constant попадает на тот же баг. Начиная с EF Core 9 EF.Constant идёт другим путём и работает.

Исправления в порядке предпочтения

1. Перейти на EF Core 11

Исправление это dotnet/efcore#36757, влитое в main 2025-09-24 и вошедшее в EF Core 11. Оно меняет проверку так, чтобы снимался любой Convert, целевой тип которого приводим к IEnumerable, причём рекурсивно:

// EF Core 11.0, src/EFCore/Query/QueryRootProcessor.cs
if (expression is UnaryExpression { NodeType: ExpressionType.Convert } convertExpression
    && convertExpression.Type.IsAssignableTo(typeof(IEnumerable)))
{
    return VisitQueryRootCandidate(convertExpression.Operand, elementClrType);
}

Исправление не было перенесено в старые версии. В ветке release/10.0 всё ещё сравнение с typeof(IEnumerable<>), и 10.0.12 всё ещё падает. dotnet/efcore#35024 (отчёт про IList/ICollection) отнесён к milestone 11.0.0, а #38839 закрыт как дубликат. Если вы на EF Core 10 LTS, до перехода на 11 планируйте использовать одну из переписанных форм ниже.

2. Вызвать Enumerable.Contains явно

Это исправление в одну строку, которое я рекомендую для EF Core 8, 9 и 10, потому что оно даёт в точности тот SQL, который вы получите в EF Core 11:

// .NET 10, Microsoft.EntityFrameworkCore.Sqlite 10.0.12
var active = db.Orders
    .Where(o => Enumerable.Contains(OrderRules.ActiveStatuses, o.Status))
    .ToList();

// WHERE "o"."Status" IN ('Open', 'Pending')

Прямой вызов статического метода заставляет компилятор привести поле к IEnumerable<string>, поэтому константа EF приходит приведённой к IEnumerable<T> и проходит старую проверку. В моих запусках это сработало для всех шести падающих форм, включая IReadOnlySet<T> и IImmutableSet<T>. OrderRules.ActiveStatuses.AsEnumerable().Contains(o.Status) делает то же самое, если вам ближе синтаксис методов. OrderRules.ActiveStatuses.Any(s => s == o.Status) тоже транслируется в тот же список IN, но читается хуже, и я бы не стал использовать его только ради обхода этой проблемы.

Один компромисс: для ISet<T> или FrozenSet<T> Enumerable.Contains в обычном LINQ to Objects пропустил бы поиск по хешу. Внутри запроса EF это не важно, потому что вызов никогда не выполняется в .NET. Он лишь описывает SQL.

3. Изменить объявленный тип

Если коллекция используется только в запросах, объявите её как IReadOnlyCollection<T>, IReadOnlyList<T> или массив. Все три доступны только для чтения, и все три транслируются во всех версиях:

// .NET 10, Microsoft.EntityFrameworkCore 10.0.12
public static class OrderRules
{
    public static readonly IReadOnlyList<string> ActiveStatuses = ["Open", "Pending"];
}

Не переходите на FrozenSet<T> ради “настоящей” неизменяемости. В EF Core с 8 по 10 он падает по причине, описанной выше.

4. Позволить EF параметризовать значения

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

// .NET 10, Microsoft.EntityFrameworkCore.Sqlite 10.0.12
var statuses = OrderRules.ActiveStatuses;
var active = db.Orders.Where(o => statuses.Contains(o.Status)).ToList();

// EF Core 10: WHERE "o"."Status" IN (@statuses1, @statuses2)
// EF Core 8/9 on SQLite: WHERE "o"."Status" IN (SELECT "s"."value" FROM json_each(@__statuses_0) AS "s")

Это работает, но меняет SQL. Помните, зачем существует путь через константы: значения никогда не меняются, поэтому их подстановка даёт базе данных фиксированный список литералов, что лучше всего для использования индексов и кеширования планов. Для короткого списка кодов статуса константы дают лучший SQL. Используйте этот вариант, когда список действительно может меняться во время выполнения, а не только ради обхода бага трансляции. Если хотите увидеть, что EF генерирует в каждом случае, запишите в журнал SQL, который генерирует EF Core до и после изменения.

Варианты, которые попадают на эту страницу по ошибке

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

Источники

Comments

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

< Назад