Руководство. Создание сложной модели данных — ASP.NET MVC с помощью EF Core

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

По завершении работы классы сущностей сформируют готовую модель данных, приведенную на следующем рисунке:

Entity diagram

Изучив это руководство, вы:

  • Настройка модели данных
  • Изменения сущности Student
  • Создание сущности Instructor
  • Создание сущности OfficeAssignment
  • Изменение сущности Course
  • Создание сущности Department
  • Изменение сущности Enrollment
  • Обновление контекста базы данных
  • Начальное заполнение базы данных тестовыми данными
  • Добавление миграции
  • Изменение строки подключения
  • Обновление базы данных

Необходимые компоненты

Настройка модели данных

В этом разделе вы узнаете, как настроить модель данных с помощью атрибутов, которые указывают правила форматирования, проверки и сопоставления базы данных. Затем в нескольких последующих разделах вы создаете всю модель данных School целиком, добавив атрибуты к уже созданным классам и создав классы для остальных типов сущностей в модели.

Атрибут DataType

Сейчас для дат зачисления студентов учащихся все веб-страницы отображают время и дату, хотя для этого поля достаточно одной даты. Используя атрибуты заметок к данным, вы можете внести в код одно изменение, позволяющее исправить формат отображения в каждом представлении, где отображаются эти данные. Чтобы рассмотреть соответствующий пример, вы добавите атрибут в свойство EnrollmentDate класса Student.

В Models/Student.cs добавьте оператор using для пространства имен System.ComponentModel.DataAnnotations, а также атрибуты DataType и DisplayFormat для свойства EnrollmentDate, как показано в следующем примере:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

Атрибут DataType позволяет указать тип данных с более точным определением по сравнению со встроенным типом базы данных. В этом случае требуется отслеживать только дату, а не дату и время. В перечислении DataType представлено множество типов данных, таких как Date, Time, PhoneNumber, Currency, EmailAddress и других. Атрибут DataType также обеспечивает автоматическое предоставление функций для определенных типов в приложении. Например, может быть создана ссылка mailto: для DataType.EmailAddress. Также в браузерах с поддержкой HTML5 может быть предоставлен селектор даты для DataType.Date. Атрибут DataType создает атрибуты HTML 5 data-, которые используются браузерами с поддержкой HTML 5. Атрибуты DataType не предназначены для проверки.

DataType.Date не задает формат отображаемой даты. По умолчанию поле данных отображается с использованием форматов, установленных в CultureInfo сервера.

С помощью атрибута DisplayFormat можно явно указать формат даты:

[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

Параметр ApplyFormatInEditMode указывает, что формат также должен применяться при отображении значения в текстовом поле для редактирования. (В некоторых случаях такое поведение нежелательно. Например, в текстовом поле для редактирования денежных значений обычно не требуется отображать символ валюты.)

Атрибут DisplayFormat можно использовать отдельно, однако чаще всего его рекомендуется применять вместе с атрибутом DataType. Атрибут DataType передает семантику данных (в отличие от способа их вывода на экран) и дает следующие преимущества по сравнению с DisplayFormat:

  • Поддержка функций HTML5 в браузере (отображение элемента управления календарем, соответствующего языковому стандарту символа валюты, ссылок электронной почты, проверки на стороне клиента и т. д.).

  • По умолчанию формат отображения данных в браузере определяется в соответствии с установленным языковым стандартом.

Дополнительные сведения см. в документации по вспомогательной функции тегов <input>.

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

Students index page showing dates without times

Атрибут StringLength

С помощью атрибутов также можно указать правила проверки данных и сообщения об ошибках проверки. Атрибут StringLength задает максимальную длину в базе данных и обеспечивает проверку на стороне клиента и на стороне сервера для ASP.NET Core MVC. В этом атрибуте также можно указать минимальную длину строки, но это минимальное значение не влияет на схему базы данных.

Предположим, вы хотите сделать так, чтобы пользователи не вводили больше 50 символов для имени. Чтобы задать это ограничение, добавьте атрибуты StringLength в свойства LastName и FirstMidName, как показано в следующем примере:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]
        public string LastName { get; set; }
        [StringLength(50)]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

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

[RegularExpression(@"^[A-Z]+[a-zA-Z]*$")]

Атрибут MaxLength предоставляет функциональность, аналогичную атрибуту StringLength, но не обеспечивает проверку на стороне клиента.

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

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

dotnet ef migrations add MaxLengthOnNames
dotnet ef database update

Команда migrations add выдает предупреждение о возможной потере данных, так как это изменение сокращает максимальную длину для двух столбцов. Функция миграций создает файл с именем <timeStamp>_MaxLengthOnNames.cs. Он содержит в методе Up код, который обновит базу данных в соответствии с текущей моделью данных. Команда database update запустила этот код.

