Relazioni molti-a-molti

Le relazioni molti-a-molti vengono usate quando qualsiasi entità numerica di un tipo di entità è associata a un numero qualsiasi di entità dello stesso tipo di entità o di un altro tipo di entità. Ad esempio, un Post oggetto può avere molti associati Tagse ognuno Tag può a sua volta essere associato a un numero qualsiasi di Posts.

Informazioni sulle relazioni molti-a-molti

Le relazioni molti-a-molti sono diverse dalle relazioni uno-a-molti e uno-a-uno , in quanto non possono essere rappresentate in modo semplice usando solo una chiave esterna. È invece necessario un tipo di entità aggiuntivo per "unire" i due lati della relazione. Questa operazione è nota come "tipo di entità join" e esegue il mapping a una "tabella join" in un database relazionale. Le entità di questo tipo di entità join contengono coppie di valori di chiave esterna, in cui una di ogni coppia punta a un'entità su un lato della relazione e l'altra punta a un'entità dall'altra parte della relazione. Ogni entità join e pertanto ogni riga della tabella join rappresenta quindi un'associazione tra i tipi di entità nella relazione.

EF Core può nascondere il tipo di entità di join e gestirlo in background. Ciò consente di usare gli spostamenti di una relazione molti-a-molti in modo naturale, aggiungendo o rimuovendo entità da ogni lato in base alle esigenze. Tuttavia, è utile comprendere cosa accade dietro le quinte in modo che il comportamento generale e in particolare il mapping a un database relazionale abbia senso. Si inizierà con una configurazione dello schema di database relazionale per rappresentare una relazione molti-a-molti tra post e tag:

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 questo schema è PostTag la tabella join. Contiene due colonne: PostsId, che è una chiave esterna per la chiave primaria della Posts tabella e TagsId, ovvero una chiave esterna alla chiave primaria della Tags tabella. Ogni riga di questa tabella rappresenta quindi un'associazione tra una Post e una Tag.

Un mapping semplicistico per questo schema in EF Core è costituito da tre tipi di entità, uno per ogni tabella. Se ognuno di questi tipi di entità è rappresentato da una classe .NET, tali classi potrebbero avere un aspetto simile al seguente:

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

Si noti che in questo mapping non esiste alcuna relazione molti-a-molti, ma piuttosto due relazioni uno-a-molti, una per ognuna delle chiavi esterne definite nella tabella join. Questo non è un modo irragionevole per eseguire il mapping di queste tabelle, ma non riflette lo scopo della tabella di join, ovvero rappresentare una singola relazione molti-a-molti, anziché due relazioni uno-a-molti.

EF consente un mapping più naturale tramite l'introduzione di due spostamenti di raccolta, uno su Post contenente il relativo Tagse un inverso su Tag contenente i relativi elementi correlati Posts. Ad esempio:

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

Suggerimento

Questi nuovi spostamenti sono noti come "skip navigations", perché ignorano l'entità join per fornire l'accesso diretto all'altro lato della relazione molti-a-molti.

Come illustrato negli esempi seguenti, è possibile eseguire il mapping di una relazione molti-a-molti in questo modo, ovvero con una classe .NET per l'entità di join e con entrambi gli spostamenti per le due relazioni uno-a-molti e ignorare gli spostamenti esposti sui tipi di entità. Ef può tuttavia gestire l'entità join in modo trasparente, senza una classe .NET definita e senza spostamenti per le due relazioni uno-a-molti. Ad esempio:

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

Per impostazione predefinita, le convenzioni di compilazione dei modelli di Entity Framework eseguiranno il mapping dei Post tipi e Tag visualizzati qui alle tre tabelle nello schema del database nella parte superiore di questa sezione. Questo mapping, senza l'uso esplicito del tipo di join, è ciò che in genere significa il termine "molti-a-molti".

Esempi

Le sezioni seguenti contengono esempi di relazioni molti-a-molti, inclusa la configurazione necessaria per ottenere ogni mapping.

Suggerimento

Il codice per tutti gli esempi seguenti è disponibile in ManyToMany.cs.

Base molti-a-molti

Nel caso più semplice di un tipo molti-a-molti, i tipi di entità in ogni fine della relazione hanno entrambi una navigazione raccolta. Ad esempio:

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

Questa relazione è mappata per convenzione. Anche se non è necessaria, una configurazione esplicita equivalente per questa relazione è illustrata di seguito come strumento di apprendimento:

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

Anche con questa configurazione esplicita, molti aspetti della relazione sono ancora configurati per convenzione. Una configurazione esplicita più completa, ancora una volta a scopo di apprendimento, è:

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

Importante

