Властивості — Типи, Конвертери, Компаратори (Частина 1)
Властивості: Типи, Конвертери, Компаратори
Чому конфігурація властивостей — це не дрібниці
Уявіть ситуацію: ваш додаток вже у продакшені, обробляє замовлення, і раптом бухгалтер помічає, що сума замовлення 1234.5678 зберігається у базі як 1234.57. Два центи втрачено через округлення. Хто винен? Не PostgreSQL, не SQL Server — вони зберегли рівно те, що їм сказали. Винний маппінг типу decimal без явного HasPrecision. EF Core за замовчуванням обрав decimal(18, 2), і цього було недостатньо для фінансових розрахунків.
Або інший сценарій: ваші enum-значення зберігаються у базі як цілі числа 0, 1, 2. Хтось вручну виправляє дані у базі, вводить 5 — а такого статусу не існує. Або ви перейменовуєте enum-член зі Shipped на InTransit і переставляєте порядок елементів. Тепер всі дані у БД означають не те, що ви думаєте.
Або третій: ваш UserId — це просто int, і компілятор не заперечує, коли ви передаєте OrderId там, де очікується UserId. Strongly Typed IDs вирішують цю проблему, але вимагають конвертера.
Всі ці сценарії — про конфігурацію властивостей у EF Core: як C#-типи маппляться на SQL-типи, і як ми можемо перехопити цей маппінг для вирішення реальних задач. Ця стаття розкриває весь арсенал інструментів, що є у вашому розпорядженні.
Маппінг C# типів на SQL типи: що відбувається без вашого втручання
EF Core виконує type mapping — автоматичне перетворення між типами C# і SQL-типами конкретної СУБД. Кожен провайдер (SQL Server, PostgreSQL, SQLite тощо) має власну таблицю відповідностей. Розуміння цих відповідностей рятує від несподіванок.
Стандартні відповідності для SQL Server
| C# тип | SQL Server тип за замовчуванням | Примітка |
|---|---|---|
int | int | 4 байти, -2B..+2B |
long | bigint | 8 байт |
short | smallint | 2 байти |
byte | tinyint | 0..255 |
bool | bit | 0 або 1 |
string | nvarchar(max) | Unicode, без ліміту |
char | nvarchar(1) | один символ |
decimal | decimal(18, 2) | ⚠️ Округлення! |
double | float | IEEE 754 |
float | real | IEEE 754, менша точність |
DateTime | datetime2 | до 100 нс точності |
DateTimeOffset | datetimeoffset | з часовим поясом |
DateOnly | date | EF Core 6+ |
TimeOnly | time | EF Core 6+ |
Guid | uniqueidentifier | 16 байт |
byte[] | varbinary(max) | бінарні дані |
enum | int | числове значення |
Стандартні відповідності для PostgreSQL
| C# тип | PostgreSQL тип | Примітка |
|---|---|---|
int | integer | |
long | bigint | |
string | text | немає максимуму за замовчуванням |
decimal | numeric | ⚠️ Без precision за замовчуванням |
bool | boolean | |
DateTime | timestamp with time zone | Npgsql конвертує у UTC |
Guid | uuid | native тип |
byte[] | bytea |
DateTime на timestamp with time zone і вимагає, щоб всі DateTime значення були в UTC. Якщо передати DateTime.Now (local time) — отримаєте виключення. Завжди використовуйте DateTime.UtcNow або DateTimeOffset.Чому string без обмеження — це проблема
Коли ви маєте public string Name { get; set; } без конфігурації, EF Core для SQL Server генерує nvarchar(max). Це проблема з точки зору:
- Продуктивності: Стовпці
nvarchar(max)не можуть бути повністю охоплені індексом (SQL Server обмежує розмір ключа індексу). Пошук поNameбез індексу — повне сканування таблиці. - Валідації: База не відхиляє рядки завдовжки 10000 символів, навіть якщо
Nameмало б бути максимум 100 символів. - Схеми:
nvarchar(max)семантично означає «рядок довільної довжини», а не «ім'я людини».
HasColumnType: явне керування SQL-типом
HasColumnType() — найпряміший інструмент: ви вказуєте рядково точний SQL-тип, що відображатиметься у DDL.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public decimal Weight { get; set; }
public byte[] Thumbnail { get; set; } = Array.Empty<byte>();
public string Tags { get; set; } = string.Empty; // JSON-рядок
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Description)
.HasColumnType("nvarchar(4000)");
// Явний тип для фінансового значення
builder.Property(p => p.Price)
.HasColumnType("decimal(10, 4)");
// Вага: менша точність достатня
builder.Property(p => p.Weight)
.HasColumnType("decimal(8, 3)");
// Фіксований розмір для мініатюри
builder.Property(p => p.Thumbnail)
.HasColumnType("varbinary(8000)");
// PostgreSQL: явно jsonb замість text
// builder.Property(p => p.Tags).HasColumnType("jsonb");
}
}
Декомпозиція конфігурації:
HasColumnType("decimal(10, 4)")— генеруєDECIMAL(10, 4)у DDL. Число10— загальна кількість цифр (precision),4— після коми (scale). Це означає максимальне значення999999.9999.HasColumnType("nvarchar(4000)")— обмежує рядок 4000 символами Unicode. На відміну відHasMaxLength(4000), який лише додає валідацію на рівні анотацій,HasColumnTypeзадає точний SQL-тип.HasColumnType("varbinary(8000)")— бінарний стовпець фіксованого максимального розміру.varbinary(max)зберігає дані поза основним рядком (LOB),varbinary(8000)— у рядку таблиці, що швидше читається.
HasColumnType() та HasMaxLength() / HasPrecision() — не взаємовиключні, але якщо вказати HasColumnType, він перевизначає будь-яке HasMaxLength. Використовуйте HasColumnType коли вам потрібна рядкова специфікація, HasPrecision — коли хочете залишити вибір SQL-типу провайдеру.HasPrecision: точний контроль числових типів
HasPrecision(precision, scale) — більш семантичний спосіб керування decimal-типами. Замість рядкового "decimal(18, 6)" ви передаєте два числа, а провайдер сам генерує правильний SQL-тип.
public class FinancialTransaction
{
public int Id { get; set; }
public decimal Amount { get; set; } // Сума транзакції
public decimal ExchangeRate { get; set; } // Курс обміну
public decimal Fee { get; set; } // Комісія
public decimal TaxAmount { get; set; } // Податок
}
public class FinancialTransactionConfiguration : IEntityTypeConfiguration<FinancialTransaction>
{
public void Configure(EntityTypeBuilder<FinancialTransaction> builder)
{
// Сума: 12 цифр всього, 4 після коми — підходить для сум до 99,999,999.9999
builder.Property(t => t.Amount)
.HasPrecision(14, 4)
.IsRequired();
// Курс обміну: потрібна висока точність після коми
builder.Property(t => t.ExchangeRate)
.HasPrecision(18, 8);
// Комісія: менші суми, менша точність
builder.Property(t => t.Fee)
.HasPrecision(10, 2);
// Податок: аналогічно до суми
builder.Property(t => t.TaxAmount)
.HasPrecision(14, 4);
}
}
Генерований DDL (SQL Server):
CREATE TABLE [FinancialTransactions] (
[Id] INT NOT NULL IDENTITY,
[Amount] DECIMAL(14, 4) NOT NULL,
[ExchangeRate] DECIMAL(18, 8) NOT NULL,
[Fee] DECIMAL(10, 2) NOT NULL,
[TaxAmount] DECIMAL(14, 4) NOT NULL,
CONSTRAINT [PK_FinancialTransactions] PRIMARY KEY ([Id])
);
Розуміння precision і scale критично для коректного зберігання фінансових даних:
- precision — максимальна кількість значущих цифр (до і після коми разом)
- scale — кількість цифр після коми
Тобто decimal(14, 4) може зберегти максимум 9999999999.9999 — десять цифр до коми і чотири після. Якщо спробувати зберегти 99999999999.9999 (одинадцять цифр до коми) — база поверне помилку переповнення.
decimal(19, 4) або навіть decimal(19, 6) — це відповідає стандарту ISO 4217 і покриває більшість реальних фінансових сценаріїв. Ніколи не використовуйте double або float для грошей — вони мають помилки округлення через бінарне представлення.Value Converters: перетворення між C# та SQL
Value Converter (конвертер значень) — це пара функцій: одна перетворює C#-значення у значення для збереження у БД, друга — значення з БД назад у C#. EF Core викликає їх автоматично при читанні та запису.
Це потужніший інструмент, ніж HasColumnType: конвертер не просто каже «який SQL-тип використати», він трансформує саме значення.
Анатомія Value Converter
// Повна форма: явний ValueConverter<TModel, TProvider>
// TModel — тип у C# (ваш клас або enum)
// TProvider — тип у БД (string, int, byte[], тощо)
var converter = new ValueConverter<OrderStatus, string>(
// convertToProviderExpression: C# → БД
v => v.ToString(),
// convertFromProviderExpression: БД → C#
v => Enum.Parse<OrderStatus>(v)
);
Ця пара лямбд — серце будь-якого конвертера. Перша викликається при SaveChanges(), друга — при матеріалізації запиту.
HasConversion: вбудований синтаксис
Fluent API надає HasConversion<T>() і HasConversion(converter) — зручний спосіб налаштувати конвертер без явного створення об'єкту ValueConverter.
Enum → string: людиночитаємі значення у БД
Зберігання enum як рядка — одне з найпоширеніших застосувань конвертерів. Порівняйте:
-- Без конвертера: числа незрозумілі
SELECT * FROM Orders WHERE Status = 2; -- 2 — це що?
-- З конвертером на string:
SELECT * FROM Orders WHERE Status = 'Shipped'; -- зрозуміло!
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled,
Refunded
}
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
// HasConversion<string>: enum → рядок, рядок → enum
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
builder.Property(o => o.CustomerName)
.IsRequired()
.HasMaxLength(200);
builder.Property(o => o.TotalAmount)
.HasPrecision(14, 2);
}
}
Генерований SQL для INSERT:
INSERT INTO [Orders] ([CustomerName], [Status], [TotalAmount])
VALUES ('John Doe', 'Shipped', 150.00);
Тепер при перейменуванні члену enum (наприклад, Shipped → InTransit) — потрібна міграція даних, але принаймні дані залишаються людиночитаємими і не ламаються від зміни порядку членів. Числові enum-значення ламаються при будь-яких структурних змінах enum.
Явний ValueConverter для enum
Якщо вам потрібен контроль над тим, яке значення зберігається (наприклад, скорочений рядок "pnd" замість "Pending"):
public class OrderStatusConverter : ValueConverter<OrderStatus, string>
{
private static readonly Dictionary<OrderStatus, string> _toDb = new()
{
[OrderStatus.Pending] = "pnd",
[OrderStatus.Processing] = "prc",
[OrderStatus.Shipped] = "shp",
[OrderStatus.Delivered] = "dlv",
[OrderStatus.Cancelled] = "cnl",
[OrderStatus.Refunded] = "rfd"
};
private static readonly Dictionary<string, OrderStatus> _fromDb =
_toDb.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
public OrderStatusConverter() : base(
v => _toDb[v],
v => _fromDb[v])
{ }
}
// Використання:
builder.Property(o => o.Status)
.HasConversion(new OrderStatusConverter())
.HasMaxLength(3)
.IsUnicode(false); // ASCII достатньо для кодів
Цей підхід дає стабільні значення у БД: перейменування enum-члену не змінює збережене значення "shp". Ціна — потрібно підтримувати словники синхронізованими.
Strongly Typed IDs: безпека типів через конвертери
Strongly Typed ID — патерн, що замінює примітивний ключ (int, Guid) на окремий тип-обгортку. Це дозволяє компілятору відловлювати помилки на кшталт «передав OrderId там, де очікувався CustomerId».
Проблема примітивних ID
// Без Strongly Typed IDs: компілятор не помітить помилку
public async Task<Order?> GetOrdersByCustomer(int orderId, int customerId)
{
// ...
}
// Виклик з переставленими аргументами — компілятор мовчить!
var order = await GetOrdersByCustomer(customerId, orderId);
Реалізація з consts struct
// Strongly Typed IDs через readonly struct
public readonly struct CustomerId : IEquatable<CustomerId>
{
public int Value { get; }
public CustomerId(int value)
{
if (value <= 0) throw new ArgumentException("CustomerId must be positive", nameof(value));
Value = value;
}
public bool Equals(CustomerId other) => Value == other.Value;
public override bool Equals(object? obj) => obj is CustomerId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => $"Customer#{Value}";
public static implicit operator int(CustomerId id) => id.Value;
public static explicit operator CustomerId(int value) => new(value);
}
public readonly struct OrderId : IEquatable<OrderId>
{
public int Value { get; }
public OrderId(int value) => Value = value;
public bool Equals(OrderId other) => Value == other.Value;
public override bool Equals(object? obj) => obj is OrderId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public static implicit operator int(OrderId id) => id.Value;
public static explicit operator OrderId(int value) => new(value);
}
public class Customer
{
public CustomerId Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public OrderId Id { get; set; }
public CustomerId CustomerId { get; set; } // FK через Strongly Typed ID
public Customer Customer { get; set; } = null!;
public decimal TotalAmount { get; set; }
}
Конфігурація конвертерів:
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
// Конвертер: CustomerId ↔ int
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.HasConversion(
id => id.Value, // CustomerId → int (у БД)
value => new CustomerId(value) // int → CustomerId (з БД)
);
builder.Property(c => c.Name)
.IsRequired()
.HasMaxLength(200);
}
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(
id => id.Value,
value => new OrderId(value)
);
// FK теж потребує конвертера
builder.Property(o => o.CustomerId)
.HasConversion(
id => id.Value,
value => new CustomerId(value)
);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId);
builder.Property(o => o.TotalAmount)
.HasPrecision(14, 2);
}
}
Тепер компілятор захищає від переплутування ID:
// Без Strongly Typed IDs:
void Process(int orderId, int customerId) { ... }
Process(customerId, orderId); // ← мовчить! Помилка лише в рантаймі
// Зі Strongly Typed IDs:
void Process(OrderId orderId, CustomerId customerId) { ... }
Process(customerId, orderId); // ← помилка компіляції! CustomerId ≠ OrderId
Value Converters для серіалізації: JSON у рядковому стовпці
До появи нативних JSON Columns (EF Core 7+) стандартним підходом було зберігання складних об'єктів як JSON-рядків через конвертер. Навіть зараз цей підхід корисний для simple values і конфігурацій.
public class UserPreferences
{
public string Theme { get; set; } = "light";
public string Language { get; set; } = "uk";
public bool EmailNotifications { get; set; } = true;
public List<string> FavoriteCategories { get; set; } = new();
public Dictionary<string, int> CustomSettings { get; set; } = new();
}
public class User
{
public int Id { get; set; }
public string Email { get; set; } = string.Empty;
// Складний об'єкт → зберігається як JSON у single column
public UserPreferences Preferences { get; set; } = new();
}
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Email)
.IsRequired()
.HasMaxLength(320);
// Конвертер: UserPreferences ↔ JSON string
builder.Property(u => u.Preferences)
.HasConversion(
prefs => JsonSerializer.Serialize(prefs, JsonOptions),
json => JsonSerializer.Deserialize<UserPreferences>(json, JsonOptions)
?? new UserPreferences()
)
.HasColumnType("nvarchar(max)");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
UserPreferences, якщо він зберігається як JSON-рядок. Якщо ви зміните user.Preferences.Theme = "dark" — EF Core не побачить цієї зміни. Потрібен Value Comparer (розглядаємо у другій частині) або явне позначення стану через context.Entry(user).Property(u => u.Preferences).IsModified = true. Або використовуйте нативні JSON Columns (стаття 11).Value Converter для шифрування: захист конфіденційних даних
Конвертери чудово підходять для прозорого шифрування та дешифрування чутливих полів — персональних даних, токенів, PII (Personally Identifiable Information).
public class EncryptedStringConverter : ValueConverter<string, string>
{
public EncryptedStringConverter(string encryptionKey) : base(
v => Encrypt(v, encryptionKey),
v => Decrypt(v, encryptionKey))
{ }
private static string Encrypt(string plainText, string key)
{
using var aes = Aes.Create();
aes.Key = Convert.FromHexString(key);
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// Зберігаємо IV разом із шифротекстом: IV (16 bytes) + ciphertext
var result = new byte[aes.IV.Length + cipherBytes.Length];
aes.IV.CopyTo(result, 0);
cipherBytes.CopyTo(result, aes.IV.Length);
return Convert.ToBase64String(result);
}
private static string Decrypt(string cipherBase64, string key)
{
var fullCipher = Convert.FromBase64String(cipherBase64);
using var aes = Aes.Create();
aes.Key = Convert.FromHexString(key);
// Перші 16 байт — IV
var iv = fullCipher[..16];
var cipherBytes = fullCipher[16..];
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
}
}
public class PatientRecord
{
public int Id { get; set; }
public string PatientName { get; set; } = string.Empty; // Шифруємо
public string SocialSecurityNumber { get; set; } = string.Empty; // Шифруємо
public string Diagnosis { get; set; } = string.Empty; // Шифруємо
public DateTime BirthDate { get; set; }
}
public class PatientRecordConfiguration : IEntityTypeConfiguration<PatientRecord>
{
private readonly string _encryptionKey;
public PatientRecordConfiguration(string encryptionKey)
{
_encryptionKey = encryptionKey;
}
public void Configure(EntityTypeBuilder<PatientRecord> builder)
{
var converter = new EncryptedStringConverter(_encryptionKey);
builder.Property(p => p.PatientName)
.HasConversion(converter)
.HasMaxLength(500); // Шифрований текст довший за початковий
builder.Property(p => p.SocialSecurityNumber)
.HasConversion(converter)
.HasMaxLength(500);
builder.Property(p => p.Diagnosis)
.HasConversion(converter)
.HasColumnType("nvarchar(max)");
}
}
Вбудовані конвертери EF Core
EF Core постачає набір вбудованих конвертерів у просторі імен Microsoft.EntityFrameworkCore.Storage.ValueConversion. Їх не потрібно писати самостійно — просто вказати:
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
// EnumToStringConverter<TEnum>: enum → string і назад
builder.Property(e => e.Status)
.HasConversion(new EnumToStringConverter<OrderStatus>());
// BoolToZeroOneConverter<TProvider>: bool → 0/1 як числовий тип
builder.Property(e => e.IsActive)
.HasConversion(new BoolToZeroOneConverter<int>());
// BoolToStringConverter: bool → кастомні рядки ("Y"/"N", "true"/"false")
builder.Property(e => e.IsVerified)
.HasConversion(new BoolToStringConverter("N", "Y"))
.HasMaxLength(1)
.IsUnicode(false);
// DateTimeKindValueConverter: додає Kind до DateTime при читанні
// Корисно для провайдерів, що не зберігають Kind
builder.Property(e => e.CreatedAt)
.HasConversion(new DateTimeKindValueConverter(DateTimeKind.Utc));
// TimeSpanToTicksConverter: TimeSpan → long (ticks)
builder.Property(e => e.Duration)
.HasConversion(new TimeSpanToTicksConverter());
// TimeSpanToStringConverter: TimeSpan → "hh:mm:ss" рядок
builder.Property(e => e.WorkingHours)
.HasConversion(new TimeSpanToStringConverter());
Огляд всіх вбудованих конвертерів
EnumToStringConverter<T>
HasMaxLength.EnumToNumberConverter<TEnum, TNumber>
BoolToZeroOneConverter<T>
bool → 0/1 у вказаному числовому типі T. Зворотній: 0 → false, інше → true.BoolToStringConverter
bool → кастомні рядки. Конструктор приймає falseValue та trueValue.DateTimeKindValueConverter
DateTimeKind при читанні з БД. Корисно для SQLite, що не зберігає Kind.TimeSpanToTicksConverter
TimeSpan → long (число тіків). Компактне, точне. Не людиночитаємо у БД.TimeSpanToStringConverter
TimeSpan → рядок формату "hh:mm:ss.fffffff". Людиночитаємо, але займає більше місця.NumberToBytesConverter<T>
Глобальні конвертери: один раз для всіх
Якщо є тип (наприклад, enum або Strongly Typed ID), що зустрічається у багатьох сутностях, реєструвати конвертер у кожній конфігурації — це повторення. EF Core підтримує глобальні конвертери через HasConversion у OnModelCreating разом з modelBuilder.Properties<T>():
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Застосовуємо конвертер до ВСІХ властивостей типу OrderStatus у ВСІХ сутностях
modelBuilder.Properties<OrderStatus>()
.HaveConversion<string>()
.HaveMaxLength(50);
// Застосовуємо до всіх DateTime: додаємо Kind = Utc
modelBuilder.Properties<DateTime>()
.HaveConversion(new DateTimeKindValueConverter(DateTimeKind.Utc));
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
modelBuilder.Properties<CustomerId>().HaveConversion(...).Практичні завдання (Частина 1)
Рівень 1 — Базовий
Завдання 1.1: Виправлення типів
Є клас BlogPost з такими властивостями:
public class BlogPost
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public decimal ViewRating { get; set; }
public DateTime PublishedAt { get; set; }
public PostStatus Status { get; set; }
}
public enum PostStatus { Draft, Published, Archived }
За допомогою Fluent API налаштуйте:
Title— максимум 300 символів, обов'язковеContent—nvarchar(max), необов'язковеViewRating—decimal(5, 2)(від 0.00 до 999.99)Status— зберігати як рядок
Завдання 1.2: Конвертер bool
Є legacy-база з таблицею Users, де IsActive зберігається як CHAR(1): 'Y' або 'N'. Напишіть конфігурацію для entity User, що правильно маппить bool IsActive на цей формат.
Завдання 1.3: TimeSpan у базі
Маєте клас VideoLesson з public TimeSpan Duration. Налаштуйте зберігання у вигляді цілого числа секунд (long). Підказка: TimeSpan ↔ long через TotalSeconds.
Рівень 2 — Логіка
Завдання 2.1: Strongly Typed ID
Для інтернет-магазину реалізуйте Strongly Typed IDs для ProductId і CategoryId (обидва int-базовані). Переконайтесь, що:
- Компілятор не дає присвоїти
CategoryIdдоProductId - EF Core зберігає як звичайні
intу БД - FK у
Product.CategoryIdкоректно маппиться
Завдання 2.2: Кастомний enum-конвертер
Є PaymentMethod enum: CreditCard, DebitCard, BankTransfer, Cryptocurrency. У базі дані зберігаються як скорочені коди: "CC", "DC", "BT", "CR". Реалізуйте конвертер, що підтримує такий маппінг. Що станеться, якщо в БД зустрінеться невідомий код?
Завдання 2.3: Глобальний конвертер для DateTime
У проєкті є 5 різних entity, кожне з полями CreatedAt та UpdatedAt типу DateTime. Налаштуйте глобальний конвертер, що при читанні з БД гарантує DateTimeKind.Utc для всіх DateTime-властивостей.
Рівень 3 — Архітектура
Завдання 3.1: Захист PII
Реалізуйте повну систему захисту персональних даних для PatientRecord:
- Поля
FullName,PhoneNumber,Emailмають зберігатися у зашифрованому вигляді - Ключ шифрування передається через конструктор конфігурації
- Конфігурація реєструється через
ApplyConfigurationsFromAssembly— придумайте, як передати ключ у конфігурацію
Завдання 3.2: JSON-конфігурація
Розробіть клас ApplicationSettings з полями: NotificationEmails (список рядків), FeatureFlags (словник bool), RetryPolicy (об'єкт з MaxRetries та DelayMs). Збережіть весь об'єкт як JSON у один стовпець nvarchar(max). Обговоріть: коли цей підхід кращий за нативні JSON Columns (стаття 11)?
Підсумок частини 1
У цій частині ми розглянули фундаментальні інструменти конфігурації властивостей:
- Стандартний маппінг типів — кожна СУБД має свою таблицю відповідностей.
decimalбезHasPrecision— це завжди ризик. HasColumnType— точний рядковий SQL-тип. ПеревизначаєHasMaxLength.HasPrecision— семантичне керуванняdecimal(p, s). Провайдер сам генерує правильний DDL.- Value Converters — пара лямбд для трансформації значень між C# і SQL. Вирішують задачі: enum → string, Strongly Typed IDs, JSON-серіалізація, шифрування.
- Вбудовані конвертери — EF Core постачає готові реалізації для найпоширеніших сценаріїв.
- Глобальні конвертери —
modelBuilder.Properties<T>()для застосування конвертера до всіх властивостей одного типу.
У другій частині ми розглянемо Value Comparers (чому вони критичні для change tracking), Value Generators, Default Values, Computed Columns, Shadow Properties, Backing Fields та Temporal Tables.
Зв'язки Advanced — Many-to-Many та Складні Сценарії
Глибокий розбір Many-to-Many зв'язків в EF Core — implicit і explicit join entity, skip navigations, alternate keys, composite FK, shadow FK, backing fields, polymorphic associations як антипатерн та правильні альтернативи, складні графи об'єктів.
Властивості — Value Comparers, Generators, Shadow Properties (Частина 2)
Продовження розбору конфігурації властивостей EF Core — Value Comparers для правильного Change Tracking, Value Generators, Default Values, Computed Columns, Shadow Properties, Backing Fields та Temporal Tables.