Start Debugging

Миграция сидинга с HasData на UseAsyncSeeding в EF Core 11

Пошаговое руководство по переносу данных сида с HasData на UseSeeding и UseAsyncSeeding в EF Core 11, включая ловушку миграции DeleteData, которая удаляет ваши существующие строки, если вы её пропустите.

Перенос данных сида с HasData на UseSeeding/UseAsyncSeeding в EF Core 11 для типичного приложения занимает полдня, и единственное, что вас укусит, это не новый API: дело в том, что удаление вызова HasData заставляет следующий dotnet ef migrations add сгенерировать инструкцию DeleteData, которая удаляет именно эти строки из каждой базы данных, на которой выполняется миграция. Если вы удалите HasData и добавите UseSeeding в одном и том же развёртывании, не подумав о порядке, вы можете стереть справочные данные в продакшене. Это руководство проходит миграцию в порядке, который этого избегает, используя .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0) и C# 14. Заложите вторую половину дня на среднее приложение, большая её часть уйдёт на аудит того, какие сиды следовало перенести ещё годы назад.

Стоит ли оно того? Для всего динамического, с генерируемым ключом, вычисляемого или условного: да, и это давно назрело. Для по-настоящему статической справочной таблицы из трёх строк оставьте её на HasData. Цель здесь не “удалить каждый HasData”, а “перенести сиды, которые никогда не принадлежали модели”.

Зачем вообще уходить с HasData

HasData встраивает ваши данные сида в снимок модели. Этот единственный факт проектирования является корнем каждой причины уйти:

Команда EF переименовала HasData в “model-managed data” именно для того, чтобы отговорить от его использования как инструмента общего сидинга. UseSeeding и UseAsyncSeeding, представленные в EF Core 9 и актуальные в EF Core 11, это обычный код приложения, который выполняется на живом DbContext, без каких-либо из этих ограничений. Если вы всё ещё решаете, какие сиды переносить, матрица решений HasData vs UseSeeding проводит границу строка за строкой.

Что ломается

ОбластьИзменениеСерьёзность
Существующие базы данныхУдаление вызова HasData генерирует DeleteData, который удаляет строки сида при миграциивысокая
ИдемпотентностьHasData дифференцировался автоматически; UseSeeding выполняется каждый раз и требует написанной вручную проверки существованиявысокая
Два пути кодаИнструментарий вызывает синхронный UseSeeding, async-запуск вызывает UseAsyncSeeding; реализуйте только один, и другой молча ничего не делаетвысокая
Контроль версийЗначения сида покидают снимок миграции и живут в коде запуска; они больше не фиксируются в истории миграцийсредняя
Ссылочная целостностьДанные, от которых зависят внешние ключи других таблиц, теперь оказываются в колбэке запуска, а не внутри транзакции миграциисредняя
Настройка тестовТесты, которые полагались на выполнение HasData через EnsureCreated, по-прежнему работают, но только если реализованы оба перегруженных методанизкая

Верхняя строка это та, которая оказывается в отчёте об инциденте. Всё остальное механическое.

Предполётный чек-лист

Прежде чем трогать хоть одну строку кода сидинга:

Шаги миграции

Порядок имеет значение. Сделать это в неправильной последовательности это ровно то, как вы удаляете справочные данные из продакшена.

1. Сначала напишите колбэки UseSeeding и UseAsyncSeeding

Добавьте новый путь сидинга до того, как что-либо удалять. Оба перегруженных метода, идентичная логика, проверка существования в начале каждого. Вынесите тело в общие методы, чтобы синхронная и async-версии не разошлись:

// Program.cs -- .NET 11, ASP.NET Core 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
builder.Services.AddDbContext<AppDbContext>(options =>
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .UseSeeding((context, _) => SeedStatuses(context))
        .UseAsyncSeeding((context, _, ct) => SeedStatusesAsync(context, ct)));

static void SeedStatuses(DbContext context)
{
    string[] required = ["Pending", "Shipped", "Delivered"];
    var existing = context.Set<OrderStatus>()
        .Where(s => required.Contains(s.Name))
        .Select(s => s.Name)
        .ToHashSet();

    var missing = required
        .Where(name => !existing.Contains(name))
        .Select(name => new OrderStatus { Name = name })
        .ToList();

    if (missing.Count > 0)
    {
        context.Set<OrderStatus>().AddRange(missing);
        context.SaveChanges();
    }
}

