У статті 23 ми розглянули як EF Core автоматично генерує міграції з C# моделі. Ця автоматика охоплює 90% повсякденних потреб: створення таблиць, додавання стовпців, зміна типів, індекси, зовнішні ключі.
Але є 10% сценаріїв де автоматика або неможлива, або генерує неправильний результат, або потребує суттєвого ручного втручання. Саме ці сценарії відрізняють досвідченого розробника від того, хто просто «робить migrations add і молиться».
Перший і найважливіший: переміщення даних разом зі схемою.
EF Core може додати нову таблицю і видалити стару — це DDL-операції, які він чудово генерує. Але він не знає що робити з даними що були у старій таблиці. Коли ви перейменовуєте стовпець, розщеплюєте одну таблицю на дві, або денормалізуєте дані для продуктивності — потрібна data migration: SQL-скрипт що рухає дані правильно, в правильний момент відносно DDL-змін.
MigrationBuilder.Sql() — метод що дозволяє виконати довільний SQL у межах міграції. Він виконується у тій самій транзакції що і решта DDL операцій міграції.
// Базовий синтаксис:
migrationBuilder.Sql("UPDATE Products SET IsActive = 1 WHERE IsActive IS NULL");
// Багаторядковий SQL:
migrationBuilder.Sql(@"
UPDATE Products
SET FullName = FirstName + ' ' + LastName
WHERE FullName IS NULL
AND FirstName IS NOT NULL
AND LastName IS NOT NULL;
");
// Підсупресія транзакції (для операцій що не підтримують транзакцію):
migrationBuilder.Sql(
"CREATE FULLTEXT INDEX ON Products(Description) KEY INDEX PK_Products;",
suppressTransaction: true // Full-text indexes не можна у транзакції!
);
Є чотири основних сценарії де .Sql() незамінний:
1. Stored Procedures, Views, Triggers — EF Core не підтримує їх через стандартні операції:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Створення View
migrationBuilder.Sql(@"
CREATE VIEW vw_ActiveProductsWithCategory AS
SELECT
p.Id,
p.Name,
p.Price,
c.Name AS CategoryName
FROM Products p
INNER JOIN Categories c ON c.Id = p.CategoryId
WHERE p.IsActive = 1;
");
// Створення Stored Procedure
migrationBuilder.Sql(@"
CREATE PROCEDURE sp_ArchiveOldOrders
@DaysOld INT = 365
AS
BEGIN
INSERT INTO OrdersArchive
SELECT * FROM Orders
WHERE PlacedAt < DATEADD(DAY, -@DaysOld, GETUTCDATE());
DELETE FROM Orders
WHERE PlacedAt < DATEADD(DAY, -@DaysOld, GETUTCDATE());
END;
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP VIEW IF EXISTS vw_ActiveProductsWithCategory;");
migrationBuilder.Sql("DROP PROCEDURE IF EXISTS sp_ArchiveOldOrders;");
}
2. Data migrations — переміщення даних при зміні схеми (детально розглядається далі).
3. Provider-specific DDL — операції специфічні для СУБД:
// PostgreSQL: увімкнення розширення (не має стандартного API у EF Core)
migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";");
migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS pg_trgm;");
// SQL Server: оптимізація таблиці зі сторінковою компресією
migrationBuilder.Sql(@"
ALTER TABLE Orders
REBUILD PARTITION = ALL
WITH (DATA_COMPRESSION = PAGE);
");
// PostgreSQL: партиціонування таблиці
migrationBuilder.Sql(@"
CREATE TABLE OrdersByYear PARTITION OF Orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
");
4. Складні constraint-и — що неможливо виразити через Fluent API:
// SQL Server: Exclusion Constraint через хак з filtered unique index
migrationBuilder.Sql(@"
CREATE UNIQUE INDEX UX_Bookings_NoOverlap
ON Bookings(RoomId, CheckIn, CheckOut)
WHERE Status != 'Cancelled';
");
// PostgreSQL: Exclusion Constraint (нативний)
migrationBuilder.Sql(@"
ALTER TABLE Bookings
ADD CONSTRAINT no_room_overlap
EXCLUDE USING gist (
room_id WITH =,
daterange(check_in, check_out) WITH &&
)
WHERE (status != 'Cancelled');
");
Коли ваш застосунок підтримує кілька провайдерів (SQL Server і PostgreSQL), потрібен provider-агностичний код:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Перевірити провайдер і виконати відповідний SQL
if (migrationBuilder.IsSqlServer())
{
migrationBuilder.Sql("CREATE COLUMNSTORE INDEX IX_CS_Orders ON Orders(Status, PlacedAt, TotalAmount);");
}
else if (migrationBuilder.IsNpgsql())
{
// PostgreSQL не має columnstore, але є BRIN для time-series
migrationBuilder.Sql("CREATE INDEX IX_BRIN_Orders ON Orders USING BRIN(placed_at);");
}
}
// Extension методи для визначення провайдера:
public static class MigrationBuilderExtensions
{
public static bool IsSqlServer(this MigrationBuilder mb)
=> mb.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer";
public static bool IsNpgsql(this MigrationBuilder mb)
=> mb.ActiveProvider == "Npgsql.EntityFrameworkCore.PostgreSQL";
public static bool IsSqlite(this MigrationBuilder mb)
=> mb.ActiveProvider == "Microsoft.EntityFrameworkCore.Sqlite";
}
Data migration — це процес трансформації даних у базі даних що відбувається паралельно зі змінами схеми. Це одна з найскладніших і найважливіших тем у роботі з міграціями.
Уявіть: ви хочете розділити поле FullName у таблиці Customers на FirstName і LastName. Вам потрібно:
FirstName і LastNameFullName у нові стовпціNOT NULLFullName (опційно)Кроки 1, 3, 4 — DDL. Крок 2 — data migration. Вони мають відбуватись у правильному порядку: спершу DDL (додати нові nullable стовпці), потім DML (заповнити дані), потім DDL (зробити NOT NULL, видалити старий).
Якщо зробити NOT NULL без попереднього заповнення — провал. Якщо видалити FullName до перенесення даних — втрата інформації назавжди.
// Міграція: SplitCustomerName
public partial class SplitCustomerName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// КРОК 1: Додати нові стовпці як nullable
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "Customers",
type: "nvarchar(100)",
nullable: true); // ← nullable спочатку!
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "Customers",
type: "nvarchar(100)",
nullable: true); // ← nullable спочатку!
// КРОК 2: Перенести дані (data migration)
migrationBuilder.Sql(@"
UPDATE Customers
SET
FirstName = CASE
WHEN CHARINDEX(' ', FullName) > 0
THEN LEFT(FullName, CHARINDEX(' ', FullName) - 1)
ELSE FullName
END,
LastName = CASE
WHEN CHARINDEX(' ', FullName) > 0
THEN RIGHT(FullName, LEN(FullName) - CHARINDEX(' ', FullName))
ELSE NULL
END
WHERE FullName IS NOT NULL;
");
// КРОК 3: Зробити NOT NULL (після заповнення!)
migrationBuilder.AlterColumn<string>(
name: "FirstName",
table: "Customers",
type: "nvarchar(100)",
nullable: false,
oldClrType: typeof(string),
oldNullable: true);
// КРОК 4: Видалити старий стовпець (якщо вирішили)
// migrationBuilder.DropColumn(name: "FullName", table: "Customers");
// ← ОБЕРЕЖНО: Спочатку переконайтесь що всі програми мігрували на нові поля!
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Відкат: зворотній порядок
migrationBuilder.AlterColumn<string>(
name: "FirstName",
table: "Customers",
nullable: true,
oldNullable: false);
// Відновити FullName з FirstName + LastName (якщо DropColumn не виконали):
migrationBuilder.Sql(@"
UPDATE Customers
SET FullName = TRIM(ISNULL(FirstName, '') + ' ' + ISNULL(LastName, ''))
WHERE FirstName IS NOT NULL;
");
migrationBuilder.DropColumn(name: "FirstName", table: "Customers");
migrationBuilder.DropColumn(name: "LastName", table: "Customers");
}
}
Складніший сценарій: денормалізація або реструктуризація де дані рухаються між таблицями:
// Сценарій: Вилучити Address з Customer у окрему таблицю CustomerAddresses
public partial class ExtractCustomerAddresses : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// КРОК 1: Створити нову таблицю
migrationBuilder.CreateTable(
name: "CustomerAddresses",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<int>(nullable: false),
Street = table.Column<string>(maxLength: 200, nullable: false),
City = table.Column<string>(maxLength: 100, nullable: false),
PostalCode = table.Column<string>(maxLength: 20, nullable: true),
IsDefault = table.Column<bool>(nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerAddresses", x => x.Id);
table.ForeignKey(
name: "FK_CustomerAddresses_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// КРОК 2: Перенести дані (INSERT INTO ... SELECT FROM ...)
migrationBuilder.Sql(@"
INSERT INTO CustomerAddresses (CustomerId, Street, City, PostalCode, IsDefault)
SELECT
Id AS CustomerId,
Address AS Street,
City,
PostalCode,
1 AS IsDefault -- перший адрес є основним
FROM Customers
WHERE Address IS NOT NULL;
");
// КРОК 3: Видалити старі стовпці (після перевірки що дані перенесені!)
migrationBuilder.DropColumn(name: "Address", table: "Customers");
migrationBuilder.DropColumn(name: "City", table: "Customers");
migrationBuilder.DropColumn(name: "PostalCode", table: "Customers");
// КРОК 4: Додати індекс на нову таблицю
migrationBuilder.CreateIndex(
name: "IX_CustomerAddresses_CustomerId",
table: "CustomerAddresses",
column: "CustomerId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Повернути стовпці
migrationBuilder.AddColumn<string>(name: "Address", table: "Customers", nullable: true);
migrationBuilder.AddColumn<string>(name: "City", table: "Customers", nullable: true);
migrationBuilder.AddColumn<string>(name: "PostalCode", table: "Customers", nullable: true);
// Відновити дані з CustomerAddresses (лише IsDefault)
migrationBuilder.Sql(@"
UPDATE c
SET
c.Address = ca.Street,
c.City = ca.City,
c.PostalCode = ca.PostalCode
FROM Customers c
INNER JOIN CustomerAddresses ca ON ca.CustomerId = c.Id AND ca.IsDefault = 1;
");
migrationBuilder.DropTable(name: "CustomerAddresses");
}
}
При мільйонах рядків — один великий UPDATE може тримати transaction lock на довгий час і призвести до timeout:
// ПРОБЛЕМА: оновлення мільйонів рядків в одній транзакції
migrationBuilder.Sql(@"
UPDATE Products -- 10M рядків → lock на 30+ хвилин!
SET PriceInCents = CAST(Price * 100 AS INT)
WHERE PriceInCents IS NULL;
");
// ВИРІШЕННЯ: batch UPDATE у циклі
migrationBuilder.Sql(@"
-- Обробляємо батчами по 10,000 рядків
DECLARE @BatchSize INT = 10000;
DECLARE @Offset INT = 0;
DECLARE @Updated INT = 1;
WHILE @Updated > 0
BEGIN
UPDATE TOP (@BatchSize) Products
SET PriceInCents = CAST(Price * 100 AS INT)
WHERE PriceInCents IS NULL;
SET @Updated = @@ROWCOUNT;
WAITFOR DELAY '00:00:00.100'; -- 100ms між батчами (дати іншим транзакціям пройти)
END;
",
suppressTransaction: true); // suppressTransaction: batch UPDATE сам управляє транзакціями
suppressTransaction: при suppressTransaction: true міграція виконується поза головною транзакцією. Якщо щось піде не так після batch UPDATE — Down() не відкотить вже оброблені батчі. Переконайтесь що Up() є ідемпотентним (повторний запуск безпечний).Це найпоширеніша проблема у роботі з EF Core Migrations. Розробник перейменовує C# властивість і виконує migrations add — EF Core генерує Drop + Add замість Rename.
EF Core не відслідковує Git-историю вашого коду. Він бачить поточний стан C# моделі і попередній стан у ModelSnapshot. Якщо стовпець Price зник і з'явився UnitPrice — EF Core не знає: це rename? це видалення і нова властивість? це дві незалежні зміни?
// До: Product.cs
public decimal Price { get; set; }
// Після: Product.cs
public decimal UnitPrice { get; set; } // перейменували
// Що ГЕНЕРУЄ EF Core (неправильно!):
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Price", table: "Products"); // ВИДАЛЕННЯ!
migrationBuilder.AddColumn<decimal>(name: "UnitPrice", ...); // ДОДАВАННЯ!
// ← КАТАСТРОФА: всі дані у Price втрачено назавжди!
}
Варіант A: виправити міграцію вручну:
// Після migrations add — ВІДКРИТИ файл і ВИПРАВИТИ генерований код:
protected override void Up(MigrationBuilder migrationBuilder)
{
// ❌ Видалити або закоментувати автогенероване Drop + Add:
// migrationBuilder.DropColumn(name: "Price", table: "Products");
// migrationBuilder.AddColumn<decimal>(name: "UnitPrice", ...);
// ✅ Замінити на RenameColumn:
migrationBuilder.RenameColumn(
name: "Price",
newName: "UnitPrice",
table: "Products");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "UnitPrice",
newName: "Price",
table: "Products");
}
Варіант B: використати [Column] атрибут або Fluent API щоб зберегти старе ім'я стовпця:
// C# клас: перейменована властивість, але стовпець БД лишається "Price"
public class Product
{
// C# говорить UnitPrice, але SQL стовпець — Price (без міграції!)
[Column("Price")]
public decimal UnitPrice { get; set; }
// або через Fluent API:
// builder.Property(p => p.UnitPrice).HasColumnName("Price");
}
Цей підхід уникає міграції взагалі — добре якщо ви хочете тільки переіменувати C# властивість без зміни БД.
// Перейменування таблиці
migrationBuilder.RenameTable(
name: "Products",
newName: "CatalogProducts");
// Переміщення до іншої схеми (SQL Server)
migrationBuilder.RenameTable(
name: "Products",
schema: "dbo",
newName: "Products",
newSchema: "catalog"); // dbo.Products → catalog.Products
// EF Core автоматично оновить FK які посилаються на перейменовану таблицю
| Зміна | EF Core розуміє? | Що генерує | Що потрібно |
|---|---|---|---|
| Додавання стовпця | ✅ | AddColumn | Нічого |
| Зміна типу стовпця | ✅ | AlterColumn | Перевірити дані |
| Видалення стовпця | ✅ | DropColumn | Перевірити що не потрібен |
| Перейменування стовпця | ❌ | Drop + Add (данні втрачаються!) | Виправити на RenameColumn |
| Перейменування таблиці | ❌ | Drop + Create | Виправити на RenameTable |
| Переміщення стовпця в іншу таблицю | ❌ | Drop + Create | migrationBuilder.Sql data migration |
| Додавання FK | ✅ | AddForeignKey | Нічого |
| Зміна ON DELETE | ✅ | DropForeignKey + AddForeignKey | Нічого |
migrations add — відкрийте згенерований файл і прочитайте Up(). Якщо бачите DropColumn там де очікували RenameColumn — зупиніться і виправте. Запуск database update з неправильним DropColumn = незворотня втрата даних у production.Через рік активної розробки у папці Migrations/ може накопичитись 200+ файлів. Кожна відповідає за маленьку зміну. Разом вони формують довгий ланцюг що:
database update (хоча кожна міграція виконується лише раз, читання файлів займає час)Squashing (або Reset) — об'єднання всіх існуючих міграцій в одну «InitialCreate» що описує поточний повний стан схеми.
Squash безпечний коли виконуються всі умови:
dotnet ef migrations list
# Всі мають бути (Applied), жодної (Pending)
# На кожному середовищі:
dotnet ef database update --connection "Server=staging-server;..."
dotnet ef database update --connection "Server=prod-server;..."
# Видалити лише .cs і .Designer.cs файли міграцій, але НЕ Snapshot
# Windows PowerShell:
Get-ChildItem Migrations/ -Filter "*.cs" |
Where-Object { $_.Name -ne "AppDbContextModelSnapshot.cs" } |
Remove-Item
# Linux/Mac bash:
find Migrations/ -name "*.cs" ! -name "*ModelSnapshot*" -delete
dotnet ef migrations add InitialCreate
EF Core прочитає ModelSnapshot (який ми не видаляли!) і згенерує одну величезну міграцію що відображає повний поточний стан схеми.
-- Виконати на production (і staging) ВРУЧНУ або через CI:
DELETE FROM __EFMigrationsHistory;
-- Тепер таблиця порожня — EF Core думає що нема жодної застосованої міграції
-- ЗАСТОСУВАТИ нову InitialCreate:
-- НЕ через database update (він спробує CREATE TABLE для вже існуючих таблиць!)
-- А через INSERT у __EFMigrationsHistory без виконання Up():
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
VALUES ('20250329000000_InitialCreate', '9.0.3');
dotnet ef migrations list
# 20250329000000_InitialCreate (Applied) ← одна міграція!
dotnet ef migrations scriptАльтернативний підхід — замість видалення файлів і маніпуляцій з __EFMigrationsHistory:
# Згенерувати повний DDL скрипт поточної моделі
dotnet ef dbcontext script -o baseline.sql
# Цей скрипт можна зберегти як "baseline" для нових середовищ
# і вказати EF Core вважати що він вже застосований:
// Програматично: baseline через кастомну InitialCreate
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Порожній Up(): таблиці вже існують у prod!
// Для нових середовищ — застосовуємо через окремий baseline.sql
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Якщо потрібно скинути все...
}
}
migrationBuilder.InsertData(), UpdateData(), DeleteData() — офіційний спосіб додавати початкові дані через міграції. Він відрізняється від HasData() (стаття 14) тим що є явними SQL операціями у конкретній міграції.
protected override void Up(MigrationBuilder migrationBuilder)
{
// Після CreateTable — вставити початкові дані
migrationBuilder.CreateTable(
name: "Roles",
columns: table => new
{
Id = table.Column<int>(nullable: false),
Name = table.Column<string>(maxLength: 50, nullable: false)
},
constraints: table => table.PrimaryKey("PK_Roles", x => x.Id));
// Seed: базові ролі
migrationBuilder.InsertData(
table: "Roles",
columns: new[] { "Id", "Name" },
values: new object[,]
{
{ 1, "Admin" },
{ 2, "Manager" },
{ 3, "Customer" },
{ 4, "Guest" }
});
// Seed одного запису:
migrationBuilder.InsertData(
table: "SystemConfig",
columns: new[] { "Key", "Value", "CreatedAt" },
values: new object[] { "MaintenanceMode", "false", DateTime.UtcNow });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// DeleteData у Down — для симетрії
migrationBuilder.DeleteData(
table: "Roles",
keyColumn: "Id",
keyValues: new object[] { 1, 2, 3, 4 });
migrationBuilder.DropTable(name: "Roles");
}
// Міграція: перейменування ролей
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "Roles",
keyColumn: "Id",
keyValue: 3, // оновити роль з Id=3
column: "Name",
value: "Member"); // Customer → Member
// Або кілька стовпців:
migrationBuilder.UpdateData(
table: "SystemConfig",
keyColumn: "Key",
keyValue: "Version",
columns: new[] { "Value", "UpdatedAt" },
values: new object[] { "2.0.0", DateTime.UtcNow });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "Roles", keyColumn: "Id", keyValue: 3,
column: "Name", value: "Customer");
}
| Аспект | migrationBuilder.InsertData | HasData (modelBuilder) |
|---|---|---|
| Де живе | У конкретній міграції .cs | У OnModelCreating / IEntityTypeConfiguration |
| Виконується | Один раз при застосуванні міграції | Генерується у кожній міграції де модель міняється |
| Керування | Явне (ви вирішуєте коли і що) | Автоматичне (EF Core sync) |
| Для великих наборів | Зручно | Незручно (весь набір у C# об'єктах) |
| Ідемпотентність | Ваша відповідальність | Забезпечує EF Core |
Рекомендація: migrationBuilder.InsertData — для reference data що змінюється (ролі, статуси, конфіг). HasData — для статичних дані що завжди мають бути у моделі (enum-like дані).
vw_OrderSummary:
SELECT o.Id, c.Name AS CustomerName, o.TotalAmount, o.Status
FROM Orders o JOIN Customers c ON c.Id = o.CustomerId
sp_GetCustomerStats(@CustomerId INT) що повертає COUNT і SUM замовленьDown() правильно видаляє їх (DROP VIEW IF EXISTS, DROP PROCEDURE IF EXISTS)database update → перевірте що View і SP існують у БДProduct.Price → Product.UnitPrice у C# моделіmigrations add RenameProductPriceDropColumn + AddColumn?RenameColumn. Додайте тестовий рядок даних у Up() перед rename → перевірте що він є після database updateПісля CreateTable("Countries"):
InsertDataDown() — відповідний DeleteDatadatabase update не дублює записиТаблиця Employees має FullName NVARCHAR(200). Вам потрібно:
FirstName і LastName як nullableFirstName NOT NULL (LastName залишити nullable)FullName як computed column: CONCAT(FirstName, ' ', ISNULL(LastName, ''))Перевірте: після міграції всі існуючі рядки мають правильні FirstName/LastName.
У таблиці Products є 1M+ рядків. Потрібно додати PriceInCents INT і заповнити: CAST(Price * 100 AS INT).
Реалізуйте batch UPDATE:
WAITFOR DELAY '00:00:00.500'suppressTransaction: trueWHERE PriceInCents IS NULL)Симулюйте накопичений борг:
database updatemigrations add InitialCreate__EFMigrationsHistoryINSERT INTO __EFMigrationsHistory для нової InitialCreatedotnet ef migrations list показує лише одну (Applied) міграціюdotnet ef dbcontext script до і після)migrationBuilder.Sql(): для всього що не підтримує стандартний API — Views, SP, тригери, provider-specific DDL, batch operations. suppressTransaction: true для операцій що потребують autocommitUp() вручну. RenameColumn() і RenameTable() — правильні методиmigrations add InitialCreate → ручна маніпуляція __EFMigrationsHistory у всіх середовищахHasData — ручний контроль коли і щоУ другій частині — Multiple DbContext: ізоляція міграцій і __EFMigrationsHistory. Кастомізація History Table. HasDefaultSchema. Handling breaking changes стратегії для production з даними.
Міграції в EF Core — Основи (Частина 2)
SQL-скрипти для production deployment, idempotent scripts, Migrations Bundle — self-contained виконуваний файл. Стратегії автоматичного застосування в CI/CD, Database.MigrateAsync, програмне управління міграціями. Workflow від коду до продакшн БД.
Міграції — Просунуті Сценарії (Частина 2)
Multiple DbContext — ізоляція міграцій і власні MigrationHistory таблиці. HasDefaultSchema для розподілу за схемами. Handling Breaking Changes — стратегії для production без простою. Database-First workflow і Reverse Engineering.