Настройка привязки модели к базе данных

198

В предыдущих примерах, когда мы каждый раз добавляли новый класс модели, мы добавляли ссылку на него в виде свойства в классе контекста, унаследованного от DbContext. Это свойство имело тип DbSet, типизированный классом модели данных. DbSet выполняет две функции. Во-первых при вызове свойства контекста данных он возвращает коллекцию объектов, полученных из базы данных. Во-вторых он указывает классу DbModelBuilder, выполняющему привязку модели к базе данных, что этот класс модели должен отображаться на таблицу базы данных.

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

  1. Использовать типизированное свойство DbSet в классе контекста.

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

  3. Можно использовать настройки Fluent API.

Допустим у нас имеется следующая простая модель:

public class Customer
{
        public int CustomerId { get; set; }
        public string FirstName { get; set; }

        // Ссылка на заказы
        public virtual List<Order> Orders { get; set; }
}

public class Order
{
        public int OrderId { get; set; }
        public string ProductName { get; set; }

        // Ссылка на покупателя
        public Customer Customer { get; set; }
}

Если вы использовали эту модель ранее, удалите ссылки на сущностные объекты DbSet в классе контекста. Чтобы указать связывателю модели, что класс Customer должен отображаться на таблицу в базе данных, можно использовать свойство Configurations объекта DbModelBuilder, как показано в примере ниже:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
     modelBuilder.Configurations.Add(
         new EntityTypeConfiguration<Customer>());
}

Методу Add() передается объект EntityTypeConfiguration, типизированный нужным классом модели. Если вы помните, при рассмотрении подхода Code-First мы использовали унаследованные классы конфигурации от EntityTypeConfiguration. Экземпляры этих классов также можно передавать методу Add() объекта конфигурации.

Установка классов и свойств модели без привязки к таблице

Ваша модель данных может содержать классы, которые не требуется отражать в виде таблиц в базе данных. Даже если вы явно не укажите эти классы в DbSet класса контекста, все равно возможны ситуации, когда Code-First попытается создать из них таблицы. Например, как было сказано выше указывать класс Order в DbSet не обязательно, т.к. класс Customer автоматически ссылается на него через навигационное свойство.

Что делать, если нам нужно указать Code-First, что класс Order не должен отображаться в базе данных? Для этих целей в аннотациях данных используется специальный атрибут NotMapped, который применяется к классу модели. В Fluent API это реализовано с помощью метода Ignore() класса DbModelBuilder. В примере ниже показано, как использовать эти средства:

[NotMapped]
public class Order
{
     // ...
}


// то же самое с помощью Fluent API

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
     modelBuilder.Ignore<Order>();
}

Если вы запустите этот пример, то в базе данных будет создана только таблица Customer. Свойство Customer.Orders фактически больше не является навигационным, т.к. класс Order больше не является объектом базы данных.

Entity Framework также позволяет не отображать конкретные свойства класса модели – зачастую, в классах требуется использовать дополнительные свойства, которые не должны быть отражены на столбцы таблицы базы данных. Для этого используются также, атрибут NotMapped и метод Ignore(), только они указываются на уровне свойства, а не на уровне класса:

public class Customer
{
    public int CustomerId { get; set; }
    public string FirstName { get; set; }

    [NotMapped]
    public bool HelperProperty { get; set; }

    // ...
}


// то же самое с помощью Fluent API

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>().Ignore(c => c.HelperProperty);
}

Привязка свойств и их доступность

В Code-First есть ряд соглашений, которые определяют как должны привязываться различные свойства модели к столбцам таблицы. Ранее мы подробно на этом моменте не останавливались, так что давайте более подробно рассмотрим эти соглашения. В разделах ниже приводится список правил по отображению свойств модели и описываются способы их конфигурирования.

Скалярные свойства

