Связи "многие ко многим"
Связи "многие ко многим" используются, когда любые сущности одного типа сущности связаны с любым числом сущностей одного или другого типа сущностей. Например, Post
может быть связано много связанных Tags
, и каждый из них Tag
может быть связан с любым числом Posts
.
Общие сведения о связях "многие ко многим"
Отношения "многие ко многим" отличаются от связей "один ко многим " и "один ко многим", что они не могут быть представлены простым способом с помощью внешнего ключа. Вместо этого для объединения двух сторон связи требуется дополнительный тип сущности. Это называется типом сущности join и сопоставляется с "таблицей соединения" в реляционной базе данных. Сущности этого типа сущности соединения содержат пары значений внешнего ключа, где одна из каждой пары указывает на сущность в одной стороне связи, а другая указывает на сущность с другой стороны связи. Каждая сущность соединения и, следовательно, каждая строка в таблице соединения, поэтому представляет одну связь между типами сущностей в связи.
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
внешний ключ к первичному ключу таблицы и TagsId
внешний ключ Posts
к первичному ключу 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",
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"));
}
Важно!
Не пытайтесь полностью настроить все, даже если он не нужен. Как видно выше, код быстро усложняется и его легко сделать ошибку. И даже в приведенном выше примере есть много вещей в модели, которые по-прежнему настроены по соглашению. Нереалистично думать, что все в модели 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(
l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagForeignKey"),
r => r.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>(
l => l.HasOne<Tag>().WithMany().HasForeignKey(e => e.TagId),
r => r.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>(
l => l.HasOne<Tag>().WithMany(e => e.PostTags),
r => r.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>(
l => l.HasOne<Tag>(e => e.Tag).WithMany(e => e.PostTags),
r => r.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>(
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));
}
Теперь сопоставленная схема базы данных:
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);
Однонаправленный "многие ко многим"
Примечание.
В EF Core 7 появились однонаправленные связи "многие ко многим". В более ранних выпусках в качестве обходного решения можно использовать частную навигацию.
Не обязательно включать навигацию по обеим сторонам связи "многие ко многим". Например:
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
сущности соединения. Этот тип зависит от связи post-tags. Однако при наличии нескольких таблиц соединения с одной и той же фигурой можно использовать один и тот же тип 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",
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"));
}
Это приводит к следующим таблицам в схеме базы данных:
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(
l => l.HasOne(typeof(Tag)).WithMany().HasPrincipalKey(nameof(Tag.AlternateKey)),
r => r.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>(
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));
}
И результирующая схема:
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(
l => l.HasOne(typeof(Tag)).WithMany().OnDelete(DeleteBehavior.Restrict),
r => r.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. Это естественно моделировается с помощью одной навигации. Например, представьте себе случай, когда человек А друзья с человеком B, а затем человек B друзья с человеком А:
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!;
}
Это не требует специального сопоставления, так как это обычные типы сущностей с обычными отношениями "один ко многим ".
Дополнительные ресурсы
- Сеанс стенда сообщества данных .NET с глубоким погружением в много-многие и инфраструктурой, лежащей в его основе.