Ef Core

Міграції — Просунуті Сценарії (Частина 1)

Коли стандартних міграцій недостатньо — migrationBuilder.Sql() для складних DDL. Data migrations для безпечного переміщення даних разом зі схемою. Проблема перейменування — Rename vs Drop+Create. Squashing міграцій для прибирання накопиченого боргу.

Міграції: Просунуті Сценарії

Межі автоматичної генерації

У статті 23 ми розглянули як EF Core автоматично генерує міграції з C# моделі. Ця автоматика охоплює 90% повсякденних потреб: створення таблиць, додавання стовпців, зміна типів, індекси, зовнішні ключі.

Але є 10% сценаріїв де автоматика або неможлива, або генерує неправильний результат, або потребує суттєвого ручного втручання. Саме ці сценарії відрізняють досвідченого розробника від того, хто просто «робить migrations add і молиться».

Перший і найважливіший: переміщення даних разом зі схемою.

EF Core може додати нову таблицю і видалити стару — це DDL-операції, які він чудово генерує. Але він не знає що робити з даними що були у старій таблиці. Коли ви перейменовуєте стовпець, розщеплюєте одну таблицю на дві, або денормалізуєте дані для продуктивності — потрібна data migration: SQL-скрипт що рухає дані правильно, в правильний момент відносно DDL-змін.


migrationBuilder.Sql(): кастомний SQL у міграціях

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 не можна у транзакції!
);

Коли потрібен migrationBuilder.Sql

Є чотири основних сценарії де .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');
");

Helper методи для provider-агностичного SQL

Коли ваш застосунок підтримує кілька провайдерів (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 Migrations: переміщення даних разом зі схемою

Data migration — це процес трансформації даних у базі даних що відбувається паралельно зі змінами схеми. Це одна з найскладніших і найважливіших тем у роботі з міграціями.

Чому data migrations окремо від code migrations

Уявіть: ви хочете розділити поле FullName у таблиці Customers на FirstName і LastName. Вам потрібно:

  1. Додати нові стовпці FirstName і LastName
  2. Перенести дані з FullName у нові стовпці
  3. Зробити нові стовпці NOT NULL
  4. Видалити старий стовпець FullName (опційно)

Кроки 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");
    }
}

Data Migrations з великими таблицями: Batching

При мільйонах рядків — один великий 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() є ідемпотентним (повторний запуск безпечний).

Renaming vs Drop+Create: пастка перейменування

Це найпоширеніша проблема у роботі з EF Core Migrations. Розробник перейменовує C# властивість і виконує migrations add — EF Core генерує Drop + Add замість Rename.

Чому EF Core не розуміє 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# властивість без зміни БД.

RenameTable і MoveToSchema

// Перейменування таблиці
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 розуміє vs не розуміє

ЗмінаEF Core розуміє?Що генеруєЩо потрібно
Додавання стовпцяAddColumnНічого
Зміна типу стовпцяAlterColumnПеревірити дані
Видалення стовпцяDropColumnПеревірити що не потрібен
Перейменування стовпцяDrop + Add (данні втрачаються!)Виправити на RenameColumn
Перейменування таблиціDrop + CreateВиправити на RenameTable
Переміщення стовпця в іншу таблицюDrop + CreatemigrationBuilder.Sql data migration
Додавання FKAddForeignKeyНічого
Зміна ON DELETEDropForeignKey + AddForeignKeyНічого
Золоте правило: після кожного migrations addвідкрийте згенерований файл і прочитайте Up(). Якщо бачите DropColumn там де очікували RenameColumn — зупиніться і виправте. Запуск database update з неправильним DropColumn = незворотня втрата даних у production.

Squashing Migrations: прибирання накопиченого боргу

Через рік активної розробки у папці Migrations/ може накопичитись 200+ файлів. Кожна відповідає за маленьку зміну. Разом вони формують довгий ланцюг що:

  • Сповільнює database update (хоча кожна міграція виконується лише раз, читання файлів займає час)
  • Захаращує папку і ускладнює навігацію
  • Збільшує час збірки проєкту (більше файлів для компіляції)
  • Ускладнює розуміння поточного стану схеми

Squashing (або Reset) — об'єднання всіх існуючих міграцій в одну «InitialCreate» що описує поточний повний стан схеми.

Коли Squash виправданий

Squash безпечний коли виконуються всі умови:

  • У всіх середовищах (dev, staging, production) вже застосовані всі поточні міграції
  • Немає старих середовищ з частково застосованими міграціями
  • Команда порозумілась про «точку відліку»

Покроковий Squash Workflow

Крок 1: Переконатись що всі міграції застосовані

dotnet ef migrations list
# Всі мають бути (Applied), жодної (Pending)

# На кожному середовищі:
dotnet ef database update --connection "Server=staging-server;..."
dotnet ef database update --connection "Server=prod-server;..."

Крок 2: Видалити всі файли міграцій (крім snapshot!)

# Видалити лише .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

Крок 3: Згенерувати нову єдину InitialCreate

dotnet ef migrations add InitialCreate

EF Core прочитає ModelSnapshot (який ми не видаляли!) і згенерує одну величезну міграцію що відображає повний поточний стан схеми.

Крок 4: Очистити __EFMigrationsHistory у всіх середовищах

-- Виконати на 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');

Крок 5: Перевірити

