Как происходит инициализация дочерних сущностей в иерархических данных в Entity Framework?

Разбираюсь с уроком "Иерархические данные" на Metanit.com. Там в качестве примера взята рекурсивная сущность MenuItem, которая может иметь ссылку как на родительскую сущность, так и на дочерние:

public class MenuItem
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public int? ParentId { get; set; }
    public MenuItem? Parent { get; set; }
    public List<MenuItem> Children { get; set; } = new();
}

Теперь, если мы инициализируем несколько MenuItem нижеследующим образом:

using (ApplicationContext db = new ApplicationContext())
{
    // пересоздаем бд
    db.Database.EnsureDeleted();
    db.Database.EnsureCreated();
 
    // добавляем начальные данные
    MenuItem file = new MenuItem { Title = "File" };
    MenuItem edit = new MenuItem { Title = "Edit" };
    MenuItem open = new MenuItem { Title = "Open", Parent = file };
    MenuItem save = new MenuItem { Title = "Save", Parent = file };
 
    MenuItem copy = new MenuItem { Title = "Copy", Parent = edit };
    MenuItem paste = new MenuItem { Title = "Paste", Parent = edit };
 
    db.MenuItems.AddRange(file, edit, open, save, copy, paste);
    db.SaveChanges();
}

то, например, у Menuitem с заголовком File будут дочерние MenuItem с заголовками Open и Save:

using (ApplicationContext db = new ApplicationContext())
{
    // получаем все пункты меню из бд
    var menuItems = db.MenuItems.ToList();
    Console.WriteLine("All Menu:");
    foreach (MenuItem m in menuItems)
    {
        Console.WriteLine(m.Title);
    }
    Console.WriteLine();
    // получаем определенный пункт меню с подменю
    var fileMenu = db.MenuItems.FirstOrDefault(m => m.Title == "File");
    if(fileMenu != null)
    {
        Console.WriteLine(fileMenu.Title);
        foreach(var m in fileMenu.Children)
        {
            Console.WriteLine($"---{m.Title}");
        }
    }
}
File
---Open
---Save

Но как произошла инициализация поля Children? В явном виде мы нигде не устанавливали ему значения, в а сгенерированном SQL-запросе такое поле вообще отсутствует:

CREATE TABLE "MenuItems" (
    "Id"    INTEGER NOT NULL,
    "Title" TEXT,
    "ParentId"  INTEGER,
    CONSTRAINT "FK_MenuItems_MenuItems_ParentId" FOREIGN KEY("ParentId") REFERENCES "MenuItems"("Id"),
    CONSTRAINT "PK_MenuItems" PRIMARY KEY("Id" AUTOINCREMENT)
);

Ответы (4 шт):

Автор решения: PoSha

В вашем примере поле ParentId хранит внешний ключ для ссылки на родительский элемент меню, а связь между Parent и Children выражается через отношение "один ко многим". Поле ParentId указывает на родительский элемент. Навигационное свойство Parent устанавливает связь с родителем. Свойство Children — это обратная ссылка на дочерние элементы, которые имеют текущий элемент как родителя (через их поле ParentId). Когда вы делаете следующее:

    foreach(var m in fileMenu.Children)
{
    Console.WriteLine($"---{m.Title}");
}

EF под капотом отправляет запрос к базе данных для получения всех элементов, у которых ParentId указывает на элемент fileMenu. Поле Children — это навигационное свойство, и оно не хранится напрямую в базе данных. В базе данных хранится только внешний ключ (ParentId), который указывает на родителя. Когда вам нужно получить дочерние элементы, EF автоматически использует внешний ключ для запроса в базу данных и подгрузки связанных данных.

→ Ссылка
Автор решения: Chaos_Sower

Всё дело в принципе работы Entity Framework.

Навигационное свойство в Entity Framework представляет собой свойство в классе сущности, позволяющее переходить к связанным сущностям. Оно используется для определения отношений между сущностями (таких как "один-к-одному", "один-ко-многим" и "многие-ко-многим"). Навигационные свойства позволяют загружать и работать с данными, связанными с текущей сущностью.

Вот более наглядный пример: представим, что есть сущности (модели) Company и User, где каждый пользователь принадлежит одной компании.

Тогда можно определить навигационные свойства следующим образом:

public class Company
{
    public int Id { get; private set; }
    public string Name { get; set; }
    public List<User> Users { get; set; } = new();
}

public class User
{
    public int Id { get; private set; }
    public string Name { get; set; }
    public int CompanyId { get; set; } // внешний ключ
    public Company Company { get; set; } // навигационное свойство
}

В данном примере Company имеет навигационное свойство Users, которое представляет собой коллекцию пользователей, связанных с этой компанией. User имеет навигационное свойство Company, которое указывает на компанию, к которой принадлежит пользователь.

Когда вы загружаете данные из базы данных, Entity Framework автоматически заполняет эти навигационные свойства, если вы используете методы загрузки по типу Include:

using (ApplicationContext db = new ApplicationContext())
{
    var company = db.Companies
        .Include(c => c.Users)
        .FirstOrDefault(c => c.Name == "Google");
    
    if (company != null)
    {
        Console.WriteLine(company.Name);
        
        foreach (var user in company.Users)
        {
            Console.WriteLine($"---{user.Name}");
        }
    }
}

Важно также помнить о том, что если данные в базе данных не связаны правильно, навигационные свойства не будут работать должным образом. Навигационные свойства зависят от наличия корректных внешних ключей, которые устанавливают отношения между сущностями.

По изначальному примеру:

Если у вас есть сущности MenuItem с полем ParentId, но это поле не заполнено или заполнено неправильно, то Entity Framework не сможет определить, какие элементы являются дочерними для данного элемента. В результате, навигационное свойство Children останется пустым.

Чтобы навигационные свойства работали корректно, нужно чтобы все внешние ключи были установлены правильно при добавлении данных в базу данных.

P. S. Определённая доля славы причитается пользователю @Alexander Petrov, который натолкнул меня на верную мысль с моей проблемой, которая косвенно затрагивает и эту, за что ему спасибо.

P. P. S. В таблицах БД устанавливайте ключевое поле ID как автоинкрементное, иначе вы потом исключений не оберётесь — проверено на своей шкуре.

Пример для Database First подхода:

-- Создание базы данных
CREATE DATABASE ContactsNotebook;
GO

-- Использование базы данных
USE ContactsNotebook;
GO

-- Создание таблицы ContactsInfo
CREATE TABLE ContactsInfo
(
    ID INT IDENTITY(1,1) PRIMARY KEY,
    Name VARCHAR(50) NOT NULL,
    Surname VARCHAR(50) NOT NULL,
    Patronymic VARCHAR(50),
    Sex CHAR(1) NOT NULL
);
GO

-- Вставка данных в таблицу ContactsInfo
INSERT INTO ContactsInfo (Name, Surname, Patronymic, Sex) VALUES
('Анна', 'Ильюшина', 'Васильевна', 'Ж'),
('Кирилл', 'Андреев', NULL, 'М')
GO

-- Создание таблицы PhoneTypes
CREATE TABLE PhoneTypes
(
    ID INT IDENTITY(1,1) PRIMARY KEY,
    PhoneType VARCHAR(50) NOT NULL
);
GO

-- Вставка данных в таблицу PhoneTypes
INSERT INTO PhoneTypes (PhoneType) VALUES
('Рабочий'),
('Мобильный'),
('Домашний'),
('Стационарный'),
('Офисный');
GO

-- Создание таблицы Contacts
CREATE TABLE Contacts
(
    ID INT IDENTITY(1,1) PRIMARY KEY,
    PhoneNumber NVARCHAR(18) NOT NULL,
    PhoneTypeID INT,
    ContactInfoID INT NOT NULL,
    FOREIGN KEY (PhoneTypeID) REFERENCES PhoneTypes(ID),
    FOREIGN KEY (ContactInfoID) REFERENCES ContactsInfo(ID)
);
GO

-- Вставка данных в таблицу Contacts
INSERT INTO Contacts (PhoneNumber, PhoneTypeID, ContactInfoID) VALUES
('+7 (917) 363-53-08', 1, 1),
('+7 (915) 223-33-63', 2, 2)
GO

Но лучше используйте Code First подход (как раз можно будет избежать лишних ошибок):

/// <summary>
/// Асинхронная задача создания БД с данными
/// </summary>
/// <returns></returns>
private async Task InitializeDatabaseAsync()
{
    Database.Create();
    await SeedAsync(this);
}

/// <summary>
/// Метод создания таблиц по заданным параметрам
/// </summary>
/// <param name="DBModelBuilder"></param>
protected override void OnModelCreating(DbModelBuilder DBModelBuilder)
{
    DBModelBuilder.Entity<ContactInfo>()
        .HasKey(c => c.ID)
        .Property(c => c.ID)
        .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
    
    DBModelBuilder.Entity<PhoneType>()
        .HasKey(p => p.ID)
        .Property(p => p.ID)
        .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
    
    DBModelBuilder.Entity<Contact>()
        .HasKey(c => c.ID)
        .Property(c => c.ID)
        .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
    
    DBModelBuilder.Entity<Contact>()
        .HasRequired(c => c.ContactInfo)
        .WithMany(ci => ci.Contacts)
        .HasForeignKey(c => c.ContactInfoID);
    
    DBModelBuilder.Entity<Contact>()
        .HasOptional(c => c.PhoneType)
        .WithMany(pt => pt.Contacts)
        .HasForeignKey(c => c.PhoneTypeID);
}

/// <summary>
/// Асинхронная задача внесения данных в таблицы БД
/// </summary>
/// <param name="ApplicationContext"></param>
/// <returns></returns>
private static async Task SeedAsync(ApplicationContext ApplicationContext)
{
    List<PhoneType> PhoneTypes =
    [
        new() { PhoneTypeText = "Рабочий" },
        new() { PhoneTypeText = "Мобильный" },
        new() { PhoneTypeText = "Домашний" },
        new() { PhoneTypeText = "Стационарный" },
        new() { PhoneTypeText = "Офисный" }
    ];
    
    List<ContactInfo> ContactsInfo =
    [
        new() { Name = "Анна", Surname = "Ильюшина", Patronymic = "Васильевна", Sex = "Ж" },
        new() { Name = "Кирилл", Surname = "Андреев", Patronymic = null, Sex = "М" }
    ];
    
    List<Contact> Contacts =
    [
        new() { PhoneNumber = "+7 (917) 363-53-08", PhoneTypeID = 1, ContactInfoID = 1 },
        new() { PhoneNumber = "+7 (915) 223-33-63", PhoneTypeID = 2, ContactInfoID = 2 }
    ];
    
    ApplicationContext.PhoneTypes?.AddRange(PhoneTypes);
    ApplicationContext.ContactsInfo?.AddRange(ContactsInfo);
    await ApplicationContext.SaveChangesAsync(); // сначала нужно сохранить таблицу, которую мы свяжем по внешнему ключу! Одновременное сохранение вызывает исключение
    
    ApplicationContext.Contacts?.AddRange(Contacts);
    await ApplicationContext.SaveChangesAsync();
}
→ Ссылка
Автор решения: DmitriySidyakin

Прочитайте статью Загрузка связанных данных. Метод Include.

Чтобы получить первый уровень вложенности дочерних элементов нужно добавить в код вызов метода Include:

using (ApplicationContext db = new ApplicationContext())
{

...

    // получаем все пункты меню из бд
    var menuItems = db.MenuItems.Include((m) => m.Children).ToList();

...

}
→ Ссылка
Автор решения: Alexander Petrov

Коротко: это результат работы механизма отслеживания изменений.

Если выполнить следующий код:

using var db = new ApplicationContext();

var fileMenu = db.MenuItems.First(m => m.Title == "File");

Console.WriteLine(fileMenu.Title);

foreach (var m in fileMenu.Children)
    Console.WriteLine($"---{m.Title}");

то выведется только File. Коллекция Children будет пуста.
Если в начало добавить строку:

var menuItems = db.MenuItems.ToList();

то - внезапно! - выведутся и значения Open, Save.
Эта строка загрузила из БД все записи и Change Tracker отследил все связи и установил соответствующие ссылки. Он смог это сделать, потому что все данные загружены и доступны ему. Если сразу после неё написать:

Console.WriteLine(db.ChangeTracker.DebugView.LongView);

то выведется:

MenuItem {Id: 1} Unchanged
    Id: 1 PK
    ParentId: <null> FK
    Title: 'File'
  Children: [{Id: 3}, {Id: 4}]
  Parent: <null>
MenuItem {Id: 2} Unchanged
    Id: 2 PK
    ParentId: <null> FK
    Title: 'Edit'
  Children: [{Id: 5}, {Id: 6}]
  Parent: <null>
MenuItem {Id: 3} Unchanged
    Id: 3 PK
    ParentId: 1 FK
    Title: 'Open'
  Children: []
  Parent: {Id: 1}
MenuItem {Id: 4} Unchanged
    Id: 4 PK
    ParentId: 1 FK
    Title: 'Save'
  Children: []
  Parent: {Id: 1}
MenuItem {Id: 5} Unchanged
    Id: 5 PK
    ParentId: 2 FK
    Title: 'Copy'
  Children: []
  Parent: {Id: 2}
MenuItem {Id: 6} Unchanged
    Id: 6 PK
    ParentId: 2 FK
    Title: 'Paste'
  Children: []
  Parent: {Id: 2}

Тут наглядно показано, что Change Tracker установил все связи между сущностями.

Подробнее смотрите в документации Changing Foreign Keys and Navigations.


Однако, загружать всю таблицу целиком слишком накладно, если записей в БД много. Тогда загружаем только необходимые данные, указав нужное навигационное свойство с помощью Include:

var fileMenu = db.MenuItems.Include(m => m.Children).First(m => m.Title == "File");

При этом генерируется следующий запрос:

SELECT [t].[Id], [t].[ParentId], [t].[Title], [m0].[Id], [m0].[ParentId], [m0].[Title]
FROM (
    SELECT TOP(1) [m].[Id], [m].[ParentId], [m].[Title]
    FROM [MenuItems] AS [m]
    WHERE [m].[Title] = N'File'
) AS [t]
LEFT JOIN [MenuItems] AS [m0] ON [t].[Id] = [m0].[ParentId]
ORDER BY [t].[Id]

Здесь хорошо видно, как происходит объединение по полям Id и ParentId.

Связанные данные можно загрузить и другими способами: Loading Related Data.


Чтобы разобраться во всей этой кухне следует прочитать всю документацию Change Tracking in EF Core.
Переключить язык на русский (или ещё какой) можно в левом нижнем углу.

Чтобы видеть генерируемые запросы, добавляем логирование в метод OnConfiguring контекста, например, так:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
→ Ссылка