Ef Core

Seed Data — SQL-скрипти, Bogus та Стратегії (Частина 2)

Seeding через migrationBuilder.Sql(), завантаження seed з CSV і JSON файлів, використання Bogus для генерації fake-даних, EnsureCreated для тестів, ExecuteSqlRaw для bulk insert та повна матриця вибору стратегії.

Seed Data: SQL-скрипти, Bogus та Стратегії

Це продовження статті «Seed Data: Початкові Дані». Читайте послідовно.


Seed через migrationBuilder.Sql()

Коли HasData() занадто обмежений, а Initialization Service — занадто важкий для простого seeding у міграціях — є третій шлях: власний SQL-скрипт безпосередньо у міграції через migrationBuilder.Sql().

Коли використовувати migrationBuilder.Sql()

  • Великий обсяг seed data (1000+ записів), що незручно описувати як C# об'єкти
  • Складна бізнес-логіка у SQL (MERGE, upsert, умовні вставки)
  • Seed з використанням SQL-функцій (NEWID(), GETUTCDATE(), рандомні дані)
  • Перенесення даних між таблицями при реструктуризації схеми
  • Провайдерно-специфічні операції, які EF Core не покриває
public partial class SeedInitialData : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Простий INSERT для невеликих довідників
        migrationBuilder.Sql(@"
            INSERT INTO [Countries] ([Code], [Name], [ContinentCode], [IsActive])
            VALUES
                ('UA', N'Україна',       'EU', 1),
                ('US', N'США',           'NA', 1),
                ('DE', N'Німеччина',     'EU', 1),
                ('GB', N'Велика Британія', 'EU', 1),
                ('PL', N'Польща',        'EU', 1);
        ");

        // Ідемпотентний INSERT: вставляємо лише якщо немає
        migrationBuilder.Sql(@"
            IF NOT EXISTS (SELECT 1 FROM [Roles] WHERE [Name] = 'Admin')
                INSERT INTO [Roles] ([Name], [Description])
                VALUES ('Admin', N'Повний доступ');

            IF NOT EXISTS (SELECT 1 FROM [Roles] WHERE [Name] = 'Customer')
                INSERT INTO [Roles] ([Name], [Description])
                VALUES ('Customer', N'Покупець');
        ");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DELETE FROM [Countries] WHERE [Code] IN ('UA','US','DE','GB','PL');");
        migrationBuilder.Sql("DELETE FROM [Roles] WHERE [Name] IN ('Admin', 'Customer');");
    }
}

MERGE (Upsert) у SQL Server

