Отношение многие ко многим

Данное руководство устарело. Актуальное руководство: Руководство по Entity Framework Core 7

Последнее обновление: 18.11.2020

Еще одним способом ассоциации объектов является связь многие-ко-многим (many-to-many). Примером подобного отношения может служить посещение студентами университетских курсов. Один студент может посещать сразу несколько курсов, и, в свою очередь, один курс может посещаться множеством студентов.

Связь многие-ко-многим в EF Core 5

Начиная с версии Entity Framework Core 5.0 во фреймворке появилась возможность автоматической генерации связи многие ко многим. И вначале рассмотрим подобную возможность, а потом посмотрим что было до версии 5.0.

Например, определим следующие классы моделей и контекста:

public class Course
{
	public int Id { get; set; }
	public string Name { get; set; }
	public List<Student> Students { get; set; }= new List<Student>();
}
public class Student
{
	public int Id { get; set; }
	public string Name { get; set; }
	public List<Course> Courses { get; set; } = new List<Course>();
}
public class ApplicationContext : DbContext
{
	public DbSet<Course> Courses { get; set; }
	public DbSet<Student> Students { get; set; }

	protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
	{
		optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=relationsdb;Trusted_Connection=True;");
	}
}

В данном случае все студенты, поступившие на курс, будут помещаться в свойство Students класса Course. Аналогично, все курсы студента будут храниться в свойстве Courses класса Student. То есть стандартная связь многие ко многие. Однако, при создании базы данных в ней будет три таблицы:

CREATE TABLE [dbo].[Courses] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NULL,
    CONSTRAINT [PK_Courses] PRIMARY KEY CLUSTERED ([Id] ASC)
);

CREATE TABLE [dbo].[Students] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NULL,
    CONSTRAINT [PK_Students] PRIMARY KEY CLUSTERED ([Id] ASC)
);

CREATE TABLE [dbo].[CourseStudent] (
    [CoursesId]  INT NOT NULL,
    [StudentsId] INT NOT NULL,
    CONSTRAINT [PK_CourseStudent] PRIMARY KEY CLUSTERED ([CoursesId] ASC, [StudentsId] ASC),
    CONSTRAINT [FK_CourseStudent_Courses_CoursesId] FOREIGN KEY ([CoursesId]) REFERENCES [dbo].[Courses] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_CourseStudent_Students_StudentsId] FOREIGN KEY ([StudentsId]) REFERENCES [dbo].[Students] ([Id]) ON DELETE CASCADE
);

То есть в реальности на уровне базы данных создается промежуточная таблица, которая хранит связи между студентами и курсами. Тем не менее на уровне кода 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 (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 5 позволяет сконфигурировать отношение многие ко многие. Как правило, подобная конфигурация требуется для настройки промежуточной таблицы. Например, мы хотим переопределить название таблицы, которая создается для промежуточной сущности.

public class ApplicationContext : DbContext
{
	public DbSet<Course> Courses { get; set; }
	public DbSet<Student> Students { get; set; }

	protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
	{
		optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=relationsdb;Trusted_Connection=True;");
	}
	
	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 5 автоматически создает промежуточную таблицу с двумя столбцами, через которые она связана с двумя другими таблицами. Однако иногда может потребоваться добавить в промежуточную таблицу еще какие-то данные. Например, в случае со студентами и курсами мы бы могли хранить в промежуточной таблице также дату поступления студента на выбранный курс. В этом случае на уровне кода C# лучше создать помежуточную сущность, которая будет содержать описание данных, которые мы хотим определить. Например:

using System;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;

namespace HelloApp
{
	
    public class Course
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public List<Student> Students { get; set; }= new List<Student>();
        public List<Enrollment> Enrollments { get; set; } = new List<Enrollment>();

    }
    public class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public List<Course> Courses { get; set; } = new List<Course>();
        public List<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
    }
    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; }       // оценка студента
        public DateTime EnrollmentDate { get; set; } // дата зачисления
    }
    public class ApplicationContext : DbContext
    {
        public DbSet<Course> Courses { get; set; }
        public DbSet<Student> Students { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=relationsdb;Trusted_Connection=True;");
        }
		
        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.EnrollmentDate).HasDefaultValueSql("CURRENT_TIMESTAMP");
                    j.Property(pt => pt.Mark).HasDefaultValue(3);
                    j.HasKey(t => new { t.CourseId, t.StudentId });
                    j.ToTable("Enrollments");
                });
        }
    }
}

