Однажды на одном их моих проектов было принято решение обновиться с версии 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. Этот пример сделан для наглядности.