Non tentare di configurare completamente tutto anche quando non è necessario. Come si può notare in precedenza, il codice diventa complicato rapidamente e il suo facile errore. Anche nell'esempio precedente sono presenti molti elementi nel modello che sono ancora configurati per convenzione. Non è realistico pensare che tutti gli elementi in un modello di Entity Framework possano essere sempre completamente configurati in modo esplicito.

Indipendentemente dal fatto che la relazione venga compilata per convenzione o usando una delle configurazioni esplicite visualizzate, lo schema mappato risultante (usando 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);

Suggerimento

Quando si usa un flusso Database First per eseguire lo scaffolding di un dbContext da un database esistente, EF Core 6 e versioni successive cerca questo modello nello schema del database e esegue lo scaffolding di una relazione molti-a-molti, come descritto in questo documento. Questo comportamento può essere modificato tramite l'uso di un modello T4 personalizzato. Per altre opzioni, vedere Relazioni molti-a-molti senza entità di join mappate sono ora sottoposte a scaffolding.

Importante

Ef Core usa Dictionary<string, object> attualmente per rappresentare le istanze di entità di join per le quali non è stata configurata alcuna classe .NET. Tuttavia, per migliorare le prestazioni, è possibile usare un tipo diverso in una versione futura di EF Core. Non dipendere dal tipo di Dictionary<string, object> join a meno che non sia stato configurato in modo esplicito.

Molti-a-molti con tabella join denominata

Nell'esempio precedente la tabella join è stata denominata PostTag per convenzione. Può essere assegnato un nome esplicito con UsingEntity. Ad esempio:

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

Tutto il resto del mapping rimane invariato, con solo il nome della tabella join che cambia:

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

Molti-a-molti con nomi di chiave esterna della tabella join

Seguendo l'esempio precedente, è anche possibile modificare i nomi delle colonne chiave esterna nella tabella join. Per eseguire questa operazione è possibile procedere in due modi: Il primo consiste nel specificare in modo esplicito i nomi delle proprietà di chiave esterna nell'entità join. Ad esempio:

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

Il secondo modo consiste nell'lasciare le proprietà con i relativi nomi per convenzione, ma quindi eseguire il mapping di queste proprietà a nomi di colonna diversi. Ad esempio:

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 entrambi i casi, il mapping rimane invariato, con solo i nomi delle colonne chiave esterna modificati:

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

Suggerimento

Anche se non illustrato qui, i due esempi precedenti possono essere combinati per modificare il nome della tabella di join e i relativi nomi di colonna chiave esterna.

Molti-a-molti con classe per l'entità join

Finora negli esempi, la tabella join è stata mappata automaticamente a un tipo di entità di tipo condiviso. In questo modo viene rimossa la necessità di creare una classe dedicata per il tipo di entità. Tuttavia, può essere utile avere una classe di questo tipo in modo che possa essere fatto riferimento facilmente, soprattutto quando gli spostamenti o un payload vengono aggiunti alla classe, come illustrato negli esempi successivi seguenti. A tale scopo, creare prima di tutto un tipo PostTag per l'entità join oltre ai tipi esistenti per Post e 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; }
}

Suggerimento

La classe può avere qualsiasi nome, ma è comune combinare i nomi dei tipi in una delle due estremità della relazione.

È ora possibile usare il UsingEntity metodo per configurare questa operazione come tipo di entità join per la relazione. Ad esempio:

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

e PostIdTagId vengono prelevati automaticamente come chiavi esterne e sono configurati come chiave primaria composita per il tipo di entità join. Le proprietà da usare per le chiavi esterne possono essere configurate in modo esplicito per i casi in cui non corrispondono alla convenzione di Entity Framework. Ad esempio:

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

Lo schema del database mappato per la tabella join in questo esempio è strutturalmente equivalente agli esempi precedenti, ma con alcuni nomi di colonna diversi:

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

Molti-a-molti con spostamenti per l'aggiunta all'entità

Seguendo l'esempio precedente, ora che è presente una classe che rappresenta l'entità join, diventa facile aggiungere spostamenti che fanno riferimento a questa classe. Ad esempio:

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

Importante

Come illustrato in questo esempio, è possibile usare gli spostamenti al tipo di entità join oltre agli spostamenti ignorati tra le due estremità della relazione molti-a-molti. Ciò significa che gli spostamenti skip possono essere usati per interagire con la relazione molti-a-molti in modo naturale, mentre gli spostamenti al tipo di entità join possono essere usati quando è necessario un maggiore controllo sulle entità di join stesse. In un certo senso, questo mapping offre il meglio di entrambi i mondi tra un semplice mapping molti-a-molti e un mapping che corrisponde in modo più esplicito allo schema del database.

