Teilen über


M:n-Beziehungen

M:N-Beziehungen werden verwendet, wenn eine beliebige Anzahl von Entitäten eines Entitätstyps einer beliebigen Anzahl von Entitäten desselben oder eines anderen Entitätstyps zugeordnet ist. Beispielsweise kann ein Post vielen Tags zugeordnet sein, und jedes Tag kann wiederum einer beliebigen Anzahl von Posts zugeordnet sein.

Grundlegendes zu M:N-Beziehungen

M:N-Beziehungen unterscheiden sich von 1:N- und 1:1-Beziehungen darin, dass sie nicht einfach mithilfe eines Fremdschlüssels dargestellt werden können. Stattdessen wird ein zusätzlicher Entitätstyp benötigt, um die beiden Seiten der Beziehung zu „verknüpfen“. Dies wird als „Joinentitätstyp“ bezeichnet und entspricht einer „Jointabelle“ in einer relationalen Datenbank. Die Entitäten dieses Joinentitätstyps enthalten Paare von Fremdschlüsselwerten, wobei eines der Paare auf eine Entität auf der einen Seite der Beziehung und das andere auf eine Entität auf der anderen Seite der Beziehung verweist. Jede Joinentität und damit jede Zeile in der Jointabelle stellt daher eine Zuordnung zwischen den Entitätstypen in der Beziehung dar.

EF Core kann den Joinentitätstyp ausblenden und im Hintergrund verwalten. Auf diese Weise können die Navigationen einer M:N-Beziehung auf natürliche Weise verwendet werden, indem Sie bei Bedarf Entitäten von jeder Seite hinzufügen oder entfernen. Es ist jedoch hilfreich zu verstehen, was im Hintergrund geschieht, sodass ihr Gesamtverhalten und insbesondere die Zuordnung zu einer relationalen Datenbank sinnvoll ist. Beginnen wir mit der Einrichtung eines relationalen Datenbankschemas, das eine M:N-Beziehung zwischen Beiträgen und Tags darstellt:

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

In diesem Schema ist PostTag die Jointabelle. Sie enthält zwei Spalten: PostsId, die ein Fremdschlüssel zum Primärschlüssel der Tabelle Posts ist, und TagsId, die ein Fremdschlüssel zum Primärschlüssel der Tabelle Tags ist. Jede Zeile in dieser Tabelle stellt also eine Zuordnung zwischen einem Post und einem Tag dar.

Eine vereinfachte Zuordnung für dieses Schema in EF Core besteht aus drei Entitätstypen – einen für jede Tabelle. Wenn jeder dieser Entitätstypen durch eine .NET-Klasse repräsentiert wird, dann könnten diese Klassen folgendermaßen aussehen:

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostsId { get; set; }
    public int TagsId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

Beachten Sie, dass es in dieser Zuordnung keine M:N-Beziehung gibt, sondern zwei 1:N-Beziehungen, eine für jeden der in der Jointabelle definierten Fremdschlüssel. Dies ist keine unvernünftige Art, diese Tabellen zuzuordnen, entspricht aber nicht der Absicht der Jointabelle, die eine einzelne M:N-Beziehung und nicht zwei 1:N-Beziehungen darstellen soll.

EF ermöglicht eine natürlichere Zuordnung durch die Einführung von zwei Sammlungsnavigationen, eine auf Post, die die zugehörigen Tags enthält, und eine umgekehrte auf Tag, die die zugehörigen Posts enthält. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = [];
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int PostsId { get; set; }
    public int TagsId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

Tipp

Diese neuen Navigationen werden als „überspringende Navigationen“ bezeichnet, da sie die Joinentität überspringen und direkten Zugriff auf die andere Seite der M:N-Beziehung bieten.

Wie in den folgenden Beispielen gezeigt wird, kann eine M:N-Beziehung auf diese Weise zugeordnet werden, d. h. mit einer .NET-Klasse für die Joinentität und mit beiden Navigationen für die beiden 1:N-Beziehungen und den überspringenden Navigationen, die für die Entitätstypen verfügbar gemacht werden. EF kann die Joinentität jedoch transparent verwalten, ohne dass eine .NET-Klasse dafür definiert ist und ohne Navigationen für die beiden 1:N-Beziehungen. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

