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

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

Отношение многие-ко-многим (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
-------------------------------
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850