Миграция сидинга с 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 встраивает ваши данные сида в снимок модели. Этот единственный факт проектирования является корнем каждой причины уйти:
- Недетерминированные значения порождают фантомные миграции.
DateTime.UtcNow,Guid.NewGuid()или хешированный пароль внутри строкиHasDataприводит к тому, что diff модели никогда не совпадает, поэтому EF считает, что модель изменилась при каждой сборке, и либо предупреждает с помощьюPendingModelChangesWarning, либо выдаёт бесконечную струйку бесполезных миграций. Это стена, в которую врезается большинство команд, часто в середине миграции с EF Core 6 на EF Core 11. - Ключи, генерируемые базой данных, невозможны.
HasDataтребует каждый первичный ключ прописанным вручную. Identity-столбцы и последовательности исключены, поэтому вы в итоге придумываете ключи и молитесь, чтобы они никогда не столкнулись с реальными вставками. - Никакой условной логики, никаких навигационных графов, никаких преобразований. “Засеять демо-тенант только в Development” или “вставить этот граф объектов через его навигационные свойства” вообще нельзя выразить в
HasData. - Сид является частью diff вашей схемы, хотите вы того или нет. Каждое редактирование значения становится
UpdateDataв миграции, что является возможностью для кодов стран и помехой для всего остального, что вы предпочли бы управлять как кодом.
Команда 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, по-прежнему работают, но только если реализованы оба перегруженных метода | низкая |
Верхняя строка это та, которая оказывается в отчёте об инциденте. Всё остальное механическое.
Предполётный чек-лист
Прежде чем трогать хоть одну строку кода сидинга:
- Подтвердите, что ваша версия EF Core 9.0 или новее.
UseSeeding/UseAsyncSeedingне существуют до EF Core 9. Выполнитеdotnet list package | findstr EntityFrameworkCoreи подтвердите 11.0 (или как минимум 9.0). - Проведите инвентаризацию каждого вызова
HasData. Сделайте grep по коду:grep -rn "HasData" .(илиfindstr /s HasData *.csна Windows). Для каждого решите оставить или перенести по правилу ниже. - Классифицируйте каждый сид. Оставьте на
HasData, если данные маленькие, фиксированные, детерминированные, с ключом, прописанным вручную, и это что-то, без чего схема бессмысленна (enum-ы статуса заказа, ISO-коды стран). Перенесите наUseSeeding, если у него генерируемые базой данных ключи, вычисляемые или недетерминированные значения, условная логика, навигационные свойства или внешние преобразования. - Сделайте резервную копию продакшена или прорепетируйте на восстановленной копии. Вы собираетесь сгенерировать миграцию, которая выдаёт инструкции
DELETE. Не открывайте её поведение в продакшене. - Имейте staging-базу данных, на которой строки
HasDataуже применены. Вам нужно увидеть, что делает миграция удаления с базой данных, засеянной по-старому, а не только с пустой.
Шаги миграции
Порядок имеет значение. Сделать это в неправильной последовательности это ровно то, как вы удаляете справочные данные из продакшена.
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, либо, что хуже, каскадируется. У вас есть три безопасных варианта:
- A. Сохраните ключи стабильными и позвольте сиду вставить заново. Если строки это чистые справочные данные без входящих внешних ключей, дать миграции удалить их, а
UseSeedingвставить их заново при следующем запуске нормально, но новые ключи будут отличаться (генерируемые базой данных), поэтому это безопасно только тогда, когда ничто не ссылается на старые ключи. - B. Отредактируйте сгенерированную миграцию вручную, чтобы убрать
DeleteData. Послеdotnet ef migrations add RemoveStatusSeedоткройте сгенерированный файл и удалите вызовDeleteData(и соответствующийInsertDataвDown). Строки остаются на месте; только снимок модели перестаёт их отслеживать. Это самый безопасный вариант, когда внешние ключи ссылаются на засеянные строки и вы хотите оставить их нетронутыми. - C. Сохраните ключи в новом сидере. Если вы должны сохранить точные значения ключей (потому что от них зависят внешние ключи), продолжайте назначать их явно в
UseSeedingи всё равно вручную уберитеDeleteData. Вы теряете преимущество генерируемого базой данных ключа для этой таблицы, но сохраняете ссылочную целостность.
Для всего, что имеет входящие внешние ключи, вариант 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 перед отправкой:
dotnet buildуспешен безPendingModelChangesWarning.dotnet ef migrations has-pending-model-changesне сообщает ни о чём ожидающем.- Приложение запускается через свой async-путь (
await context.Database.MigrateAsync()), и засеянные строки существуют один раз. dotnet ef database updateна свежей базе данных также засевает правильно (это задействует синхронныйUseSeeding, который вызывает инструментарий).- Перезапуск приложения не вставляет ничего нового (идемпотентность сохраняется).
- Связи внешних ключей, которые указывали на старые ключи сида, по-прежнему разрешаются.
- Ни одна таблица не показывает симптом фантомной миграции: добавление свежей миграции не порождает никакого связанного с сидом
InsertData/DeleteData.
План отката
Эта миграция обратима, но читайте мелкий шрифт. Если вы выбрали вариант B (вручную убрали DeleteData), Down на уровне схемы для данных ничего не делает, поэтому откат миграции оставляет строки на месте и просто восстанавливает снимок модели. Повторное добавление блока HasData и генерация новой миграции возвращает вас в старый мир, хотя EF захочет выполнить InsertData для любых отсутствующих строк.
Если вы выбрали вариант A (позволили удалению-и-повторному-засеву произойти), откат более рискован: строки теперь имеют генерируемые базой данных ключи, поэтому возврат к модели HasData, которая ожидает фиксированные ключи, не совпадёт. В этом случае не пытайтесь выполнить откат живых данных на месте. Восстановите из резервной копии, которую вы сделали на предполётном шаге. Вот почему предполётная резервная копия не опциональна.
Ловушки, в которые мы попали
- Удаление каскадировалось через внешний ключ. Таблица
Orderссылалась наOrderStatus.Id.DeleteDataмиграции удаления вызвалFOREIGN KEY constraint failed, что по крайней мере падает громко. Если бы связь была настроена с каскадным удалением, оно бы тихо унесло заказы с собой. Вариант B (убратьDeleteData) это исправление. Смотрите исправление FOREIGN KEY constraint failed для механики того, почему удаление распространяется. - Был реализован только async-перегруженный метод, поэтому
dotnet ef database updateничего не засеял. Инструментарий вызывает синхронныйUseSeeding. Приложение работало в продакшене (async-путь запуска), ноdatabase updateв CI выдал пустую таблицу-справочник, и интеграционные тесты упали. Реализуйте оба, всегда. - Проверка существования запрашивала
DateTime, а не ключ. Кто-то написал охрану какif (!context.Set<User>().Any(u => u.CreatedAt == seedTime)). ПосколькуCreatedAtбылDateTime.UtcNow, проверка никогда не совпадала, и сидер вставлял нового администратора при каждом запуске. Делайте охрану по стабильному естественному ключу (email, имя статуса), никогда по недетерминированному столбцу. Полный шаблон есть в как засевать данные с UseSeeding и UseAsyncSeeding. - Засев при запуске каждого экземпляра требовал права записи повсюду.
UseSeedingвыполняется наMigrateв каждой реплике. Если ваши продакшен-поды приложения работают с преимущественно читающими правами на базу данных, колбэк сида бросает исключение. Для продакшена предпочтите одноразовый шаг инициализации во время развёртывания вместо засева при каждом запуске;UseSeedingблистает для локальной разработки и тестов. - Частичный перенос оставил строку
HasData, от которой зависели другие сиды. Миграция половины сидов означала, что вставка колбэка запуска ссылалась на строку-справочник, которую миграция удаления только что удалила. Переносите зависимые сиды вместе или оставьте справочник наHasDataи перенесите только зависимые данные.
Если ваши сущности это record-ы, ничего из этого не меняется: record-ы правильно работают с EF Core 11 как под HasData, так и под UseSeeding. И если вы попутно наводите порядок в слое данных, та же дисциплина “что выполняется и когда” проявляется в перехватчиках EF Core 11 для аудита.
Связанное
- HasData vs UseSeeding для засева данных в EF Core 11
- Как засевать данные с UseSeeding и UseAsyncSeeding в EF Core 11
- Миграция с EF Core 6 на EF Core 11: ломающие изменения, которые действительно кусают
- Fix: FOREIGN KEY constraint failed при удалении сущности в EF Core 11
- Как правильно использовать record-ы с EF Core 11
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.