🔥
EF Core migrations - это компилятор схемы базы, а не кнопка «создать таблицу»Опытный C#-разработчик не оставляет бизнес-инварианты только внутри
SaveChangesAsync().
Проверку в приложении можно обойти прямым SQL, импортом данных, фоновым процессом или второй версией сервиса. Поэтому правила, нарушение которых делает данные некорректными, стоит закреплять в самой базе.
public sealed class ProductConfiguration
: IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products", table =>
{
table.HasCheckConstraint(
"CK_Products_Price_Range",
"[Price] > 0 AND [Price] <= 10000000");
});
builder.Property(x => x.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(x => x.NormalizedName)
.HasMaxLength(200)
.IsRequired();
builder.Property(x => x.Price)
.HasPrecision(18, 2);
builder.HasIndex(x => x.NormalizedName)
.IsUnique()
.HasDatabaseName("UX_Products_NormalizedName");
}
}
HasPrecision(18, 2) управляет физическим представлением
decimal, но не проверяет допустимый диапазон цены. За это отвечает
CHECK.
Уникальный индекс решает другую важную проблему - гонку между запросами.
if (!await db.Products.AnyAsync(x => x.Name == name))
{
db.Products.Add(product);
await db.SaveChangesAsync();
}
Два параллельных запроса могут одновременно пройти
AnyAsync() и попытаться создать одинаковые записи. Только уникальное ограничение базы гарантированно остановит второй запрос.
Использовать для уникальности исходный
Name тоже опасно. Результат будет зависеть от collation базы:
Laptop,
LAPTOP и
Laptop могут считаться одинаковыми или разными. Поэтому часто сохраняют нормализованное значение:
product.NormalizedName = product.Name
.Trim()
.ToUpperInvariant();
После изменения модели создаём миграцию:
dotnet ef migrations add HardenProductSchema
Но миграцию нельзя принимать вслепую. EF Core сравнивает текущую модель с
ModelSnapshot и генерирует предполагаемый переход между двумя состояниями.
Например, обычное переименование свойства может быть распознано как удаление старого столбца и создание нового:
migrationBuilder.DropColumn(
name: "Name",
table: "Products");
migrationBuilder.AddColumn<string>(
name: "DisplayName",
table: "Products",
nullable: false);
В production это означает потерю данных. Правильную операцию нужно указать вручную:
migrationBuilder.RenameColumn(
name: "Name",
table: "Products",
newName: "DisplayName");
Для крупных таблиц полезен подход
expand -> backfill -> contract.
Сначала добавляется nullable-столбец:
migrationBuilder.AddColumn<string>(
name: "NormalizedName",
table: "Products",
type: "nvarchar(200)",
nullable: true);
Затем данные заполняются порциями отдельной задачей. После обновления всех строк новая версия приложения начинает записывать оба значения. И только в следующем релизе столбец переводится в
NOT NULL, добавляется индекс и удаляется устаревшая логика.
Так миграция не держит долгую блокировку и остаётся совместимой сразу с несколькими версиями приложения.
В CI стоит проверять, не забыл ли разработчик создать миграцию:
dotnet ef migrations has-pending-model-changes
Для production лучше генерировать проверяемый SQL:
dotnet ef migrations script \
--idempotent \
--output migrations.sql
Либо собирать отдельный executable:
dotnet ef migrations bundle \
--self-contained \
-r linux-x64
efbundle можно запускать в deployment job без исходников проекта и установленного EF CLI.