Andrey on .NET | Entity Framework: Режимы отображения иерархии объектов

Entity Framework: Режимы отображения иерархии объектов

Для отображения иерархии объектов, связанных наследованием, 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() указывает на необходимость загрузки списка товаров при чтении данных выбранной категории. Ошибки при удалении в данном случае возникать не будет.

Комментарии (17) -

Так и не увидел различия в конфигурации режимов TPT и TPH. В своё время долго добивался поведения TPT.

Хорошая статья, хорошая оптимизация под телефон)

@ ShurikEv: Честно говоря даже не знаю что ответить, код конфигурации TPT и TPH абсолютно разный. Сам принцип организации таблиц тоже (TPH – все в одной, TPT – все основное в одной, уточнения в индивидуальных для каждого типа).

@ vlad: Пожалуйста за статью. А вот по поводу оптимизации для телефона – это заслуга авторов BlogEngine и мобильного шаблона. Есть правда сложности с отображением исходного кода, но его как на узких экранах не показывай – все равно визуально выходит каша.

@Andrey: Минимальные действия в обоих случаях одинаковы - определение иерархии классов

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; }
}

В одном случае можно сделать необязательное
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"));

во втором также не обязательное
modelBuilder.Entity<Product>().ToTable("Product");

modelBuilder.Entity<LocalProduct>().ToTable("LocalProduct");
modelBuilder.Entity<ImportedProduct>().ToTable("ImportedProduct");


Я ошибаюсь?

@ ShurikEv: Ошибаетесь. Во втором (TPT) конфигурация обязательна. Если её не определить будет вариант по умолчанию - TPH. А вот в первом случае (TPH), она действительно опциональна и нужна только для переопределения колонки Discriminator.

Если я не ошибаюсь, то для случая ТРТ

modelBuilder.Entity<Product>().ToTable("Product");

modelBuilder.Entity<LocalProduct>().ToTable("LocalProduct");
modelBuilder.Entity<ImportedProduct>().ToTable("ImportedProduct");

данную конфигурацию можно не писать, т.к. эта конфигурация не меняет описания классов.  Ведь достаточно добавить объекты в DbSet'ы и всё (по умолчанию создаются таблицы с тем же именем, как и классы называются).
Я не утверждаю, но именно этот факт поставил меня в тупик при реализации ТРТ.

@ ShurikEv: Если явно не указать конфигурацию, то вы получите вариант по умолчанию - TPH. Поэтому, используя Fluent API, необходимо определить как будут отражены классы в таблицах.

Ааа, вот в чем тонкость. Спасибо.

Спасибо большое!

DallasMarlboro 07.03.2012 15:38:17

Отличная статья, спасибо!

Как использовать классы (Table per hierarchy) в более сложных сценариях? Например, как замэппить класс D на иерархию A,B,C ?

    
public class A
    {
        public int Id { get; set; }
        public string TextA { get; set; }
        public D RefD { get; set; }
    }
    
    public class B : A
    {
        public string TextB { get; set; }
    }
    
    public class C : A
    {
        public string TextC { get; set; }
    }
    
    public class D
    {
        public int Id;
        
        public IList<A> ListA { get; set; }
        public IList<B> ListB { get; set; }
        public IList<C> ListC { get; set; }
    }

Что-то не сложилось с редактором кода. Андрей, если не сложно удалите два предыдущих поста.

@ SergPn: Ну с ходу вижу:
1) Задать у D primary key по соглашению EF CF: public int Id { get; set; }
2) Заменить IList на virtual ICollection (указать что это навигационные поля)
3) Указать связь А и D
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"))
    .HasOptional(m => m.RefD).WithMany();

@ Andrey:
Спасибо за оперативный ответ.