Метка времени, добавленная в качестве префикса к имени файла миграций, используется платформой Entity Framework для упорядочения миграций. Вы можете создать несколько миграций перед выполнением команды update-database, после чего все миграции применяются в порядке их создания.

Запустите приложение, выберите вкладку Students (Учащиеся), щелкните Create New (Создать) и попробуйте ввести любое имя длиннее 50 символов. Приложение должно отобразить ошибку.

Атрибут Column

Вы также можете использовать атрибуты, чтобы управлять сопоставлением классов и свойств с базой данных. Предположим, что вы использовали имя FirstMidName для поля имени, так как это поле также может содержать отчество. Но вам нужно, чтобы столбец базы данных назывался FirstName, так как к этому имени привыкли пользователи, которые будут составлять нерегламентированные запросы к базе данных. Чтобы выполнить это сопоставление, можно использовать атрибут Column.

Атрибут Column указывает, что при создании базы данных столбец таблицы Student, сопоставляемый со свойством FirstMidName, будет называться FirstName. Другими словами, когда ваш код ссылается на Student.FirstMidName, данные будут браться из столбца FirstName таблицы Student или обновляться в нем. Если не указать имена столбцов, им назначается имя, совпадающее с именем свойства.

В файле Student.cs добавьте оператор using для System.ComponentModel.DataAnnotations.Schema и атрибут имени столбца в свойство FirstMidName, как показано в следующем выделенном коде:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [StringLength(50)]
        public string LastName { get; set; }
        [StringLength(50)]
        [Column("FirstName")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        public DateTime EnrollmentDate { get; set; }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

Добавление атрибута Column изменяет модель для поддержки SchoolContext, поэтому она не будет соответствовать базе данных.

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

dotnet ef migrations add ColumnFirstName
dotnet ef database update

В обозревателе объектов SQL Server откройте конструктор таблиц учащихся, дважды щелкнув таблицу Student (Учащийся).

Students table in SSOX after migrations

До применения двух первых миграций столбцы имен имели тип nvarchar(MAX). Теперь они относятся к типу nvarchar(50), а имя столбца изменилось с FirstMidName на FirstName.

Примечание.

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

Изменения сущности Student

Student entity

Замените Models/Student.csкод, добавленный ранее, следующим кодом. Изменения выделены.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Student
    {
        public int ID { get; set; }
        [Required]
        [StringLength(50)]
        [Display(Name = "Last Name")]
        public string LastName { get; set; }
        [Required]
        [StringLength(50)]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        public string FirstMidName { get; set; }
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Enrollment Date")]
        public DateTime EnrollmentDate { get; set; }
        [Display(Name = "Full Name")]
        public string FullName
        {
            get
            {
                return LastName + ", " + FirstMidName;
            }
        }

        public ICollection<Enrollment> Enrollments { get; set; }
    }
}

Атрибут Required

Атрибут Required делает свойства имен обязательными полями. Атрибут Required не нужен для типов, не допускающих значения null, например для типов значений (DateTime, int, double, float и т. д.). Типы, которые не могут принимать значение null, автоматически обрабатываются как обязательные поля.

Для применения MinimumLength нужно использовать атрибут Required с MinimumLength.

[Display(Name = "Last Name")]
[Required]
[StringLength(50, MinimumLength=2)]
public string LastName { get; set; }

Атрибут Display

Атрибут Display указывает, что заголовки для текстовых полей должны иметь вид "First Name" (Имя), "Last Name" (Фамилия), "Full Name" (Полное имя) и "Enrollment Date" (Дата зачисления) вместо имени свойства в каждом экземпляре (в котором не используется пробел для разделения слов).

Вычисляемое свойство FullName

FullName — это вычисляемое свойство, которое возвращает значение, созданное путем объединения двух других свойств. Поэтому оно имеет только метод доступа get, и в базе данных не будет создан столбец FullName.

Создание сущности Instructor

Instructor entity

Создайте Models/Instructor.cs, заменив код шаблона следующим кодом:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Instructor
    {
        public int ID { get; set; }

        [Required]
        [Display(Name = "Last Name")]
        [StringLength(50)]
        public string LastName { get; set; }

        [Required]
        [Column("FirstName")]
        [Display(Name = "First Name")]
        [StringLength(50)]
        public string FirstMidName { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Hire Date")]
        public DateTime HireDate { get; set; }

        [Display(Name = "Full Name")]
        public string FullName
        {
            get { return LastName + ", " + FirstMidName; }
        }

        public ICollection<CourseAssignment> CourseAssignments { get; set; }
        public OfficeAssignment OfficeAssignment { get; set; }
    }
}

