Поделиться через


Связи "многие ко многим"

Связи «многие ко многим» используются, когда любое количество сущностей одного типа связано с любым количеством сущностей этого же или другого типа. Например, у Post может быть много связанных Tags, и каждый Tag может быть связан с любым числом Posts.

Общие сведения о связях "многие ко многим"

Отношения типа "многие ко многим" отличаются от связей типа "один ко многим" и "один к одному" тем, что их невозможно представить просто с помощью внешнего ключа. Вместо этого для объединения двух сторон связи требуется дополнительный тип сущности. Это называется "типом соединения" и сопоставляется с "таблицей соединения" в реляционной базе данных. Сущности этого типа сущности соединения содержат пары значений внешнего ключа, где одна из каждой пары указывает на сущность в одной стороне связи, а другая указывает на сущность с другой стороны связи. Каждая сущность соединения и, следовательно, каждая строка в таблице соединения, поэтому представляет одну связь между типами сущностей в связи.

EF Core может скрыть тип сущности соединения и управлять им в фоновом режиме. Это позволяет естественным образом использовать навигации связи "многие ко многим", добавляя или удаляя сущности с обеих сторон по мере необходимости. Однако полезно понять, что происходит за кулисами, чтобы их общее поведение, и, в частности, сопоставление с реляционной базой данных имеет смысл. Начнем с установки схемы реляционной базы данных для представления связи между записями и тегами:

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

В этой схеме PostTag является таблицей соединения. Он содержит два столбца: PostsId, который является внешним ключом к первичному ключу таблицы Posts, и TagsId, который является внешним ключом к первичному ключу таблицы Tags. Таким образом, каждая строка в этой таблице представляет связь между одной Post и одной Tag.

Упрощенное сопоставление для этой схемы в EF Core состоит из трех типов сущностей—один для каждой таблицы. Если каждый из этих типов сущностей представлен классом .NET, эти классы могут выглядеть следующим образом:

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

Обратите внимание, что в этом сопоставлении нет связи "многие ко многим", а есть две связи "один ко многим" для каждого из внешних ключей, определенных в таблице соединения. Это не является нелогичным способом сопоставления этих таблиц, но оно не отражает назначение таблицы соединения, которое состоит в представлении одной связи "многие ко многим", а не двух связей "один ко многим".

EF позволяет более естественно осуществить сопоставление через внедрение двух навигаций по коллекции: одна на Post, содержащая его связанные Tags, и противоположная на Tag, содержащая его связанные Posts. Рассмотрим пример.

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

Подсказка

Эти новые навигации называются "пропускающие навигации", так как они пропускают сущность соединения, чтобы предоставить прямой доступ на другую сторону связи типа "многие ко многим".

Как показано в приведенном выше примере, связь "многие ко многим" может быть сопоставлена таким образом, то есть с классом .NET для сущности соединения и с обоими навигациями для двух связей "один ко многим" и пропускать навигации, предоставляемые в типах сущностей. Однако EF может прозрачно управлять сущностью соединения без класса .NET, определенного для него, и без навигации для двух связей "один ко многим". Рассмотрим пример.

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

Действительно, соглашения о создании моделей EF по умолчанию сопоставляют Post и Tag типы, показанные здесь, с тремя таблицами в схеме базы данных в верхней части этого раздела. Это сопоставление, без конкретного указания типа соединения, обычно подразумевается под термином "многие ко многим".

Примеры

В следующих разделах содержатся примеры связей "многие ко многим", включая конфигурацию, необходимую для достижения каждого сопоставления.

Подсказка

Код для всех приведенных ниже примеров можно найти в ManyToMany.cs.

Базовый принцип отношения многие ко многим

В наиболее простом случае для связи многие-ко-многим типы сущностей на каждом конце связи оба имеют навигацию коллекцией. Рассмотрим пример.

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

Эта связь сопоставляется по соглашению. Хотя это не требуется, эквивалентная явная конфигурация для этой связи показана ниже как средство обучения:

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

Даже с этой явной конфигурацией многие аспекты связи по-прежнему настраиваются по соглашению. Более полная явная конфигурация, опять же, для целей обучения:

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

Это важно

Не пытайтесь полностью настроить все, даже если он не нужен. Как видно выше, код быстро усложняется и его легко сделать ошибку. И даже в приведенном выше примере есть много элементов в модели, которые все еще настроены по соглашению. Нереалистично думать, что все в модели EF всегда может быть полностью настроено явным образом.