In der Tat werden die EF-Konventionen für die Modellerstellung standardmäßig die hier gezeigten Post- und Tag-Typen den drei Tabellen im Datenbankschema oben in diesem Abschnitt zuordnen. Diese Zuordnung ohne explizite Verwendung des Jointyps ist das, was typischerweise mit dem Begriff „M:N“ gemeint ist.

Beispiele

In den folgenden Abschnitten finden Sie Beispiele für M:N-Beziehungen, einschließlich der Konfiguration, die für jede Zuordnung erforderlich ist.

Tipp

Den Code für alle folgenden Beispiele finden Sie in ManyToMany.cs.

Einfache M:N-Beziehung

Im einfachsten Fall einer M:N-Beziehung verfügen die Entitäten auf beiden Seiten der Beziehung über eine Sammlungsnavigation. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

Diese Beziehung wird gemäß Konvention zugeordnet. Auch wenn dies nicht erforderlich ist, wird im Folgenden eine äquivalente explizite Konfiguration für diese Beziehung als Lerntool gezeigt:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts);
}

Selbst mit dieser expliziten Konfiguration werden viele Aspekte der Beziehung immer noch gemäß Konventionen konfiguriert. Eine vollständigere explizite Konfiguration, wiederum zu Lernzwecken, ist:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            "PostTag",
            l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagsId").HasPrincipalKey(nameof(Tag.Id)),
            r => r.HasOne(typeof(Post)).WithMany().HasForeignKey("PostsId").HasPrincipalKey(nameof(Post.Id)),
            j => j.HasKey("PostsId", "TagsId"));
}

Wichtig

Versuchen Sie nicht, alles vollständig zu konfigurieren, auch wenn dies nicht erforderlich ist. Wie Sie oben sehen, wird der Code schnell kompliziert und es ist leicht, einen Fehler zu machen. Und selbst im obigen Beispiel gibt es viele Aspekte im Modell, die immer noch gemäß Konvention konfiguriert sind. Es ist nicht realistisch zu glauben, dass alles in einem EF-Modell immer vollständig explizit konfiguriert werden kann.

Unabhängig davon, ob die Beziehung gemäß Konvention oder mithilfe einer der gezeigten expliziten Konfigurationen erstellt wird, lautet das resultierende zugeordnete Schema (unter Verwendung von SQLite):

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Tipp

Wenn Sie einen Database-First-Flow verwenden, um ein DbContext-Gerüst aus einer bestehenden Datenbank zu erstellen, sucht EF Core 6 und höher nach diesem Muster im Datenbankschema und erstellt ein M:N-Beziehungsgerüst, wie in diesem Dokument beschrieben. Dieses Verhalten kann mithilfe einer benutzerdefinierten T4-Vorlage geändert werden. Weitere Optionen finden Sie unter Für M:N-Beziehungen ohne zugeordnete Joinentitäten wird jetzt ein Gerüst verwendet.

Wichtig

Derzeit verwendet EF Core Dictionary<string, object>, um Joinentitäten darzustellen, für die keine .NET-Klasse konfiguriert wurde. Um die Leistung zu verbessern, wird jedoch in einer zukünftigen EF Core-Version möglicherweise ein anderer Typ verwendet. Verlassen Sie sich nicht darauf, dass der Jointyp Dictionary<string, object> ist, es sei denn, dies wurde explizit konfiguriert.

M:N-Beziehung mit benannter Jointabelle

Im vorigen Beispiel wurde die Jointabelle gemäß Konvention PostTag genannt. Mit UsingEntity können Sie einen expliziten Namen zuweisen. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity("PostsToTagsJoinTable");
}

Alles andere an der Zuordnung bleibt gleich, nur der Name der Jointabelle ändert sich:

CREATE TABLE "PostsToTagsJoinTable" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostsToTagsJoinTable" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostsToTagsJoinTable_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostsToTagsJoinTable_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung mit Fremdschlüsselnamen der Jointabelle

In Anlehnung an das vorherige Beispiel können auch die Namen der Fremdschlüsselspalten in der Jointabelle geändert werden. Es gibt hierbei zwei Möglichkeiten. Die erste besteht darin, die Namen der Fremdschlüsseleigenschaften für die Joinentität explizit anzugeben. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagForeignKey"),
            r => r.HasOne(typeof(Post)).WithMany().HasForeignKey("PostForeignKey"));
}