Обратите внимание, что некоторые свойства являются одинаковыми в сущностях Student и Instructor. В руководстве по реализации наследования далее в этой серии вы выполните рефакторинг данного кода, чтобы устранить избыточность.

Несколько атрибутов можно расположить на одной строке, поэтому записать атрибуты HireDate можно следующим образом:

[DataType(DataType.Date),Display(Name = "Hire Date"),DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]

Свойства навигации CourseAssignments и OfficeAssignment

CourseAssignments и OfficeAssignment — это свойства навигации.

Преподаватель может проводить любое количество курсов, поэтому CourseAssignments определен как коллекция.

public ICollection<CourseAssignment> CourseAssignments { get; set; }

Если свойство навигации может содержать несколько сущностей, оно должно иметь тип списка, допускающий добавление, удаление и обновление записей. Вы можете указать тип ICollection<T> либо, например, тип List<T> или HashSet<T>. Если указан тип ICollection<T>, платформа EF по умолчанию создает коллекцию HashSet<T>.

Причина того, что это сущности CourseAssignment, описана ниже в разделе о связях многие ко многим.

Бизнес-правила университета Contoso указывают, что преподаватель может иметь не более одного кабинета, поэтому свойство OfficeAssignment содержит отдельную сущность OfficeAssignment (которая может иметь значение null, если кабинет не назначен).

public OfficeAssignment OfficeAssignment { get; set; }

Создание сущности OfficeAssignment

OfficeAssignment entity

Создайте Models/OfficeAssignment.cs, используя следующий код:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class OfficeAssignment
    {
        [Key]
        public int InstructorID { get; set; }
        [StringLength(50)]
        [Display(Name = "Office Location")]
        public string Location { get; set; }

        public Instructor Instructor { get; set; }
    }
}

Атрибут Key

Между сущностями Instructor и OfficeAssignment действует связь "один к нулю или к одному". Назначение кабинета существует только в связи с преподавателем, которому оно назначено, поэтому его первичный ключ также является внешним ключом для сущности Instructor. Однако Entity Framework не распознает InstructorID в качестве первичного ключа этой сущности автоматически, так как ее имя не соответствует соглашению об именовании ID или classnameID. Таким образом, атрибут Key используется для определения ее в качестве ключа:

[Key]
public int InstructorID { get; set; }

Атрибут Key также можно использовать, если сущность имеет собственный первичный ключ, но нужно задать для свойства имя, отличное от classnameID или ID.

По умолчанию EF считает ключ созданным не базой данных, так как столбец предназначен для идентифицирующего отношения.

Свойство навигации Instructor

Сущность Instructor имеет свойство навигации OfficeAssignment, допускающее значение null (так как у преподавателя может не быть назначения кабинета), а сущность OfficeAssignment имеет свойство навигации Instructor, не допускающее значение null (так как назначение кабинета не может существовать без преподавателя — InstructorID не допускает значение null). Когда сущность Instructor имеет связанную сущность OfficeAssignment, каждая из них имеет ссылку на другую в своем свойстве навигации.

Можно поместить атрибут [Required] в свойство навигации Instructor, чтобы указать, что должен присутствовать связанный преподаватель, однако это необязательно, так как внешний ключ InstructorID (который также является ключом для этой таблицы) не допускает значение null.

Изменение сущности Course

Course entity

Замените Models/Course.csкод, добавленный ранее, следующим кодом. Изменения выделены.

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Course
    {
        [DatabaseGenerated(DatabaseGeneratedOption.None)]
        [Display(Name = "Number")]
        public int CourseID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Title { get; set; }

        [Range(0, 5)]
        public int Credits { get; set; }

        public int DepartmentID { get; set; }

        public Department Department { get; set; }
        public ICollection<Enrollment> Enrollments { get; set; }
        public ICollection<CourseAssignment> CourseAssignments { get; set; }
    }
}

Сущность курса имеет свойство внешнего ключа DepartmentID, указывающее на связанную сущность Department, а также она имеет свойство навигации Department.

Платформа Entity Framework не требует добавлять свойство внешнего ключа в модель данных при наличии свойства навигации для связанной сущности. EF автоматически создает внешние ключи в базе данных по мере необходимости, а также создает для них теневые свойства. Однако наличие внешнего ключа в модели данных позволяет сделать обновления проще и эффективнее. Например, при извлечении сущности Course для редактирования сущность Department имеет значение NULL, если вы ее не загружаете. Таким образом, при обновлении сущности Course вам потребуется сначала получить сущность Department. Если свойство внешнего ключа DepartmentID включено в модель данных, получать сущность Department перед обновлением не нужно.

Атрибут DatabaseGenerated

