EntityFrameworkCore игнорирует Include в запросах после обновления c 2.1
Однажды на одном их моих проектов было принято решение обновиться с версии dotnet core 2.1 на последнюю LTS версию (3.1 на момент написания статьи). До этого у меня уже был неоднократный опыт обновления небольших проектов, как и c классического .NET, так и с более старых версий dotnet core. Но большинство из этих проектов вместо полноценных ORM использовали Dapper. Обновлять же непосредственно EntityFrameworkCore, мне приходилось всего раза 2. И стоит отметить, что никаких особых проблем я при обновлении не встретил. До этого момента.
И так дело пошло: вначале обновление до 2.2, затем 3.0, и финальный 3.1. После каждого обновления была выполнена
минимальная проверка: что приложение стартует и выполняет самые базовые запросы. Все вроде бы шло нормально, но в момент
финальной проверки работоспособности приложения, уже на версии 3.1, я начал замечать, что данные, полученные с помощью
EF, падают по NullReferenceException
. Спустя некоторое время я заметил, что падают они при наличии одной конкретной
конструкции в запросе, а именно .Include(p => p.NavProperty)
.
Вот упрощенный пример кода, который выдавал ошибку:
var profiles = await context.Profiles
.Include(p => p.User)
.ToArrayAsync();
foreach (var profile in profiles) {
await SendEmail(profile.User.Email, CreateNotification(profile));
}
Ошибка падала в момент доступа к свойству Email
, так как User
оказался равным null
. Сразу же была исключена
возможность такой ситуации, так как entyties User и Profile создаются одновременно и не могут быть удалены из приложения
в принципе. Так же проверка базы показала, что все Profiles ссылаются на соответствующие Users.
Схема таблиц представляет собой вот такую связь один к одному (В данном примере упрощены все таблицы и оставлены только пару полей для наглядности):
И так первое, на что хотется посмотреть - это на конфигурацию Entity. Все что там удалось найти, это вполне правильное
задание связи один к одному c указанием Id
как FK ключа:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasOne(p => p.Profile)
.WithOne(p => p.User)
.HasForeignKey<Profile>(p => p.Id);
modelBuilder.Entity<Profile>()
.HasOne(p => p.User)
.WithOne(p => p.Profile)
.HasForeignKey<User>(p => p.Id);
base.OnModelCreating(modelBuilder);
}
Следующее логичное предположение то, что генерируется некорректный SQL код. Но если посмотреть данный запрос под профайлером, мы внезапно обнаружим, что запрос полностью корректен (запрос был отформатирован для простоты чтения):
SELECT
[p].[Id],
[p].[FirstName],
[p].[LastName],
[u].[Id],
[u].[Email]
FROM [Profiles] AS [p]
LEFT JOIN [Users] AS [u] ON [p].[Id] = [u].[Id]
И если его выполнить, мы увидим, что все данные возвращаются корректно, и таблица Users приджойнена правильно:
В приложении же мы получаем вот это (Для наглядности будут представлены сериализованные данные в JSON, все данные взяты
из примера, который находится в конце статьи в файле Cases/Case1.cs
):
[
{ "Id": 1, "FirstName": "Ellen", "LastName": "Hunt", "User": null },
{ "Id": 2, "FirstName": "Lisa", "LastName": "Lilly", "User": null },
{ "Id": 3, "FirstName": "Brianna", "LastName": "Salter", "User": null },
{ "Id": 4, "FirstName": "Aidan", "LastName": "Synnot", "User": null },
{ "Id": 5, "FirstName": "Alice", "LastName": "Paten", "User": null }
]
Следующая попытка была модифицировать запрос, чтобы выбирать NavigationProperty User в отдельное поле объекта. Хоть и правильно построенный запрос в предыдущем примере исключал возможность неправильной оптимизации. Тем не менее хотелось увидеть результат.
Запрос был переписан вот таким образом:
var profiles = await context.Profiles
.Include(p => p.User)
.Select(p => new
{
User = p.User,
Profile = p
})
.ToArrayAsync();
SQL запрос от этого не изменился вовсе, но вот результат маппинга уже совершенно другой. Сейчас фреймворк смог достать
как User так и Profile. И даже смог заполнить то свойство, которое до этого было null
. Но ради справедливости стоит
отметить, что более глубокие Navigation Properties все равно остались null
(В примере можно найти данный case в
Cases/Case2.cs
).
[
{
"User": {
"Id": 1,
"Email": "user1@main.com",
"Profile": {
"Id": 1,
"FirstName": "Ellen",
"LastName": "Hunt"
}
},
"Profile": {
"Id": 1,
"FirstName": "Ellen",
"LastName": "Hunt",
"User": {
"Id": 1,
"Email": "user1@main.com"
}
}
}
]
Дальнейшие эксперименты привели к тому, что стоит к запросу добавить .AsNoTracking()
и Entity Framework уже загружает
данные без всяких ухищрений (В примере можно найти данный case тут Cases/Case3.cs
).
var profiles = await context.Profiles
.Include(p => p.User)
.AsNoTracking()
.ToArrayAsync();
[
{
"Id": 1,
"FirstName": "Ellen",
"LastName": "Hunt",
"User": { "Id": 1, "Email": "user1@main.com" }
},
{
"Id": 2,
"FirstName": "Lisa",
"LastName": "Lilly",
"User": { "Id": 2, "Email": "user2@main.com" }
},
{
"Id": 3,
"FirstName": "Brianna",
"LastName": "Salter",
"User": { "Id": 3, "Email": "user3@main.com" }
},
{
"Id": 4,
"FirstName": "Aidan",
"LastName": "Synnot",
"User": { "Id": 4, "Email": "user4@main.com" }
},
{
"Id": 5,
"FirstName": "Alice",
"LastName": "Paten",
"User": { "Id": 5, "Email": "user5@main.com" }
}
]
И последний симптом странного поведения, который я смог найти, это то, что уже предзагруженные в контекст Users маппятся
корректно при запросе (В примере это Cases/Case4.cs
):
var userIds = new[] {3, 5};
var preloadedUsers = await context.Users
.Where(p => userIds.Contains(p.Id))
.ToListAsync();
var items = await context.Profiles
.Include(p => p.User)
.ToArrayAsync();
И данный код выдаст вот такой результат — для профайлов с Id
3 и 5 User был успешно загружен.
[
{ "Id": 1, "FirstName": "Ellen", "LastName": "Hunt", "User": null },
{ "Id": 2, "FirstName": "Lisa", "LastName": "Lilly", "User": null },
{ "Id": 3, "FirstName": "Brianna", "LastName": "Salter", "User": { "Id": 3, "Email": "user3@main.com" } },
{ "Id": 4, "FirstName": "Aidan", "LastName": "Synnot", "User": null },
{ "Id": 5, "FirstName": "Alice", "LastName": "Paten", "User": { "Id": 5, "Email": "user5@main.com" } }
]
Выглядит очень странно, и ведет себя определенно как баг. Тем не менее причина была не менее удивительная. Всему виной было определение модели, найдите тут ошибку:
public class User
{
public User()
{
Profile = new Profile();
}
public int Id { get; set; }
public string Email { get; set; }
public Profile Profile { get; set; }
}
Конечно на тестовом примере она прямо бросается в глаза, тем не менее в реальном проекте я не обратил на это внимания,
что стоило мне пару сотен потраченных нервных клеток. Да, именно, создание пустого Profile всему виной. И простое
удаление конструктора полностью решает проблему. По какой-то причине Entity Framework Core последних версий не может
правильно смаппить данные, если в свойство было уже что-то записано. Причем вместо того, чтобы оставить значение поля
EF, насильно задаст туда null
, чем, на мой взгляд, усложнит понимание проблемы. Если взять EF версии 2.1 ,он абсолютно
нормально задает такие же проинициализированные свойства. Это можно увидеть
в небольшом примере;
В данном примере рассматривается 3 консольных приложения. Для чистоты эксперимента для каждой версии dotnet было создана
своя копия правильно и неправильно сконфигурированного контекста. Так же для каждого примера есть все кейсы запросов,
описанные в статье. Не забудьте задать актуальные ConnectionStrings
в appsettions.json
.
- DemoConsoleApp5 - Пример поведения на основе dotnet core 5.0 RC
- DemoConsoleApp31 - Пример поведения на основе dotnet core 3.1
- DemoConsoleApp21 - Пример всех
запросов на основе dotnet core 2.1, в которых не воспроизводилась потеря данных загруженных через
.Include
. Этот пример сделан для наглядности.