Relacionamento muitos para muitos

Relações muitos para muitos são usadas quando qualquer número de entidades de um tipo de entidade é associado a qualquer número de entidades do mesmo tipo de entidade ou outro tipo de entidade. Por exemplo, um Post pode ter muitos associados Tags, e cada Tag pode, por sua vez, ser associado a qualquer número de Posts.

Noções básicas sobre relacionamentos muitos para muitos

Relações muitos para muitos são diferentes de relações um-para-muitos e um-para-um, pois eles não podem ser representados de forma simples usando apenas uma chave estrangeira. Em vez disso, um tipo de entidade adicional é necessário para "unir" os dois lados da relação. Isso é conhecido como o "tipo de entidade de junção" e é mapeado para uma "tabela de junção" em um banco de dados relacional. As entidades desse tipo de entidade de junção contêm pares de valores de chave estrangeira, em que um de cada par aponta para uma entidade de um lado da relação e o outro aponta para uma entidade do outro lado da relação. Cada entidade de junção e, portanto, cada linha na tabela de junção, portanto, representa uma associação entre os tipos de entidade na relação.

O EF Core pode ocultar o tipo de entidade de junção e gerenciá-lo nos bastidores. Isso permite que as navegações de um relacionamento de muitos para muitos sejam usadas de maneira natural, adicionando ou removendo entidades de cada lado, conforme necessário. No entanto, é útil entender o que está acontecendo nos bastidores para que seu comportamento geral e, em particular, o mapeamento para um banco de dados relacional, faça sentido. Vamos começar com uma configuração de esquema de banco de dados relacional para representar uma relação muitos para muitos entre postagens e marcas:

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

Neste esquema, PostTag é a tabela de junção. Ele contém duas colunas: PostsId, que é uma chave estrangeira para a chave primária da tabela Posts, e TagsId, que é uma chave estrangeira para a chave primária da tabela Tags. Cada linha nesta tabela, portanto, representa uma associação entre uma Post e uma Tag.

Um mapeamento simplista para esse esquema no EF Core consiste em três tipos de entidade: um para cada tabela. Se cada um desses tipos de entidade for representado por uma classe .NET, essas classes poderão ter a seguinte aparência:

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

Observe que neste mapeamento não há nenhuma relação muitos para muitos, mas sim duas relações um-para-muitos, uma para cada uma das chaves estrangeiras definidas na tabela de junção. Esta não é uma maneira irracional de mapear essas tabelas, mas não reflete a intenção da tabela de junção, que é representar uma única relação muitos para muitos, em vez de duas relações um-para-muitos.

O EF permite um mapeamento mais natural por meio da introdução de duas navegações de coleção, uma em Post, contendo seu parente Tags, e uma inversa em Tag, contendo seu parente Posts. Por exemplo:

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

Dica

Essas novas navegações são conhecidas como "skip navigations", pois ignoram a entidade de união para fornecer acesso direto ao outro lado do relacionamento muitos-para-muitos.

Como mostrado nos exemplos abaixo, um relacionamento muitos-para-muitos pode ser mapeado dessa forma, ou seja, com uma classe .NET para a entidade de união e com ambas as navegações para os dois relacionamentos um-para-muitos e as navegações de salto expostas nos tipos de entidade. No entanto, o EF pode gerenciar a entidade de junção de forma transparente, sem uma classe .NET definida para ela e sem navegação para as duas relações um para muitos. Por exemplo:

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

De fato, as convenções de construção do modelo EF mapearão, por padrão, os tipos Post e Tag mostrados aqui para as três tabelas no esquema de banco de dados na parte superior desta seção. Esse mapeamento, sem uso explícito do tipo de junção, é o que normalmente significa o termo "muitos para muitos".

Exemplos

As seções a seguir contêm exemplos de relações muitos para muitos, incluindo a configuração necessária para alcançar cada mapeamento.

Dica

O código para todos os exemplos abaixo pode ser encontrado em ManyToMany.cs.

Muitos para muitos básicos

No caso mais básico para muitos para muitos, os tipos de entidade em cada extremidade da relação têm uma navegação de coleção. Por exemplo:

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

Essa relação é mapeada por convenção. Embora não seja necessário, uma configuração explícita equivalente para essa relação é mostrada abaixo como uma ferramenta de aprendizado:

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

Mesmo com essa configuração explícita, muitos aspectos da relação ainda são configurados por convenção. Uma configuração explícita mais completa, novamente para fins de aprendizado, é:

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