Атрибут DatabaseGenerated с параметром None в свойстве CourseID указывает, что значения первичного ключа предоставлены пользователем, а не созданы базой данных.

[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Display(Name = "Number")]
public int CourseID { get; set; }

По умолчанию Entity Framework предполагает, что значения первичного ключа созданы базой данных. Именно это и требуется для большинства сценариев. Однако для сущностей Course вы будете использовать определяемый пользователем номер курса, например серия 1000 для одной кафедры, серия 2000 для другой и так далее.

Атрибут DatabaseGenerated также можно использовать для создания значения по умолчанию, как в случае, когда столбцы базы данных используются для записи даты, когда строка была создана или обновлена. Дополнительные сведения см. в разделе Созданные свойства.

Свойства внешнего ключа и навигации

Свойства внешнего ключа и свойства навигации в сущности Course отражают следующие связи:

Курс назначается одной кафедре, поэтому по указанным выше причинам имеется внешний ключ DepartmentID и свойство навигации Department.

public int DepartmentID { get; set; }
public Department Department { get; set; }

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

public ICollection<Enrollment> Enrollments { get; set; }

Курс могут вести несколько преподавателей, поэтому свойство навигации CourseAssignments является коллекцией (тип CourseAssignment описан ниже):

public ICollection<CourseAssignment> CourseAssignments { get; set; }

Создание сущности Department

Department entity

Создайте Models/Department.cs, используя следующий код:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Name { get; set; }

        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

        public Instructor Administrator { get; set; }
        public ICollection<Course> Courses { get; set; }
    }
}

Атрибут Column

Ранее атрибут Column использовался, чтобы изменить сопоставление имени столбца. В коде для сущности Department атрибут Column используется для изменения сопоставления типов данных SQL, поэтому столбец будет определяться с помощью типа money SQL Server в базе данных:

[Column(TypeName="money")]
public decimal Budget { get; set; }

Сопоставление столбцов обычно не требуется, так как Entity Framework выбирает подходящий тип данных SQL Server на основе типа среды CLR, определяемого вами для свойства. Тип decimal среды CLR сопоставляется с типом decimal SQL Server. Но в этом случае вы знаете, что столбец будет содержать суммы в валюте, хотя для этого лучше подходит тип данных money.

Свойства внешнего ключа и навигации

Свойства внешнего ключа и навигации отражают следующие связи:

Кафедра может иметь или не иметь администратора, и администратор всегда является преподавателем. Поэтому InstructorID свойство включается в качестве внешнего ключа в сущность Instructor, а после int обозначения типа добавляется знак вопроса, указывающий, что свойство допускает значение null. Свойство навигации называется Administrator, но содержит сущность Instructor:

public int? InstructorID { get; set; }
public Instructor Administrator { get; set; }

Кафедра может иметь несколько курсов, поэтому доступно свойство навигации Courses:

public ICollection<Course> Courses { get; set; }

Примечание.

По соглашению Entity Framework разрешает каскадное удаление для внешних ключей, не допускающих значение null, и связей многие ко многим. Это может привести к циклическим правилам каскадного удаления, которые вызывают исключение при попытке добавить миграцию. Например, если вы не определили свойство Department.InstructorID как допускающее значение NULL, EF настраивает правило каскадного удаления для удаления кафедры при удалении преподавателя, что вам не нужно. Если бизнес-правила требуют, чтобы свойство InstructorID не допускало значение null, используйте следующий оператор текучего API, чтобы отключить каскадное удаление для этой связи:

modelBuilder.Entity<Department>()
   .HasOne(d => d.Administrator)
   .WithMany()
   .OnDelete(DeleteBehavior.Restrict)

Изменение сущности Enrollment

Enrollment entity

Замените Models/Enrollment.csдобавленный ранее код следующим кодом:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public enum Grade
    {
        A, B, C, D, F
    }

    public class Enrollment
    {
        public int EnrollmentID { get; set; }
        public int CourseID { get; set; }
        public int StudentID { get; set; }
        [DisplayFormat(NullDisplayText = "No grade")]
        public Grade? Grade { get; set; }

        public Course Course { get; set; }
        public Student Student { get; set; }
    }
}

Свойства внешнего ключа и навигации

Свойства внешнего ключа и навигации отражают следующие связи:

Запись зачисления предназначена для одного курса, поэтому доступно свойство первичного ключа CourseID и свойство навигации Course:

public int CourseID { get; set; }
public Course Course { get; set; }

Запись зачисления предназначена для одного учащегося, поэтому доступно свойство первичного ключа StudentID и свойство навигации Student:

public int StudentID { get; set; }
public Student Student { get; set; }

Связи "многие ко многим"

