Start Debugging

Как засеять связь многие-ко-многим в EF Core 11

Заполнение таблицы связки для отношения многие-ко-многим в EF Core 11: неявные теневые ключи, которые нужно именовать вручную, шаблон UsingEntity HasData и альтернатива UseSeeding во время выполнения, работающая с навигациями пропуска.

Чтобы засеять связь многие-ко-многим в EF Core 11, вы заполняете не навигацию пропуска. Вы заполняете таблицу связки напрямую, потому что HasData не может заполнить навигацию. При неявной связке по умолчанию (без класса для сущности связки) вы добираетесь до отношения через UsingEntity и вызываете HasData на сущности связки, передавая анонимные объекты, имена свойств которых — это теневые внешние ключи, генерируемые EF; для отношения Post.Tags / Tag.Posts это PostsId и TagsId. Вам также нужно засеять оба конца (Post и Tag) фиксированными значениями первичного ключа, потому что заполнение, управляемое миграциями, требует, чтобы каждый ключ был прописан вручную. Если вы предпочитаете заполнять данные во время выполнения по реальным данным, используйте UseSeeding/UseAsyncSeeding и загрузите сущности, чтобы добавлять в навигацию пропуска обычным образом. В этой статье используются .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0) и C# 14.

Причина, по которой это сбивает многих с толку, в том, что в типичной модели связь многие-ко-многим не имеет третьего класса. Вы пишете Post со List<Tag> и Tag со List<Post>, и EF создаёт таблицу связки за вас. Это удобство испаряется в тот момент, когда вам нужны данные заполнения, потому что HasData работает с типами сущностей и их ключами, а “сущность” связки, в которую вам нужно попасть, в вашем коде невидима.

Почему HasData не может просто засеять навигацию

Начнём с модели, которую на самом деле пишут все:

// .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public List<Tag> Tags { get; } = [];
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Post> Posts { get; } = [];
}

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

HasData — это механизм заполнения на этапе миграции. Он работает, присоединяя строки к конкретному типу сущности, вычисляя вставки путём сравнения со снимком модели. В вашем коде нет типа сущности для ассоциации между постом и тегом, поэтому HasData не к чему присоединяться. Вы также не можете написать post.Tags.Add(tag) в OnModelCreating: построение модели настраивает форму модели, оно не выполняется относительно DbContext, и навигации пропуска там не заполняются. Ассоциация живёт в таблице связки, и именно таблицу связки вам нужно заполнить.

Это то же семейство ограничений, которое в целом делает HasData неудобным: ему нужны детерминированные данные с явно заданными ключами, и им владеют миграции, а не ваше приложение. Если этот компромисс для вас в новинку, более широкая картина изложена в статье как заполнять данные с помощью UseSeeding и UseAsyncSeeding в EF Core 11, где разбирается, когда управляемый миграциями HasData — совершенно не тот инструмент.

Заполнение неявной связки с UsingEntity и HasData

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

  1. Засейте оба основных типа сущностей через HasData, задав каждой строке фиксированный первичный ключ. EF не будет генерировать ключи для данных заполнения, поэтому вы назначаете их.
  2. Доберитесь до сущности связки через UsingEntity, явно указав имя таблицы связки, чтобы конфигурация была стабильной.
  3. Вызовите HasData на сущности связки, передавая анонимные объекты, имена свойств которых совпадают с теневыми внешними ключами (PostsId, TagsId).

В совокупности конфигурация в OnModelCreating выглядит так:

// .NET 11, EF Core 11, C# 14 -- seeding an implicit (unmapped) join table
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>().HasData(
        new Post { Id = 1, Title = "Span<T> in depth" },
        new Post { Id = 2, Title = "EF Core 11 changes" });

    modelBuilder.Entity<Tag>().HasData(
        new Tag { Id = 1, Name = "dotnet" },
        new Tag { Id = 2, Name = "performance" },
        new Tag { Id = 3, Name = "efcore" });

    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(t => t.Posts)
        .UsingEntity(
            "PostTag",
            r => r.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagsId"),
            l => l.HasOne(typeof(Post)).WithMany().HasForeignKey("PostsId"),
            j => j.HasData(
                new { PostsId = 1, TagsId = 1 },   // "Span<T>" tagged "dotnet"
                new { PostsId = 1, TagsId = 2 },   // "Span<T>" tagged "performance"
                new { PostsId = 2, TagsId = 1 },   // "EF Core 11" tagged "dotnet"
                new { PostsId = 2, TagsId = 3 })); // "EF Core 11" tagged "efcore"
}