Die zweite Möglichkeit besteht darin, die Eigenschaften mit ihren konventionellen Namen zu belassen, diese Eigenschaften dann aber anderen Spaltennamen zuzuordnen. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            j =>
            {
                j.Property("PostsId").HasColumnName("PostForeignKey");
                j.Property("TagsId").HasColumnName("TagForeignKey");
            });
}

In beiden Fällen bleibt die Zuordnung gleich, nur die Namen der Fremdschlüsselspalten werden geändert:

CREATE TABLE "PostTag" (
    "PostForeignKey" INTEGER NOT NULL,
    "TagForeignKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostForeignKey", "TagForeignKey"),
    CONSTRAINT "FK_PostTag_Posts_PostForeignKey" FOREIGN KEY ("PostForeignKey") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagForeignKey" FOREIGN KEY ("TagForeignKey") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Tipp

Obwohl hier nicht gezeigt, können die beiden vorherigen Beispiele kombiniert werden, um den Namen der Jointabelle und die Spaltennamen der Fremdschlüssel zu ändern.

M:N-Beziehung mit Klasse für die Joinentität

In den bisherigen Beispielen wurde die Jointabelle automatisch zu einer Entität vom Typ freigegeben zugeordnet. Dadurch wird die Notwendigkeit beseitigt, eine dedizierte Klasse für den Entitätstyp zu erstellen. Es kann jedoch hilfreich sein, eine solche Klasse zu verwenden, damit leicht auf sie verwiesen werden kann, insbesondere wenn der Klasse Navigationen oder Nutzdaten hinzugefügt werden, wie in den folgenden Beispielen gezeigt wird. Dazu erstellen Sie zunächst einen Typ PostTag für die Joinentität, zusätzlich zu den vorhandenen Typen für Post und Tag:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
}

Tipp

Die Klasse kann einen beliebigen Namen besitzen, aber es ist üblich, die Namen der Typen an beiden Enden der Beziehung zu kombinieren.

Jetzt können Sie die UsingEntity-Methode verwenden, um dies als den Typ der Joinentität für die Beziehung zu konfigurieren. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

PostId und TagId werden automatisch als Fremdschlüssel übernommen und als zusammengesetzter Primärschlüssel für den Joinentitätstyp konfiguriert. Die für die Fremdschlüssel zu verwendenden Eigenschaften können explizit für Fälle konfiguriert werden, in denen sie nicht mit der EF-Konvention übereinstimmen. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>().WithMany().HasForeignKey(e => e.TagId),
            r => r.HasOne<Post>().WithMany().HasForeignKey(e => e.PostId));
}

Das zugeordnete Datenbankschema für die Jointabelle in diesem Beispiel entspricht strukturell den vorherigen Beispielen, allerdings mit einigen anderen Spaltennamen:

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung mit Navigationen zur Joinentität

Da es jetzt nach dem vorherigen Beispiel eine Klasse gibt, die die Joinentität darstellt, ist es ein Leichtes, Navigationen hinzuzufügen, die auf diese Klasse verweisen. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
}

Wichtig

Wie in diesem Beispiel gezeigt, können Navigationen zum Joinentitätstyp zusätzlich zu den überspringenden Navigationen zwischen den beiden Enden der M:N-Beziehung verwendet werden. Dies bedeutet, dass die überspringenden Navigationen verwendet werden können, um auf natürliche Weise mit der M:N-Beziehung zu interagieren, während die Navigationen zum Joinentitätstyp verwendet werden können, wenn eine größere Kontrolle über die Joinentitäten selbst erforderlich ist. In gewisser Weise bietet diese Zuordnung das Beste aus beiden Welten: eine einfache M:N-Zuordnung und eine Zuordnung, die expliziter mit dem Datenbankschema übereinstimmt.

In dem Aufruf von UsingEntity muss nichts geändert werden, da die Navigationen zur Joinentität gemäß Konvention übernommen werden. Daher ist die Konfiguration für dieses Beispiel dieselbe wie für das letzte Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

Die Navigationen können explizit für Fälle konfiguriert werden, in denen eine Bestimmung per Konvention nicht möglich ist. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>().WithMany(e => e.PostTags),
            r => r.HasOne<Post>().WithMany(e => e.PostTags));
}