Скалярные свойства будут отображаться на столбцы только в том случае, если их тип поддерживается в модели EDM (Entity Data Model), иначе говоря, если этот тип можно будет преобразовать в SQL-совместимый тип данных. Допустимыми типами EDM являются: Binary, Boolean, Byte, DateTime, DateTimeOffset, Time, Decimal, Double, Guid, Int16, Int32, Int64, SByte, Single и String. Скалярные свойства, которые не могут быть отображены на тип EDM игнорируются (например, перечисления и целые числа без знака – ulong, long и т.д.)

Модификаторы доступа и поддержка чтения/записи для свойств

Напомню, в C# поддержка к записи для свойств определяется оператором set, к чтению – оператором get. Ранее в каждой модели данных мы использовали автоматически определяемые свойства. Также, каждое свойство помечается модификатором доступа: public, protected, internal или private. Code-First использует следующие соглашения в плане доступности свойств и поддержки чтения/записи:

  1. Все открытые свойства (модификатор public) будут автоматически отображены на столбец таблицы в базе данных.

  2. Доступ к записи для свойства можно указать явно, используя оператор set, но при этом доступ к чтению свойства должен быть открытым, чтобы Code-First автоматически отобразил его в таблице.

  3. Закрытые свойства (помеченные модификаторами private, internal или protected) автоматически не отображаются на таблицу, но это поведение можно изменить используя Fluent API.

Для конфигурации закрытых свойств вы должны быть в состоянии получить доступ к свойству в том месте, где вы выполняете конфигурацию. Например, если у вас есть класс Customer со свойством LastName, помеченным модификатором internal и он находится в той же сборке что и класс контекста данных, у вас будет доступ к этому свойству из метода OnModelCreating():

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
     modelBuilder.Entity<Customer>().Property(c => c.LastName);
}

Однако, если классы Customer и SampleContext (наш класс контекста из примеров) находятся в разных сборках, настраивать закрытое свойство таким образом нельзя. Эту проблему можно решить, если вы определите класс конфигурации (унаследованный от EntityTypeConfiguration<Customer>) в той же сборке, где находится класс Customer, добавите ссылку на эту сборку в сборке, где находится класс контекста и в методе OnModelCreating() укажите объекту DbModelBuilder ссылку на эту конфигурацию.

Таким же способом можно определить закрытые и защищенные свойства (модификаторы private и protected). Важно запомнить, чтобы этот подход работал, класс конфигурации должен быть вложен в класс модели, к которому он применяется. Ниже показан пример для класса Customer, в котором мы определяем закрытое свойство LastName и класс конфигурации, вложенный в класс Customer:

public class Customer
{
    public int CustomerId { get; set; }
    public string FirstName { get; set; }

    private string LastName { get; set; }

    public class CustomerConfig : EntityTypeConfiguration<Customer>
    {
        public CustomerConfig()
        {
        this.Property(c => c.LastName);
        }
    }
}

Обратите внимание, для того чтобы указать Code-First, что свойство является частью таблицы, достаточно просто вызвать метод Property() в объекте конфигурации без использования дополнительных методов.

Теперь сослаться на эту конфигурации можно в классе контекста, используя Fluent API:

// Следующий метод указывается в классе контекста

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
     modelBuilder.Configurations.Add(new Customer.CustomerConfig());
}

Наследование в классах модели

Entity Framework поддерживает различные иерархии наследования в модели. Ранее, при обсуждении возможностей Entity Framework мы не обращали внимание на возможность наследования классов модели, поэтому у вас может возникнуть вопрос о том, как Code-First работает с такими моделями.

Отображение Table Per Hierarchy (TPH)

Отображение “таблица на иерархию” (Table Per Hierarchy - TPH) используется в Code-First по умолчанию и означает, что иерархия унаследованных классов отображается на одну таблицу в базе данных. Чтобы увидеть это отображение в действие, давайте изменим модель и укажем класс User, унаследованный от Customer, добавляющий два новых свойства:

public class Customer
{
    public int CustomerId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

public class User : Customer
{
    public int Age { get; set; }