Между сущностями Student и Course имеется связь "многие ко многим", а сущность Enrollmentвыступает в качестве таблицы соединения "многие ко многим" с полезными данными в базе данных. Фраза "с полезными данными" означает, что таблица Enrollment содержит дополнительные данные, кроме внешних ключей для присоединяемых таблиц (в данном случае — первичный ключ и свойство Grade).

На следующем рисунке показано, как выглядят эти связи на схеме сущностей. (Эта схема была создана с помощью Entity Framework Power Tools для EF 6.x. Создание схемы не является частью руководства, оно просто используется здесь в качестве примера.)

Student-Course many to many relationship

Каждая линия связи имеет 1 на одном конце и звездочку (*) на другом, указывая характер один ко многим.

Если таблица Enrollment не включала в себя сведения об оценках, ей потребуется содержать всего два внешних ключа — CourseID и StudentID. В данном случае это будет таблица соединения многие ко многим без полезных данных (ее также называют чистой таблицей соединения) в базе данных. Между сущностями Instructor и Course действует связь "многие ко многим", и следующим шагом является создание класса сущности, выступающего в качестве таблицы соединения без полезных данных.

EF Core поддерживает неявные таблицы соединения для связей "многие ко многим", но этот обучающий элемент не был обновлен, чтобы использовать неявную таблицу соединения. См. раздел Связи "многие ко многим" в обновленной версии этого учебника по Razor Pages.

Сущность CourseAssignment

CourseAssignment entity

Создайте Models/CourseAssignment.cs, используя следующий код:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class CourseAssignment
    {
        public int InstructorID { get; set; }
        public int CourseID { get; set; }
        public Instructor Instructor { get; set; }
        public Course Course { get; set; }
    }
}

Имена для сущностей соединения

В базе данных для связи многие ко многим между Instructor и Courses нужна таблица соединения, которая должна быть представлена набором сущностей. Обычно для сущности соединения используется имя EntityName1EntityName2, которое в данном случае будет иметь значение CourseInstructor. Однако рекомендуется выбрать имя, которое описывает эту связь. Модели данных создаются простыми и разрастаются, а соединения без полезных данных часто начинают включать эти данные позднее. Если вначале задать описательное имя сущности, его не потребуется менять позднее. Оптимально, если сущность соединения имеет собственное естественное имя (возможно, из одного слова) в бизнес-среде. Например, Books и Customers можно связать через Ratings. Для этой связи CourseAssignment подходит лучше, чем CourseInstructor.

Составной ключ

Так как внешние ключи не допускают значение null и совместно однозначно определяют каждую строку таблицы, отдельный первичный ключ не требуется. CourseID Свойства InstructorID должны функционировать как составной первичный ключ. Единственным способом указать составные первичные ключи для EF является текучий API (с помощью атрибутов это сделать невозможно). Настройка составного первичного ключа описана в следующем разделе.

Составной ключ позволяет использовать несколько строк для одного курса и несколько строк для одного преподавателя, а также не позволяет использовать несколько строк для одного преподавателя и курса. Сущность соединения Enrollment определяет собственный первичный ключ, поэтому подобные дубликаты возможны. Для предотвращения таких повторяющихся значений добавьте уникальный индекс для полей внешнего ключа или настройте Enrollment с первичным составным ключом аналогично CourseAssignment. Дополнительные сведения см. в разделе Индексы.

Обновление контекста базы данных

Добавьте следующий выделенный код в файл Data/SchoolContext.cs:

using ContosoUniversity.Models;
using Microsoft.EntityFrameworkCore;

namespace ContosoUniversity.Data
{
    public class SchoolContext : DbContext
    {
        public SchoolContext(DbContextOptions<SchoolContext> options) : base(options)
        {
        }

        public DbSet<Course> Courses { get; set; }
        public DbSet<Enrollment> Enrollments { get; set; }
        public DbSet<Student> Students { get; set; }
        public DbSet<Department> Departments { get; set; }
        public DbSet<Instructor> Instructors { get; set; }
        public DbSet<OfficeAssignment> OfficeAssignments { get; set; }
        public DbSet<CourseAssignment> CourseAssignments { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Course>().ToTable("Course");
            modelBuilder.Entity<Enrollment>().ToTable("Enrollment");
            modelBuilder.Entity<Student>().ToTable("Student");
            modelBuilder.Entity<Department>().ToTable("Department");
            modelBuilder.Entity<Instructor>().ToTable("Instructor");
            modelBuilder.Entity<OfficeAssignment>().ToTable("OfficeAssignment");
            modelBuilder.Entity<CourseAssignment>().ToTable("CourseAssignment");

            modelBuilder.Entity<CourseAssignment>()
                .HasKey(c => new { c.CourseID, c.InstructorID });
        }
    }
}