Das zugeordnete Datenbankschema wird durch das Einbeziehen von Navigationen in das Modell nicht beeinträchtigt:

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung mit Navigationen zu und von der Joinentität

Im vorigen Beispiel wurden Navigationen zum Joinentitätstyp von den Entitätstypen an beiden Enden der M:N-Beziehung hinzugefügt. Navigationen können auch in die andere Richtung oder in beide Richtungen hinzugefügt werden. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

In dem Aufruf von UsingEntity muss nichts geändert werden, da die Navigationen zur Joinentität gemäß Konvention übernommen werden. Daher ist die Konfiguration für dieses Beispiel dieselbe wie für das letzte Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

Die Navigationen können explizit für Fälle konfiguriert werden, in denen eine Bestimmung per Konvention nicht möglich ist. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags));
}

Das zugeordnete Datenbankschema wird durch das Einbeziehen von Navigationen in das Modell nicht beeinträchtigt:

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung mit Navigationen und geänderten Fremdschlüsseln

Das vorherige Beispiel zeigte eine M:N-Beziehung mit Navigationen zu und von dem Joinentitätstyp. Dieses Beispiel ist dasselbe, nur dass die verwendeten Fremdschlüsseleigenschaften ebenfalls geändert werden. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostForeignKey { get; set; }
    public int TagForeignKey { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

Auch hier wird die Methode UsingEntity verwendet, um Folgendes zu konfigurieren:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags).HasForeignKey(e => e.TagForeignKey),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags).HasForeignKey(e => e.PostForeignKey));
}

Das zugeordnete Datenbankschema lautet jetzt:

CREATE TABLE "PostTag" (
    "PostForeignKey" INTEGER NOT NULL,
    "TagForeignKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostForeignKey", "TagForeignKey"),
    CONSTRAINT "FK_PostTag_Posts_PostForeignKey" FOREIGN KEY ("PostForeignKey") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagForeignKey" FOREIGN KEY ("TagForeignKey") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Unidirektionale M:N-Beziehung

Hinweis

Unidirektionale M:N-Beziehungen wurden in EF Core 7 eingeführt. In früheren Versionen konnte eine private Navigation als Problemumgehung verwendet werden.

Es ist nicht notwendig, auf beiden Seiten der M:N-Beziehung eine Navigation einzubeziehen. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
}

EF muss konfiguriert werden, um zu wissen, dass es sich um eine M:N-Beziehung und nicht um eine 1:N-Beziehung handelt. Dies geschieht mithilfe von HasMany und WithMany, wobei jedoch kein Argument auf der Seite ohne Navigation übergeben wird. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany();
}

Das Entfernen der Navigation hat keine Auswirkungen auf das Datenbankschema:

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT);

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung und Jointabelle mit Nutzdaten

In den bisherigen Beispielen wurde die Jointabelle nur dazu verwendet, die Fremdschlüsselpaare zu speichern, die die einzelnen Zuordnungen repräsentieren. Sie kann jedoch auch verwendet werden, um Informationen über die Zuordnung zu speichern – z. B. den Zeitpunkt, zu dem sie erstellt wurde. In solchen Fällen ist es am besten, einen Typ für die Joinentität zu definieren und diesem Typ die „Zuordnungsnutzdaten“-Eigenschaften hinzuzufügen. Es ist auch üblich, zusätzlich zu den „überspringenden Navigationen“, die für die M:N-Beziehung verwendet werden, Navigationen für die Joinentität zu erstellen. Durch diese zusätzlichen Navigationen kann einfach aus dem Code heraus auf die Joinentität verwiesen werden, was das Lesen und/oder Ändern der Nutzdaten erleichtert. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public DateTime CreatedOn { get; set; }
}

Es ist auch üblich, generierte Werte für Nutzdateneigenschaften zu verwenden, z. B. einen Datenbankzeitstempel, der automatisch festgelegt wird, wenn die Zuordnungszeile eingefügt wird. Dies erfordert eine minimale Konfiguration. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

Das Ergebnis entspricht einem Entitätstypschema mit einem Zeitstempel, der automatisch festgelegt wird, wenn eine Zeile eingefügt wird:

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Tipp