    [Column(TypeName="image")]
    public byte[] Photo { get; set; }
}

Для такой модели Entity Framework сгенерирует одну таблицу Customers, имеющую следующую структуру:

Структура базы данных при отношении TPH между классами модели

Теперь данные из этих двух классов хранятся в одной общей таблице. Обратите внимание, что Code-First создаст дополнительный столбец Discriminator имеющий тип NVARCHAR(128). По умолчанию, Code-First будет использовать имя класса каждого типа в иерархии в качестве значения, хранящегося в столбце дискриминатора. Например, если вы попытаетесь выполнить следующий код, в котором в таблицу вставляются данные заказчика с помощью класса Customer, столбец Discriminator будет хранить имя “Customer”:

protected void Page_Load(object sender, EventArgs e)
{
    Customer customer = new Customer
    {
        FirstName = "Вася",
        LastName = "Пупкин"
    };

    SampleContext context = new SampleContext();

    context.Customers.Add(customer);
    context.SaveChanges();
}

Если вы используете для вставки данных класс User, как показано в примере ниже, то Code-First вставит в столбец Discriminator значение “User”:

protected void Page_Load(object sender, EventArgs e)
{
    User user = new User
    {
        FirstName = "Вася",
        LastName = "Пупкин",
        Age = 20,
        Photo = new byte[100]
    };

    SampleContext context = new SampleContext();

    context.Customers.Add(user);
    context.SaveChanges();
}

Фактически, с помощью этого столбца Entity Framework отслеживает, какой класс в иерархии наследования использовался для изменения/вставки данных в таблицу.

У вас есть возможность настроить имя и тип специального столбца Discriminator, а также возможные значения, которые будут использоваться для разграничения типов. Эти настройки не поддерживаются в аннотациях данных (т.к. столбец Discriminator генерируется автоматически), поэтому нужно использовать Fluent API:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .Map(m =>
        {
            m.Requires("CustomerType").HasValue("Добавлено из Customer");
        })
        .Map<User>(m =>
        {
            m.Requires("CustomerType").HasValue("Добавлено из User");
        });
}

В этой настройки для указания имени столбца дискриминатора используется метод Requires() объекта конфигурации EntityMappingConfiguration. Мы явно указали имя CustomerType для столбца дискриминатора. С помощью метода HasValue() можно установить значение, которое будет генерироваться для каждого класса. Теперь, если вы вставите запись в таблицу Customers с помощью класса User, столбец CustomerType будет хранить значение “Добавлено из User”.

Можно также изменить тип столбца дискриминатора. Например, если вам известно, что в иерархии классов модели используются всего два класса (как, в нашем примере), то можно определить столбец IsUser типа bool (в SQL тип BIT), который будет указывать, добавлена ли строка с помощью Customer, либо с помощью User:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .Map(m =>
        {
            m.Requires("IsUser").HasValue(false);
        })
        .Map<User>(m =>
        {
            m.Requires("IsUser").HasValue(true);
        });
}

Структура таблицы Customers с использованием нового столбца IsUser показана на рисунке ниже:

Изменение имени и типа столбца конфигурации

Отображение Table Per Type (TPT)

В то время, как отображение TPH хранит одну таблицу для иерархии наследованных типов, отображение “таблица на тип” (Table Per Type - TPT) позволяет создать таблицу для каждого типа, при этом в таблицах, отображающих наследуемые классы хранится внешний ключ, ссылающийся на их родительский класс (таблицу). Если ваша схема базы данных должна использовать отдельные таблицы для иерархии классов, вы должны явно сконфигурировать производные. Для этого вам нужно просто указать имя таблицы для производного типа. Вы можете сделать это с помощью аннотаций данных или Fluent API:

[Table("User")]
public class User : Customer
{
     // ...
}

// то же самое с помощью Fluent API

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .Map<User>(m =>
        {
            m.ToTable("User");
        });
}

В результате запуска этого примера, Code-First создаст две таблицы: Customers и User. На рисунке ниже показана их структура:

Структура базы данных при отношении TPT между классами модели

В таблице User есть столбец CustomerId, который является первичным ключом этой таблицы и внешним ключом, ссылающимся на таблицу Customers, т.е. между таблицами было создано отношение ноль-или-один-к-одному. Если вам интересно, EF не включил каскадное удаление для этого внешнего ключа, при удалении записи из таблицы Customers, Code-First автоматически позаботится об удалении связанной записи из таблицы User, если она существует.

Когда вы добавляете новую запись с помощью класса User, Code-First сначала создаст новую запись в таблице Customers, а затем вставит новую запись в таблицу User, ссылающуюся на созданную запись в таблице Customers.

Отображение Table Per Concrete Type (TPC)

Отображение “таблица на конкретный тип” (Table Per Concrete Type - TPC) похоже на отображение TPT тем, что при таком отображении для каждого класса в иерархии наследования создается таблица. Но при этом таблицы, созданные на основе этой иерархии никак не связаны, и в каждой таблице отображается весь набор свойств из родительских классов. Такое отображение бывает полезно когда вы хотите расширить старую таблицу при этом не изменяя ее и не удаляя, а просто создав новую таблицу со старыми и новыми столбцами.

Вы можете настроить отображение TPC с использованием Fluent API (аннотации данных для этого отображения не поддерживаются). Давайте изменим отображение для наших классов Customer и User. Для настройки TPC используется вспомогательный метод MapInheritedProperties(), который доступен только на объекте EntityMappingConfiguration, т.е. в вызове метода Map(). Ниже показан соответствующий пример:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ToTable("Customer")
        .Map<User>(m =>
        {
            m.ToTable("User");
            m.MapInheritedProperties();
        });
}

Обратите внимание, что вы должны явно включить отображение имени таблицы с помощью метода ToTable() для базовой таблицы. С отображением TPT это не требуется, но с TPC это является обязательным. Метод MapInheritedProperties() указывает Code-First, что он должен отобразить все свойства, унаследованные от базового класса к новым колонкам в таблице, созданной на основе производного типа. Структура таблиц Customer и User выглядит следующим образом:

Структура базы данных при отношении TPC между классами модели

Абстрактные классы модели

Все создаваемые ранее классы модели не были абстрактными, это означает, что в коде мы могли создавать объекты этих классов. В C# поддерживается возможность создания абстрактных классов, экземпляры которых нельзя создавать в коде. Возникает вопрос, как работает Entity Framework с такими классами модели?

Давайте изменим нашу модель классов Customer-User, и укажем, что класс Customer должен являться абстрактным, а также добавим новый класс Client, который наследуется от Customer:

public abstract class Customer
{
    public int CustomerId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

public class User : Customer
{
    public int? Age { get; set; }

    [Column(TypeName="image")]
    public byte[] Photo { get; set; }
}

public class Client : Customer
{
    public string AddressCity { get; set; }
    public string AddressStreet { get; set; }
}

Теперь вы не можете создавать экземпляры класса Customer, поэтому удалите создание этих объектов, если вы использовали их ранее где-то в приложении. Также удалите настройки TPC в методе OnModelCreating(), в результате чего Code-First будет использовать по умолчанию подход для отображения TPH – все поля из производных классов будут содержаться в таблице Customers.

Если вы запустите этот пример, то увидите, что структура таблицы Customers идентична той, что у нас получалась при рассмотрении TPH, за тем лишь исключением, что были добавлены поля из класса Client.

Фактически поведение Code-First при использовании абстрактных классов идентично поведению для обычных классов, но при этом меняется способ взаимодействия с таблицей Customers. Для вставки данных в эту таблицу вы можете использовать объекты унаследованных классов Client и User. Если вы реализуете отображение TPC, очевидно, что при использовании абстрактного класса вы потеряете доступ к вставке/изменению данных в таблице Customers.

Пройди тесты
Лучший чат для C# программистов