По примеру понятно, но в реальном проекте не получилось.
Вот упрощенный вариант классов:

    [Table("Activities", Schema = "Marketing")]
    public class Activity
    {
        [Key]
        public int ActivityId { get; set; }

        [Required, StringLength(64)]
        public string ActivityName { get; set; }

        public virtual ICollection<ActivityCalcItemBase> ActivityCalcItems { get; set; }
        public virtual ICollection<ActivityCalcItemDiscountCardType> DiscountCardTypes { get; set; }
    }

    [Table("ActivityCalcItems", Schema = "Marketing")]
    public class ActivityCalcItemBase
    {
        [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public Guid ObjectId { get; set; }

        [Required]//[ForeignKey("Activity")]
        public int ActivityId { get; set; }

        public virtual Activity MyActivity { get; set; }
    }

    [Table("ActivityCalcItems", Schema = "Marketing")]
    public class ActivityCalcItemDiscountCardType : ActivityCalcItemBase
    {
        public decimal? Discount { get; set; }
    }

    public class DbContextMarketing : DbContext
    {
        public DbSet<Activity> MarketingActivities { get; set; }
        public DbSet<ActivityCalcItemBase> ActivityCalcItems { get; set; }
        public DbSet<ActivityCalcItemDiscountCardType> ActivityCalcItemDiscountCardTypes { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.Entity<ActivityCalcItemBase>()
                .Map<ActivityCalcItemBase>(m => m.Requires("TypeListID").HasValue((byte)0))
                .Map<ActivityCalcItemDiscountCardType>(m => m.Requires("TypeListID").HasValue((byte)1))
                .HasRequired(a => a.MyActivity)
                .WithMany();
                //.HasForeignKey(f => f.ActivityId);
        }

    }

При обращении к классу ActivityCalcItemBase получаем запрос

SELECT TOP (2147483647)
[Extent1].[ActivityId] AS [ActivityId],
[Extent1].[TypeListID] AS [TypeListID],
[Extent1].[ObjectID] AS [ObjectID],
[Extent1].[Discount] AS [Discount],
[Extent1].[MyActivity_ActivityId] AS [MyActivity_ActivityId],
[Extent1].[Activity_ActivityId] AS [Activity_ActivityId],
[Extent1].[Activity_ActivityId1] AS [Activity_ActivityId1]
FROM [Marketing].[ActivityCalcItems] AS [Extent1]
WHERE [Extent1].[TypeListID] IN (1,0)

Т.е. система генерирует внешние ключи по соглашениям (MyActivity_ActivityId, Activity_ActivityId, Activity_ActivityId1). Хотя есть колонка ActivityId.
Не помогает HasForeignKey(f => f.ActivityId).
Возможно нужно явно связать классы Activity и ActivityCalcItemBase через Fluent Api, например,
            modelBuilder.Entity<Activity>()
                .HasMany(i => i.ActivityCalcItems)
                .WithRequired(a => a.MyActivity)
                .HasForeignKey(f => f.ActivityId);

            modelBuilder.Entity<Activity>()
                .HasMany(d => d.DiscountCardTypes)
                .WithRequired(a => a.MyActivity)
                .HasForeignKey(f => f.ActivityId);
Но получаем ошибку.
Так же, обращаю внимание на то, что в запросе нет join-а на табличку Msrketing.Activities.

Подскажите, пожалуйста, что не так делаю.

Предыдущий вопрос снимается.
Следующий код частично решил проблему:

            modelBuilder.Entity<ActivityCalcItemBase>()
                .Map<ActivityCalcItemBase>(m => m.Requires("TypeListID").HasValue((byte)0))
                .Map<ActivityCalcItemDiscountCardType>(m => m.Requires("TypeListID").HasValue((byte)1))
                .HasRequired(a => a.MyActivity)
                .WithMany(m => m.ActivityCalcItems)
                .HasForeignKey(f => f.ActivityId);

Но в запросе все равно генерируется не нужный внешний ключ Activity_ActivityId:

SELECT
  [Limit1].[ActivityId] AS [ActivityId],
  [Limit1].[TypeListID] AS [TypeListID],
  [Limit1].[ObjectID] AS [ObjectID],
  [Limit1].[DctID] AS [DctID],
  [Limit1].[Discount] AS [Discount],
  [Extent2].[ActivityId] AS [ActivityId1],
  [Extent2].[ActivityName] AS [ActivityName],
  [Limit1].[Activity_ActivityId] AS [Activity_ActivityId]
FROM   (
  SELECT TOP (2147483647) [Extent1].[ObjectID] AS [ObjectID], [Extent1].[ActivityId] AS [ActivityId],
    [Extent1].[Discount] AS [Discount], [Extent1].[Activity_ActivityId] AS [Activity_ActivityId], [Extent1].[TypeListID] AS [TypeListID]
  FROM   [Marketing].[ActivityCalcItems] AS [Extent1]
  WHERE   [Extent1].[TypeListID] IN (1,0)
  ) AS [Limit1]
  LEFT OUTER JOIN [Marketing].[Activities] AS [Extent2] ON [Limit1].[ActivityId] = [Extent2].[ActivityId]

Проблема в навигационном свойстве:
        public virtual ICollection<ActivityCalcItemDiscountCardType> DiscountCardTypes { get; set; }
Если его убрать, то лишний Activity_ActivityId не генерируется и все работает.

Как запретить генерацию или явно указать, что внешний ключ ActivityId для ActivityCalcItemDiscountCardType?

@ SergPn:
С ходу придумать такой вариант не могу (без удаления свойства DiscountCardTypes). Скорее всего это не возможно, т.к. EF не будет "сортировать" и при запросе все связанные с Activity будут в коллекции ActivityCalcItems. Для сортировки можно использовать if (activityItem is ActivityCalcItemDiscountCardType) ... (ну или подобный код на "as" и проверку на null).

@ Andrey:
Тоже пришел к такому выводу. Решил проблему следующим образом: пометил DiscountCardTypes атрибутом [NotMapped] и делаю выборку из ActivityCalcItems.
Т.е. имеем

        [Browsable(false)]
        public virtual IList<ActivityCalcItemBase> ActivityCalcItems { get; set; }

        [NotMapped]
        public virtual IList<ActivityCalcItemDiscountCardType> DiscountCardTypes
        {
            get
            {
                if (ActivityCalcItems == null)
                    return null;
                else
                    return ActivityCalcItems.OfType<ActivityCalcItemDiscountCardType>().ToList();
            }
        }

Спасибо за цикл статей по EFSmile
Жду выхода EF6 и продолжения статей...


@ SergPn: Я бы по другому сделал. Само свойство ActivityCalcItems назвал бы так, чтобы оно отражало что это хранилище всех Activity. Получение отдельных типов вообще вынес в методы расширения. Получится - сущность с данными отдельно, выборка по условиям отдельно, но в IntelliSense все под рукой. И никаких [NotMapped].

Добавить комментарий