Отношение многие-ко-многим (many-to-many) представляет связь, при которой объект одной сущности может ссылаться на множество объектов второй сущности, а объект второй сущности, в свою очередь, может ссылаться на множество объектов первой сущности. Примером подобного отношения может служить посещение студентами университетских курсов. Один студент может посещать сразу несколько курсов, и, в свою очередь, один курс может посещаться множеством студентов.
Например, определим следующие классы моделей и контекста:
using Microsoft.EntityFrameworkCore; public class Course { public int Id { get; set; } public string? Name { get; set; } public List<Student> Students { get; set; } = new(); } public class Student { public int Id { get; set; } public string? Name { get; set; } public List<Course> Courses { get; set; } = new(); } public class ApplicationContext : DbContext { public DbSet<Course> Courses { get; set; } = null!; public DbSet<Student> Students { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("Data Source=helloapp.db"); } }
В данном случае все студенты, поступившие на курс, будут помещаться в свойство Students класса Course. Аналогично, все курсы студента будут храниться в свойстве Courses класса Student. То есть стандартная связь многие ко многие. Однако, при создании базы данных в ней будет три таблицы. Например, в случае с SQLite они будут выглядеть следующим образом:
CREATE TABLE "Courses" ( "Id" INTEGER NOT NULL, "Name" TEXT, CONSTRAINT "PK_Courses" PRIMARY KEY("Id" AUTOINCREMENT) ); CREATE TABLE "Students" ( "Id" INTEGER NOT NULL, "Name" TEXT, CONSTRAINT "PK_Students" PRIMARY KEY("Id" AUTOINCREMENT) ); CREATE TABLE "CourseStudent" ( "CoursesId" INTEGER NOT NULL, "StudentsId" INTEGER NOT NULL, CONSTRAINT "FK_CourseStudent_Courses_CoursesId" FOREIGN KEY("CoursesId") REFERENCES "Courses"("Id") ON DELETE CASCADE, CONSTRAINT "FK_CourseStudent_Students_StudentsId" FOREIGN KEY("StudentsId") REFERENCES "Students"("Id") ON DELETE CASCADE, CONSTRAINT "PK_CourseStudent" PRIMARY KEY("CoursesId","StudentsId") );
То есть в реальности на уровне базы данных создается промежуточная таблица, которая хранит связи между студентами и курсами. Тем не менее на уровне кода C# нам не надо создавать промежуточную сущность, Entity Framework Core начиная с версии 5.0 умеет управлять подобной связью.
Рассмотрим, как мы можем работать с моделями в связи многие ко многим. Добавление:
using (ApplicationContext db = new ApplicationContext()) { // пересоздадим базу данных db.Database.EnsureDeleted(); db.Database.EnsureCreated(); // создание и добавление моделей Student tom = new Student { Name = "Tom" }; Student alice = new Student { Name = "Alice" }; Student bob = new Student { Name = "Bob" }; db.Students.AddRange(tom, alice, bob); Course algorithms = new Course { Name = "Алгоритмы" }; Course basics = new Course { Name = "Основы программирования" }; db.Courses.AddRange(algorithms, basics); // добавляем к студентам курсы tom.Courses.Add(algorithms); tom.Courses.Add(basics); alice.Courses.Add(algorithms); bob.Courses.Add(basics); db.SaveChanges(); }
Стоит отметить, что здесь мы добавляем курсы к студентам, но также можем сделать и наоборот - добавить студентов к курсам:
algorithms.Students.AddRange(new List<Student>() { tom, bob });
Вывод данных:
using Microsoft.EntityFrameworkCore; using (ApplicationContext db = new ApplicationContext()) { var courses = db.Courses.Include(c => c.Students).ToList(); // выводим все курсы foreach (var c in courses) { Console.WriteLine($"Course: {c.Name}"); // выводим всех студентов для данного кура foreach (Student s in c.Students) Console.WriteLine($"Name: {s.Name}"); Console.WriteLine("-------------------"); } }
Консольный вывод:
Course: Алгоритмы Name: Tom Name: Alice ------------------------------- Course: Основы программирования Name: Tom Name: Bob -------------------------------
Обновление данных (например, удалим у студента один курс и добавим другой):
using (ApplicationContext db = new ApplicationContext()) { Student? alice = db.Students.Include(s => s.Courses).FirstOrDefault(s => s.Name == "Alice"); Course? algorithms = db.Courses.FirstOrDefault(c => c.Name == "Алгоритмы"); Course? basics = db.Courses.FirstOrDefault(c => c.Name == "Основы программирования"); if (alice != null && algorithms != null && basics != null) { // удаление курса у студента alice.Courses.Remove(algorithms); // добавление нового курса студенту alice.Courses.Add(basics); db.SaveChanges(); } }
Удаление же студента или курса из базы данных приведет к тому, что все строки из промежуточной таблицы, которые связаны с удаляемым объектом, также будут удалены:
Student? student = db.Students.FirstOrDefault(); db.Students.Remove(student); db.SaveChanges();
EF Core позволяет сконфигурировать отношение многие ко многие. Обычно подобная конфигурация требуется для настройки промежуточной таблицы. Например, мы хотим переопределить название таблицы, которая создается для промежуточной сущности.
public class ApplicationContext : DbContext { public DbSet<Course> Courses { get; set; } = null!; public DbSet<Student> Students { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("Data Source=helloapp.db"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Course>() .HasMany(c => c.Students) .WithMany(s => s.Courses) .UsingEntity(j => j.ToTable("Enrollments")); } }
Последний метод в цепочке - UsingEntity позволяет настроить промежуточную таблицу. Фактически объект, представленный буквой j как раз представляет условную промежуточную сущность, для которой создается таблица. Так, в данном случае промежуточная таблица будет называться "Enrollments".
EF Core автоматически создает промежуточную таблицу с двумя столбцами, через которые она связана с двумя другими таблицами. Однако иногда может потребоваться добавить в промежуточную таблицу еще какие-то данные. Например, в случае со студентами и курсами мы бы могли хранить в промежуточной таблице также дату поступления студента на выбранный курс. В этом случае на уровне кода C# лучше создать помежуточную сущность, которая будет содержать описание данных, которые мы хотим определить. Например:
public class Course { public int Id { get; set; } public string? Name { get; set; } public List<Student> Students { get; set; } = new(); public List<Enrollment> Enrollments { get; set; } = new(); } public class Student { public int Id { get; set; } public string? Name { get; set; } public List<Course> Courses { get; set; } = new(); public List<Enrollment> Enrollments { get; set; } = new(); } public class Enrollment { public int StudentId { get; set; } public Student? Student { get; set; } public int CourseId { get; set; } public Course? Course { get; set; } public int Mark { get; set; } // оценка студента }
Для определения данных, которые будут храниться в промежуточной таблице, здесь определена промежуточная сущность Enrollment, которая содержит навигационные свойства на сущности Student и Course, а также содержит дополнительное свойство Mark (оценка студента).
Для настройки связи определим следующий контекст данных:
using Microsoft.EntityFrameworkCore; public class ApplicationContext : DbContext { public DbSet<Course> Courses { get; set; } = null!; public DbSet<Student> Students { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("Data Source=helloapp.db"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .Entity<Course>() .HasMany(c => c.Students) .WithMany(s => s.Courses) .UsingEntity<Enrollment>( j => j .HasOne(pt => pt.Student) .WithMany(t => t.Enrollments) .HasForeignKey(pt => pt.StudentId), j => j .HasOne(pt => pt.Course) .WithMany(p => p.Enrollments) .HasForeignKey(pt => pt.CourseId), j => { j.Property(pt => pt.Mark).HasDefaultValue(3); j.HasKey(t => new { t.CourseId, t.StudentId }); j.ToTable("Enrollments"); }); } }
Для конфигурации промежуточной сущности Enrollment также используется метод UsingEntity>Enrollment<()
.
Вначале настраиваются внешние ключи таблиц:
j => j .HasOne(pt => pt.Student) .WithMany(t => t.Enrollments) .HasForeignKey(pt => pt.StudentId), // связь с таблицей Students через StudentId j => j .HasOne(pt => pt.Course) .WithMany(p => p.Enrollments) .HasForeignKey(pt => pt.CourseId), // связь с таблицей Courses через CourseId
В последней части настраиваем свойства сущности Enrollment, а также имя соответствующей таблицы и ее ключи:
j => { j.Property(pt => pt.Mark).HasDefaultValue(3); j.HasKey(t => new { t.CourseId, t.StudentId }); j.ToTable("Enrollments"); });
Здесь для конфигурации таблицы и столбцов мы можем применять все ранее рассмотренные методы, например, HasDefaultValue
, который
для столбца Mark устанавливает значение по умолчанию - число 3.
На уровне базы данных в случае с SQLite для промежуточной сущности будет создаваться следующая таблица:
CREATE TABLE "Enrollments" ( "StudentId" INTEGER NOT NULL, "CourseId" INTEGER NOT NULL, "Mark" INTEGER NOT NULL DEFAULT 3, CONSTRAINT "FK_Enrollments_Courses_CourseId" FOREIGN KEY("CourseId") REFERENCES "Courses"("Id") ON DELETE CASCADE, CONSTRAINT "FK_Enrollments_Students_StudentId" FOREIGN KEY("StudentId") REFERENCES "Students"("Id") ON DELETE CASCADE, CONSTRAINT "PK_Enrollments" PRIMARY KEY("CourseId","StudentId") );
Теперь рассмотрим некоторые операции. Добавление:
using (ApplicationContext db = new ApplicationContext()) { // пересоздадим базу данных db.Database.EnsureDeleted(); db.Database.EnsureCreated(); // создание и добавление моделей Student tom = new Student { Name = "Tom" }; Student alice = new Student { Name = "Alice" }; Student bob = new Student { Name = "Bob" }; db.Students.AddRange(tom, alice, bob); Course algorithms = new Course { Name = "Алгоритмы" }; Course basics = new Course { Name = "Основы программирования" }; db.Courses.AddRange(algorithms, basics); // добавляем к студентам курсы tom.Enrollments.Add(new Enrollment { Course = algorithms }); tom.Courses.Add(basics); alice.Enrollments.Add(new Enrollment { Course = algorithms, Mark = 4 }); bob.Enrollments.Add(new Enrollment { Course = basics }); db.SaveChanges(); }
Особенностью работы с данными в данном случае будет то, что мы можем использовать два способа для установки связи одной сущности с другой:
tom.Enrollments.Add(new Enrollment { Course = algorithms, EnrollmentDate = DateTime.Now }); tom.Courses.Add(basics);
В первом случае устанавливаем связь студента с курсом через добавление объекта Enrollment, который обеспечивает эту связь. Во втором случае добавляем курс напрямую в коллекцию курсов студента, что в сою очередь также автоматически добавить данные в связующую таблицу Enrollments.
Вывод данных:
using (ApplicationContext db = new ApplicationContext()) { var courses = db.Courses.Include(c => c.Students).ToList(); // выводим все курсы foreach (var c in courses) { Console.WriteLine($"Course: {c.Name}"); // выводим всех студентов для данного кура foreach (Student s in c.Students) Console.WriteLine($"Name: {s.Name}"); Console.WriteLine("-------------------"); } }
Консольный вывод:
Course: Алгоритмы Name: Tom Name: Alice ------------------------------- Course: Основы программирования Name: Tom Name: Bob -------------------------------
Либо задействуем промежуточную сущность:
using (ApplicationContext db = new ApplicationContext()) { var courses = db.Courses.Include(c => c.Students).ToList(); // выводим все курсы foreach (var c in courses) { Console.WriteLine($"Course: {c.Name}"); // выводим всех студентов для данного кура foreach (var s in c.Enrollments) Console.WriteLine($"Name: {s.Student?.Name} Mark: {s.Mark}"); Console.WriteLine("-------------------"); } }
Консольный вывод:
Course: Алгоритмы Name: Tom Mark: 3 Name: Alice Mark: 4 ------------------------------- Course: Основы программирования Name: Tom Mark: 3 Name: Bob Mark: 3 -------------------------------