Não tente configurar totalmente tudo mesmo quando não for necessário. Como pode ser visto acima, o código fica complicado rapidamente e é fácil cometer um erro. E, mesmo no exemplo acima, há muitas coisas no modelo que ainda estão configuradas por convenção. Não é realista pensar que tudo em um modelo EF sempre pode ser totalmente configurado explicitamente.

Independentemente de a relação ser criada por convenção ou usar qualquer uma das configurações explícitas mostradas, o esquema mapeado resultante (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);

Dica

Ao usar um fluxo de Banco de Dados Primeiro para estruturar um DbContext de um banco de dados existente, o EF Core 6 e posterior procura esse padrão no esquema de banco de dados e estrutura uma relação muitos para muitos, conforme descrito neste documento. Esse comportamento pode ser alterado por meio do uso de um modelo T4 personalizado. Para obter outras opções, consulte Relacionamentos muitos-para-muitos sem entidades de união mapeadas agora são estruturados em andaimes.

Importante

Atualmente, o EF Core usa Dictionary<string, object> para representar instâncias de entidade de união para as quais nenhuma classe .NET foi configurada. No entanto, para melhorar o desempenho, um tipo diferente pode ser usado em uma versão futura do EF Core. Não dependa de o tipo de união ser Dictionary<string, object>, a menos que isso tenha sido configurado explicitamente.

Muitos para muitos com tabela de junção nomeada

No exemplo anterior, a tabela de junção foi nomeada PostTag por convenção. Ele pode receber um nome explícito com UsingEntity. Por exemplo:

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

Todo o resto sobre o mapeamento permanece o mesmo, com apenas o nome da tabela de junção mudando:

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

Muitos para muitos com nomes de chave estrangeira da tabela de junção

Seguindo o exemplo anterior, os nomes das colunas de chave estrangeira na tabela de junção também podem ser alterados. Há duas maneiras de fazer isso. A primeira é especificar explicitamente os nomes de propriedade de chave estrangeira na entidade de junção. Por exemplo:

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

A segunda maneira é deixar as propriedades com seus nomes por convenção, mas mapear essas propriedades para nomes de coluna diferentes. Por exemplo:

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

Em ambos os casos, o mapeamento permanece o mesmo, com apenas os nomes de coluna de chave estrangeira alterados:

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

Dica

Embora não seja mostrado aqui, os dois exemplos anteriores podem ser combinados para mapear a alteração do nome da tabela de junção e seus nomes de coluna de chave estrangeira.

Muitos para muitos com classe para entidade de junção

Até agora, nos exemplos, a tabela de junção foi mapeada automaticamente para um tipo de entidade de tipo compartilhado. Isso remove a necessidade de uma classe dedicada ser criada para o tipo de entidade. No entanto, pode ser útil ter essa classe para que ela possa ser referenciada facilmente, especialmente quando navegações ou uma carga útil são adicionadas à classe, como mostrado nos exemplos posteriores abaixo. Para fazer isso, primeiro crie um tipo PostTag para a entidade de junção, além dos tipos existentes para 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; }
}

Dica

A classe pode ter qualquer nome, mas é comum combinar os nomes dos tipos em ambas as extremidades da relação.

Agora, o método UsingEntity pode ser usado para configurar isso como o tipo de entidade de junção para a relação. Por exemplo:

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

As chaves PostId e TagId são automaticamente selecionadas como chaves estrangeiras e são configuradas como chave primária composta para o tipo de entidade de união. As propriedades a serem usadas para as chaves estrangeiras podem ser configuradas explicitamente para casos em que não correspondem à convenção EF. Por exemplo:

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

O esquema de banco de dados mapeado para a tabela de junção neste exemplo é estruturalmente equivalente aos exemplos anteriores, mas com alguns nomes de coluna diferentes:

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

Muitos para muitos com navegações para unir entidades

Continuando com o exemplo anterior, agora que há uma classe que representa a entidade de união, fica fácil adicionar navegações que fazem referência a essa classe. Por exemplo:

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

Conforme mostrado neste exemplo, as navegações para o tipo de entidade de união podem ser usadas além das navegações de salto entre as duas extremidades do relacionamento muitos-para-muitos. Isso significa que as navegações de ignorar podem ser usadas para interagir com o relacionamento muitos-para-muitos de forma natural, enquanto as navegações para o tipo de entidade de união podem ser usadas quando for necessário maior controle sobre as próprias entidades de união. De certa forma, esse mapeamento fornece o melhor dos dois mundos entre um mapeamento simples de muitos para muitos e um mapeamento que corresponde mais explicitamente ao esquema de banco de dados.