Non è necessario modificare nulla nella UsingEntity chiamata, perché gli spostamenti all'entità di join vengono prelevati per convenzione. Di conseguenza, la configurazione per questo esempio è identica a quella dell'ultimo esempio:

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

Gli spostamenti possono essere configurati in modo esplicito per i casi in cui non possono essere determinati per convenzione. Ad esempio:

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

Lo schema del database mappato non è interessato dall'inclusione degli spostamenti nel modello:

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

Molti-a-molti con spostamenti da e verso l'entità di join

Nell'esempio precedente sono stati aggiunti spostamenti al tipo di entità join dai tipi di entità alla fine della relazione molti-a-molti. Gli spostamenti possono anche essere aggiunti nell'altra direzione o in entrambe le direzioni. Ad esempio:

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

Non è necessario modificare nulla nella UsingEntity chiamata, perché gli spostamenti all'entità di join vengono prelevati per convenzione. Di conseguenza, la configurazione per questo esempio è identica a quella dell'ultimo esempio:

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

Gli spostamenti possono essere configurati in modo esplicito per i casi in cui non possono essere determinati per convenzione. Ad esempio:

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

Lo schema del database mappato non è interessato dall'inclusione degli spostamenti nel modello:

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

Molti-a-molti con spostamenti e chiavi esterne modificate

Nell'esempio precedente è stato illustrato uno spostamento molti-a-molti con spostamenti da e verso il tipo di entità join. Questo esempio è lo stesso, ad eccezione del fatto che vengono modificate anche le proprietà della chiave esterna usate. Ad esempio:

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

Anche in questo caso, il UsingEntity metodo viene usato per configurare quanto segue:

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

Lo schema del database mappato è ora:

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

Unidirezionale molti-a-molti

Nota

Le relazioni molti-a-molti unidirezionali sono state introdotte in EF Core 7. Nelle versioni precedenti, una navigazione privata può essere usata come soluzione alternativa.

Non è necessario includere uno spostamento su entrambi i lati della relazione molti-a-molti. Ad esempio:

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

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

Ef necessita di una configurazione per sapere che deve trattarsi di una relazione molti-a-molti, anziché uno-a-molti. Questa operazione viene eseguita usando HasMany e WithMany, ma senza alcun argomento passato sul lato senza uno spostamento. Ad esempio:

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

La rimozione dello spostamento non influisce sullo schema del database:

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

Tabella molti-a-molti e join con payload

Negli esempi finora la tabella join è stata usata solo per archiviare le coppie di chiavi esterne che rappresentano ogni associazione. Tuttavia, può anche essere usato per archiviare informazioni sull'associazione, ad esempio il momento in cui è stato creato. In questi casi è consigliabile definire un tipo per l'entità join e aggiungere le proprietà "payload di associazione" a questo tipo. È anche comune creare spostamenti all'entità join oltre a "ignorare gli spostamenti" usati per la relazione molti-a-molti. Questi spostamenti aggiuntivi consentono di fare facilmente riferimento all'entità join dal codice, semplificando così la lettura e/o la modifica dei dati del payload. Ad esempio:

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

È anche comune usare valori generati per le proprietà del payload, ad esempio un timestamp del database impostato automaticamente quando viene inserita la riga di associazione. Questa operazione richiede una configurazione minima. Ad esempio:

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

Il risultato esegue il mapping a uno schema del tipo di entità con un timestamp impostato automaticamente quando viene inserita una riga:

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

Suggerimento

Sql illustrato di seguito è per SQLite. In SQL Server/Azure SQL usare .HasDefaultValueSql("GETUTCDATE()") e per TEXT leggere datetime.

Tipo di entità di tipo condiviso personalizzato come entità join

Nell'esempio precedente è stato usato il tipo come tipo PostTag di entità join. Questo tipo è specifico della relazione post-tag. Tuttavia, se sono presenti più tabelle di join con la stessa forma, è possibile usare lo stesso tipo CLR per tutti. Si supponga, ad esempio, che tutte le tabelle join abbiano una CreatedOn colonna. È possibile eseguire il mapping di questi usando JoinType la classe mappata come tipo di entità di tipo condiviso:

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

Questo tipo può quindi essere fatto riferimento come tipo di entità join da più relazioni molti-a-molti diverse. Ad esempio:

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

Queste relazioni possono quindi essere configurate in modo appropriato per eseguire il mapping del tipo di join a una tabella diversa per ogni relazione:

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

Ciò comporta le tabelle seguenti nello schema del database:

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

Molti-a-molti con chiavi alternative