dotnet ef migrations list
# 20250329000000_InitialCreate (Applied)  ← одна міграція!

Автоматизований Squash через 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)
    {
        // Якщо потрібно скинути все...
    }
}

Seeding через міграції: InsertData, UpdateData, DeleteData

migrationBuilder.InsertData(), UpdateData(), DeleteData() — офіційний спосіб додавати початкові дані через міграції. Він відрізняється від HasData() (стаття 14) тим що є явними SQL операціями у конкретній міграції.

migrationBuilder.InsertData

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");
}

migrationBuilder.UpdateData: оновлення reference data

// Міграція: перейменування ролей
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");
}

Різниця між InsertData у міграції та HasData

АспектmigrationBuilder.InsertDataHasData (modelBuilder)
Де живеУ конкретній міграції .csУ OnModelCreating / IEntityTypeConfiguration
ВиконуєтьсяОдин раз при застосуванні міграціїГенерується у кожній міграції де модель міняється
КеруванняЯвне (ви вирішуєте коли і що)Автоматичне (EF Core sync)
Для великих наборівЗручноНезручно (весь набір у C# об'єктах)
ІдемпотентністьВаша відповідальністьЗабезпечує EF Core

Рекомендація: migrationBuilder.InsertData — для reference data що змінюється (ролі, статуси, конфіг). HasData — для статичних дані що завжди мають бути у моделі (enum-like дані).


Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: migrationBuilder.Sql для View і Stored Procedure

  1. Додайте у міграцію View vw_OrderSummary:
    SELECT o.Id, c.Name AS CustomerName, o.TotalAmount, o.Status
    FROM Orders o JOIN Customers c ON c.Id = o.CustomerId
    
  2. Додайте Stored Procedure sp_GetCustomerStats(@CustomerId INT) що повертає COUNT і SUM замовлень
  3. Перевірте що Down() правильно видаляє їх (DROP VIEW IF EXISTS, DROP PROCEDURE IF EXISTS)
  4. Запустіть database update → перевірте що View і SP існують у БД

Завдання 1.2: Rename Column безпечно

  1. Перейменуйте Product.PriceProduct.UnitPrice у C# моделі
  2. migrations add RenameProductPrice
  3. Відкрийте згенерований файл — чи є DropColumn + AddColumn?
  4. Якщо так — виправте на RenameColumn. Додайте тестовий рядок даних у Up() перед rename → перевірте що він є після database update

Завдання 1.3: Seed через InsertData

Після CreateTable("Countries"):

  1. Заповніть базові дані: Ukraine, USA, Germany, France (мінімум) через InsertData
  2. У Down() — відповідний DeleteData
  3. Перевірте що повторний database update не дублює записи

Рівень 2 — Логіка

Завдання 2.1: Data Migration — розщеплення стовпця

Таблиця Employees має FullName NVARCHAR(200). Вам потрібно:

  1. Додати FirstName і LastName як nullable
  2. Заповнити через SQL (split по пробілу)
  3. Зробити FirstName NOT NULL (LastName залишити nullable)
  4. Залишити FullName як computed column: CONCAT(FirstName, ' ', ISNULL(LastName, ''))

Перевірте: після міграції всі існуючі рядки мають правильні FirstName/LastName.

Завдання 2.2: Batch Data Migration

У таблиці Products є 1M+ рядків. Потрібно додати PriceInCents INT і заповнити: CAST(Price * 100 AS INT).

Реалізуйте batch UPDATE:

  • Батч по 50,000 рядків
  • Між батчами — WAITFOR DELAY '00:00:00.500'
  • suppressTransaction: true
  • Перевірте що повторний запуск безпечний (ідемпотентність через WHERE PriceInCents IS NULL)

Рівень 3 — Архітектура

Завдання 3.1: Squash 20+ Migrations

Симулюйте накопичений борг:

  1. Створіть 20 невеликих міграцій (додавати стовпці, індекси, тощо)
  2. Застосуйте всі через database update
  3. Виконайте Squash:
    • Видаліть усі .cs файли (крім Snapshot)
    • migrations add InitialCreate
    • Очистіть __EFMigrationsHistory
    • INSERT INTO __EFMigrationsHistory для нової InitialCreate
  4. Перевірте що dotnet ef migrations list показує лише одну (Applied) міграцію
  5. Переконайтесь що DDL схема BД не змінилась (порівняйте dotnet ef dbcontext script до і після)

Підсумок частини 1

  • migrationBuilder.Sql(): для всього що не підтримує стандартний API — Views, SP, тригери, provider-specific DDL, batch operations. suppressTransaction: true для операцій що потребують autocommit
  • Data migrations: DDL і DML у правильному порядку. Nullable спочатку → заповнити → NOT NULL. Batch UPDATE для великих таблиць
  • Rename vs Drop+Create: EF Core не розуміє rename — завжди перевіряйте Up() вручну. RenameColumn() і RenameTable() — правильні методи
  • Squashing: видалити файли міграцій (не Snapshot!) → migrations add InitialCreate → ручна маніпуляція __EFMigrationsHistory у всіх середовищах
  • InsertData/UpdateData/DeleteData: явний seeding у міграціях. Відрізняється від HasData — ручний контроль коли і що

У другій частині — Multiple DbContext: ізоляція міграцій і __EFMigrationsHistory. Кастомізація History Table. HasDefaultSchema. Handling breaking changes стратегії для production з даними.