Имена свойств в анонимных объектах — это контракт здесь. Они должны быть в точности PostsId и TagsId, совпадающими с теневыми внешними ключами, которые объявил EF. Сделайте опечатку в одном, иначе образуйте множественное число или используйте единственное PostId, и генерация миграции выбросит The seed entity for entity type 'PostTag' cannot be added because the value 'PostId' is not present, потому что этого свойства на сущности связки не существует.

Запустите dotnet ef migrations add SeedPostTags, и сгенерированная миграция вставит посты, теги и четыре строки в PostTag. С этого момента каждый dotnet ef database update применяет эти данные один раз, и EF отслеживает их в снимке модели, так что знает, что не нужно вставлять повторно.

Вы можете именовать сущность связки, не именуя таблицу, передав только конфигурацию-лямбду, но я рекомендую всегда передавать явную строку имени "PostTag". Имя по умолчанию выводится из имён ваших типов, и если вы когда-нибудь переименуете Post в Article, безымянная связка молча переименует таблицу и осиротит ваши существующие данные. Закрепление имени делает переименование преднамеренным, проверяемым изменением.

Когда у вас есть класс связки с полезной нагрузкой

Если ваша таблица связки несёт дополнительные столбцы — метку времени CreatedOn, порядок сортировки, флаг “основной тег” — у вас будет реальный класс для неё, и внешние ключи следуют единственному соглашению PostId / TagId, а не удвоенному PostsId / TagsId неявного случая. Это различие застаёт врасплох тех, кто переходит от неявной связки к явной.

// .NET 11, EF Core 11, C# 14 -- join entity with payload
public class PostTag
{
    public int PostId { get; set; }
    public int TagId { get; set; }
    public DateTime TaggedOn { get; set; }
}

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

modelBuilder.Entity<Post>()
    .HasMany(p => p.Tags)
    .WithMany(t => t.Posts)
    .UsingEntity<PostTag>();

modelBuilder.Entity<PostTag>().HasData(
    new PostTag { PostId = 1, TagId = 1, TaggedOn = new DateTime(2026, 6, 1) },
    new PostTag { PostId = 1, TagId = 2, TaggedOn = new DateTime(2026, 6, 1) },
    new PostTag { PostId = 2, TagId = 3, TaggedOn = new DateTime(2026, 6, 2) });

Обратите внимание, что DateTime — это жёстко заданная константа, а не DateTime.UtcNow. Данные заполнения должны быть детерминированными: значение, которое меняется при каждой сборке, заставляет модель выглядеть изменённой, и EF выдаёт PendingModelChangesWarning и хочет создавать новую миграцию каждый раз. Это одна из шероховатостей, которая кусается во время миграции с EF Core 6 на EF Core 11, где более строгое обнаружение изменений модели превращает вчерашнее молчаливое повторное заполнение в предупреждение сборки. Если вам нужна метка времени вставки, настройте значение базы данных по умолчанию через HasDefaultValueSql("GETUTCDATE()") на свойстве и полностью оставьте его вне объекта заполнения.

Оговорка о типе неявной связки

Команда EF прямо говорит об этом в документации: неявная сущность связки в настоящее время представлена Dictionary<string, object>, но вы не должны на это полагаться. Будущий выпуск EF Core может изменить тип времени выполнения ради производительности. Для заполнения это имеет значение одним практическим образом. Не пытайтесь заполнять, конструируя Dictionary<string, object> самостоятельно или ссылаясь на тип напрямую. Придерживайтесь формы анонимного объекта внутри UsingEntity(...).HasData(...). Анонимный объект сопоставляется по имени свойства со свойствами сущности связки, поэтому он изолирован от того, какой конкретный CLR-тип EF использует под капотом.

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

Заполнение во время выполнения через UseSeeding

Управляемый миграциями HasData — правильный инструмент для небольших фиксированных справочных ассоциаций, которые поставляются вместе со схемой — известный набор системных тегов, привязанных к известным постам. Это неправильный инструмент для чего-либо динамического, чего-либо с ключами из базы данных или чего-либо, что вы предпочли бы выразить как “добавь этот тег к этому посту” применительно к живым объектам. Для этого заполняйте во время выполнения через UseSeeding и UseAsyncSeeding, где у вас есть реальный DbContext и можно использовать навигацию пропуска так, как она была задумана.

// Program.cs -- .NET 11, ASP.NET Core 11, EF Core 11
builder.Services.AddDbContext<AppDbContext>(options =>
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .UseSeeding((context, _) => SeedPostTags(context))
        .UseAsyncSeeding(async (context, _, ct) => await SeedPostTagsAsync(context, ct)));