migrationBuilder.Sql(@"
    MERGE INTO [Settings] AS target
    USING (VALUES
        ('app.name',              N'MyStore'),
        ('app.support_email',     N'support@mystore.com'),
        ('app.max_file_size_mb',  N'10'),
        ('app.default_currency',  N'UAH')
    ) AS source ([Key], [Value])
    ON target.[Key] = source.[Key]
    WHEN NOT MATCHED THEN
        INSERT ([Key], [Value]) VALUES (source.[Key], source.[Value]);
    -- Не оновлюємо WHEN MATCHED — дозволяємо адміну змінювати налаштування
");

PostgreSQL: INSERT ... ON CONFLICT (Upsert)

// PostgreSQL варіант для ідемпотентного seed
migrationBuilder.Sql(@"
    INSERT INTO ""Settings"" (""Key"", ""Value"")
    VALUES
        ('app.name',            'MyStore'),
        ('app.support_email',   'support@mystore.com'),
        ('app.max_file_size_mb', '10')
    ON CONFLICT (""Key"") DO NOTHING;
    -- DO NOTHING: пропустити якщо вже існує
");

Seeding з CSV та JSON файлів

Для великих обсягів seed data зручно зберігати дані у форматі CSV або JSON файлів, що читаються під час seed.

Seed з JSON файлу через Initialization Service

// seed/countries.json (файл у проєкті, BuildAction: EmbeddedResource)
[
  { "code": "UA", "name": "Україна", "continentCode": "EU" },
  { "code": "US", "name": "США", "continentCode": "NA" },
  { "code": "DE", "name": "Німеччина", "continentCode": "EU" }
]
public class CountriesSeeder : ISeeder
{
    public int Order => 1;

    public async Task SeedAsync(AppDbContext context, CancellationToken ct)
    {
        if (await context.Countries.AnyAsync(ct)) return;

        // Читаємо embedded resource
        var assembly = typeof(CountriesSeeder).Assembly;
        var resourceName = assembly.GetManifestResourceNames()
            .Single(n => n.EndsWith("countries.json"));

        await using var stream = assembly.GetManifestResourceStream(resourceName)!;

        var countries = await JsonSerializer.DeserializeAsync<List<Country>>(
            stream,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
            ct)!;

        await context.Countries.AddRangeAsync(countries!, ct);
        await context.SaveChangesAsync(ct);
    }
}
<!-- .csproj: додайте JSON як EmbeddedResource -->
<ItemGroup>
  <EmbeddedResource Include="seed\countries.json" />
  <EmbeddedResource Include="seed\currencies.json" />
  <EmbeddedResource Include="seed\categories.json" />
</ItemGroup>

Seed з CSV через CsvHelper

dotnet add package CsvHelper
// seed/products.csv
Id,Name,CategoryId,Price,Stock
1,MacBook Pro 14,4,89990,15
2,iPhone 15 Pro,5,44990,30
3,Wireless Mouse,4,850,100
public class ProductsSeeder : ISeeder
{
    public int Order => 5;

    public async Task SeedAsync(AppDbContext context, CancellationToken ct)
    {
        if (await context.Products.AnyAsync(ct)) return;

        await using var stream = GetType().Assembly
            .GetManifestResourceStream("MyApp.seed.products.csv")!;
        using var reader = new StreamReader(stream);
        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

        var products = csv.GetRecords<Product>().ToList();
        await context.Products.AddRangeAsync(products, ct);
        await context.SaveChangesAsync(ct);
    }
}

Bogus: генерація реалістичних fake-даних

Bogus — бібліотека для генерації синтетичних, але реалістичних тестових даних. Імена, адреси, email, номери телефонів, продукти, компанії — всі генеруються з реальних словників і форматів.

dotnet add package Bogus

Базове використання Bogus

using Bogus;

public class DevDataSeeder : ISeeder
{
    public int Order => 100; // Останній, тільки для dev

    public async Task SeedAsync(AppDbContext context, CancellationToken ct)
    {
        if (await context.Customers.CountAsync(ct) > 10) return; // вже є достатньо

        // Faker для Customer з Ukrainian locale
        var customerFaker = new Faker<Customer>("uk")
            .RuleFor(c => c.FirstName, f => f.Name.FirstName())
            .RuleFor(c => c.LastName,  f => f.Name.LastName())
            .RuleFor(c => c.Email,     (f, c) => f.Internet.Email(c.FirstName, c.LastName))
            .RuleFor(c => c.PhoneNumber, f => f.Phone.PhoneNumber("+380## ### ## ##"))
            .RuleFor(c => c.RegisteredAt, f => f.Date.Past(2))
            .RuleFor(c => c.IsActive, f => f.Random.Bool(0.9f)); // 90% активних

        var customers = customerFaker.Generate(50);
        await context.Customers.AddRangeAsync(customers, ct);
        await context.SaveChangesAsync(ct);
    }
}

Faker для складних об'єктів з FK

public async Task SeedAsync(AppDbContext context, CancellationToken ct)
{
    // Спочатку читаємо існуючі FK
    var categoryIds = await context.Categories.Select(c => c.Id).ToListAsync(ct);
    var customerIds = await context.Customers.Select(c => c.Id).ToListAsync(ct);

    if (!categoryIds.Any() || !customerIds.Any()) return;

    // Faker для Product з реальними FK
    var productFaker = new Faker<Product>("uk")
        .RuleFor(p => p.Name,       f => f.Commerce.ProductName())
        .RuleFor(p => p.Price,      f => f.Random.Decimal(100, 50000))
        .RuleFor(p => p.Stock,      f => f.Random.Int(0, 500))
        .RuleFor(p => p.CategoryId, f => f.PickRandom(categoryIds))
        .RuleFor(p => p.Sku,        f => f.Commerce.Ean13())
        .RuleFor(p => p.CreatedAt,  f => f.Date.Past(1));

    var products = productFaker.Generate(100);
    await context.Products.AddRangeAsync(products, ct);
    await context.SaveChangesAsync(ct);

    var productIds = products.Select(p => p.Id).ToList();

    // Faker для Order з рядками
    var orderFaker = new Faker<Order>("uk")
        .RuleFor(o => o.CustomerId,   f => f.PickRandom(customerIds))
        .RuleFor(o => o.Status,       f => f.PickRandom<OrderStatus>())
        .RuleFor(o => o.PlacedAt,     f => f.Date.Past(1))
        .RuleFor(o => o.TotalAmount,  f => 0m) // розрахуємо після рядків
        .FinishWith((f, o) =>
        {
            // Генеруємо від 1 до 5 рядків у замовленні
            var lineCount = f.Random.Int(1, 5);
            for (int i = 0; i < lineCount; i++)
            {
                var qty   = f.Random.Int(1, 3);
                var price = f.Random.Decimal(100, 5000);
                o.LineItems.Add(new OrderLineItem
                {
                    ProductId   = f.PickRandom(productIds),
                    Quantity    = qty,
                    UnitPrice   = price
                });
                o.TotalAmount += qty * price;
            }
        });

    var orders = orderFaker.Generate(200);
    await context.Orders.AddRangeAsync(orders, ct);
    await context.SaveChangesAsync(ct);
}

Богус і детермінована генерація (seed для seed)

// Якщо потрібен завжди однаковий результат — вкажіть seed для Bogus
var faker = new Faker<Product>()
    .UseSeed(12345) // той самий seed → той самий результат кожного разу
    .RuleFor(p => p.Name, f => f.Commerce.ProductName());

Фіксований seed Bogus корисний для тестів, де потрібна відтворюваність.


EnsureCreated і EnsureDeleted для тестів

При написанні інтеграційних тестів часто потрібно:

  • Створити чисту базу на початку тесту
  • Видалити базу після тесту
  • Застосувати схему (без міграцій — швидше для тестів)

EnsureCreated: схема без міграцій

// В тесті або TestFixture — швидке створення схеми
await context.Database.EnsureCreatedAsync();

EnsureCreated читає поточну модель EF Core і створює таблиці напряму, без міграцій. Це набагато швидше для тестів, але не підходить для production — немає версіонування.

// Типовий шаблон для Integration Tests з xUnit
public class ShopDbContextFixture : IAsyncLifetime
{
    public AppDbContext Context { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite("Data Source=:memory:") // In-Memory SQLite для тестів
            .Options;

        Context = new AppDbContext(options);
        await Context.Database.EnsureCreatedAsync();

        // Seed testових даних
        await SeedTestDataAsync();
    }

    public async Task DisposeAsync()
    {
        await Context.Database.EnsureDeletedAsync();
        await Context.DisposeAsync();
    }

    private async Task SeedTestDataAsync()
    {
        Context.Categories.AddRange(
            new Category { Id = 1, Name = "Electronics" },
            new Category { Id = 2, Name = "Clothing" }
        );
        Context.Products.AddRange(
            new Product { Id = 1, Name = "Laptop",     CategoryId = 1, Price = 35000m },
            new Product { Id = 2, Name = "Smartphone", CategoryId = 1, Price = 22000m },
            new Product { Id = 3, Name = "T-Shirt",    CategoryId = 2, Price = 250m }
        );
        await Context.SaveChangesAsync();
    }
}

WebApplicationFactory зі Seeding для інтеграційних тестів

// CustomWebApplicationFactory для ASP.NET Core Integration Tests
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Замінюємо реальну БД на тестову In-Memory
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (descriptor is not null)
                services.Remove(descriptor);

            services.AddDbContext<AppDbContext>(options =>
                options.UseSqlite($"DataSource=test_{Guid.NewGuid()}.db"));
        });

        builder.UseEnvironment("Testing");
    }

    public async Task SeedAsync()
    {
        using var scope = Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        await context.Database.EnsureCreatedAsync();

        // Seed мінімальний набір для тестів
        if (!await context.Roles.AnyAsync())
        {
            context.Roles.AddRange(
                new Role { Id = 1, Name = "Admin" },
                new Role { Id = 2, Name = "Customer" }
            );
            await context.SaveChangesAsync();
        }
    }
}