Das hier gezeigte SQL ist für SQLite gedacht. Verwenden Sie in SQL Server/Azure SQL .HasDefaultValueSql("GETUTCDATE()") und für TEXT lesen Sie datetime.

Benutzerdefinierter Entitätstyp „Freigegeben“ als Joinentität

Im vorherigen Beispiel wurde der Typ PostTag als Joinentitätstyp verwendet. Dieser Typ ist spezifisch für die Beziehung zwischen Beiträgen und Tags. Wenn Sie jedoch über mehrere Jointabellen mit derselben Form verfügen, können Sie denselben CLR-Typ für alle Jointabellen verwenden. Stellen Sie sich z. B. vor, dass alle unsere Jointabellen eine CreatedOn-Spalte aufweisen. Wir können diese mithilfe der JoinType-Klasse als Entitätstyp „Freigegeben“ zuordnen:

public class JoinType
{
    public int Id1 { get; set; }
    public int Id2 { get; set; }
    public DateTime CreatedOn { get; set; }
}

Auf diesen Typ kann dann durch mehrere unterschiedliche M:N-Beziehungen als Joinentitätstyp verwiesen werden. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<JoinType> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
    public List<JoinType> PostTags { get; } = [];
}

public class Blog
{
    public int Id { get; set; }
    public List<Author> Authors { get; } = [];
    public List<JoinType> BlogAuthors { get; } = [];
}

public class Author
{
    public int Id { get; set; }
    public List<Blog> Blogs { get; } = [];
    public List<JoinType> BlogAuthors { get; } = [];
}

Und diese Beziehungen können dann entsprechend konfiguriert werden, um den Jointyp für jede Beziehung einer anderen Tabelle zuzuordnen:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<JoinType>(
            "PostTag",
            l => l.HasOne<Tag>().WithMany(e => e.PostTags).HasForeignKey(e => e.Id1),
            r => r.HasOne<Post>().WithMany(e => e.PostTags).HasForeignKey(e => e.Id2),
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));

    modelBuilder.Entity<Blog>()
        .HasMany(e => e.Authors)
        .WithMany(e => e.Blogs)
        .UsingEntity<JoinType>(
            "BlogAuthor",
            l => l.HasOne<Author>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id1),
            r => r.HasOne<Blog>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id2),
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

Dies führt zu den folgenden Tabellen im Datenbankschema:

CREATE TABLE "BlogAuthor" (
    "Id1" INTEGER NOT NULL,
    "Id2" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_BlogAuthor" PRIMARY KEY ("Id1", "Id2"),
    CONSTRAINT "FK_BlogAuthor_Authors_Id1" FOREIGN KEY ("Id1") REFERENCES "Authors" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BlogAuthor_Blogs_Id2" FOREIGN KEY ("Id2") REFERENCES "Blogs" ("Id") ON DELETE CASCADE);


CREATE TABLE "PostTag" (
    "Id1" INTEGER NOT NULL,
    "Id2" INTEGER NOT NULL,
    "CreatedOn" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("Id1", "Id2"),
    CONSTRAINT "FK_PostTag_Posts_Id2" FOREIGN KEY ("Id2") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_Id1" FOREIGN KEY ("Id1") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung mit Alternativschlüsseln

Bis jetzt haben alle Beispiele gezeigt, dass die Fremdschlüssel in der Joinentität auf die Primärschlüssel der Entitätstypen auf beiden Seiten der Beziehung beschränkt sind. Jeder der oder beide Fremdschlüssel können stattdessen auf einen Alternativschlüssel beschränkt werden. Betrachten Sie z. B. dieses Modell, bei dem Tag und Post Alternativschlüsseleigenschaften aufweisen:

public class Post
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Post> Posts { get; } = [];
}

Die Konfiguration für dieses Modell lautet:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().HasPrincipalKey(nameof(Tag.AlternateKey)),
            r => r.HasOne(typeof(Post)).WithMany().HasPrincipalKey(nameof(Post.AlternateKey)));
}