Этот код добавляет новые сущности и настраивает составной первичный ключ сущности CourseAssignment.

Сведения об альтернативе текучему API

Код в предыдущем методе OnModelCreating класса DbContext использует для настройки поведения EF текучий API. API называется "fluent", так как он часто используется при строке ряда вызовов методов в одну инструкцию, как в этом примере из EF Core документации:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property(b => b.Url)
        .IsRequired();
}

В этом руководстве текучий API используется только для сопоставления базы данных, которое невозможно выполнить с помощью атрибутов. Однако текучий API позволяет задать большинство правил форматирования, проверки и сопоставления, которые можно указать с помощью атрибутов. Некоторые атрибуты, такие как MinimumLength, невозможно применить с текучим API. Как упоминалось ранее, MinimumLength не изменяет схему, а лишь применяет правило проверки на стороне клиента и сервера.

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

Дополнительные сведения о сравнении атрибутов и текучего API см. в разделе Методы конфигурации.

Схема сущностей, показывающая связи

Ниже показана схема, создаваемая средствами Entity Framework Power Tools для завершенной модели School.

Entity diagram

Кроме линий связей "один ко многим" (1 к *), здесь можно видеть линию связи "один к нулю или к одному" (1 к 0...1) между сущностями Instructor и OfficeAssignment, а также линию связи "нуль или один ко многим" (0...1 к *) между сущностями Instructor и Department.

Начальное заполнение базы данных тестовыми данными

Замените код в Data/DbInitializer.cs файле следующим кодом, чтобы предоставить начальные данные для созданных сущностей.

using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ContosoUniversity.Models;

namespace ContosoUniversity.Data
{
    public static class DbInitializer
    {
        public static void Initialize(SchoolContext context)
        {
            //context.Database.EnsureCreated();

            // Look for any students.
            if (context.Students.Any())
            {
                return;   // DB has been seeded
            }

            var students = new Student[]
            {
                new Student { FirstMidName = "Carson",   LastName = "Alexander",
                    EnrollmentDate = DateTime.Parse("2010-09-01") },
                new Student { FirstMidName = "Meredith", LastName = "Alonso",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Arturo",   LastName = "Anand",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Gytis",    LastName = "Barzdukas",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Yan",      LastName = "Li",
                    EnrollmentDate = DateTime.Parse("2012-09-01") },
                new Student { FirstMidName = "Peggy",    LastName = "Justice",
                    EnrollmentDate = DateTime.Parse("2011-09-01") },
                new Student { FirstMidName = "Laura",    LastName = "Norman",
                    EnrollmentDate = DateTime.Parse("2013-09-01") },
                new Student { FirstMidName = "Nino",     LastName = "Olivetto",
                    EnrollmentDate = DateTime.Parse("2005-09-01") }
            };

            foreach (Student s in students)
            {
                context.Students.Add(s);
            }
            context.SaveChanges();

            var instructors = new Instructor[]
            {
                new Instructor { FirstMidName = "Kim",     LastName = "Abercrombie",
                    HireDate = DateTime.Parse("1995-03-11") },
                new Instructor { FirstMidName = "Fadi",    LastName = "Fakhouri",
                    HireDate = DateTime.Parse("2002-07-06") },
                new Instructor { FirstMidName = "Roger",   LastName = "Harui",
                    HireDate = DateTime.Parse("1998-07-01") },
                new Instructor { FirstMidName = "Candace", LastName = "Kapoor",
                    HireDate = DateTime.Parse("2001-01-15") },
                new Instructor { FirstMidName = "Roger",   LastName = "Zheng",
                    HireDate = DateTime.Parse("2004-02-12") }
            };

            foreach (Instructor i in instructors)
            {
                context.Instructors.Add(i);
            }
            context.SaveChanges();

            var departments = new Department[]
            {
                new Department { Name = "English",     Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Abercrombie").ID },
                new Department { Name = "Mathematics", Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Fakhouri").ID },
                new Department { Name = "Engineering", Budget = 350000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Harui").ID },
                new Department { Name = "Economics",   Budget = 100000,
                    StartDate = DateTime.Parse("2007-09-01"),
                    InstructorID  = instructors.Single( i => i.LastName == "Kapoor").ID }
            };

            foreach (Department d in departments)
            {
                context.Departments.Add(d);
            }
            context.SaveChanges();

            var courses = new Course[]
            {
                new Course {CourseID = 1050, Title = "Chemistry",      Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Engineering").DepartmentID
                },
                new Course {CourseID = 4022, Title = "Microeconomics", Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Economics").DepartmentID
                },
                new Course {CourseID = 4041, Title = "Macroeconomics", Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "Economics").DepartmentID
                },
                new Course {CourseID = 1045, Title = "Calculus",       Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "Mathematics").DepartmentID
                },
                new Course {CourseID = 3141, Title = "Trigonometry",   Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "Mathematics").DepartmentID
                },
                new Course {CourseID = 2021, Title = "Composition",    Credits = 3,
                    DepartmentID = departments.Single( s => s.Name == "English").DepartmentID
                },
                new Course {CourseID = 2042, Title = "Literature",     Credits = 4,
                    DepartmentID = departments.Single( s => s.Name == "English").DepartmentID
                },
            };

            foreach (Course c in courses)
            {
                context.Courses.Add(c);
            }
            context.SaveChanges();

            var officeAssignments = new OfficeAssignment[]
            {
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Fakhouri").ID,
                    Location = "Smith 17" },
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Harui").ID,
                    Location = "Gowan 27" },
                new OfficeAssignment {
                    InstructorID = instructors.Single( i => i.LastName == "Kapoor").ID,
                    Location = "Thompson 304" },
            };