Bulk Seeding: ExecuteSqlRaw для великих обсягів

Якщо потрібно вставити тисячи записів — EF Core AddRange може бути повільним (N окремих INSERTs або великий батч). Для справжнього bulk insert — ExecuteSqlRaw:

public async Task SeedLargeDataSetAsync(AppDbContext context)
{
    // 10,000 записів через SQL VALUES
    const int batchSize = 1000;
    var faker = new Faker<DemoLog>()
        .RuleFor(l => l.Message, f => f.Lorem.Sentence())
        .RuleFor(l => l.Level,   f => f.PickRandom("Info", "Warning", "Error"))
        .RuleFor(l => l.CreatedAt, f => f.Date.Past(1));

    var logs = faker.Generate(10_000);
    var batches = logs.Chunk(batchSize);

    foreach (var batch in batches)
    {
        // Будуємо VALUES рядок для bulk INSERT
        var values = string.Join(",\n", batch.Select(l =>
            $"(N'{EscapeSql(l.Message)}', '{l.Level}', '{l.CreatedAt:o}')"));

        await context.Database.ExecuteSqlRawAsync($@"
            INSERT INTO [DemoLogs] ([Message], [Level], [CreatedAt])
            VALUES {values}
        ");
    }
}

private static string EscapeSql(string value) =>
    value.Replace("'", "''");
SQL Injection у bulk seed: При формуванні SQL рядків через конкатенацію — ризик SQL Injection. Для довільних даних (особливо з контенту користувача) завжди використовуйте параметризовані запити або TVP (Table Valued Parameters) для SQL Server. Для контрольованих seed-даних зі статичних файлів — прийнятно, але будьте обережні.

Альтернатива: EFCore.BulkExtensions або SqlBulkCopy для SQL Server:

dotnet add package EFCore.BulkExtensions
// Bulk insert через EFCore.BulkExtensions
await context.BulkInsertAsync(logs, new BulkConfig
{
    BatchSize = 1000,
    BulkCopyTimeout = 120
});

Матриця: яку стратегію обрати

Зведемо все в єдину порівняльну таблицю:

КритерійHasData()migrationBuilder.Sql()Initialization ServiceBogus + Dev Seed
Вбудовано у міграціюТакТакНіНі
ВерсіонованістьАвтоматичноВручнуНі (логіка в коді)Ні
ІдемпотентністьТак (EF відстежує)Вручну (IF NOT EXISTS)Вручну (AnyAsync)Вручну
Великий обсягПогано (C# масиви)Добре (SQL VALUES)Добре (файли)Добре
Environment-залежність❌ ніЧастково✅ так✅ так
Бізнес-логіка у seed❌ ніОбмежено✅ повна✅ повна
Owned TypesСкладноПросто (SQL)Просто (C#)Просто
ТестиЗайвеЗайвеEnsureCreatedEnsureCreated
Production safe✅ (якщо обережно)❌ (dev-only)

Рекомендований підхід по типах даних

Довідники (Roles, Countries, Currencies):
  → HasData() якщо < 50 записів
  → migrationBuilder.Sql() якщо > 50 або потрібен MERGE/upsert

Дефолтні налаштування:
  → Initialization Service (upsert без перезапису існуючих)

Demo/тестові дані:
  → Dev-only Initialization Service + Bogus

Великий обсяг (1000+):
  → migrationBuilder.Sql() або Initialization Service + EFCore.BulkExtensions

Інтеграційні тести:
  → EnsureCreated + мінімальний ручний seed

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

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

Завдання 1.1: migrationBuilder.Sql з MERGE

Реалізуйте окрему міграцію SeedDefaultSettings що через MERGE (SQL Server) або INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) вставляє 10 системних налаштувань. Напишіть Down() що видаляє тільки ті налаштування, які були вставлені (не всю таблицю).

Завдання 1.2: Seed з JSON файлу

Додайте до проєкту EmbeddedResource файл seed/currencies.json з 15 валютами. Реалізуйте CurrenciesSeeder : ISeeder, що:

  1. Читає JSON через GetManifestResourceStream
  2. Десеріалізує через System.Text.Json
  3. Вставляє лише ті записи, що відсутні (порівнює за Code)

Завдання 1.3: EnsureCreated для тестів

Напишіть DbContextFixture для xUnit з SQLite In-Memory:

  • InitializeAsync: EnsureCreated + seed мінімальних даних (3 категорії, 5 продуктів)
  • DisposeAsync: EnsureDeleted
  • Напишіть 2 тести що використовують цей fixture

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

Завдання 2.1: Bogus для реалістичних замовлень

Реалізуйте DevDataSeeder що генерує:

  • 30 клієнтів (Faker з "uk" locale: українські імена, телефони +380...)
  • 100 продуктів (реальні назви Commerce, ціни від 100 до 50000 грн)
  • 200 замовлень (кожне: 1-5 рядків, статус розподілений: 40% Delivered, 30% Processing, 20% Pending, 10% Cancelled)

Переконайтеся, що при повторному запуску — нові записи не дублюються.

Завдання 2.2: Детермінований seed для snapshot-тестів

Реалізуйте seed з фіксованим Bogus seed (UseSeed(42)), щоб кожен запуск генерував точно ті самі дані. Напишіть snapshot-тест (або просто Assert), що перевіряє конкретне ім'я першого згенерованого клієнта незмінним.

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

Завдання 3.1: Production-ready Seeding Pipeline

Реалізуйте повний seeding pipeline для multi-tenant SaaS:

  1. MasterSeeder: системні дані спільні для всіх (Countries, Currencies, Plans)
  2. TenantSeeder(tenantId): дані для нового тенанту при реєстрації (Default Roles, Default Settings, Welcome Email Template)
  3. DevSeeder: демо-тенант з Bogus-даними (лише в Development)

TenantSeeder має бути ідемпотентним — можна запустити кілька разів для одного тенанту. Реалізуйте через ISeeder з додатковим параметром або окремий інтерфейс ITenantSeeder.

Напишіть юніт-тест для кожного seeder (Mock DbContext або реальний In-Memory SQLite).


Підсумок статті 14

Ця стаття розкрила весь спектр стратегій Seed Data в EF Core:

Частина 1:

  • HasData(): декларативний seed у Fluent API, вбудовується у міграції, автоматичний diff при змінах. Ідеальний для невеликих довідників < 50 записів. Обмеження: не для dynamic/environment-залежних даних.
  • SHA з FK: порядок seed важливий, EF Core сортує INSERT у міграції.
  • Shadow Properties у HasData: анонімний об'єкт з усіма полями.
  • Initialization Service: повний контроль, ідемпотентність через AnyAsync, середовищ-залежність, бізнес-логіка.

Частина 2:

  • migrationBuilder.Sql(): для великих обсягів, MERGE/upsert, провайдерно-специфічних операцій. Down() — вручну.
  • CSV/JSON файли: EmbeddedResource + GetManifestResourceStream для структурованих даних.
  • Bogus: реалістичні fake-дані з locale-підтримкою, UseSeed() для детермінованості.
  • EnsureCreated/EnsureDeleted: для тестів без міграційного overhead.
  • Bulk Seeding: ExecuteSqlRaw або EFCore.BulkExtensions для 1000+ записів.
  • Матриця вибору: HasData → невеликі довідники; migrationBuilder.Sql → великий обсяг у міграції; Initialization Service → динамічні дані; Bogus → dev-only.

Наступна стаття — Global Query Filters (стаття 15) — розкриє механізм автоматичної фільтрації для soft delete, multi-tenancy та row-level security.


Додаткові ресурси