Und das resultierende Datenbankschema, das auch die Tabellen mit den Alternativschlüsseln enthält:

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Posts_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Tags_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "PostTag" (
    "PostsAlternateKey" INTEGER NOT NULL,
    "TagsAlternateKey" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsAlternateKey", "TagsAlternateKey"),
    CONSTRAINT "FK_PostTag_Posts_PostsAlternateKey" FOREIGN KEY ("PostsAlternateKey") REFERENCES "Posts" ("AlternateKey") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsAlternateKey" FOREIGN KEY ("TagsAlternateKey") REFERENCES "Tags" ("AlternateKey") ON DELETE CASCADE);

Die Konfiguration für die Verwendung von Alternativschlüsseln ist etwas anders, wenn der Joinentitätstyp durch einen .NET-Typ dargestellt wird. Beispiel:

public class Post
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Tag> Tags { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public int AlternateKey { get; set; }
    public List<Post> Posts { get; } = [];
    public List<PostTag> PostTags { get; } = [];
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

Die Konfiguration kann jetzt die generische UsingEntity<>-Methode verwenden:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>(
            l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags).HasPrincipalKey(e => e.AlternateKey),
            r => r.HasOne<Post>(e => e.Post).WithMany(e => e.PostTags).HasPrincipalKey(e => e.AlternateKey));
}

Und das resultierende Schema lautet:

CREATE TABLE "Posts" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Posts_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "AlternateKey" INTEGER NOT NULL,
    CONSTRAINT "AK_Tags_AlternateKey" UNIQUE ("AlternateKey"));

CREATE TABLE "PostTag" (
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostId", "TagId"),
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("AlternateKey") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("AlternateKey") ON DELETE CASCADE);

M:N-Beziehung und Jointabelle mit separatem Primärschlüssel

Bisher verfügt die Joinentität in allen Beispielen über einen Primärschlüssel, der sich aus den beiden Fremdschlüsseleigenschaften zusammensetzt. Das liegt daran, dass jede Kombination von Werten für diese Eigenschaften höchstens einmal vorkommen kann. Diese Eigenschaften bilden daher einen natürlichen Primärschlüssel.

Hinweis

EF Core unterstützt keine doppelten Entitäten in einer Sammlungsnavigation.

Wenn Sie das Datenbankschema kontrollieren, gibt es keinen Grund, warum die Jointabelle eine zusätzliche Primärschlüsselspalte besitzen sollte. Es ist jedoch möglich, dass eine vorhandene Jointabelle eine Primärschlüsselspalte definiert hat. EF kann mit einer gewissen Konfiguration immer noch entsprechend zugeordnet werden.

Am einfachsten ist es vielleicht, eine Klasse zu erstellen, die die Joinentität darstellt. Beispiel:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

public class PostTag
{
    public int Id { get; set; }
    public int PostId { get; set; }
    public int TagId { get; set; }
}

Diese PostTag.Id-Eigenschaft wird jetzt gemäß Konvention als Primärschlüssel übernommen, sodass die einzige erforderliche Konfiguration ein Aufruf von UsingEntity für den PostTag-Typ ist:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<PostTag>();
}

Und das resultierende Schema für die Jointabelle lautet:

CREATE TABLE "PostTag" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_PostTag" PRIMARY KEY AUTOINCREMENT,
    "PostId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    CONSTRAINT "FK_PostTag_Posts_PostId" FOREIGN KEY ("PostId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

Sie können der Joinentität auch einen Primärschlüssel hinzufügen, ohne dafür eine Klasse zu definieren. Beispielsweise mit ausschließlich Post- und Tag-Typen:

public class Post
{
    public int Id { get; set; }
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public List<Post> Posts { get; } = [];
}

Der Schlüssel kann mit dieser Konfiguration hinzugefügt werden:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            j =>
            {
                j.IndexerProperty<int>("Id");
                j.HasKey("Id");
            });
}

Das Ergebnis ist eine Jointabelle mit einer separaten Primärschlüsselspalte:

CREATE TABLE "PostTag" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_PostTag" PRIMARY KEY AUTOINCREMENT,
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE);

M:N-Beziehung ohne kaskadierendes Delete

In allen oben gezeigten Beispielen werden die Fremdschlüssel, die zwischen der Jointabelle und den beiden Seiten der M:N-Beziehung erstellt wurden, mit kaskadierendem Delete-Verhalten erstellt. Dies ist sehr nützlich, da dies bedeutet, dass die Zeilen in der Jointabelle für diese Entität automatisch gelöscht werden, wenn eine Entität auf beiden Seiten der Beziehung gelöscht wird. Oder anders ausgedrückt: Wenn eine Entität nicht mehr vorhanden ist, dann existieren auch ihre Beziehungen zu anderen Entitäten nicht mehr.

Es ist schwer vorstellbar, wann es sinnvoll erscheint, dieses Verhalten zu ändern, aber es ist bei Bedarf möglich. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity(
            l => l.HasOne(typeof(Tag)).WithMany().OnDelete(DeleteBehavior.Restrict),
            r => r.HasOne(typeof(Post)).WithMany().OnDelete(DeleteBehavior.Restrict));
}

Das Datenbankschema für die Jointabelle wendet ein eingeschränktes Löschverhalten auf die Fremdschlüsseleinschränkung an:

CREATE TABLE "PostTag" (
    "PostsId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PostTag" PRIMARY KEY ("PostsId", "TagsId"),
    CONSTRAINT "FK_PostTag_Posts_PostsId" FOREIGN KEY ("PostsId") REFERENCES "Posts" ("Id") ON DELETE RESTRICT,
    CONSTRAINT "FK_PostTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE RESTRICT);

Auf sich selbst verweisende M:N-Beziehung

Derselbe Entitätstyp kann an beiden Enden einer M:N-Beziehung verwendet werden. Dies wird als „auf sich selbst verweisende“ Beziehung bezeichnet. Beispiel:

public class Person
{
    public int Id { get; set; }
    public List<Person> Parents { get; } = [];
    public List<Person> Children { get; } = [];
}

Dies führt zu einer Jointabelle mit dem Namen PersonPerson, wobei beide Fremdschlüssel auf die Tabelle People zurückverweisen:

CREATE TABLE "PersonPerson" (
    "ChildrenId" INTEGER NOT NULL,
    "ParentsId" INTEGER NOT NULL,
    CONSTRAINT "PK_PersonPerson" PRIMARY KEY ("ChildrenId", "ParentsId"),
    CONSTRAINT "FK_PersonPerson_People_ChildrenId" FOREIGN KEY ("ChildrenId") REFERENCES "People" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_PersonPerson_People_ParentsId" FOREIGN KEY ("ParentsId") REFERENCES "People" ("Id") ON DELETE CASCADE);

Symmetrische, auf sich selbst verweisende M:N-Beziehung

Manchmal ist eine M:N-Beziehung naturgemäß symmetrisch. Das heißt, wenn Entität A mit Entität B verknüpft ist, dann ist Entität B auch mit Entität A verbunden. Dies wird natürlich mithilfe einer einzelnen Navigation modelliert. Stellen Sie sich z. B. den Fall vor, dass Person A mit Person B befreundet ist, dann ist Person B auch mit Person A befreundet:

public class Person
{
    public int Id { get; set; }
    public List<Person> Friends { get; } = [];
}

Leider ist dies nicht einfach zuzuordnen. Die gleiche Navigation kann nicht für beide Enden der Beziehung verwendet werden. Die beste Möglichkeit besteht darin, sie als unidirektionale M:N-Beziehung zuzuordnen. Beispiel:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Person>()
        .HasMany(e => e.Friends)
        .WithMany();
}

Um jedoch sicherzustellen, dass zwei Personen miteinander verknüpft sind, muss jede Person manuell zur Friends-Sammlung der anderen Person hinzugefügt werden. Beispiel:

ginny.Friends.Add(hermione);
hermione.Friends.Add(ginny);

Direkte Verwendung der Jointabelle

Alle obigen Beispiele verwenden die M:N-Zuordnungsmuster von EF Core. Es ist jedoch auch möglich, eine Jointabelle einem normalen Entitätstyp zuzuordnen und nur die beiden 1:N-Beziehungen für alle Vorgänge zu verwenden.

Diese Entitätstypen stellen z. B. die Zuordnung von zwei normalen Tabellen und einer Jointabelle dar, ohne M:N-Beziehungen zu verwenden:

public class Post
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = new();
}

public class Tag
{
    public int Id { get; set; }
    public List<PostTag> PostTags { get; } = new();
}

public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public Post Post { get; set; } = null!;
    public Tag Tag { get; set; } = null!;
}

Hierfür ist keine besondere Zuordnung erforderlich, da es sich um normale Entitätstypen mit normalen 1:N-Beziehungen handelt.

Zusätzliche Ressourcen