            foreach (OfficeAssignment o in officeAssignments)
            {
                context.OfficeAssignments.Add(o);
            }
            context.SaveChanges();

            var courseInstructors = new CourseAssignment[]
            {
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Kapoor").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Microeconomics" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Zheng").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Macroeconomics" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Zheng").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Calculus" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Fakhouri").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Trigonometry" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Harui").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Composition" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Abercrombie").ID
                    },
                new CourseAssignment {
                    CourseID = courses.Single(c => c.Title == "Literature" ).CourseID,
                    InstructorID = instructors.Single(i => i.LastName == "Abercrombie").ID
                    },
            };

            foreach (CourseAssignment ci in courseInstructors)
            {
                context.CourseAssignments.Add(ci);
            }
            context.SaveChanges();

            var enrollments = new Enrollment[]
            {
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID,
                    Grade = Grade.A
                },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics" ).CourseID,
                    Grade = Grade.C
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alexander").ID,
                    CourseID = courses.Single(c => c.Title == "Macroeconomics" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                        StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Calculus" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                        StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Trigonometry" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Alonso").ID,
                    CourseID = courses.Single(c => c.Title == "Composition" ).CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry" ).CourseID
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Anand").ID,
                    CourseID = courses.Single(c => c.Title == "Microeconomics").CourseID,
                    Grade = Grade.B
                    },
                new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Barzdukas").ID,
                    CourseID = courses.Single(c => c.Title == "Chemistry").CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Li").ID,
                    CourseID = courses.Single(c => c.Title == "Composition").CourseID,
                    Grade = Grade.B
                    },
                    new Enrollment {
                    StudentID = students.Single(s => s.LastName == "Justice").ID,
                    CourseID = courses.Single(c => c.Title == "Literature").CourseID,
                    Grade = Grade.B
                    }
            };

            foreach (Enrollment e in enrollments)
            {
                var enrollmentInDataBase = context.Enrollments.Where(
                    s =>
                            s.Student.ID == e.StudentID &&
                            s.Course.CourseID == e.CourseID).SingleOrDefault();
                if (enrollmentInDataBase == null)
                {
                    context.Enrollments.Add(e);
                }
            }
            context.SaveChanges();
        }
    }
}

Как можно было заметить в первом руководстве, основная часть кода просто создает объекты сущности и загружает демонстрационные данные в свойства для тестирования. Обратите внимание, как обрабатываются связи многие ко многим: код формирует связи, создавая сущности в наборах сущностей соединения Enrollments и CourseAssignment.

Добавление миграции

Сохраните изменения и выполните сборку проекта. Затем откройте командное окно в папке проекта и введите команду migrations add (команду update-database пока не выполняйте):

dotnet ef migrations add ComplexDataModel

Вы получаете предупреждение о возможной потере данных.

An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.
Done. To undo this action, use 'ef migrations remove'

Если попытаться выполнить команду database update на этом этапе (пока этого делать не нужно), возникнет следующая ошибка:

The ALTER TABLE statement conflicted with the FOREIGN KEY constraint "FK_dbo.Course_dbo.Department_DepartmentID". The conflict occurred in database "ContosoUniversity", table "dbo.Department", column 'DepartmentID'. (Оператор ALTER TABLE конфликтовал с ограничением FOREIGN KEY "FK_dbo.Course_dbo.Department_DepartmentID". Конфликт возник в столбце "DepartmentID" таблицы "dbo.Department" базы данных "ContosoUniversity".)

