10.3. Моделювання сутностей (Entity Configuration)
10.3. Моделювання сутностей (Entity Configuration)
Вступ: Як EF Core «розуміє» ваші класи
У попередніх статтях ми створювали прості класи — Book, Author — і EF Core автоматично маппив їх на таблиці. Але як він визначав, що Id — це Primary Key? Що string → NVARCHAR(MAX)? І що AuthorId — це Foreign Key?
EF Core використовує систему конфігурації з трьох рівнів:
- Конвенції (Conventions) — автоматичні правила, що працюють «за замовчуванням»
- Data Annotations (атрибути) — конфігурація через
[Attributes]на класах та властивостях - Fluent API — конфігурація в
OnModelCreating()через ланцюжки методів
Кожен наступний рівень перевизначає попередній: Fluent API > Data Annotations > Conventions.
Конвенції (Convention over Configuration)
EF Core аналізує ваші C#-класи та застосовує набір конвенцій для побудови моделі бази даних. Це «магія», яка працює без жодної додаткової конфігурації.
Основні конвенції
| C#-конструкція | Конвенція EF Core | Результат у БД |
|---|---|---|
Клас Book | → Таблиця | Books (множина) |
int Id або int BookId | → Primary Key + IDENTITY | Id INT IDENTITY(1,1) PK |
Guid Id | → Primary Key + автогенерація | Id UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID() |
string Property | → NVARCHAR(MAX) NOT NULL | NRT: nullable string? → NVARCHAR(MAX) NULL |
int Property | → INT NOT NULL | INT NOT NULL |
int? Property | → INT NULL | INT NULL |
bool Property | → BIT NOT NULL | BIT NOT NULL |
decimal Property | → DECIMAL(18,2) | DECIMAL(18,2) NOT NULL |
DateTime Property | → DATETIME2(7) | DATETIME2(7) NOT NULL |
enum Property | → INT | Зберігає числове значення |
int AuthorId + Author Author | → Foreign Key | AuthorId INT FK → Authors(Id) |
Як працює виявлення Primary Key
EF Core шукає Primary Key у такому порядку:
- Властивість з ім'ям
Id - Властивість з ім'ям
{TypeName}Id(наприклад,BookIdдля класуBook) - Тип повинен бути числовим (
int,long,Guid) для автогенерації
// Конвенція 1: Властивість "Id"
public class Book
{
public int Id { get; set; } // ← PK, IDENTITY(1,1)
}
// Конвенція 2: Властивість "{Type}Id"
public class Author
{
public int AuthorId { get; set; } // ← PK, IDENTITY(1,1)
}
// Конвенція 3: Guid → автоматичний GUID
public class Order
{
public Guid Id { get; set; } // ← PK, NEWSEQUENTIALID()
}
Nullable Reference Types (NRT) та конвенції
З увімкненими NRT (за замовчуванням у .NET 8+), EF Core розрізняє nullable та non-nullable:
public class Book
{
public string Title { get; set; } = ""; // NOT NULL (non-nullable string)
public string? Subtitle { get; set; } // NULL (nullable string?)
public int Year { get; set; } // NOT NULL (value type)
public int? PageCount { get; set; } // NULL (nullable value type)
}
Data Annotations (Атрибути)
Коли конвенцій недостатньо, використовуйте атрибути з System.ComponentModel.DataAnnotations та System.ComponentModel.DataAnnotations.Schema:
Основні атрибути
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
[Table("Books")] // Явне ім'я таблиці (якщо відрізняється від конвенції)
public class Book
{
[Key] // Primary Key (якщо назва не Id/BookId)
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required] // NOT NULL (для nullable типів)
[MaxLength(200)] // NVARCHAR(200) замість NVARCHAR(MAX)
public string Title { get; set; } = "";
[Required]
[MaxLength(200)]
public string Author { get; set; } = "";
[Range(1450, 2100)] // Валідація (не впливає на SQL)
public int Year { get; set; }
[Required]
[MaxLength(20)]
[Column("ISBN")] // Явне ім'я стовпця
public string Isbn { get; set; } = "";
[Column(TypeName = "decimal(10,2)")] // Точний SQL-тип
public decimal Price { get; set; }
public bool IsAvailable { get; set; } = true;
[NotMapped] // НЕ маппити на стовпець
public string DisplayTitle => $"{Title} ({Year})";
[Timestamp] // Для оптимістичної конкурентності
public byte[] RowVersion { get; set; } = null!;
}
Розбір атрибутів:
- Рядок 4:
[Table("Books")]— явно задає ім'я таблиці. Корисно, якщо конвенція дає небажаний результат. - Рядки 12-13:
[Required]+[MaxLength(200)]—NVARCHAR(200) NOT NULL. БезMaxLengthбуло бNVARCHAR(MAX). - Рядок 20:
[Range]— валідація на рівні додатку, не створює SQL constraint. - Рядок 25:
[Column("ISBN")]— стовпець у БД називаєтьсяISBN, а неIsbn. - Рядок 28:
[Column(TypeName = "decimal(10,2)")]— точний SQL-тип замість дефолтногоDECIMAL(18,2). - Рядок 32:
[NotMapped]— обчислювана властивість, яка не має стовпця в БД. - Рядок 34:
[Timestamp]— для оптимістичної конкурентності (розглянемо в статті про CRUD).
Складові ключі та індекси
// Складовий Primary Key (Data Annotations не підтримують — лише Fluent API)
// Для індексів використовуйте атрибут [Index]:
[Index(nameof(Isbn), IsUnique = true)] // UNIQUE INDEX
[Index(nameof(Author), nameof(Year))] // Складений індекс
public class Book
{
public int Id { get; set; }
public string Author { get; set; } = "";
public int Year { get; set; }
public string Isbn { get; set; } = "";
}
Fluent API: Повний контроль
Fluent API — найпотужніший спосіб конфігурації. Все, що можна зробити через атрибути, можна зробити через Fluent API, але не навпаки. Деякі функції (складові ключі, TPH discriminator, query filters) доступні тільки через Fluent API.
Конфігурація в OnModelCreating
public class LibraryContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(entity =>
{
// Таблиця
entity.ToTable("Books");
// Primary Key
entity.HasKey(b => b.Id);
// Властивості
entity.Property(b => b.Title)
.IsRequired()
.HasMaxLength(200);
entity.Property(b => b.Author)
.IsRequired()
.HasMaxLength(200);
entity.Property(b => b.Isbn)
.IsRequired()
.HasMaxLength(20)
.HasColumnName("ISBN");
entity.Property(b => b.Price)
.HasColumnType("decimal(10,2)")
.HasDefaultValue(0m);
entity.Property(b => b.IsAvailable)
.HasDefaultValue(true);
// Індекси
entity.HasIndex(b => b.Isbn)
.IsUnique();
entity.HasIndex(b => new { b.Author, b.Year });
// Ігнорувати обчислювану властивість
entity.Ignore(b => b.DisplayTitle);
});
}
}
Розбір коду:
- Рядок 7:
modelBuilder.Entity<Book>(entity => { ... })— конфігурація для типуBook. - Рядки 16-18:
Property.IsRequired().HasMaxLength(200)— ланцюжок викликів (fluent interface). Кожен метод повертає builder для наступного виклику. - Рядки 30-31:
HasColumnType+HasDefaultValue— точний SQL-тип та дефолтне значення. - Рядки 38-39:
HasIndex.IsUnique()— створює UNIQUE INDEX. - Рядок 41:
HasIndex(b => new { b.Author, b.Year })— складений індекс на два стовпці.
IEntityTypeConfiguration — виніс конфігурації
Коли OnModelCreating стає занадто великим, виносіть конфігурацію кожної сутності в окремий клас:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.ToTable("Books");
builder.HasKey(b => b.Id);
builder.Property(b => b.Title)
.IsRequired()
.HasMaxLength(200);
builder.Property(b => b.Author)
.IsRequired()
.HasMaxLength(200);
builder.Property(b => b.Isbn)
.IsRequired()
.HasMaxLength(20)
.HasColumnName("ISBN");
builder.HasIndex(b => b.Isbn)
.IsUnique();
}
}
public class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
public void Configure(EntityTypeBuilder<Author> builder)
{
builder.ToTable("Authors");
builder.HasKey(a => a.Id);
builder.Property(a => a.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(a => a.Country)
.HasMaxLength(100);
}
}
Реєстрація в OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Варіант 1: Кожну окремо
modelBuilder.ApplyConfiguration(new BookConfiguration());
modelBuilder.ApplyConfiguration(new AuthorConfiguration());
// Варіант 2: Усі з поточної збірки (рекомендовано)
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryContext).Assembly);
}
ApplyConfigurationsFromAssembly — найзручніший підхід. EF Core автоматично знайде всі класи, що реалізують IEntityTypeConfiguration<T>, та застосує їх. Додаєте нову сутність — додаєте лише конфігураційний клас, і він підхоплюється автоматично.Value Conversions (Перетворення значень)
Value Conversion дозволяє перетворювати значення при збереженні та читанні з бази. Найтиповіший приклад — зберігання enum як рядка:
Enum → String
public enum BookStatus
{
Available,
Borrowed,
Reserved,
Lost
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public BookStatus Status { get; set; } // За замовчуванням зберігає як INT
}
Без конфігурації BookStatus.Available зберігається як 0 (число). З конфігурацією:
builder.Property(b => b.Status)
.HasConversion<string>() // Зберігає як 'Available', 'Borrowed' тощо
.HasMaxLength(20);
Тепер у базі буде NVARCHAR(20) зі значеннями 'Available', 'Borrowed' замість 0, 1.
Custom Value Conversion
// DateTime → завжди зберігати в UTC
builder.Property(b => b.CreatedAt)
.HasConversion(
v => v.ToUniversalTime(), // C# → SQL
v => DateTime.SpecifyKind(v, DateTimeKind.Utc) // SQL → C#
);
// List<string> → JSON (зберігання колекції в одному стовпці)
builder.Property(b => b.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new()
);
Owned Types (Value Objects)
Owned Types дозволяють маппити Value Objects на стовпці тієї ж таблиці (або окремої):
// Value Object — не має власного Id
public class Address
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public string Country { get; set; } = "";
}
public class Publisher
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Address MainAddress { get; set; } = new(); // Owned Type
}
Конфігурація:
builder.OwnsOne(p => p.MainAddress, address =>
{
address.Property(a => a.Street).HasMaxLength(200).HasColumnName("Address_Street");
address.Property(a => a.City).HasMaxLength(100).HasColumnName("Address_City");
address.Property(a => a.PostalCode).HasMaxLength(10).HasColumnName("Address_PostalCode");
address.Property(a => a.Country).HasMaxLength(100).HasColumnName("Address_Country");
});
Результат у БД — одна таблиця Publishers:
| Id | Name | Address_Street | Address_City | Address_PostalCode | Address_Country |
|----|----------|----------------|--------------|--------------------|-----------------|
| 1 | O'Reilly | 1005 Gravenstein | Sebastopol | 95472 | USA |
Shadow Properties
Shadow Properties — властивості, які існують тільки в моделі EF Core (у C#-класі їх немає):
// У C#-класі немає LastUpdated
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
}
// Але EF Core знає про нього
builder.Property<DateTime>("LastUpdated");
Читання та запис через Change Tracker:
// Запис
context.Entry(book).Property("LastUpdated").CurrentValue = DateTime.UtcNow;
// Читання
var lastUpdated = context.Entry(book).Property<DateTime>("LastUpdated").CurrentValue;
// Використання в LINQ
var recent = context.Books
.Where(b => EF.Property<DateTime>(b, "LastUpdated") > DateTime.UtcNow.AddDays(-7))
.ToList();
Автоматичне оновлення через SaveChanges
Практичний приклад — автоматичний LastUpdated при кожному збереженні:
public class LibraryContext : DbContext
{
public override int SaveChanges()
{
foreach (var entry in ChangeTracker.Entries<Book>())
{
if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
{
entry.Property("LastUpdated").CurrentValue = DateTime.UtcNow;
}
}
return base.SaveChanges();
}
}
Порівняння підходів
| Можливість | Conventions | Data Annotations | Fluent API |
|---|---|---|---|
| Primary Key | ✅ (за ім'ям) | ✅ [Key] | ✅ HasKey() |
| Складовий PK | ❌ | ❌ | ✅ |
| Required | ✅ (NRT) | ✅ [Required] | ✅ IsRequired() |
| MaxLength | ❌ | ✅ [MaxLength] | ✅ HasMaxLength() |
| Ім'я стовпця | ✅ (за властивістю) | ✅ [Column] | ✅ HasColumnName() |
| Ім'я таблиці | ✅ (множина класу) | ✅ [Table] | ✅ ToTable() |
| Індекси | ❌ | ✅ [Index] | ✅ HasIndex() |
| Value Conversion | ❌ | ❌ | ✅ HasConversion() |
| Owned Types | ❌ | ❌ | ✅ OwnsOne() |
| Shadow Properties | ❌ | ❌ | ✅ Property<T>() |
| Query Filters | ❌ | ❌ | ✅ HasQueryFilter() |
Практичні завдання
Рівень 1: Базовий
Завдання 1.1: Конфігурація через атрибути
Створіть клас Product з атрибутами:
Id— Primary Key.Name— обов'язкове, макс. 150 символів.Description— необов'язкове, макс. 2000 символів.Price—decimal(10,2), дефолт 0.Sku— обов'язкове, унікальний індекс.DisplayInfo—[NotMapped], повертає$"{Name} - {Price:C}".
Завдання 1.2: Конфігурація через Fluent API
Перенесіть конфігурацію Product у ProductConfiguration : IEntityTypeConfiguration<Product>. Видаліть усі атрибути з класу.
Рівень 2: Складніше
Завдання 2.1: Value Conversion
- Створіть
enum ProductCategory { Electronics, Books, Clothing, Food }. - Збережіть як рядок через
HasConversion<string>(). - Додайте
List<string> Tagsі збережіть як JSON через Value Conversion.
Завдання 2.2: Owned Type
- Створіть
Money(Amount, Currency) як Value Object. - Зробіть
Product.PriceтипуMoney. - Налаштуйте
OwnsOneз кастомними іменами стовпців.
Рівень 3: Архітектура
Завдання 3.1: Shadow Properties
- Додайте
CreatedAtтаUpdatedAtяк Shadow Properties. - Автоматично оновлюйте їх у перевизначеному
SaveChanges(). - Виведіть
CreatedAtчерезEF.Property<DateTime>()у LINQ.
Завдання 3.2: Повна модель
Створіть модель інтернет-магазину з окремими конфігураціями:
Product(Name, Price, Category, Sku).Customer(Name, Email, Address як Owned Type).Order(OrderDate, Status як enum→string, TotalAmount).- Зареєструйте через
ApplyConfigurationsFromAssembly.
Резюме
Conventions
Data Annotations
Fluent API
IEntityTypeConfiguration