Finora, tutti gli esempi hanno mostrato che le chiavi esterne nel tipo di entità join sono vincolate alle chiavi primarie dei tipi di entità su entrambi i lati della relazione. Ogni chiave esterna, o entrambe, può invece essere vincolata a una chiave alternativa. Si consideri ad esempio questo modello in cuiTag e Post hanno proprietà chiave alternative:

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 configurazione per questo modello è:

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

E lo schema del database risultante, per maggiore chiarezza, incluse anche le tabelle con le chiavi alternative:

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 configurazione per l'uso di chiavi alternative è leggermente diversa se il tipo di entità join è rappresentato da un tipo .NET. Ad esempio:

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 configurazione può ora usare il metodo generico UsingEntity<> :

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

E lo schema risultante è:

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

Tabella molti-a-molti e join con chiave primaria separata

Finora, il tipo di entità join in tutti gli esempi ha una chiave primaria composta dalle due proprietà della chiave esterna. Ciò è dovuto al fatto che ogni combinazione di valori per queste proprietà può verificarsi al massimo una volta. Queste proprietà formano quindi una chiave primaria naturale.

Nota

EF Core non supporta entità duplicate in nessuna navigazione nella raccolta.

Se si controlla lo schema del database, non esiste alcun motivo per cui la tabella join abbia una colonna chiave primaria aggiuntiva, tuttavia, è possibile che una tabella join esistente abbia una colonna chiave primaria definita. Entity Framework può comunque eseguire il mapping a questo oggetto con alcune configurazioni.

È forse più semplice creare una classe per rappresentare l'entità join. Ad esempio:

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

Questa PostTag.Id proprietà viene ora prelevata come chiave primaria per convenzione, quindi l'unica configurazione necessaria è una chiamata a UsingEntity per il PostTag tipo:

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

E lo schema risultante per la tabella join è:

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

È anche possibile aggiungere una chiave primaria all'entità join senza definirne una classe. Ad esempio, con just Post e Tag types:

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 chiave può essere aggiunta con questa configurazione:

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

Viene quindi creata una tabella join con una colonna chiave primaria separata:

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

Molti-a-molti senza eliminazione a catena

In tutti gli esempi illustrati in precedenza, le chiavi esterne create tra la tabella join e i due lati della relazione molti-a-molti vengono create con il comportamento di eliminazione a catena. Ciò è molto utile perché significa che se un'entità su entrambi i lati della relazione viene eliminata, le righe nella tabella di join per tale entità vengono eliminate automaticamente. In altre parole, quando un'entità non esiste più, anche le relazioni con altre entità non esistono più.

È difficile immaginare quando è utile modificare questo comportamento, ma può essere fatto se lo si desidera. Ad esempio:

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

Lo schema del database per la tabella di join usa un comportamento di eliminazione limitato nel vincolo di chiave esterna:

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

Self-referencing molti-a-molti

Lo stesso tipo di entità può essere usato a entrambe le estremità di una relazione molti-a-molti; si tratta di una relazione di riferimento automatico. Ad esempio:

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

Viene eseguito il mapping a una tabella di join denominata PersonPerson, con entrambe le chiavi esterne che puntano alla People tabella:

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

Self-referencing simmetrico molti-a-molti

A volte una relazione molti-a-molti è naturalmente simmetrica. Ovvero, se l'entità A è correlata all'entità B, l'entità B è correlata anche all'entità A. Questo modello è naturalmente modellato usando un'unica navigazione. Si supponga, ad esempio, che il caso in cui la persona A sia amica della persona B, quindi la persona B sia amica della persona A:

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

Sfortunatamente, questo non è facile da mappare. Non è possibile utilizzare la stessa struttura di spostamento per entrambe le estremità della relazione. Il meglio che può essere fatto è mapparlo come una relazione molti-a-molti unidirezionale. Ad esempio:

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

Tuttavia, per assicurarsi che due persone siano entrambe correlate tra loro, ogni persona dovrà essere aggiunta manualmente alla raccolta dell'altra Friends persona. Ad esempio:

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

Uso diretto della tabella join

Tutti gli esempi precedenti usano i modelli di mapping molti-a-molti di EF Core. Tuttavia, è anche possibile eseguire il mapping di una tabella di join a un tipo di entità normale e usare solo le due relazioni uno-a-molti per tutte le operazioni.

Ad esempio, questi tipi di entità rappresentano il mapping di due tabelle normali e di una tabella di join senza usare relazioni molti-a-molti:

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

Questo non richiede alcun mapping speciale, poiché si tratta di tipi di entità normali con relazioni uno-a-molti normali.

Risorse aggiuntive

  • Sessione di standup della community di dati .NET, con un approfondimento su molti-a-molti e sull'infrastruttura alla base dell'infrastruttura.