Иногда при выполнении миграций с существующими данными необходимо вставить данные-заглушки в базу данных для соблюдения ограничений внешнего ключа. Созданный код в методе Up добавляет внешний ключ DepartmentID, не допускающий значение NULL, в таблицу Course. Если при запуске кода в таблице Course уже имеются строки, операция AddColumn завершается неудачей, так как SQL Server не знает, какое значение поставить в столбце, который не допускает значение null. В этом учебнике вы запустите миграцию в новую базу данных, но в реальном приложении потребуется обеспечить обработку существующих данных в этой миграции, соответствующий пример приведен ниже.

Чтобы заставить эту миграцию работать с существующими данными, нужно изменить код, чтобы присвоить новому столбцу значение по умолчанию, а также создать кафедру-заглушку с именем "Temp" для использования по умолчанию. В результате существующие строки Course будут связаны с кафедрой "Temp" после выполнения метода Up.

  • Откройте файл {timestamp}_ComplexDataModel.cs.

  • Закомментируйте строку кода, которая добавляет столбец DepartmentID в таблицу Course.

    migrationBuilder.AlterColumn<string>(
        name: "Title",
        table: "Course",
        maxLength: 50,
        nullable: true,
        oldClrType: typeof(string),
        oldNullable: true);
                
    //migrationBuilder.AddColumn<int>(
    //    name: "DepartmentID",
    //    table: "Course",
    //    nullable: false,
    //    defaultValue: 0);
    
  • Добавьте выделенный ниже код после кода, создающего таблицу Department:

    migrationBuilder.CreateTable(
        name: "Department",
        columns: table => new
        {
            DepartmentID = table.Column<int>(nullable: false)
                .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
            Budget = table.Column<decimal>(type: "money", nullable: false),
            InstructorID = table.Column<int>(nullable: true),
            Name = table.Column<string>(maxLength: 50, nullable: true),
            StartDate = table.Column<DateTime>(nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Department", x => x.DepartmentID);
            table.ForeignKey(
                name: "FK_Department_Instructor_InstructorID",
                column: x => x.InstructorID,
                principalTable: "Instructor",
                principalColumn: "ID",
                onDelete: ReferentialAction.Restrict);
        });
    
    migrationBuilder.Sql("INSERT INTO dbo.Department (Name, Budget, StartDate) VALUES ('Temp', 0.00, GETDATE())");
    // Default value for FK points to department created above, with
    // defaultValue changed to 1 in following AddColumn statement.
    
    migrationBuilder.AddColumn<int>(
        name: "DepartmentID",
        table: "Course",
        nullable: false,
        defaultValue: 1);
    

В реальном приложении вам потребуется написать код или сценарии для добавления строк Department, а также для связи строк Course с новыми строками Department. После этого кафедра "Temp" и значение по умолчанию в столбце Course.DepartmentID вам больше не понадобятся.

Сохраните изменения и выполните сборку проекта.

Изменение строки подключения

Теперь у вас есть новый код в классе DbInitializer, который добавляет начальные данные для новых сущностей в пустую базу данных. Чтобы указать EF создать пустую базу данных, в файле appsettings.json измените имя базы данных в строке подключения на ContosoUniversity3 или другое имя, которое вы еще не использовали на компьютере, с которым работаете.

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ContosoUniversity3;Trusted_Connection=True;MultipleActiveResultSets=true"
  },

Сохраните изменения в файл appsettings.json.

Примечание.

Вместо изменения имени базы данных можно удалить ее. Воспользуйтесь обозревателем объектов SQL Server (SSOX) или командой интерфейса командной строки database drop:

dotnet ef database drop

Обновление базы данных

После изменения имени базы данных или ее удаления запустите команду database update в командном окне, чтобы выполнить миграции.

dotnet ef database update

Запустите приложение, чтобы метод DbInitializer.Initialize запустился и заполнил новую базу данных.

Откройте базу данных в SSOX, как уже делали это раньше, а затем разверните узел Таблицы, чтобы увидеть все созданные таблицы. (Если SSOX уже был открыт, нажмите кнопку Обновить.)

Tables in SSOX

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

Щелкните правой кнопкой мыши таблицу CourseAssignment и выберите пункт Просмотреть данные, чтобы убедиться в наличии данных.

CourseAssignment data in SSOX

Получение кода

Скачайте или ознакомьтесь с готовым приложением.

Следующие шаги

Изучив это руководство, вы:

  • Настройка модели данных
  • Изменения сущности Student
  • Создание сущности Instructor
  • Создание сущности OfficeAssignment
  • Изменение сущности Course
  • Создание сущности Department
  • Изменение сущности Enrollment
  • Обновление контекста базы данных
  • Начальное заполнение базы данных тестовыми данными
  • Добавление миграции
  • Изменение строки подключения
  • Обновление базы данных

В следующем руководстве описано, как получить доступ к связанным данным.