Как происходит инициализация дочерних сущностей в иерархических данных в 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 шт):
В вашем примере поле ParentId хранит внешний ключ для ссылки на родительский элемент меню, а связь между Parent и Children выражается через отношение "один ко многим". Поле ParentId указывает на родительский элемент. Навигационное свойство Parent устанавливает связь с родителем. Свойство Children — это обратная ссылка на дочерние элементы, которые имеют текущий элемент как родителя (через их поле ParentId). Когда вы делаете следующее:
foreach(var m in fileMenu.Children) { Console.WriteLine($"---{m.Title}"); }
EF под капотом отправляет запрос к базе данных для получения всех элементов, у которых ParentId указывает на элемент fileMenu. Поле Children — это навигационное свойство, и оно не хранится напрямую в базе данных. В базе данных хранится только внешний ключ (ParentId), который указывает на родителя. Когда вам нужно получить дочерние элементы, EF автоматически использует внешний ключ для запроса в базу данных и подгрузки связанных данных.
Всё дело в принципе работы 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();
}
Прочитайте статью Загрузка связанных данных. Метод Include.
Чтобы получить первый уровень вложенности дочерних элементов нужно добавить в код вызов метода Include:
using (ApplicationContext db = new ApplicationContext())
{
...
// получаем все пункты меню из бд
var menuItems = db.MenuItems.Include((m) => m.Children).ToList();
...
}
Коротко: это результат работы механизма отслеживания изменений.
Если выполнить следующий код:
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);