Nada precisa ser alterado na chamada UsingEntity, já que as navegações para a entidade de união são captadas por convenção. Portanto, a configuração deste exemplo é a mesma do último exemplo:

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

As navegações podem ser configuradas explicitamente para os casos em que não podem ser determinadas por convenção. Por exemplo:

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

O esquema de banco de dados mapeado não é afetado pela inclusão de navegação no modelo:

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

Muitos para muitos com navegações de e para a entidade de união

O exemplo anterior adicionou navegação ao tipo de entidade de junção dos tipos de entidade em ambas as extremidades da relação muitos para muitos. As navegações também podem ser adicionadas na outra direção, ou em ambas as direções. Por exemplo:

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

Nada precisa ser alterado na chamada UsingEntity, já que as navegações para a entidade de união são captadas por convenção. Portanto, a configuração deste exemplo é a mesma do último exemplo:

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

As navegações podem ser configuradas explicitamente para os casos em que não podem ser determinadas por convenção. Por exemplo:

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

O esquema de banco de dados mapeado não é afetado pela inclusão de navegação no modelo:

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

Muitos para muitos com navegações e chaves estrangeiras alteradas

O exemplo anterior mostrou uma entidade muitos-para-muitos com navegações de e para o tipo de entidade de união. Este exemplo é o mesmo, exceto que as propriedades de chave estrangeira usadas também são alteradas. Por exemplo:

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

Novamente, o método UsingEntity é usado para configurar isso:

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

O esquema de banco de dados mapeado agora é:

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

Muitos para muitos unidirecionais

Observação

Relações unidirecionais muitos para muitos foram introduzidas no EF Core 7. Em versões anteriores, uma navegação privada poderia ser usada como uma solução alternativa.

Não é necessário incluir uma navegação em ambos os lados da relação muitos para muitos. Por exemplo:

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

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

O EF precisa de alguma configuração para saber que essa deve ser uma relação muitos para muitos, em vez de um para muitos. Isso é feito usando HasMany e WithMany, mas sem nenhum argumento passado para o lado sem uma navegação. Por exemplo:

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

Remover a navegação não afeta o esquema de banco de dados:

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

Muitos para muitos e tabela de junção com conteúdo

Nos exemplos até agora, a tabela de junção foi usada apenas para armazenar os pares de chave estrangeira que representam cada associação. No entanto, ele também pode ser usado para armazenar informações sobre a associação, por exemplo, a hora em que ela foi criada. Nesses casos, é melhor definir um tipo para a entidade de junção e adicionar as propriedades de "conteúdo de associação" a esse tipo. Também é comum criar navegações para a entidade de união, além das "Ignorar navegações" usadas para o relacionamento muitos-para-muitos. Essas navegações adicionais permitem que a entidade de união seja facilmente referenciada a partir do código, facilitando assim a leitura e/ou a alteração dos dados da carga útil. Por exemplo:

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

Também é comum usar valores gerados para propriedades de conteúdo, por exemplo, um carimbo de data/hora de banco de dados definido automaticamente quando a linha de associação é inserida. Isso requer uma configuração mínima. Por exemplo:

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

O resultado é mapeado para um esquema de tipo de entidade com um conjunto de carimbo de data/hora automaticamente quando uma linha é inserida:

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

Dica

O SQL mostrado aqui é para SQLite. No SQL Server/SQL do Azure, use .HasDefaultValueSql("GETUTCDATE()") e para TEXT leitura datetime.

Tipo de entidade de tipo compartilhado personalizado como uma entidade de junção

O exemplo anterior usava o tipo PostTag como o tipo de entidade de junção. Esse tipo é específico para a relação post-marcas. No entanto, se você tiver várias tabelas de junção com a mesma forma, o mesmo tipo CLR poderá ser usado para todas elas. Por exemplo, imagine que todas as nossas tabelas de junção tenham uma coluna CreatedOn. Podemos mapeá-los usando a classe JoinType mapeada como um tipo de entidade de tipo compartilhado:

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

Esse tipo pode ser referenciado como o tipo de entidade de junção por várias relações de muitos para muitos diferentes. Por exemplo:

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

E essas relações podem ser configuradas adequadamente para mapear o tipo de junção para uma tabela diferente para cada relação:

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

Isso resulta nas seguintes tabelas no esquema de banco de dados:

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

Muitos para muitos com chaves alternativas