static async Task SeedStatusesAsync(DbContext context, CancellationToken ct)
{
    string[] required = ["Pending", "Shipped", "Delivered"];
    var existing = await context.Set<OrderStatus>()
        .Where(s => required.Contains(s.Name))
        .Select(s => s.Name)
        .ToListAsync(ct);

    var missing = required
        .Where(name => !existing.Contains(name))
        .Select(name => new OrderStatus { Name = name })
        .ToList();

    if (missing.Count > 0)
    {
        context.Set<OrderStatus>().AddRange(missing);
        await context.SaveChangesAsync(ct);
    }
}

Обратите внимание, что эта версия больше не прописывает Id вручную. Его назначает база данных. В этом весь смысл: если раньше вы назначали ключи вручную в HasData, существующие ссылки внешних ключей могут указывать на эти конкретные значения, что и делает шаг 2 деликатным.

Проверка: запустите приложение на пустой локальной базе данных, оставив вызовы HasData на месте. Оно должно чисто запуститься без дублирующихся строк. Проверка существования должна сделать второй запуск ничего не делающим (один запрос на чтение, ноль записей).

2. Решите, как сохранить существующие строки, прежде чем удалять HasData

Это шаг, который все пропускают и о котором сожалеют. Когда вы удаляете вызов HasData и выполняете dotnet ef migrations add, EF генерирует DeleteData в методе Up для каждой ранее засеянной строки:

// .NET 11, EF Core 11 -- what removing HasData generates
migrationBuilder.DeleteData(
    table: "OrderStatuses",
    keyColumn: "Id",
    keyValues: new object[] { 1, 2, 3 });

На существующей базе данных этот DELETE выполняется. Если у других таблиц есть внешние ключи, указывающие на эти строки, удаление либо завершается ошибкой FOREIGN KEY constraint failed, либо, что хуже, каскадируется. У вас есть три безопасных варианта:

Для всего, что имеет входящие внешние ключи, вариант B почти всегда то, что вам нужно. Строки не нужно перемещать; нужно только владение ими.

Проверка: после генерации миграции удаления прочитайте сгенерированный файл, прежде чем применять его. Убедитесь, что вызовы DeleteData нацелены точно на те строки, которые вы ожидаете, и что вы выбрали и применили A, B или C для каждой таблицы.

3. Удалите вызовы HasData из OnModelCreating

Теперь удалите блок HasData для сидов, которые вы переносите:

// Before -- .NET 11, EF Core 11
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<OrderStatus>().HasData(
        new OrderStatus { Id = 1, Name = "Pending" },
        new OrderStatus { Id = 2, Name = "Shipped" },
        new OrderStatus { Id = 3, Name = "Delivered" });
}

// After -- the HasData block is gone; seeding lives in UseSeeding now
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // OrderStatus seeding moved to UseSeeding in Program.cs
}

Проверка: проект по-прежнему компилируется, и dotnet ef migrations has-pending-model-changes сообщает об ожидающем изменении (удалённый сид), которое вы собираетесь зафиксировать.

4. Сгенерируйте и просмотрите миграцию удаления

# .NET 11, EF Core 11
dotnet ef migrations add RemoveOrderStatusSeed

Откройте сгенерированную миграцию и примените ваше решение из шага 2. Если вы выбрали вариант B, удалите DeleteData из Up и соответствующий InsertData из Down. Оставьте изменения снимка модели нетронутыми; они правильные и необходимые.

Проверка: выполните dotnet ef migrations script на вашей staging-базе данных и прочитайте SQL. Убедитесь, что никакой неожиданный DELETE FROM OrderStatuses не уцелел, если вы намеревались сохранить строки.

5. Примените на staging и убедитесь, что данные уцелели

# .NET 11, EF Core 11
dotnet ef database update

Затем запустите приложение, чтобы выполнился UseAsyncSeeding.

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

Дымовой тест после миграции

Прогоните этот чек-лист на staging перед отправкой:

План отката

Эта миграция обратима, но читайте мелкий шрифт. Если вы выбрали вариант B (вручную убрали DeleteData), Down на уровне схемы для данных ничего не делает, поэтому откат миграции оставляет строки на месте и просто восстанавливает снимок модели. Повторное добавление блока HasData и генерация новой миграции возвращает вас в старый мир, хотя EF захочет выполнить InsertData для любых отсутствующих строк.

Если вы выбрали вариант A (позволили удалению-и-повторному-засеву произойти), откат более рискован: строки теперь имеют генерируемые базой данных ключи, поэтому возврат к модели HasData, которая ожидает фиксированные ключи, не совпадёт. В этом случае не пытайтесь выполнить откат живых данных на месте. Восстановите из резервной копии, которую вы сделали на предполётном шаге. Вот почему предполётная резервная копия не опциональна.

Ловушки, в которые мы попали

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

Связанное

Источники

Comments

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

< Назад