Ef Core

Властивості — Типи, Конвертери, Компаратори (Частина 1)

Глибокий розбір конфігурації властивостей в EF Core — маппінг C# типів на SQL, HasColumnType, HasPrecision, Value Converters (HasConversion), вбудовані конвертери, strongly typed IDs, шифрування та JSON-серіалізація через конвертери.

Властивості: Типи, Конвертери, Компаратори

Чому конфігурація властивостей — це не дрібниці

Уявіть ситуацію: ваш додаток вже у продакшені, обробляє замовлення, і раптом бухгалтер помічає, що сума замовлення 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 тип за замовчуваннямПримітка
intint4 байти, -2B..+2B
longbigint8 байт
shortsmallint2 байти
bytetinyint0..255
boolbit0 або 1
stringnvarchar(max)Unicode, без ліміту
charnvarchar(1)один символ
decimaldecimal(18, 2)⚠️ Округлення!
doublefloatIEEE 754
floatrealIEEE 754, менша точність
DateTimedatetime2до 100 нс точності
DateTimeOffsetdatetimeoffsetз часовим поясом
DateOnlydateEF Core 6+
TimeOnlytimeEF Core 6+
Guiduniqueidentifier16 байт
byte[]varbinary(max)бінарні дані
enumintчислове значення

Стандартні відповідності для PostgreSQL

C# типPostgreSQL типПримітка
intinteger
longbigint
stringtextнемає максимуму за замовчуванням
decimalnumeric⚠️ Без precision за замовчуванням
boolboolean
DateTimetimestamp with time zoneNpgsql конвертує у UTC
Guiduuidnative тип
byte[]bytea
PostgreSQL Npgsql провайдер за замовчуванням маппить 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 (наприклад, ShippedInTransit) — потрібна міграція даних, але принаймні дані залишаються людиночитаємими і не ламаються від зміни порядку членів. Числові 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
    };
}
Проблема з Change Tracking: EF Core не може відстежити зміни всередині об'єкту 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)");
    }
}
Продумайте управління ключами: ключ шифрування не повинен зберігатися у коді або appsettings. Використовуйте Azure Key Vault, AWS Secrets Manager або HashiCorp Vault. Втрата ключа = втрата всіх зашифрованих даних без можливості відновлення.

Вбудовані конвертери 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>

Enum → назва члену enum як рядок. Двонапрямлений. Потребує HasMaxLength.

EnumToNumberConverter<TEnum, TNumber>

Enum → числовий тип (int, byte, short). Корисно для зміни розміру числового типу.

BoolToZeroOneConverter<T>

bool0/1 у вказаному числовому типі T. Зворотній: 0false, інше → true.

BoolToStringConverter

bool → кастомні рядки. Конструктор приймає falseValue та trueValue.

DateTimeKindValueConverter

Додає або змінює DateTimeKind при читанні з БД. Корисно для SQLite, що не зберігає Kind.

TimeSpanToTicksConverter

TimeSpanlong (число тіків). Компактне, точне. Не людиночитаємо у БД.

TimeSpanToStringConverter

TimeSpan → рядок формату "hh:mm:ss.fffffff". Людиночитаємо, але займає більше місця.

NumberToBytesConverter<T>

Число → масив байт у little-endian. Рідко використовується.

Глобальні конвертери: один раз для всіх

Якщо є тип (наприклад, 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);
}
Глобальні конвертери особливо корисні з Strongly Typed IDs: у великих проєктах таких типів може бути десятки, і реєструвати конвертер для кожного вручну — забудькувато. Натомість — зареєструйте один раз через 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 символів, обов'язкове
  • Contentnvarchar(max), необов'язкове
  • ViewRatingdecimal(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). Підказка: TimeSpanlong через 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.