Até agora, todos os exemplos mostraram as chaves estrangeiras no tipo de entidade de junção sendo restritas às chaves primárias dos tipos de entidade em ambos os lados da relação. Cada chave estrangeira, ou ambas, pode ser restrita a uma chave alternativa. Por exemplo, considere este modelo onde Tag e Post tenha propriedades de chave alternativas:

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

A configuração deste modelo é:

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 o esquema de banco de dados resultante, para maior clareza, incluindo também as tabelas com as chaves alternativas:

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

A configuração para usar chaves alternativas será ligeiramente diferente se o tipo de entidade de junção for representado por um tipo .NET. Por exemplo:

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

A configuração agora pode usar o método genérico 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 o esquema resultante é:

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

Muitos para muitos e tabela de junção com chave primária separada

Até agora, o tipo de entidade de junção em todos os exemplos tem uma chave primária composta pelas duas propriedades de chave estrangeira. Isso ocorre porque cada combinação de valores para essas propriedades pode ocorrer no máximo uma vez. Essas propriedades, portanto, formam uma chave primária natural.

Observação

O EF Core não dá suporte a entidades duplicadas em nenhuma navegação de coleção.

Se você controlar o esquema de banco de dados, não haverá motivo para a tabela de junção ter uma coluna de chave primária adicional, no entanto, é possível que uma tabela de junção existente possa ter uma coluna de chave primária definida. O EF ainda pode mapear para isso com alguma configuração.

Talvez seja mais fácil criar uma classe para representar a entidade de junção. Por exemplo:

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

Essa propriedade PostTag.Id agora é escolhida como a chave primária por convenção, portanto, a única configuração necessária é uma chamada para UsingEntity o tipo PostTag:

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

E o esquema resultante da tabela de junção é:

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

Uma chave primária também pode ser adicionada à entidade de junção sem definir uma classe para ela. Por exemplo, com apenas os tipos 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; } = [];
}

A chave pode ser adicionada com esta configuração:

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

O que resulta em uma tabela de junção com uma coluna de chave primária separada:

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

Muitos para muitos sem excluir em cascata

Em todos os exemplos mostrados acima, as chaves estrangeiras criadas entre a tabela de junção e os dois lados da relação muitos para muitos são criadas com o comportamento de exclusão em cascata. Isso é muito útil porque significa que, se uma entidade de ambos os lados da relação for excluída, as linhas na tabela de junção dessa entidade serão excluídas automaticamente. Ou, em outras palavras, quando uma entidade não existe mais, suas relações com outras entidades também não existem mais.

É difícil imaginar quando é útil alterar esse comportamento, mas pode ser feito se desejado. Por exemplo:

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

O esquema de banco de dados para a tabela de junção usa o comportamento de exclusão restrita na restrição de chave estrangeira:

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

Auto-referenciando muitos para muitos

O mesmo tipo de entidade pode ser usado em ambas as extremidades de uma relação muitos para muitos; isso é conhecido como uma relação de "auto-referência". Por exemplo:

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

Isso é mapeado para uma tabela de junção chamada PersonPerson, com ambas as chaves estrangeiras apontando de volta para a tabela 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-referenciação simétrica de muitos para muitos

Às vezes, uma relação muitos para muitos é naturalmente simétrica. Ou seja, se a entidade A estiver relacionada à entidade B, a entidade B também estará relacionada à entidade A. Isso é naturalmente modelado usando uma única navegação. Por exemplo, imagine o caso em que a pessoa A é amiga da pessoa B e, em seguida, a pessoa B é amiga da pessoa A:

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

Infelizmente, isso não é fácil de mapear. A mesma navegação não pode ser usada para ambas as extremidades da relação. O melhor que pode ser feito é mapeá-lo como uma relação unidirecional muitos para muitos. Por exemplo:

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

No entanto, para garantir que duas pessoas sejam parentes uma da outra, cada pessoa precisará ser adicionada manualmente à coleção Friends da outra pessoa. Por exemplo:

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

Uso direto da tabela de junção

Todos os exemplos acima usam os padrões de mapeamento muitos para muitos do EF Core. No entanto, também é possível mapear uma tabela de junção para um tipo de entidade normal e apenas usar as duas relações um-para-muitos para todas as operações.

Por exemplo, esses tipos de entidade representam o mapeamento de duas tabelas normais e a tabela de junção sem usar relações muitos para muitos:

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

Isso não requer nenhum mapeamento especial, pois são tipos de entidade normais com relações normais um para muitos.

Recursos adicionais