Будь-який реальний застосунок потребує певних даних у базі для коректної роботи. Ці дані можна розділити на три категорії.
Довідники (Reference / Lookup data): списки значень, що рідко змінюються і потрібні для роботи бізнес-логіки. Ролі користувачів (Admin, Manager, Customer), категорії товарів (Electronics, Clothing, Food), статуси замовлень, коди країн (ISO 3166), коди валют (ISO 4217). Без цих даних застосунок не може функціонувати — новий замовник не може обрати роль, новий товар не може потрапити до категорії.
Дефолтні налаштування (Default configuration): конфігурація, що має бути в базі і зазвичай не змінюється. Конфігурація email-шаблонів, дефолтні тарифні плани для SaaS, початкові налаштування фіче-флагів.
Демо або тестові дані (Demo / Test data): використовуються при розробці або для демонстрації. Тестові користувачі, замовлення, продукти — щоб розробник міг відразу працювати зі змістовними даними, не вводячи їх вручну.
Правильна стратегія Seed Data:
EF Core пропонує кілька підходів, і вибір між ними залежить від типу даних і середовища.
HasData — офіційний механізм EF Core для вбудовування seed data у модель. Дані, оголошені через HasData, потрапляють у міграції і застосовуються разом зі схемою бази.
public class Country
{
public string Code { get; set; } = string.Empty; // PK: "UA", "US", "DE"
public string Name { get; set; } = string.Empty;
public string ContinentCode { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class CountryConfiguration : IEntityTypeConfiguration<Country>
{
public void Configure(EntityTypeBuilder<Country> builder)
{
builder.HasKey(c => c.Code);
builder.Property(c => c.Code).HasMaxLength(2).IsUnicode(false);
builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
builder.Property(c => c.ContinentCode).HasMaxLength(2).IsUnicode(false);
// HasData: початкові дані вбудовані у конфігурацію
builder.HasData(
new Country { Code = "UA", Name = "Україна", ContinentCode = "EU" },
new Country { Code = "US", Name = "США", ContinentCode = "NA" },
new Country { Code = "DE", Name = "Німеччина", ContinentCode = "EU" },
new Country { Code = "GB", Name = "Велика Британія", ContinentCode = "EU" },
new Country { Code = "FR", Name = "Франція", ContinentCode = "EU" },
new Country { Code = "PL", Name = "Польща", ContinentCode = "EU" },
new Country { Code = "JP", Name = "Японія", ContinentCode = "AS" }
);
}
}
Після Add-Migration InitialSeed або dotnet ef migrations add InitialSeed у файлі міграції з'явиться:
// Автоматично генерований код міграції:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Countries",
columns: table => new { ... },
constraints: table => { table.PrimaryKey("PK_Countries", x => x.Code); });
migrationBuilder.InsertData(
table: "Countries",
columns: new[] { "Code", "ContinentCode", "IsActive", "Name" },
values: new object[,]
{
{ "DE", "EU", true, "Німеччина" },
{ "FR", "EU", true, "Франція" },
{ "GB", "EU", true, "Велика Британія" },
{ "JP", "AS", true, "Японія" },
{ "PL", "EU", true, "Польща" },
{ "UA", "EU", true, "Україна" },
{ "US", "NA", true, "США" }
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Countries",
keyColumn: "Code",
keyValues: new object[] { "DE", "FR", "GB", "JP", "PL", "UA", "US" });
}
Down() автоматично видаляє seed-записи. Це одна з переваг HasData — повна версіонованість через міграційний механізм.
При HasData з int-ідентифікаторами (IDENTITY) обов'язково вказуйте значення PK у seed data. EF Core не може використовувати IDENTITY у seed контексті — значення має бути явним:
public class Role
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
public class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.HasKey(r => r.Id);
builder.Property(r => r.Name).IsRequired().HasMaxLength(100);
builder.Property(r => r.Description).HasMaxLength(500);
// Id вказано явно! Інакше — помилка
builder.HasData(
new Role { Id = 1, Name = "Admin", Description = "Повний доступ" },
new Role { Id = 2, Name = "Manager", Description = "Управління" },
new Role { Id = 3, Name = "Customer", Description = "Покупець" },
new Role { Id = 4, Name = "Support", Description = "Підтримка" }
);
}
}
IDENTITY, а seed data має фіксовані Id — база потребує SET IDENTITY_INSERT Roles ON при INSERT. EF Core автоматично виконує це для InsertData у міграціях. Але будьте обережні: після seed Id = 1,2,3,4 — наступний IDENTITY буде 5. Якщо seed data прибрати пізніше і додати нову — треба перевіряти, що IDENTITY не конфліктує.Якщо потрібно змінити seed (додати нову роль, виправити назву) — просто змінюємо HasData і додаємо нову міграцію:
// Додаємо нову роль:
builder.HasData(
new Role { Id = 1, Name = "Admin", Description = "Повний доступ" },
new Role { Id = 2, Name = "Manager", Description = "Управління" },
new Role { Id = 3, Name = "Customer", Description = "Покупець" },
new Role { Id = 4, Name = "Support", Description = "Підтримка" },
new Role { Id = 5, Name = "Auditor", Description = "Тільки перегляд" } // НОВА
);
dotnet ef migrations add AddAuditorRole
Генерована міграція:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Roles",
columns: new[] { "Id", "Description", "Name" },
values: new object[] { 5, "Тільки перегляд", "Auditor" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(table: "Roles", keyColumn: "Id", keyValue: 5);
}
EF Core відстежує зміни у HasData — нова міграція містить лише diff, не повний перезапис.
Якщо seed-записи мають FK — порядок важливий: спочатку батьківські записи.
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int? ParentCategoryId { get; set; } // само-посилання
public Category? ParentCategory { get; set; }
public ICollection<Category> SubCategories { get; set; } = new List<Category>();
}
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.HasOne(c => c.ParentCategory)
.WithMany(c => c.SubCategories)
.HasForeignKey(c => c.ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict);
// Спочатку батьківські (ParentCategoryId = null)
builder.HasData(
new Category { Id = 1, Name = "Електроніка", ParentCategoryId = null },
new Category { Id = 2, Name = "Одяг", ParentCategoryId = null },
new Category { Id = 3, Name = "Книги", ParentCategoryId = null },
// Потім дочірні (Id батька вже є у seed)
new Category { Id = 4, Name = "Ноутбуки", ParentCategoryId = 1 },
new Category { Id = 5, Name = "Смартфони", ParentCategoryId = 1 },
new Category { Id = 6, Name = "Телевізори", ParentCategoryId = 1 },
new Category { Id = 7, Name = "Чоловічий одяг", ParentCategoryId = 2 },
new Category { Id = 8, Name = "Жіночий одяг", ParentCategoryId = 2 },
new Category { Id = 9, Name = "Технічна л-ра", ParentCategoryId = 3 },
new Category { Id = 10, Name = "Художня л-ра", ParentCategoryId = 3 }
);
}
}
EF Core сортує INSERT у правильному порядку в міграції — спочатку записи без FK, потім залежні.
Seed data може також заповнювати Shadow Properties (тіньові властивості) — ті, що не є частиною C#-класу:
public class BlogPost
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
// CreatedAt — shadow property (не у класі)
}
public class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
public void Configure(EntityTypeBuilder<BlogPost> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Title).IsRequired().HasMaxLength(300);
// Shadow property
builder.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("GETUTCDATE()");
// HasData з Shadow Property через анонімний об'єкт
builder.HasData(new[]
{
new
{
Id = 1,
Title = "Вступ до EF Core",
Content = "Entity Framework Core — це...",
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
},
new
{
Id = 2,
Title = "LINQ-запити в EF Core",
Content = "LINQ дозволяє писати запити...",
CreatedAt = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)
}
});
}
}
Зверніть: при shadow properties у HasData обов'язково використовувати анонімний об'єкт з усіма полями, включно з тіньовими.
Попри зручність HasData, у нього є принципові обмеження, які важливо розуміти.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Address ShippingAddress { get; set; } = null!; // Owned Type
}
// НЕПРАВИЛЬНО — Owned Type через HasData напряму:
builder.HasData(new Product
{
Id = 1,
Name = "Laptop",
ShippingAddress = new Address { Street = "...", City = "Kyiv" } // ← помилка!
});
Owned Types у HasData потребують окремого seed через свій builder:
// ПРАВИЛЬНО: спочатку Product
builder.HasData(new { Id = 1, Name = "Laptop" });
// Потім Address через OwnsOne builder:
builder.OwnsOne(p => p.ShippingAddress).HasData(new
{
ProductId = 1, // FK до Product
Street = "вул. Хрещатик, 1",
City = "Київ",
Country = "Україна",
PostalCode = "01001"
});
HasData з тисячами записів — погана ідея:
Для великих обсягів seed data (100+ записів) — використовуйте Database Initialization Сервіс (розглянемо далі) або migrationBuilder.Sql().
// НЕПРАВИЛЬНО: динамічні дані у HasData
builder.HasData(
new Setting { Key = "JwtSecret", Value = Environment.GetEnvironmentVariable("JWT_SECRET")! }
// ← значення залежне від середовища, не повинно бути у міграції
);
Seed data через HasData — статична і однакова для всіх середовищ. Конфігурацію, що залежить від середовища, треба вносити через Initialization Service або IConfiguration.
// АНТИПАТЕРН: тестові дані у production конфігурації
builder.HasData(
new Customer { Id = 1, Name = "Test User", Email = "test@test.com", Password = "123456" },
new Customer { Id = 2, Name = "John Doe", Email = "john@doe.com", Password = "password" }
);
// Ці дані потраплять у production БД!
Тестові/demo дані — лише для dev середовища. Виноси їх у окремий seed-сервіс, що активується лише для Development environment.
Для складнішого seeding — окремий сервіс, що виконується при старті застосунку. Цей підхід дає:
public interface IDatabaseInitializer
{
Task InitializeAsync(CancellationToken cancellationToken = default);
}
public class DatabaseInitializer : IDatabaseInitializer
{
private readonly AppDbContext _context;
private readonly ILogger<DatabaseInitializer> _logger;
public DatabaseInitializer(AppDbContext context, ILogger<DatabaseInitializer> logger)
{
_context = context;
_logger = logger;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
// 1. Застосувати міграції (якщо є)
await _context.Database.MigrateAsync(cancellationToken);
// 2. Засіяти дані
await SeedCountriesAsync(cancellationToken);
await SeedRolesAsync(cancellationToken);
await SeedDefaultSettingsAsync(cancellationToken);
}
private async Task SeedCountriesAsync(CancellationToken ct)
{
// Ідемпотентна перевірка: вже є хоч один запис?
if (await _context.Countries.AnyAsync(ct))
{
_logger.LogInformation("Countries already seeded, skipping.");
return;
}
var countries = new[]
{
new Country { Code = "UA", Name = "Україна", ContinentCode = "EU" },
new Country { Code = "US", Name = "США", ContinentCode = "NA" },
new Country { Code = "DE", Name = "Німеччина", ContinentCode = "EU" },
new Country { Code = "PL", Name = "Польща", ContinentCode = "EU" },
};
await _context.Countries.AddRangeAsync(countries, ct);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Seeded {Count} countries.", countries.Length);
}
private async Task SeedRolesAsync(CancellationToken ct)
{
var existingRoleNames = await _context.Roles
.Select(r => r.Name)
.ToHashSetAsync(ct);
// Додаємо лише нові ролі, не перезаписуємо
var rolesToSeed = new[]
{
new Role { Name = "Admin", Description = "Повний доступ" },
new Role { Name = "Manager", Description = "Управління" },
new Role { Name = "Customer", Description = "Покупець" },
};
var newRoles = rolesToSeed
.Where(r => !existingRoleNames.Contains(r.Name))
.ToList();
if (newRoles.Count == 0) return;
await _context.Roles.AddRangeAsync(newRoles, ct);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Seeded {Count} new roles.", newRoles.Count);
}
private async Task SeedDefaultSettingsAsync(CancellationToken ct)
{
// Upsert: вставити якщо немає, оновити якщо є і значення змінилось
var defaults = new Dictionary<string, string>
{
["app.name"] = "MyStore",
["app.support_email"] = "support@mystore.com",
["app.max_file_size_mb"] = "10",
["app.default_currency"] = "UAH",
};
foreach (var (key, value) in defaults)
{
var setting = await _context.Settings.FindAsync([key], ct);
if (setting is null)
{
await _context.Settings.AddAsync(new Setting { Key = key, Value = value }, ct);
}
// Не оновлюємо якщо вже існує — адмін міг змінити налаштування
}
await _context.SaveChangesAsync(ct);
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IDatabaseInitializer, DatabaseInitializer>();
var app = builder.Build();
// Запуск ініціалізатора при старті
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<IDatabaseInitializer>();
await initializer.InitializeAsync();
}
app.Run();
Завдання 1.1: HasData для довідника
Реалізуйте seed для Currency (Code varchar(3) PK, Name, Symbol, DecimalPlaces int) через HasData у конфігурації. Засійте мінімум 10 валют (UAH, USD, EUR, GBP, JPY, CHF, CAD, AUD, PLN, CZK). Перевірте в міграції, що генерується InsertData.
Завдання 1.2: Ієрархічний seed
Department (Id, Name, ParentDepartmentId?) має само-посилання. Засійте ієрархію:
Перевірте порядок InsertData у міграції — спочатку батьківські чи дочірні?
Завдання 1.3: Seed з Shadow Properties
Article (Id, Title, Body) має shadow properties CreatedAt і ViewCount (int, default 0). Запишіть через HasData 3 статті з явними значеннями CreatedAt. Що відбудеться, якщо не вказати ViewCount у seed — яке значення буде у БД?
Завдання 2.1: Ідемпотентний Initialization Service
Реалізуйте DatabaseInitializer для магазину:
SeedCategoriesAsync: засіяти дерево категорій (Electronics → Laptops, Phones; Clothing → Men, Women)SeedDefaultAdminAsync: якщо жодного Admin-користувача немає — створити дефолтного (email з env variable, хешований пароль)SeedSettingsAsync: upsert для налаштувань без перезапису існуючихПри повторному запуску — жодних помилок і жодних дублікатів.
Завдання 2.2: Середовищ-залежний Seed
Extend DatabaseInitializer для підтримки dev-seed:
if (env.IsDevelopment())
{
await SeedDemoCustomersAsync(ct); // 20 тестових клієнтів
await SeedDemoProductsAsync(ct); // 50 тестових продуктів
await SeedDemoOrdersAsync(ct); // 100 тестових замовлень з рядками
}
Використайте Bogus (бібліотека генерації fake-даних) для реалістичних тестових даних.
Завдання 3.1: Seeding Framework
Реалізуйте власний мінімальний seeding framework:
ISeeder interface: int Order { get; } (пріоритет виконання), Task SeedAsync(AppDbContext ctx, CancellationToken ct)CountriesSeeder : ISeeder (Order = 1)RolesSeeder : ISeeder (Order = 2)DefaultUsersSeeder : ISeeder (Order = 3, залежить від Roles)CategoriesSeeder : ISeeder (Order = 4)DatabaseInitializer знаходить всі ISeeder через DI (зареєстровані як IEnumerable<ISeeder>), сортує за Order і виконує послідовно. Новий seeder — просто реалізуй інтерфейс і зареєструй у DI.
У першій половині статті ми охопили:
HasData(): декларативний seed у Fluent API конфігурації. Вбудовується у міграції, генерує InsertData і DeleteData у Up()/Down(). Відстежує diff між міграціями.HasData через OwnsOne(...).HasData(...).HasData: не для великих обсягів, не для динамічних/environment-залежних даних, не для тестових даних у production коді.У другій частині розглянемо: seed через migrationBuilder.Sql(), seeding великих обсягів через CSV/JSON, EnsureDeleted/EnsureCreated для тестів, Bogus для генерації fake-даних і порівняльна матриця всіх підходів.
Індекси, Обмеження та Схема (Частина 2)
Check Constraints, Database Sequences і Hi-Lo, Collation, Database Comments, HasDefaultSchema, схема бази даних через Fluent API. Повний розбір обмежень цілісності в EF Core.
Seed Data — SQL-скрипти, Bogus та Стратегії (Частина 2)
Seeding через migrationBuilder.Sql(), завантаження seed з CSV і JSON файлів, використання Bogus для генерації fake-даних, EnsureCreated для тестів, ExecuteSqlRaw для bulk insert та повна матриця вибору стратегії.