Це продовження статті «Seed Data: Початкові Дані». Читайте послідовно.
Коли HasData() занадто обмежений, а Initialization Service — занадто важкий для простого seeding у міграціях — є третій шлях: власний SQL-скрипт безпосередньо у міграції через migrationBuilder.Sql().
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');");
}
}
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 варіант для ідемпотентного 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: пропустити якщо вже існує
");
Для великих обсягів seed data зручно зберігати дані у форматі CSV або JSON файлів, що читаються під час seed.
// 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>
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 — бібліотека для генерації синтетичних, але реалістичних тестових даних. Імена, адреси, email, номери телефонів, продукти, компанії — всі генеруються з реальних словників і форматів.
dotnet add package 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);
}
}
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 для Bogus
var faker = new Faker<Product>()
.UseSeed(12345) // той самий seed → той самий результат кожного разу
.RuleFor(p => p.Name, f => f.Commerce.ProductName());
Фіксований seed Bogus корисний для тестів, де потрібна відтворюваність.
При написанні інтеграційних тестів часто потрібно:
// В тесті або 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();
}
}
// 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();
}
}
}
Якщо потрібно вставити тисячи записів — 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("'", "''");
Альтернатива: 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 Service | Bogus + Dev Seed |
|---|---|---|---|---|
| Вбудовано у міграцію | Так | Так | Ні | Ні |
| Версіонованість | Автоматично | Вручну | Ні (логіка в коді) | Ні |
| Ідемпотентність | Так (EF відстежує) | Вручну (IF NOT EXISTS) | Вручну (AnyAsync) | Вручну |
| Великий обсяг | Погано (C# масиви) | Добре (SQL VALUES) | Добре (файли) | Добре |
| Environment-залежність | ❌ ні | Частково | ✅ так | ✅ так |
| Бізнес-логіка у seed | ❌ ні | Обмежено | ✅ повна | ✅ повна |
| Owned Types | Складно | Просто (SQL) | Просто (C#) | Просто |
| Тести | Зайве | Зайве | EnsureCreated | EnsureCreated |
| 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
Завдання 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, що:
GetManifestResourceStreamSystem.Text.JsonCode)Завдання 1.3: EnsureCreated для тестів
Напишіть DbContextFixture для xUnit з SQLite In-Memory:
InitializeAsync: EnsureCreated + seed мінімальних даних (3 категорії, 5 продуктів)DisposeAsync: EnsureDeletedЗавдання 2.1: Bogus для реалістичних замовлень
Реалізуйте DevDataSeeder що генерує:
"uk" locale: українські імена, телефони +380...)Переконайтеся, що при повторному запуску — нові записи не дублюються.
Завдання 2.2: Детермінований seed для snapshot-тестів
Реалізуйте seed з фіксованим Bogus seed (UseSeed(42)), щоб кожен запуск генерував точно ті самі дані. Напишіть snapshot-тест (або просто Assert), що перевіряє конкретне ім'я першого згенерованого клієнта незмінним.
Завдання 3.1: Production-ready Seeding Pipeline
Реалізуйте повний seeding pipeline для multi-tenant SaaS:
MasterSeeder: системні дані спільні для всіх (Countries, Currencies, Plans)TenantSeeder(tenantId): дані для нового тенанту при реєстрації (Default Roles, Default Settings, Welcome Email Template)DevSeeder: демо-тенант з Bogus-даними (лише в Development)TenantSeeder має бути ідемпотентним — можна запустити кілька разів для одного тенанту. Реалізуйте через ISeeder з додатковим параметром або окремий інтерфейс ITenantSeeder.
Напишіть юніт-тест для кожного seeder (Mock DbContext або реальний In-Memory SQLite).
Ця стаття розкрила весь спектр стратегій Seed Data в EF Core:
Частина 1:
HasData(): декларативний seed у Fluent API, вбудовується у міграції, автоматичний diff при змінах. Ідеальний для невеликих довідників < 50 записів. Обмеження: не для dynamic/environment-залежних даних.AnyAsync, середовищ-залежність, бізнес-логіка.Частина 2:
migrationBuilder.Sql(): для великих обсягів, MERGE/upsert, провайдерно-специфічних операцій. Down() — вручну.EmbeddedResource + GetManifestResourceStream для структурованих даних.UseSeed() для детермінованості.ExecuteSqlRaw або EFCore.BulkExtensions для 1000+ записів.HasData → невеликі довідники; migrationBuilder.Sql → великий обсяг у міграції; Initialization Service → динамічні дані; Bogus → dev-only.Наступна стаття — Global Query Filters (стаття 15) — розкриє механізм автоматичної фільтрації для soft delete, multi-tenancy та row-level security.
Seed Data — Початкові Дані (Частина 1)
Стратегії наповнення бази початковими даними в EF Core — HasData() через Fluent API, обмеження і практичні нюанси, Alternate Key у seed data, маппінг Shadow Properties, Data Seeding через Initialization сервіс.
Global Query Filters — Глобальні Фільтри (Частина 1)
Global Query Filters в EF Core — механізм автоматичної фільтрації запитів. Soft Delete через IsDeleted, Multi-Tenancy через TenantId, Row-Level Security. Конфігурація, HasQueryFilter, IgnoreQueryFilters, підводні камені з JOIN.