Независимо от того, строится ли связь по соглашению или используется любая из отображаемых явных конфигураций, результирующая сопоставленная схема (с помощью 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);

Подсказка

При использовании потока 'Сначала база данных' для создания DbContext из существующей базы данных, EF Core 6 определяет этот шаблон в схеме базы данных и создает отношение "многие ко многим", как описано в этом документе. Это поведение можно изменить с помощью пользовательского шаблона T4. Другие параметры см. в разделе Многие ко многим отношения без сопоставленных соединительных сущностей теперь генерируются автоматически.

Это важно

В настоящее время EF Core использует Dictionary<string, object> для представления экземпляров объединяемых сущностей, для которых не настроен класс .NET. Однако для повышения производительности в будущем выпуске EF Core может использоваться другой тип. Не полагайтесь на тип соединения Dictionary<string, object>, если это явно не настроено.

Связь многие ко многим с именованной таблицей соединений

В предыдущем примере таблица соединения была названа PostTag по соглашению. Можно присвоить явное имя с помощью UsingEntity. Рассмотрим пример.

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

Все остальное в сопоставлении остается прежним, меняется только название таблицы соединения.

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

Связь многие ко многим с именами внешних ключей в таблице соединений

После предыдущего примера имена столбцов внешнего ключа в таблице соединения также можно изменить. Это можно сделать двумя способами. Сначала необходимо явно указать имена свойств внешнего ключа для объединенной сущности. Рассмотрим пример.

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

Второй способ — оставить свойства с именами, принятыми по соглашению, но затем сопоставить эти свойства с другими именами столбцов. Рассмотрим пример.

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

В любом случае сопоставление остается неизменным, при этом изменены только имена столбцов внешнего ключа:

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

Подсказка

Хотя здесь не показано, предыдущие два примера можно объединить, чтобы изменить имя таблицы соединения и соответствующие ему имена столбцов внешнего ключа.

Многие ко многим с классом для сущности соединения

До сих пор в примерах таблица соединения автоматически сопоставляется с типом сущности общего типа. Это удаляет необходимость создания выделенного класса для типа сущности. Однако такой класс может быть полезен, чтобы на него можно было легко ссылаться, особенно если навигации или полезные данные (полезные данные — это любые дополнительные данные в таблице соединения. Например, метка времени, в которой создается запись в таблице соединения.) добавляются в класс, как показано в примерах ниже. Для этого сначала создайте тип PostTag для сущности соединения в дополнение к существующим типам для Post и 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; }
}

Подсказка

Класс может иметь любое имя, но обычно принято сочетать названия типов с обоих концов связи.

Теперь метод UsingEntity можно использовать для настройки этого в качестве типа связывающей сущности для связи. Рассмотрим пример.

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

PostId и TagId автоматически определяются как внешние ключи и настраиваются как составной первичный ключ для связываемого типа сущности. Свойства, используемые для внешних ключей, можно явно настроить для случаев, когда они не соответствуют соглашению EF. Рассмотрим пример.

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

Схема сопоставленной базы данных для таблицы соединения в этом примере структурно эквивалентна предыдущим примерам, но с некоторыми различными именами столбцов:

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

Многие ко многим с навигациями для присоединения сущности

После предыдущего примера теперь, когда существует класс, представляющий сущность соединения, становится легко добавлять навигации, ссылающиеся на этот класс. Рассмотрим пример.

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

Это важно

Как показано в этом примере, переходы к типу сущности соединения можно использовать в дополнение к переходу между двумя концами связи "многие ко многим". Это означает, что переходы можно использовать для взаимодействия с связью "многие ко многим" естественным образом, а переходы к типу сущности соединения можно использовать, когда требуется более широкий контроль над сущностями соединения. В некотором смысле, эта схема сопоставления объединяет лучшее из обоих подходов: простого сопоставления "многие ко многим" и того, которое более явно соответствует схеме базы данных.

Ничего не нужно изменять в вызове UsingEntity, поскольку навигации связанные сущности обрабатываются по соглашению. Таким образом, конфигурация для этого примера совпадает с последним примером:

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

Навигации можно настроить явно в тех случаях, когда они не могут быть определены по соглашению. Рассмотрим пример.

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

Сопоставленная схема базы данных не влияет на включение навигаций в модель:

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

Модель «многие ко многим» с навигацией к связанной сущности и от нее

В предыдущем примере добавлены навигационные свойства к типу связующей сущности из типов сущностей на обоих концах связи "многие ко многим". Навигации также можно добавлять в противоположное направление или в обоих направлениях. Рассмотрим пример.

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

Ничего не нужно изменять в вызове UsingEntity, поскольку навигации связанные сущности обрабатываются по соглашению. Таким образом, конфигурация для этого примера совпадает с последним примером:

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

Навигации можно настроить явно в тех случаях, когда они не могут быть определены по соглашению. Рассмотрим пример.

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

Сопоставленная схема базы данных не влияет на включение навигаций в модель:

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

Многие ко многим с навигацией и изменёнными внешними ключами

В предыдущем примере показана связь "многие ко многим" с навигацией к и от типа связующей сущности. Этот пример такой же, за исключением того, что используемые свойства внешнего ключа также изменяются. Рассмотрим пример.

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

Опять же, для настройки используется этот метод UsingEntity.

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

Сопоставленная схема базы данных сейчас выглядит так:

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

Однонаправленная связь "многие ко многим"

Нет необходимости включать элементы навигации на обеих сторонах связи «многие ко многим». Рассмотрим пример.

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

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

EF нуждается в некоторой конфигурации, чтобы определить, что это должно быть отношение "многие ко многим", а не "один ко многим". Это делается с помощью HasMany и WithMany, но без передачи аргументов на стороне, где отсутствует навигация. Рассмотрим пример.

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

Удаление навигации не влияет на схему базы данных:

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

Таблица "многие ко многим" и присоединение к ней с полезными данными

В примерах до сих пор таблица соединения использовалась только для хранения пар внешних ключей, представляющих каждую ассоциацию. Однако его также можно использовать для хранения сведений об ассоциации, например времени его создания. В таких случаях рекомендуется определить тип для сущности соединения и добавить в этот тип свойства "полезные данные ассоциации". Кроме того, в дополнение к "пропускающим навигациям", которые используются для связи "многие ко многим", также часто создаются навигационные связи для сущности соединения. Эти дополнительные навигации позволяют легко ссылаться на сущность соединения из кода, тем самым упрощая чтение и/или изменение данных полезной нагрузки. Рассмотрим пример.

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

Также часто используются автоматически генерируемые значения для атрибутов полезной нагрузки, например, метка времени базы данных, которая автоматически устанавливается при вставке ассоциативной строки. Для этого требуется минимальная конфигурация. Рассмотрим пример.

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

Результат сопоставляется со схемой типа сущности, при этом метка времени устанавливается автоматически при вставке строки.

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

Подсказка

Показанный здесь SQL предназначен для SQLite. На SQL Server/Azure SQL используйте .HasDefaultValueSql("GETUTCDATE()"), и читайте TEXT для datetime.

Пользовательский тип сущности общего типа в качестве сущности соединения

В предыдущем примере тип PostTag использовался в качестве объединяющей сущности. Этот тип специфичен для связи постов и тегов. Однако при наличии нескольких таблиц соединения с одной и той же фигурой можно использовать один и тот же тип CLR для всех этих таблиц. Например, представьте, что все таблицы связей имеют CreatedOn столбец. Эти параметры можно сопоставить с помощью JoinType класса, сопоставленного с типом сущности общего типа:

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

Затем этот тип может использоваться как тип сущности соединения в нескольких различных отношениях «многие ко многим». Рассмотрим пример.

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