Для определения данных, которые будут храниться в промежуточной таблице, здесь определена промежуточная сущность Enrollment, которая содержит навигационные свойства на сущности Student и Course, а также содержит два дополнительных свойства EnrollmentDate (дата зачисления на курс) и Mark (оценка студента).

Для конфигурации промежуточной сущности 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.EnrollmentDate).HasDefaultValueSql("CURRENT_TIMESTAMP");
	j.Property(pt => pt.Mark).HasDefaultValue(3);
	j.HasKey(t => new { t.CourseId, t.StudentId });
	j.ToTable("Enrollments");
});

Здесь для конфигурации таблицы и столбцов мы можем применять все ранее рассмотренные методы, например, HasDefaultValueSql использует встроенную в T-SQL функцию CURRENT_TIMESTAMP для установки значения по умолчанию. Также для столбца Mark будет устанавливаться значение по умолчанию - число 3.

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

CREATE TABLE [dbo].[Enrollments] (
    [StudentId]      INT           NOT NULL,
    [CourseId]       INT           NOT NULL,
    [Mark]           INT           DEFAULT ((3)) NOT NULL,
    [EnrollmentDate] DATETIME2 (7) DEFAULT (getdate()) NOT NULL,
    CONSTRAINT [PK_Enrollments] PRIMARY KEY CLUSTERED ([CourseId] ASC, [StudentId] ASC),
    CONSTRAINT [FK_Enrollments_Courses_CourseId] FOREIGN KEY ([CourseId]) REFERENCES [dbo].[Courses] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_Enrollments_Students_StudentId] FOREIGN KEY ([StudentId]) REFERENCES [dbo].[Students] ([Id]) ON DELETE CASCADE
);

Теперь рассмотрим некоторые операции. Добавление:

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, EnrollmentDate = DateTime.Now });
	tom.Courses.Add(basics);
	alice.Enrollments.Add(new Enrollment { Course = algorithms, EnrollmentDate = DateTime.Now, Mark = 4 });
	bob.Enrollments.Add(new Enrollment  { Course = basics, EnrollmentDate = DateTime.Now });

	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}  Date: {s.EnrollmentDate.ToShortDateString()}  Mark: {s.Mark}");
		Console.WriteLine("-------------------");
	}
}

Консольный вывод:

Course: Алгоритмы
Name: Tom  Date: 17.11.2000 Mark: 3
Name: Alice  Date: 17.11.2000 Mark: 4
-------------------------------
Course: Основы программирования
Name: Tom  Date: 17.11.2000 Mark: 3
Name: Bob  Date: 17.11.2000 Mark: 3
-------------------------------

Связь многие-ко-многим до EntityFramework Core 5

Также рассмотрим, как можно работать со связью многие ко многим в EntityFramework Core до версии 5.0.

public class ApplicationContext : DbContext
{
    public DbSet<Course> Courses { get; set; }
    public DbSet<Student> Students { get; set; }
	
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<StudentCourse>()
            .HasKey(t => new { t.StudentId, t.CourseId });

        modelBuilder.Entity<StudentCourse>()
            .HasOne(sc => sc.Student)
            .WithMany(s => s.StudentCourses)
            .HasForeignKey(sc => sc.StudentId);

        modelBuilder.Entity<StudentCourse>()
            .HasOne(sc => sc.Course)
            .WithMany(c => c.StudentCourses)
            .HasForeignKey(sc => sc.CourseId);
    }
	
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=relationsdb;Trusted_Connection=True;");
    }
}

public class Course
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<StudentCourse> StudentCourses { get; set; }
	
	public Course()
    {
        StudentCourses = new List<StudentCourse>();
    }
}
public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<StudentCourse> StudentCourses { get; set; }
	
	public Student()
    {
        StudentCourses = new List<StudentCourse>();
    }
}
public class StudentCourse
{
    public int StudentId { get; set; }
    public Student Student { get; set; }

    public int CourseId { get; set; }
    public Course Course { get; set; }
	
	public System.DateTime EnrollmentDate { get; set; }
}

Фактически связь многие-ко-многим здесь разбивается на две связи один-ко-многим: один Student - много StudentCourse, один Course - много StudentCourse. Класс StudentCourse в этом случае выполняет роль связующей сущности.

В итоге в создаваемой базе данных будет три таблицы:

CREATE TABLE [dbo].[Courses] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NULL,
    CONSTRAINT [PK_Courses] PRIMARY KEY CLUSTERED ([Id] ASC)
);

CREATE TABLE [dbo].[Students] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NULL,
    CONSTRAINT [PK_Students] PRIMARY KEY CLUSTERED ([Id] ASC)
);

CREATE TABLE [dbo].[StudentCourse] (
    [StudentId]       INT           NOT NULL,
    [CourseId]        INT           NOT NULL,
    [EnrollmentDate] DATETIME2 (7) NOT NULL,
    CONSTRAINT [PK_StudentCourse] PRIMARY KEY CLUSTERED ([StudentId] ASC, [CourseId] ASC),
    CONSTRAINT [FK_StudentCourse_Courses_CourseId] FOREIGN KEY ([CourseId]) REFERENCES [dbo].[Courses] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_StudentCourse_Students_StudentId] FOREIGN KEY ([StudentId]) REFERENCES [dbo].[Students] ([Id]) ON DELETE CASCADE
);

Используем модели. Добавление данных:

using (ApplicationContext db = new ApplicationContext())
{
	// пересоздадим базу данных
	db.Database.EnsureDeleted();
    db.Database.EnsureCreated();
			
    Student s1 = new Student { Name = "Tom"};
    Student s2 = new Student { Name = "Alice" };
    Student s3 = new Student { Name = "Bob" };
    db.Students.AddRange(s1, s2, s3);

    Course c1 = new Course { Name = "Алгоритмы" };
    Course c2 = new Course { Name = "Основы программирования" };
    db.Courses.AddRange(c1, c2);

    // добавляем к студентам курсы
	s1.StudentCourses.Add(new StudentCourse { Course = c1, Student = s1, EnrollmentDate = DateTime.Now });
	s2.StudentCourses.Add(new StudentCourse { Course = c1, Student = s2, EnrollmentDate = DateTime.Now });
	s2.StudentCourses.Add(new StudentCourse { Course = c2, Student = s2, EnrollmentDate = DateTime.Now });
				
    db.SaveChanges();
}

Получение данных:

    
using (ApplicationContext db = new ApplicationContext())
{
    var courses = db.Courses.Include(c => c.StudentCourses)
							.ThenInclude(sc => sc.Student)
							.ToList();
	// выводим все курсы
    foreach (var c in  courses)
    {
        Console.WriteLine($"\nCourse: {c.Name}");
		// выводим всех студентов для данного кура
        foreach (StudentCourse sc in c.StudentCourses)
			Console.WriteLine($"Name: {sc.Student?.Name}  Date: {sc.EnrollmentDate.ToShortDateString()}");
		Console.WriteLine("-------------------");	// для красоты
    }
}

Консольный вывод:

Course: Алгоритмы
Name: Tom  Date: 17.11.2020
Name: Alice  Date: 17.11.2020
-------------------------------
Course: Основы программирования
Name: Alice  Date: 17.11.2020
-------------------------------

Редактирование:

// удаление курса у студента
Student student = db.Students.Include(s=>s.StudentCourses).FirstOrDefault(s => s.Name == "Alice");
Course course = db.Courses.FirstOrDefault(c => c.Name == "Алгоритмы");
if (student != null && course != null)
{
    var studentCourse = student.StudentCourses.FirstOrDefault(sc => sc.CourseId == course.Id);
    student.StudentCourses.Remove(studentCourse);
    db.SaveChanges();
}
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850