Для отображения иерархии объектов, связанных наследованием, Entity Framework Code First предоставляет три варианта. Рассмотрим чем они отличаются и какие есть особенности их применения.
Режим "Таблица для иерархии (Table per hierarchy)"
Данный вариант используется в Entity Framework Code First по умолчанию. Для всех объектов иерархии создается одна общая таблица, содержащая поля для всех встречающихся в них свойств. В неё добавляется служебная колонка Discriminator, указывающая на тип сохранённого экземпляра. При записи данных, отсутствующие в конкретном типе поля будут заполнены значением null. Поэтому у потомков, все свойства значимого типа отображаются в таблицах как nullable-типы.
В качестве примера рассмотрим следующую иерархию классов:
public class A
{
public int Id { get; set; }
public string TextA { get; set; }
}
public class B : A
{
public string TextB { get; set; }
}
public class C : A
{
public string TextC { get; set; }
}
При использовании режима "Таблица для иерархии" будет создана одна таблица с полями: Id, TextA, TextB, TextC и служебным Discriminator.
Конфигурация
Как правило, дополнительных шагов для настройки в данном случае не требуется. Однако, при необходимости, можно в методе OnModelCreating() класса контекста переопределить параметры колонки Discriminator, используя FluentAPI:
modelBuilder.Entity<A>()
.Map<A>(m => m.Requires("Type").HasValue("A"))
.Map<B>(m => m.Requires("Type").HasValue("B"))
.Map<C>(m => m.Requires("Type").HasValue("C"));
Теперь служебное поле будет называться "Type" и содержать одно из трех значений: "A", "B" или "C".
Плюсы
- Простота в использовании.
Минусы
- Добавление в таблицу служебного поля;
- Уменьшение производительности при чтении записей конкретного типа из-за фильтрации;
- Большой размер таблицы.
Режим "Таблица для каждого конкретного типа (Table per concrete type)"
Пусть существует следующая иерархия объектов:
- public abstract class Content – базовая абстракция, описывающая публикацию на сайте;
- public class News : Content – новостная заметка;
- public class Article : Content – статья на сайте.
Как не сложно предположить, наиболее часто будут запрашиваться записи одного типа – или новости или статьи. В этом случае выгоднее разделить их по отдельным таблицам, чтобы избежать лишней выборки по типу объекта. Это и позволяет сделать режим "Таблица для каждого конкретного типа".
Для описанного выше примера будут созданы таблицы News и Articles. Каждая из них будет содержать полный набор полей для хранения всех свойств объектов соответствующего типа.
Конфигурация
Для настройки рассматриваемого режима отображения воспользуемся методом Map(). В качестве параметра передадим ему Action, в котором устанавливаются необходимые параметры. В частности, используя метод MapInheritedProperties() дается указание отобразить родительские свойства и определяется имя создаваемой таблицы с помощью ToTable():
modelBuilder.Entity<News>().Map(m =>
{
m.MapInheritedProperties();
m.ToTable("News");
});
modelBuilder.Entity<Article>().Map(m => {
m.MapInheritedProperties();
m.ToTable("Articles");
});
Плюсы
- Более быстрая обработка запросов за счет:
- отсутствия фильтрации по типу (как в случае с режимом "Таблица для иерархии");
- разделения большой таблицы на несколько менее объёмных.
Минусы
- Меньшая производительность при получении общего списка базового типа из-за необходимости объединения таблиц.
Режим "Таблица для каждого типа (Table per type)"
Рассмотрим еще один вариант иерархии объектов:
- Product – базовый класс, содержащий ключевую информацию о товаре;
- LocalProduct, ImportedProduct – уточнения класса Product, описывающие различные типы товаров.
Предположим, что в процессе работы приложения в большинстве случаев требуются только свойства, описанные в базовом классе Product. Например, наиболее часто запрашивается общий каталог, а не подробные данные об отдельных товарах. Кроме того, наследники могут содержать "тяжеловесные" поля, значения которые накладно считывать без прямой необходимости.
В данной ситуации оптимальным будет использование режима отображения "Таблица для каждого типа". При этом Entity Framework развернет одну общую таблицу на основе базового класса. Дополнительно, для каждого из потомков будет создана отдельная таблица для хранения объявленных в них свойств. Таким образом, информация сохранятся в две таблицы: базовую и специфическую для конкретного типа.
В данном случае это ускорит получение общего списка продуктов. Однако для получения полного описания Entity Framework придется объединять данные из двух таблиц.
Данный режим может быть использован для выноса "тяжеловесных" и редко используемых полей в отдельные таблицы. Для это необходимо перенести соответствующие свойства в класс-наследник.
Конфигурация
Конфигурация рассматриваемого режима очень простая и вряд ли требует пояснений:
modelBuilder.Entity<Product>().ToTable("Product");
modelBuilder.Entity<LocalProduct>().ToTable("LocalProduct");
modelBuilder.Entity<ImportedProduct>().ToTable("ImportedProduct");
Плюсы
- Быстрое получение общего списка в виде объектов базового типа;
- Возможность выноса "тяжелых" и редко используемых полей в отдельные таблицы.
Минусы
- Меньшая скорость (относительно остальных вариантов отображения) при чтении записей конкретного типа. Это происходит из-за необходимости объединения данных из двух таблиц.
Особенности удаления записей
При использовании режима "Таблица для каждого типа" Entity Framework не устанавливает признак каскадного удаления записей в таблицах для потомков. Давайте рассмотрим на примере к каким последствиям это может привести.
Предположим существуют следующие классы:
public class Product
{
public int Id { get; set; }
public int CategoryId { get; set; }
[StringLength(64)]
public string Title { get; set; }
public virtual Category Category { get; set; }
}
public class DetailedProduct : Product
{
public string Description { get; set; }
}
public class Category
{
public int Id { get; set; }
public string Title { get; set; }
public virtual ICollection<Product> Products { get; set; }
}
Пусть в базу данных занесены несколько экземпляров DetailedProduct. Вопрос: что будет если попробовать удалить из базы данных некую категорию (Category)? Вначале кажется что связанные с ней объекты типа Product также должны быть удалены. Однако, если попытаться выполнить код:
Category category = db.Categories.Find(id);
db.Categories.Remove(category);
db.SaveChanges();
то будет выброшено исключение, свидетельствующее о возможном нарушении целостности базы данных в результате данной операции.
Все дело в том, что Entity Framework попробует удалить все записи в таблице Products у которых CategoryId равно заданному значению. При отсутствии признака каскадного удаления, как было сказано выше, записи из DetailedProduct удаляться не будут. А поскольку Product и DetailedProduct связаны через внешний ключ, то результатом и будет исключение.
Решение проблемы с удалением записей
Добавить ON DELETE CASCADE
Самостоятельно добавить ON DELETE CASCADE например через SQL запрос или вручную. Минус такого решения – необходимость корректировать базу данных при каждом её создании. Однако скорость работы будет максимальной.
Получить список зависящих записей
Свойство Products класса Categories является навигационным, поэтому по умолчанию для него используется отложенная инициализация. Если заполнить его значениями, то Entity Framework может удалять конкретные экземпляры продуктов. В данном случае исходный код, представленный выше, можно переписать так:
Category category = db.Categories.Include("Products").FirstOrDefault(c => c.Id == id);
db.Categories.Remove(category);
db.SaveChanges();
Здесь метод Include() указывает на необходимость загрузки списка товаров при чтении данных выбранной категории. Ошибки при удалении в данном случае возникать не будет.