static void SeedPostTags(DbContext context)
{
    var post = context.Set<Post>()
        .Include(p => p.Tags)
        .FirstOrDefault(p => p.Title == "EF Core 11 changes");
    if (post is null) return;

    var efcore = context.Set<Tag>().FirstOrDefault(t => t.Name == "efcore");
    if (efcore is not null && !post.Tags.Any(t => t.Id == efcore.Id))
    {
        post.Tags.Add(efcore);   // EF inserts the join row for you
        context.SaveChanges();
    }
}

static async Task SeedPostTagsAsync(DbContext context, CancellationToken ct)
{
    var post = await context.Set<Post>()
        .Include(p => p.Tags)
        .FirstOrDefaultAsync(p => p.Title == "EF Core 11 changes", ct);
    if (post is null) return;

    var efcore = await context.Set<Tag>().FirstOrDefaultAsync(t => t.Name == "efcore", ct);
    if (efcore is not null && !post.Tags.Any(t => t.Id == efcore.Id))
    {
        post.Tags.Add(efcore);
        await context.SaveChangesAsync(ct);
    }
}

Обратите внимание на две вещи. Во-первых, вы делаете Include(p => p.Tags), чтобы существующие ассоциации были загружены; без этого охранник !post.Tags.Any(...) видит пустую коллекцию, и вы рискуете вставкой с дублирующимся ключом в таблицу связки. Во-вторых, проверка существования обязательна, потому что эти обратные вызовы выполняются при каждом Migrate, EnsureCreated или dotnet ef database update, а не только при первом. Добавьте тег безусловно, и вы получите нарушение первичного ключа на PostTag во второй раз, когда запустится сеялка. Полные правила того, когда срабатывают эти обратные вызовы, и почему вам нужно реализовать как синхронную, так и асинхронную перегрузки, изложены в подробном разборе UseSeeding.

Выгода в том, что post.Tags.Add(efcore) — естественный API. Трекер изменений EF видит новую запись в навигации пропуска и сам выдаёт вставку в таблицу связки. Вы никогда не называете PostsId или TagsId, никогда не конструируете анонимный объект, и код читается как остальное ваше приложение. Цена в том, что это выполняется при запуске относительно живой базы данных, а не запекается в миграцию, поэтому это лучше всего подходит для разработки, тестов и идемпотентных справочных данных, а не для продакшен-данных с версионированием схемы.

Ошибки, которые порождают запутанные сообщения об ошибках

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

Заполнение только строк связки и забвение засеять основные сущности даёт вам нарушение внешнего ключа во время database update, потому что PostsId = 1 ссылается на строку Posts, которой не существует. Всегда заполняйте оба конца теми же фиксированными ключами, на которые вы ссылаетесь в связке.

Использование неправильных имён теневых ключей — PostId вместо PostsId для неявной связки — даёт сбой на этапе формирования миграции с сообщением о свойстве, которого нет на сущности связки. Удвоенная форма (PostsId, TagsId) — для неявной, неотображённой связки; единственная форма (PostId, TagId) — для явного класса связки. Они не взаимозаменяемы.

Позволение столбцу полезной нагрузки иметь по умолчанию недетерминированное значение, такое как DateTime.UtcNow, в объекте заполнения порождает бесконечный поток миграций “модель изменилась”. Жёстко задайте значение или перенесите его в значение базы данных по умолчанию.

Наконец, если у вашей основной сущности вообще нет определённого ключа — бесключевой или неправильно настроенный тип — заполнение никогда не дойдёт до этого этапа; сначала вы увидите the entity type requires a primary key to be defined. Исправьте модель, прежде чем беспокоиться о данных заполнения.

Выбор между двумя подходами сводится к владению. Если ассоциации являются частью идентичности вашей схемы и должны путешествовать внутри миграций, заполняйте сущность связки через UsingEntity(...).HasData(...) и примите ручную бухгалтерию ключей. Если это данные времени выполнения, которые вы предпочли бы выразить применительно к живым объектам, используйте UseSeeding и добавляйте в навигацию пропуска. Большинство реальных приложений в итоге используют HasData для горстки системных ассоциаций и UseSeeding для всего остального, и это разделение — ровно то, что команда EF спроектировала эти два механизма покрывать.

Источники: документация по отношениям многие-ко-многим EF Core на Microsoft Learn подробно описывает неявную связку, теневые ключи PostsId/TagsId, перегрузки UsingEntity и предостережение против опоры на Dictionary<string, object>; документация по заполнению данных EF Core охватывает HasData против UseSeeding/UseAsyncSeeding и требование детерминированности; обсуждение на GitHub в dotnet/efcore#23363 показывает подтверждённый сообществом шаблон UsingEntity(...).HasData(...) для заполнения таблицы связки.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад