Relations plusieurs-à-plusieurs

Les relations plusieurs-à-plusieurs sont utilisées quand un nombre d’entités d’un type d’entité est associé à un nombre quelconque d’entités du même type d’entité ou d’un autre type d’entité. Par exemple, un Post peut avoir de nombreux Tagsassociés, et chaque Tag peut à son tour être associée à n’importe quel nombre de Posts.

Comprendre les relations plusieurs-à-plusieurs

Les relations plusieurs-à-plusieurs sont différentes de un-à-plusieurs et relations un-à-un dans le sens où elles ne peuvent pas être représentées d’une manière simple à l’aide d’une clé étrangère. Au lieu de cela, un type d’entité supplémentaire est nécessaire pour « joindre » les deux côtés de la relation. Il s’agit du « type d’entité de jointure » et est mappé à une « table de jointure » dans une base de données relationnelle. Les entités de ce type d’entité de jointure contiennent des paires de valeurs de clé étrangère, où l’une des paires pointe vers une entité d’un côté de la relation, et l’autre pointe vers une entité de l’autre côté de la relation. Chaque entité de jointure, et donc chaque ligne de la table de jointure, représente donc une association entre les types d’entités dans la relation.

EF Core peut masquer le type d’entité de jointure et le gérer en arrière-plan. Cela permet aux navigations d’une relation plusieurs-à-plusieurs d’être utilisées de manière naturelle, en ajoutant ou en supprimant des entités de chaque côté en fonction des besoins. Toutefois, il est utile de comprendre ce qui se passe en arrière-plan afin que leur comportement global, et en particulier le mappage à une base de données relationnelle, soit logique. Commençons par une configuration de schéma de base de données relationnelle pour représenter une relation plusieurs-à-plusieurs entre les publications et les balises :

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);

Dans ce schéma, PostTag est la table de jointure. Il contient deux colonnes : PostsId, qui est une clé étrangère à la clé primaire de la table Posts et TagsId, qui est une clé étrangère à la clé primaire de la table Tags. Chaque ligne de cette table représente donc une association entre une Post et une Tag.

Un mappage simpliste pour ce schéma dans EF Core se compose de trois types d’entités, un pour chaque table. Si chacun de ces types d’entités est représenté par une classe .NET, ces classes peuvent se présenter comme suit :

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!;
}

Notez que dans ce mappage, il n’existe aucune relation plusieurs-à-plusieurs, mais plutôt deux relations un-à-plusieurs, une pour chacune des clés étrangères définies dans la table de jointure. Ce n’est pas un moyen déraisonnable de mapper ces tables, mais ne reflète pas l’intention de la table de jointure, qui consiste à représenter une relation plusieurs-à-plusieurs unique, plutôt que deux relations un-à-plusieurs.

EF permet un mappage plus naturel grâce à l’introduction de deux navigations de collection, une sur Post contenant ses Tagsconnexes et un inverse sur Tag contenant ses Postsconnexes. Par exemple :

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!;
}

Conseil

Ces nouvelles navigations sont appelées « ignorer les navigations », car elles ignorent l’entité de jointure pour fournir un accès direct à l’autre côté de la relation plusieurs-à-plusieurs.

Comme indiqué dans les exemples ci-dessous, une relation plusieurs-à-plusieurs peut être mappée de cette façon, avec une classe .NET pour l’entité de jointure, et avec les deux navigations pour les deux relations un-à-plusieurs et ignorer les navigations exposées sur les types d’entités. Toutefois, EF peut gérer l’entité de jointure de manière transparente, sans classe .NET définie pour elle et sans navigation pour les deux relations un-à-plusieurs. Par exemple :

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; } = [];
}

En effet, EF conventions de création de modèles mappera, par défaut, les types Post et Tag présentés ici aux trois tables du schéma de base de données en haut de cette section. Ce mappage, sans utilisation explicite du type de jointure, est ce qui est généralement destiné par le terme « plusieurs-à-plusieurs ».

Exemples

Les sections suivantes contiennent des exemples de relations plusieurs-à-plusieurs, notamment la configuration nécessaire pour atteindre chaque mappage.

Conseil

Le code de tous les exemples ci-dessous est disponible dans ManyToMany.cs.

De base plusieurs-à-plusieurs

Dans le cas le plus simple pour un plusieurs-à-plusieurs, les types d’entités à chaque fin de la relation ont tous deux une navigation de collection. Par exemple :

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; } = [];
}

Cette relation est mappée par la convention. Même s’il n’est pas nécessaire, une configuration explicite équivalente pour cette relation est illustrée ci-dessous sous la forme d’un outil d’apprentissage :

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

Même avec cette configuration explicite, de nombreux aspects de la relation sont toujours configurés par convention. Une configuration explicite plus complète, à nouveau à des fins d’apprentissage, est la suivante :

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"));
}

Important

N’essayez pas de configurer entièrement tout, même s’il n’est pas nécessaire. Comme vous pouvez le voir ci-dessus, le code se complique rapidement et il est facile de faire une erreur. Et même dans l’exemple ci-dessus, il existe de nombreuses choses dans le modèle qui sont toujours configurés par convention. Il n’est pas réaliste de penser que tout dans un modèle EF peut toujours être entièrement configuré explicitement.

Quelle que soit la relation générée par convention ou à l’aide de l’une des configurations explicites affichées, le schéma mappé obtenu (à l’aide de SQLite) est :

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);

Conseil

Lors de l’utilisation d’un flux Database First pour générer une structure DbContext à partir d’une base de données existante, EF Core 6 et versions ultérieures recherche ce modèle dans le schéma de base de données et génère une structure plusieurs-à-plusieurs, comme décrit dans ce document. Ce comportement peut être modifié à l’aide d’un modèle T4 personnalisé . Pour d’autres options, consultez relations plusieurs-à-plusieurs sans entités de jointure mappées sont désormais générées.

Important

Actuellement, EF Core utilise Dictionary<string, object> pour représenter les instances d’entité de jointure pour lesquelles aucune classe .NET n’a été configurée. Toutefois, pour améliorer les performances, un autre type peut être utilisé dans une prochaine version d’EF Core. Ne dépendez pas du type de jointure Dictionary<string, object>, sauf s’il a été configuré explicitement.

Table de jointure plusieurs-à-plusieurs avec une table de jointure nommée

Dans l’exemple précédent, la table de jointure a été nommée PostTag par convention. Il peut être donné un nom explicite avec UsingEntity. Par exemple :

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

Tout le reste du mappage reste le même, avec uniquement le nom de la table de jointure changeant :

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);

Plusieurs-à-plusieurs avec des noms de clés étrangères de la table de jointure

À partir de l’exemple précédent, les noms des colonnes clés étrangères de la table de jointure peuvent également être modifiés. Deux méthodes s’offrent à vous pour ce faire. La première consiste à spécifier explicitement les noms de propriétés de clé étrangère sur l’entité de jointure. Par exemple :

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"));
}

La deuxième façon est de laisser les propriétés avec leurs noms par convention, mais ensuite mapper ces propriétés à différents noms de colonnes. Par exemple :

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");
            });
}

Dans les deux cas, le mappage reste le même, avec uniquement les noms de colonnes de clé étrangère modifiés :

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);

Conseil

Bien qu’il ne soit pas indiqué ici, les deux exemples précédents peuvent être combinés pour mapper le nom de la table de jointure et ses noms de colonnes clés étrangères.

Plusieurs-à-plusieurs avec la classe pour l’entité de jointure

Jusqu’à présent dans les exemples, la table de jointure a été automatiquement mappée à un type d’entité de type partagé. Cela supprime la nécessité de créer une classe dédiée pour le type d’entité. Toutefois, il peut être utile d’avoir une telle classe afin qu’elle puisse être référencée facilement, en particulier lorsque des navigations ou une charge utile sont ajoutées à la classe, comme illustré dans les exemples ultérieurs ci-dessous. Pour ce faire, créez d’abord un type PostTag pour l’entité de jointure en plus des types existants pour Post et 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; }
}

Conseil

La classe peut avoir n’importe quel nom, mais il est courant de combiner les noms des types à l’une ou l’autre des extrémités de la relation.

À présent, la méthode UsingEntity peut être utilisée pour configurer ce paramètre en tant que type d’entité de jointure pour la relation. Par exemple :

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

Les PostId et les TagId sont automatiquement récupérés en tant que clés étrangères et sont configurés comme clé primaire composite pour le type d’entité de jointure. Les propriétés à utiliser pour les clés étrangères peuvent être configurées explicitement pour les cas où elles ne correspondent pas à la convention EF. Par exemple :

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));
}

Le schéma de base de données mappé pour la table de jointure dans cet exemple est structurellement équivalent aux exemples précédents, mais avec des noms de colonnes différents :

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);

Plusieurs-à-plusieurs avec des navigations pour joindre une entité

À partir de l’exemple précédent, maintenant qu’il existe une classe représentant l’entité de jointure, il devient facile d’ajouter des navigations qui référencent cette classe. Par exemple :

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; }
}

Important

Comme illustré dans cet exemple, les navigations vers le type d’entité de jointure peuvent être utilisées en plus de les navigations ignorer entre les deux extrémités de la relation plusieurs-à-plusieurs. Cela signifie que les navigations skip peuvent être utilisées pour interagir avec la relation plusieurs-à-plusieurs de manière naturelle, tandis que les navigations vers le type d’entité de jointure peuvent être utilisées quand un meilleur contrôle sur les entités de jointure eux-mêmes est nécessaire. Dans un sens, ce mappage offre le meilleur des deux mondes entre un simple mappage plusieurs-à-plusieurs et un mappage qui correspond plus explicitement au schéma de base de données.

Rien ne doit être modifié dans l’appel UsingEntity , car les navigations vers l’entité de jointure sont récupérées par convention. Par conséquent, la configuration de cet exemple est la même que pour le dernier exemple :

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

Les navigations peuvent être configurées explicitement pour les cas où elles ne peuvent pas être déterminées par convention. Par exemple :

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));
}

Le schéma de base de données mappé n’est pas affecté par l’inclusion de navigations dans le modèle :

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);

Plusieurs-à-plusieurs avec des navigations vers et depuis l’entité de jointure

L’exemple précédent a ajouté des navigations au type d’entité de jointure à partir des types d’entités à la fin de la relation plusieurs-à-plusieurs. Les navigations peuvent également être ajoutées dans l’autre direction ou dans les deux directions. Par exemple :

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!;
}

Rien ne doit être modifié dans l’appel UsingEntity , car les navigations vers l’entité de jointure sont récupérées par convention. Par conséquent, la configuration de cet exemple est la même que pour le dernier exemple :

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

Les navigations peuvent être configurées explicitement pour les cas où elles ne peuvent pas être déterminées par convention. Par exemple :

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));
}

Le schéma de base de données mappé n’est pas affecté par l’inclusion de navigations dans le modèle :

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);

Plusieurs à plusieurs avec des navigations et des clés étrangères modifiées

L’exemple précédent a montré un nombre à plusieurs avec des navigations vers et depuis le type d’entité de jointure. Cet exemple est le même, sauf que les propriétés de clé étrangère utilisées sont également modifiées. Par exemple :

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!;
}

Là encore, la méthode UsingEntity est utilisée pour configurer ceci :

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));
}

Le schéma de base de données mappé est maintenant :

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);

Plusieurs à plusieurs unidirectionnels

Remarque

Des relations unidirectionnelles plusieurs-à-plusieurs ont été introduites dans EF Core 7. Dans les versions antérieures, une navigation privée peut être utilisée comme solution de contournement.

Il n’est pas nécessaire d’inclure une navigation sur les deux côtés de la relation plusieurs-à-plusieurs. Par exemple :

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

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

EF a besoin d’une configuration pour savoir qu’il doit s’agir d’une relation plusieurs-à-plusieurs, plutôt que d’une relation un-à-plusieurs. Cette opération est effectuée à l’aide de HasMany et de WithMany, mais sans argument transmis du côté sans navigation. Par exemple :

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

La suppression de la navigation n’affecte pas le schéma de base de données :

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);

Table plusieurs-à-plusieurs et jointure avec charge utile

Dans les exemples jusqu’à présent, la table de jointure n’a été utilisée que pour stocker les paires de clés étrangères représentant chaque association. Toutefois, il peut également être utilisé pour stocker des informations sur l’association, par exemple l’heure à laquelle elle a été créée. Dans ce cas, il est préférable de définir un type pour l’entité de jointure et d’ajouter les propriétés « charge utile d’association » à ce type. Il est également courant de créer des navigations vers l’entité de jointure en plus des « navigations ignorer » utilisées pour la relation plusieurs-à-plusieurs. Ces navigations supplémentaires permettent à l’entité de jointure d’être facilement référencée à partir du code, ce qui facilite la lecture et/ou la modification des données de charge utile. Par exemple :

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; }
}

Il est également courant d’utiliser des valeurs générées pour les propriétés de charge utile, par exemple, un horodatage de base de données qui est automatiquement défini lorsque la ligne d’association est insérée. Cela nécessite une configuration minimale. Par exemple :

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"));
}

Le résultat est mappé à un schéma de type d’entité avec un horodatage défini automatiquement lorsqu’une ligne est insérée :

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);

Conseil

Le code SQL indiqué ici est destiné à SQLite. Sur SQL Server/Azure SQL, utilisez .HasDefaultValueSql("GETUTCDATE()") et pour TEXT, lisez datetime.

Type d’entité de type partagé personnalisé en tant qu’entité de jointure

L’exemple précédent a utilisé le type PostTag comme type d’entité de jointure. Ce type est spécifique à la relation posts-tags. Toutefois, si vous avez plusieurs tables de jointure avec la même forme, le même type CLR peut être utilisé pour tous ces tableaux. Par exemple, imaginez que toutes nos tables de jointure ont une colonne CreatedOn. Nous pouvons les mapper à l’aide de JoinType classe mappée en tant que type d’entité de type partagé :

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

Ce type peut ensuite être référencé comme type d’entité de jointure par plusieurs relations plusieurs-à-plusieurs différentes. Par exemple :

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; } = [];
}

Ces relations peuvent ensuite être configurées de manière appropriée pour mapper le type de jointure à une table différente pour chaque relation :

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"));
}

Vous trouverez ainsi les tableaux suivants dans le schéma de base de données :

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);

Plusieurs à plusieurs avec d’autres clés

Jusqu’à présent, tous les exemples ont montré les clés étrangères dans le type d’entité de jointure qui sont contraintes aux clés primaires des types d’entités de chaque côté de la relation. Chaque clé étrangère, ou les deux, peut être contrainte à une autre clé. Par exemple, considérez ce modèle oùTag et Post ont d’autres propriétés de clé :

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; } = [];
}

La configuration de ce modèle est la suivante :

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)));
}

Et le schéma de base de données obtenu, pour plus de clarté, y compris les tables avec les autres clés :

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);

La configuration d’utilisation de clés alternatives est légèrement différente si le type d’entité de jointure est représenté par un type .NET. Par exemple :

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!;
}

La configuration peut désormais utiliser la méthode UsingEntity<> générique :

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));
}

Et le schéma obtenu est le suivant :

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);

Table plusieurs-à-plusieurs et jointure avec une clé primaire distincte

Jusqu’à présent, le type d’entité de jointure dans tous les exemples a une clé primaire composée des deux propriétés de clé étrangère. Cela est dû au fait que chaque combinaison de valeurs pour ces propriétés peut se produire au plus une fois. Ces propriétés forment donc une clé primaire naturelle.

Remarque

EF Core ne prend pas en charge les entités en double dans une navigation de collection.

Si vous contrôlez le schéma de base de données, il n’existe aucune raison pour que la table de jointure ait une colonne de clé primaire supplémentaire. Toutefois, il est possible qu’une table de jointure existante ait une colonne de clé primaire définie. EF peut toujours mapper à ceci avec une configuration.

Il est peut-être plus simple de le faire en créant une classe pour représenter l’entité de jointure. Par exemple :

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; }
}

Cette propriété PostTag.Id est désormais récupérée comme clé primaire par convention. La seule configuration nécessaire est donc un appel à UsingEntity pour le type de PostTag :

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

Et le schéma obtenu pour la table de jointure est le suivant :

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);

Une clé primaire peut également être ajoutée à l’entité de jointure sans définir de classe pour celle-ci. Par exemple, avec uniquement des types Post et 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; } = [];
}

La clé peut être ajoutée avec cette configuration :

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");
            });
}

Ce qui entraîne une table de jointure avec une colonne de clé primaire distincte :

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);

Plusieurs-à-plusieurs sans suppression en cascade

Dans tous les exemples présentés ci-dessus, les clés étrangères créées entre la table de jointure et les deux côtés de la relation plusieurs-à-plusieurs sont créées avec comportement de suppression en cascade. Cela est très utile, car cela signifie que si une entité de chaque côté de la relation est supprimée, les lignes de la table de jointure pour cette entité sont automatiquement supprimées. En d’autres termes, lorsqu’une entité n’existe plus, ses relations avec d’autres entités n’existent plus.

Il est difficile d’imaginer quand il est utile de modifier ce comportement, mais il peut être fait si vous le souhaitez. Par exemple :

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));
}

Le schéma de base de données de la table de jointure utilise un comportement de suppression restreint sur la contrainte de clé étrangère :

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);

Référencement automatique de plusieurs-à-plusieurs

Le même type d’entité peut être utilisé aux deux extrémités d’une relation plusieurs-à-plusieurs ; il s’agit d’une relation « auto-référencement ». Par exemple :

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

Cela correspond à une table de jointure appelée PersonPerson, avec les deux clés étrangères pointant vers la table People :

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);

Auto-référencement symétrique plusieurs-à-plusieurs

Parfois, une relation plusieurs-à-plusieurs est naturellement symétrique. Autrement dit, si l’entité A est liée à l’entité B, l’entité B est également liée à l’entité A. Ceci est naturellement modélisé à l’aide d’une navigation unique. Par exemple, imaginez le cas où est la personne A est des amis avec la personne B, puis la personne B est des amis avec la personne A :

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

Malheureusement, ce n’est pas facile à mapper. La même navigation ne peut pas être utilisée pour les deux extrémités de la relation. La meilleure solution possible consiste à la mapper en tant que relation unidirectionnelle plusieurs-à-plusieurs. Par exemple :

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

Toutefois, pour vous assurer que deux personnes sont liées les unes aux autres, chaque personne doit être ajoutée manuellement à la collection de Friends de l’autre personne. Par exemple :

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

Utilisation directe de la table de jointure

Tous les exemples ci-dessus utilisent les modèles de mappage plusieurs-à-plusieurs EF Core. Toutefois, il est également possible de mapper une table de jointure à un type d’entité normal et d’utiliser simplement les deux relations un-à-plusieurs pour toutes les opérations.

Par exemple, ces types d’entités représentent le mappage de deux tables normales et d’une table de jointure sans utiliser de relations plusieurs-à-plusieurs :

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!;
}

Cela ne nécessite aucun mappage spécial, car il s’agit de types d’entités normaux avec des relations un-à-plusieurs normales.

Ressources supplémentaires