Затем эти связи можно настроить соответствующим образом, чтобы сопоставить тип соединения с другой таблицей для каждой связи:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(e => e.Tags)
        .WithMany(e => e.Posts)
        .UsingEntity<JoinType>(
            "PostTag",
            r => r.HasOne<Tag>().WithMany(e => e.PostTags).HasForeignKey(e => e.Id1),
            l => l.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",
            r => r.HasOne<Author>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id1),
            l => l.HasOne<Blog>().WithMany(e => e.BlogAuthors).HasForeignKey(e => e.Id2),
            j => j.Property(e => e.CreatedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

Это приводит к следующим таблицам в схеме базы данных:

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

Отношение многие ко многим с альтернативными ключами

До сих пор все примеры показали, что внешние ключи в типе сущности соединения ограничены первичными ключами типов сущностей на обеих сторонах отношений. Каждый внешний ключ или оба могут быть ограничены альтернативным ключом. Например, рассмотрим эту модель, гдеTag и Post имеются альтернативные ключевые свойства:

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

Конфигурация для этой модели:

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

И результирующая схема базы данных для ясности, включая также таблицы с альтернативными ключами:

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

Конфигурация использования альтернативных ключей немного отличается, если тип сущности соединения представлен типом .NET. Рассмотрим пример.

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

Теперь конфигурация может использовать универсальный UsingEntity<> метод:

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

И результирующая схема:

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

Таблица "многие ко многим" и присоединение к ней с отдельным первичным ключом

До сих пор тип сущности соединения во всех примерах содержит первичный ключ, состоящий из двух свойств внешнего ключа. Это связано с тем, что каждое сочетание значений для этих свойств может происходить не более одного раза. Поэтому эти свойства образуют естественный первичный ключ.

Примечание.

EF Core не поддерживает повторяющиеся сущности в любой навигации по коллекции.

Если вы управляете схемой базы данных, то для таблицы соединения нет причин наличия дополнительного столбца первичного ключа, однако возможно, что существующая таблица соединения может иметь определенный столбец первичного ключа. EF все еще можно настроить, чтобы он соответствовал этому.

Это, возможно, проще всего, создав класс для представления сущности соединения. Рассмотрим пример.

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

Это PostTag.Id свойство теперь выбирается в качестве первичного ключа по соглашению, поэтому требуется только конфигурация для вызова UsingEntity для типа PostTag.

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

И результирующая схема для таблицы соединения:

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

Первичный ключ также можно добавить в сущность соединения без определения класса для него. Например, всего лишь с типами Post и 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; } = [];
}

Ключ можно добавить с помощью этой конфигурации:

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

Это приводит к образованию таблицы связей с отдельным первичным ключевым столбцом.

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

Многие-ко-многим без каскадного удаления

Во всех приведенных выше примерах внешние ключи, созданные между таблицей соединения и двумя сторонами связи "многие ко многим", создаются с каскадным поведением удаления. Это очень полезно, так как это означает, что если сущность на любой стороне связи удаляется, строки в таблице соединения для этой сущности автоматически удаляются. Или, другими словами, когда сущность больше не существует, то ее отношения с другими сущностями больше не существуют.

Трудно представить, когда полезно изменить это поведение, но это можно сделать, если это нужно. Рассмотрим пример.

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

Схема базы данных для таблицы соединения использует ограниченное поведение удаления для ограничения внешнего ключа:

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

Самоссылочная связь "многие ко многим"

Тот же тип сущности может быть использован на обоих концах отношения "многие ко многим"; это называется "самоссылочным" отношением. Рассмотрим пример.

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

Это сопоставляется с таблицей соединения под названием PersonPerson, где оба внешних ключа указывают на таблицу 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);

Симметричное самоссылочное многие ко многим

Иногда связь "многие ко многим" естественно является симметричной. То есть, если сущность A связана с сущностью B, то сущность B также связана с сущностью A. Это естественно моделировается с помощью одной навигации. Например, представьте себе случай, когда человек A является друзьями с человеком B, и человек B является друзьями с человеком A:

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

К сожалению, это не так просто понять. Для обоих концов связи нельзя использовать одну и ту же навигацию. Самое лучшее, что можно сделать, заключается в том, чтобы сопоставить его как однонаправленное отношение 'многие ко многим'. Рассмотрим пример.

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

Тем не менее, чтобы убедиться, что два человека связаны друг с другом, каждый человек должен быть вручную добавлен в коллекцию другого Friends человека. Рассмотрим пример.

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

Прямое использование таблицы соединения

Все приведенные выше примеры используют шаблоны сопоставления "многие ко многим" EF Core. Однако возможно также сопоставить таблицу соединений с обычным типом сущности и просто использовать две связи «один ко многим» для всех операций.

Например, эти типы сущностей представляют сопоставление двух обычных таблиц и таблицы соединений без использования отношений «многие ко многим».

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

Это не требует специального сопоставления, так как это обычные типы сущностей с обычными отношениями "один ко многим ".

Дополнительные ресурсы