Wprowadzenie
Jedną z nowości, jaka pojawiła się w Entity Framework Core 5 jest obsługa relacji wiele do wielu bez konieczności dodawania klasy dla tabeli łączącej. Trochę to zajęło, szczególnie, że było to dostępne w zwykłym Entity Framework. Jednak, co istotne, systematycznie są dodawane kolejne rzeczy do Entity Framework Core. Bardzo fajne jest to, że z relacji możemy korzystać na dwa sposoby, o czym w tym wpisie 🙂
Relacja wiele do wielu
Relacje wiele do wielu realizuje się za pomocą dodatkowej tabeli łączącej, w której na ogół znajdują się dwie kolumny z identyfikatorami rekordów, które łączymy. Co widać poniżej, gdzie mamy tabelę Employees dla pracowników, Projects dla projektów oraz EmployeeProject, która jest właśnie tabelą łączącą.
Część ORMów ma wsparcie dla relacji wiele do wielu, dzięki czemu nie musimy tworzyć dodatkowej klasy łączącej, które będzie odpowiadała tabeli łączącej. W modelu obiektowym mamy dwie klasy, w przykładzie Employee oraz Project, które następnie mają właściwości typu ICollection do drugiej klasy. Sam ORM tłumaczy wszystko na tę dodatkową tabele łączącą.
Entity Framework Core od wersji 5 dołączył do grona ORMów, które zaczęły wspierać relacje wiele do wielu. W wcześniejszych wersjach niestety musieliśmy dodawać klasę łączącą.
Wiele do wielu w EF Core 5
Relacja wiele do wielu w przypadku Entity Framework Core 5 jest dość łatwa do realizacji. Gdy chcemy połączyć dwie klasy tego typu relacją, to wystarczy tylko dodać właściwość typu ICollection<typ> w drugiej klasie, co najlepiej widać na poniższym przykładzie, w którym łączymy klasę pracownika z klasą projektu. Pracownik może pracować nad kilkoma projektami oraz projekt może być realizowany przez kilku pracowników:
public class Employee | |
{ | |
public Guid Id { get; set; } = Guid.NewGuid(); | |
public string FirstName { get; set; } | |
public string LastName { get; set; } | |
public virtual ICollection<Project> Projects { get; set; } = new List<Project>(); | |
} |
public class Project | |
{ | |
public Guid Id { get; set; } = Guid.NewGuid(); | |
public string Name { get; set; } | |
public virtual ICollection<Employee> Employees { get; set; } = new List<Employee>(); | |
} |
Właściwości, o których mowa, są odpowiednio w linijce 9 klasy Employee oraz w linijce 7 klasy Project. W tym momencie nie jest potrzebna żadna dodatkowa konfiguracja, możemy bazować na domyślnych konwencjach.
Dla porządku jeszcze zawartość klasy DataContext (w przykładzie używam bazy Sqlite, aby łatwiej można było uruchomić projekt po jego pobraniu z githuba):
public class DataContext : DbContext | |
{ | |
public DataContext() | |
{ | |
} | |
public DataContext(DbContextOptions options) : base(options) | |
{ | |
} | |
public DbSet<Project> Projects { get; set; } | |
public DbSet<Employee> Employees { get; set; } | |
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | |
{ | |
optionsBuilder.UseSqlite("Filename=EFCoreManyToMany.db", options => | |
{ | |
options.MigrationsAssembly(this.GetType().Assembly.FullName); | |
}); | |
base.OnConfiguring(optionsBuilder); | |
} | |
} |
W efekcie wygenerowana migracja wygląda tak (jest w nich również tworzenie samych tabel Employees oraz Projects):
public partial class Initial : Migration | |
{ | |
protected override void Up(MigrationBuilder migrationBuilder) | |
{ | |
migrationBuilder.CreateTable( | |
name: "Employees", | |
columns: table => new | |
{ | |
Id = table.Column<Guid>(type: "TEXT", nullable: false), | |
FirstName = table.Column<string>(type: "TEXT", nullable: true), | |
LastName = table.Column<string>(type: "TEXT", nullable: true) | |
}, | |
constraints: table => | |
{ | |
table.PrimaryKey("PK_Employees", x => x.Id); | |
}); | |
migrationBuilder.CreateTable( | |
name: "Projects", | |
columns: table => new | |
{ | |
Id = table.Column<Guid>(type: "TEXT", nullable: false), | |
Name = table.Column<string>(type: "TEXT", nullable: true) | |
}, | |
constraints: table => | |
{ | |
table.PrimaryKey("PK_Projects", x => x.Id); | |
}); | |
migrationBuilder.CreateTable( | |
name: "EmployeeProject", | |
columns: table => new | |
{ | |
EmployeesId = table.Column<Guid>(type: "TEXT", nullable: false), | |
ProjectsId = table.Column<Guid>(type: "TEXT", nullable: false) | |
}, | |
constraints: table => | |
{ | |
table.PrimaryKey("PK_EmployeeProject", x => new { x.EmployeesId, x.ProjectsId }); | |
table.ForeignKey( | |
name: "FK_EmployeeProject_Employees_EmployeesId", | |
column: x => x.EmployeesId, | |
principalTable: "Employees", | |
principalColumn: "Id", | |
onDelete: ReferentialAction.Cascade); | |
table.ForeignKey( | |
name: "FK_EmployeeProject_Projects_ProjectsId", | |
column: x => x.ProjectsId, | |
principalTable: "Projects", | |
principalColumn: "Id", | |
onDelete: ReferentialAction.Cascade); | |
}); | |
migrationBuilder.CreateIndex( | |
name: "IX_EmployeeProject_ProjectsId", | |
table: "EmployeeProject", | |
column: "ProjectsId"); | |
} | |
protected override void Down(MigrationBuilder migrationBuilder) | |
{ | |
migrationBuilder.DropTable( | |
name: "EmployeeProject"); | |
migrationBuilder.DropTable( | |
name: "Employees"); | |
migrationBuilder.DropTable( | |
name: "Projects"); | |
} | |
} |
Jak widać migracja tworzy trzy tabele, z czego ostatnia EmployeeProject jest właśnie tą tabelą łączącą. Zawiera one dwie kolumny (EmployeesId oraz ProjectsId), które są kluczami obcymi oraz razem tworzą klucz główny tabeli.
Przykład działania relacji wiele do wielu
Samo użycie takiej relacji jest dość proste i Entity Framework Core uwzględni za nas istnienie tabeli łączącej. Na przykład dodanie obiektów do bazy wygląda tak:
private static async Task AddData() | |
{ | |
using (var db = new DataContext()) | |
{ | |
var project1 = new Project() | |
{ | |
Name = "project 1" | |
}; | |
await db.Projects.AddAsync(project1); | |
var project2 = new Project() | |
{ | |
Name = "project 2" | |
}; | |
await db.Projects.AddAsync(project2); | |
var employee = new Employee() | |
{ | |
FirstName = "Daniel", | |
LastName = "Plawgo", | |
Projects = new List<Project>() | |
{ | |
project1, | |
project2 | |
} | |
}; | |
await db.Employees.AddAsync(employee); | |
await db.SaveChangesAsync(); | |
} | |
} |
Powyższy fragment kodu tworzy dwa projekty i dodaje je do bazy. Następnie tworzy pracownika i przypisuje do niego wcześniej utworzone projekty. Tutaj po prostu dodajemy te projekty do kolekcji Projects w klasie pracownika.
W efekcie na bazie danych wykonają się takie zapytania (poniżej jest log z Entity Framework Core, z którego usunąłem zbędne rzeczy):
INSERT INTO "Employees" ("Id", "FirstName", "LastName") | |
VALUES (@p0, @p1, @p2); | |
INSERT INTO "Projects" ("Id", "Name") | |
VALUES (@p0, @p1); | |
INSERT INTO "Projects" ("Id", "Name") | |
VALUES (@p0, @p1); | |
INSERT INTO "EmployeeProject" ("EmployeesId", "ProjectsId") | |
VALUES (@p2, @p3); | |
INSERT INTO "EmployeeProject" ("EmployeesId", "ProjectsId") | |
VALUES (@p0, @p1); |
Jak widać Entity Framework Core sam obsłużył tabelę łączącą.
Podobny efekt jest w momencie pobierania danych z bazy:
private static async Task ShowData() | |
{ | |
using (var db = new DataContext()) | |
{ | |
var employee = await db.Employees | |
.Include(e => e.Projects) | |
.FirstOrDefaultAsync(); | |
Console.WriteLine($"{employee.FirstName} {employee.LastName}:"); | |
foreach (var project in employee.Projects) | |
{ | |
Console.WriteLine($"\t{project.Name}"); | |
} | |
} | |
} |
Powyższy fragment kodu pobiera pierwszego pracownika (dodanego wcześniej) wraz z jego projektami (wywołanie metody Include). W efekcie na bazie danych wykona się takie zapytanie:
SELECT "t"."Id", "t"."FirstName", "t"."LastName", "t0"."EmployeesId", "t0"."ProjectsId", "t0"."Id", "t0"."Name" | |
FROM ( | |
SELECT "e"."Id", "e"."FirstName", "e"."LastName" | |
FROM "Employees" AS "e" | |
LIMIT 1 | |
) AS "t" | |
LEFT JOIN ( | |
SELECT "e0"."EmployeesId", "e0"."ProjectsId", "p"."Id", "p"."Name" | |
FROM "EmployeeProject" AS "e0" | |
INNER JOIN "Projects" AS "p" ON "e0"."ProjectsId" = "p"."Id" | |
) AS "t0" ON "t"."Id" = "t0"."EmployeesId" | |
ORDER BY "t"."Id", "t0"."EmployeesId", "t0"."ProjectsId", "t0"."Id" |
Tutaj też dobrze widać obsługę tabeli łączącej (linijki 8-10) i nie musimy tego robić sami.
Dodanie klasy łączącej
A co w sytuacji, gdy jednak z czasem będziemy potrzebowali dodać jakieś dodatkowe dane w relacji? Na przykład potrzebujemy określić rolę użytkownika w projekcie. Czyli chcielibyśmy dodać kolumnę Role do EmployeeProject (dla uproszczenia przykładu dodam tekstową kolumnę zamiast klucza do tabeli z rolami).
W Entity Framework Core 5 fajne jest to, że możemy dodatkowo dodać typ dla tabeli łączącej obok już istniejącej relacji. Dzięki czemu nie będziemy musieli usuwać starej relacji z kodu, a co za tym idzie modyfikować kod w wielu miejscach. Możemy równolegle korzystać z jednego i drugiego.
Aby to zrealizować na początku musimy dodać nową klasę dla tabeli łączącej:
public class EmployeeProject | |
{ | |
public Guid EmployeesId { get; set; } | |
public virtual Employee Employee { get; set; } | |
public Guid ProjectsId { get; set; } | |
public virtual Project Project { get; set; } | |
public string Role { get; set; } | |
} |
W niej mamy po dwie właściwości (jedna dla klucza, druga dla obiektu) dla każdego końca relacji oraz dodatkową właściwość dla informacji o roli użytkownika (wspomniana wcześniej kolumna tekstowa Role).
Następnie w klasie Employee oraz Project dodajemy drugą kolekcję, która teraz będzie przechowywać obiekty EmployeeProject. Zwróć uwagę, że wcześniejsze kolekcje działają i dalej możemy z nich korzystać.
public class Employee | |
{ | |
public Guid Id { get; set; } = Guid.NewGuid(); | |
public string FirstName { get; set; } | |
public string LastName { get; set; } | |
public virtual ICollection<Project> Projects { get; set; } = new List<Project>(); | |
public virtual ICollection<EmployeeProject> EmployeeProjects { get; set; } = new List<EmployeeProject>(); | |
} |
public class Project | |
{ | |
public Guid Id { get; set; } = Guid.NewGuid(); | |
public string Name { get; set; } | |
public virtual ICollection<Employee> Employees { get; set; } = new List<Employee>(); | |
public virtual ICollection<EmployeeProject> EmployeeProjects { get; set; } = new List<EmployeeProject>(); | |
} |
Na końcu zostaje nam jeszcze skonfigurować w Entity Framework Core typ dla klasy łączącej. Na listingu poniżej wrzuciłem tylko zawartość metody OnModelCreating z klasy DataContext (w realnym projekcie użyłbym klasy EntityTypeConfiguration), pozostała jej część jest taka sama jak wyżej (całość również znajdziesz w githubie):
protected override void OnModelCreating(ModelBuilder modelBuilder) | |
{ | |
modelBuilder.Entity<Project>() | |
.HasMany(p => p.Employees) | |
.WithMany(p => p.Projects) | |
.UsingEntity<EmployeeProject>( | |
j => j | |
.HasOne(pt => pt.Employee) | |
.WithMany(t => t.EmployeeProjects) | |
.HasForeignKey(pt => pt.EmployeesId), | |
j => j | |
.HasOne(pt => pt.Project) | |
.WithMany(p => p.EmployeeProjects) | |
.HasForeignKey(pt => pt.ProjectsId), | |
j => | |
{ | |
j.Property(pt => pt.Role).HasDefaultValueSql("'employee'"); | |
j.HasKey(t => new { t.EmployeesId, t.ProjectsId }); | |
}); | |
} |
W powyższym fragmencie kodu konfigurujemy naszą relację wiele do wielu, gdzie kluczowe jest wywołanie metody UsingEntity (linijka 6). W niej właśnie określamy użycie dodatkowej klasy EmployeeProject i zależności między właściwościami.
Dodatkowo ostatni fragment jest również ważny, w szczególności linijka 17. W niej konfigurujemy domyślną wartość dla kolumny Role. Jest to o tyle istotne, że dalej możemy korzystać z wcześniejszych kolekcji, w których nie będziemy mogli ustawić wartości dla roli, wtedy będzie użyta właśnie ta wartość domyślna.
Co fajne, w tym momencie możemy korzystać ze starych właściwości z pierwszej wersji relacji oraz nowych z dodatkowym typem dla tabeli łączącej.
Działanie relacji z typem dla tabeli łączącej
Na początku zobaczmy, jak skorzystać z nowej kolekcji. Robimy to w identyczny sposób, jak byśmy zrobili, mając od razu relacje wiele do wielu z typem dla tabeli łączącej.
Dodanie nowego projektu dla użytkownika z rolą Owner może wyglądać tak:
private static async Task AddDataWithRole() | |
{ | |
using (var db = new DataContext()) | |
{ | |
var project3 = new Project() | |
{ | |
Name = "project 3" | |
}; | |
await db.Projects.AddAsync(project3); | |
var employee = await db.Employees | |
.FirstOrDefaultAsync(); | |
var employeeProject = new EmployeeProject() | |
{ | |
Project = project3, | |
Employee = employee, | |
Role = "Owner" | |
}; | |
employee.EmployeeProjects.Add(employeeProject); | |
await db.SaveChangesAsync(); | |
} | |
} |
Czyli pobieramy obiekt pracownika, tworzymy nową instancję klasy EmployeeProject i dodajemy do kolekcji EmployeeProjects. Na końcu wszystko zapisujemy.
Pobranie danych i ich wyświetlenie jest podobne jak wcześniej, tylko że ponownie korzystamy z kolekcji EmployeeProjects. Tym razem musimy wykonać jeszcze dodatkowego include, aby pobrać projekty:
private static async Task ShowDataWithRole() | |
{ | |
using (var db = new DataContext()) | |
{ | |
var employee = await db.Employees | |
.Include(e => e.EmployeeProjects) | |
.ThenInclude(p => p.Project) | |
.FirstOrDefaultAsync(); | |
Console.WriteLine($"{employee.FirstName} {employee.LastName}:"); | |
foreach (var item in employee.EmployeeProjects) | |
{ | |
Console.WriteLine($"\t{item.Project.Name} - {item.Role}"); | |
} | |
} | |
} |
W obu sytuacjach (korzystanie z właściwości Project oraz EmployeeProjects) powoduje to wykonanie na bazie danych tego samego zapytania. Do tego Entity Framework Core 5 po wykonaniu zapytania uzupełnia dane w obu właściwościach.
Przykład
Przykład do tego wpisu znajduje się na githubie (https://github.com/danielplawgo/EFCoreManyToMany). Po jego pobraniu możesz go od razu uruchomić. Aplikacja wykorzystuje Sqlite do przechowywania danych.
Podsumowanie
Fajnie, że Microsoft uzupełnia braki w Entity Framework Core w stosunku do starego Entity Framework. Co prawda w ostatnim czasie rzadko używałem relacji wielu do wielu bez klasy dla tabeli łączącej. Ale warto wiedzieć, że w Entity Framework Core można łatwo z czasem dodać klasę dla tabeli łączącej i dodać tam dodatkowe właściwości.
A co Ty o tym myślisz?
Dzięki!
Relację zrobić umiałem, ale zastosowania nie znałem i nie mogłem znaleźć.
W pierwszym przykładzie dwukrotnie dodajemy do bazy project1?
Hej Krzysztof!
Faktycznie wkradł tam się drobny błąd, ale w tym przypadku to on nie ma znaczenia. W tym przypadku Entity Framework Core nie doda drugi raz tego samego obiektu do bazy, bo metoda AddAsync oznacza go do dodania, natomiast dodaje się on fizycznie podczas zapisu w SaveChangesAsync.
Natomiast project2 dodaje się tutaj w sposób niejawny poprzez dodanie employee. Entity Framework dodaje również obiekty powiązane podczas dodawania jakiegoś obiektu. Czyli tutaj dodając obiekt employee, dodawany jest również i project2. Więc w tym przypadku tak naprawdę dodanie projektów jest zbędne, zostałyby on i tak dodane podczas dodawania pracownika.
Błąd poprawiłem, dzięki za zwrócenie uwagi!