Ef Core

Властивості — Value Comparers, Generators, Shadow Properties (Частина 2)

Продовження розбору конфігурації властивостей EF Core — Value Comparers для правильного Change Tracking, Value Generators, Default Values, Computed Columns, Shadow Properties, Backing Fields та Temporal Tables.

Властивості: Value Comparers, Generators, Shadow Properties

Це продовження статті «Властивості: Типи, Конвертери». Рекомендується читати послідовно.


Value Comparers: чому Change Tracker не бачить ваших змін

Розглянемо ситуацію, що регулярно дивує розробників на початку роботи з Value Converters:

var user = await context.Users.FindAsync(userId);

// Змінюємо preferences через конвертований JSON-об'єкт
user!.Preferences.Theme = "dark";
user.Preferences.FavoriteCategories.Add("gaming");

await context.SaveChangesAsync();
// SQL: (порожньо!) — жодного UPDATE не виконано!

Чому? Тому що EF Core не знає, що об'єкт Preferences змінився. Change Tracker порівнює поточне значення з оригінальним значенням, але для посилальних типів (class) == перевіряє посилання, а не вміст. Оскільки user.Preferences — той самий об'єкт у пам'яті (не нова копія), Change Tracker вирішує: «присвоєння не було, значення те саме — нічого не змінилось».

Саме для вирішення цієї проблеми існує Value Comparer (компаратор значень).

Що таке Value Comparer

ValueComparer<T> — клас, що визначає три операції для EF Core:

  1. EqualsExpression: як порівняти два значення типу T (чи змінилось?)
  2. HashCodeExpression: як обчислити хеш (для внутрішніх структур Change Tracker)
  3. SnapshotExpression: як зробити глибоку копію значення (snapshot для відстеження змін)

Без правильного SnapshotExpression EF Core зберігає посилання на оригінальний об'єкт, а не його копію. Коли об'єкт мутується — «оригінал» і «поточне» вказують на один і той самий об'єкт, тому порівняння завжди дає «не змінилось».

Value Comparer для JSON-серіалізованого об'єкта

public class UserPreferencesComparer : ValueComparer<UserPreferences>
{
    public UserPreferencesComparer() : base(
        // EqualsExpression: серіалізуємо обидва і порівнюємо рядки
        (a, b) => JsonSerializer.Serialize(a, JsonOptions) == JsonSerializer.Serialize(b, JsonOptions),
        // HashCodeExpression: хеш серіалізованого рядка
        v => JsonSerializer.Serialize(v, JsonOptions).GetHashCode(),
        // SnapshotExpression: глибока копія через серіалізацію/десеріалізацію
        v => JsonSerializer.Deserialize<UserPreferences>(JsonSerializer.Serialize(v, JsonOptions), JsonOptions)!
    )
    { }

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };
}

Реєстрація компаратора разом з конвертером:

builder.Property(u => u.Preferences)
       .HasConversion(
           prefs => JsonSerializer.Serialize(prefs, JsonOptions),
           json => JsonSerializer.Deserialize<UserPreferences>(json, JsonOptions) ?? new UserPreferences()
       )
       .Metadata.SetValueComparer(new UserPreferencesComparer());

Або через HasConversion з явним компаратором (перевантажений метод):

var converter = new ValueConverter<UserPreferences, string>(
    prefs => JsonSerializer.Serialize(prefs, JsonOptions),
    json => JsonSerializer.Deserialize<UserPreferences>(json, JsonOptions) ?? new UserPreferences()
);

var comparer = new UserPreferencesComparer();

builder.Property(u => u.Preferences)
       .HasConversion(converter, comparer)
       .HasColumnType("nvarchar(max)");

Тепер зміна user.Preferences.Theme = "dark" буде виявлена Change Tracker при DetectChanges():

var user = await context.Users.FindAsync(userId);
// EF Core зберігає snapshot: JsonSerializer.Serialize(preferences)

user!.Preferences.Theme = "dark"; // мутуємо об'єкт

await context.SaveChangesAsync();
// DetectChanges: serialize(current) ≠ serialize(snapshot) → Modified!
// SQL: UPDATE Users SET Preferences = '{"theme":"dark",...}' WHERE Id = ...

Value Comparer для колекцій

Аналогічна проблема існує для List<T> та інших колекцій, що зберігаються через конвертери:

public class TagListComparer : ValueComparer<List<string>>
{
    public TagListComparer() : base(
        // Порівнюємо вміст (не посилання): однакові елементи в тому ж порядку
        (a, b) => (a == null && b == null) ||
                  (a != null && b != null && a.SequenceEqual(b)),
        // Хеш на основі вмісту
        v => v == null ? 0 : v.Aggregate(0, (hash, item) =>
            HashCode.Combine(hash, item.GetHashCode())),
        // Deep copy через новий List:
        v => v == null ? null! : new List<string>(v)
    )
    { }
}
// Конвертер: List<string> ↔ JSON-рядок
builder.Property(p => p.Tags)
       .HasConversion(
           tags => JsonSerializer.Serialize(tags, (JsonSerializerOptions?)null),
           json => JsonSerializer.Deserialize<List<string>>(json, (JsonSerializerOptions?)null) ?? new List<string>()
       )
       .Metadata.SetValueComparer(new TagListComparer());
Правило: Якщо використовуєте HasConversion для посилального типу (class, List, Dictionary, масив) — завжди додавайте Value Comparer з правильним SnapshotExpression. Примітивні типи (string, int, enum) EF Core порівнює коректно без comparer.

Value Generators: автоматична генерація значень

Value Generator (генератор значень) — механізм автоматичного заповнення властивостей перед збереженням. Стандартний приклад — IDENTITY/AUTOINCREMENT для PK. Але Value Generators дозволяють реалізувати власну логіку генерації.

ValueGeneratedOnAdd, ValueGeneratedOnUpdate, ValueGeneratedOnAddOrUpdate

Ці методи не генерують значення самостійно — вони лише інформують EF Core про те, коли значення генерується (базою або генератором). EF Core за замовчуванням очікує значення від БД після INSERT/UPDATE.

public class AuditedEntity
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    // Генерується при INSERT (базою або C#-генератором)
    public DateTime CreatedAt { get; set; }

    // Генерується при UPDATE (базою через DEFAULT або через Interceptor)
    public DateTime UpdatedAt { get; set; }

    // Генерується при INSERT або UPDATE (row version, timestamp)
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
public class AuditedEntityConfiguration : IEntityTypeConfiguration<AuditedEntity>
{
    public void Configure(EntityTypeBuilder<AuditedEntity> builder)
    {
        builder.HasKey(e => e.Id);

        // CreatedAt: значення генерується при INSERT
        // HasDefaultValueSql встановлює SQL DEFAULT — БД генерує
        builder.Property(e => e.CreatedAt)
               .ValueGeneratedOnAdd()
               .HasDefaultValueSql("GETUTCDATE()"); // SQL Server

        // UpdatedAt: значення генерується при UPDATE
        // Для автоматичного оновлення — Interceptor або Computed Column
        builder.Property(e => e.UpdatedAt)
               .ValueGeneratedOnAddOrUpdate();

        // RowVersion: для оптимістичної конкурентності
        builder.Property(e => e.RowVersion)
               .IsRowVersion(); // ValueGeneratedOnAddOrUpdate + IsConcurrencyToken
    }
}

HasValueGenerator: власна логіка генерації

HasValueGenerator<T>() підключає власний C#-клас для генерації значень. Це виконується у C#, а не у SQL — тобто ваш код генерує значення перед INSERT.

// Генератор унікальних номерів замовлень у форматі ORD-2024-000001
public class OrderNumberGenerator : ValueGenerator<string>
{
    // IsTemporary: false — значення постійне, true — тимчасове до збереження
    public override bool GeneratesTemporaryValues => false;

    public override string Next(EntityEntry entry)
    {
        var year = DateTime.UtcNow.Year;
        // В реальному проєкті: атомарний лічильник з БД або розподілений генератор
        var sequence = Guid.NewGuid().ToString("N")[..6].ToUpper();
        return $"ORD-{year}-{sequence}";
    }
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.OrderNumber)
               .HasValueGenerator<OrderNumberGenerator>()
               .ValueGeneratedOnAdd()
               .IsRequired()
               .HasMaxLength(20)
               .IsUnicode(false);
    }
}

Або GUID-генератор зі специфічним форматом:

public class SequentialGuidGenerator : ValueGenerator<Guid>
{
    public override bool GeneratesTemporaryValues => false;

    public override Guid Next(EntityEntry entry)
    {
        // Sequential GUID: починається з Timestamp → кращий для індексів B-tree
        // Уникає фрагментації у SQL Server clustered index
        var timestamp = BitConverter.GetBytes(DateTime.UtcNow.Ticks);
        var guidBytes = Guid.NewGuid().ToByteArray();

        // Замінюємо перші 8 байт на timestamp для гарантованої послідовності
        timestamp.CopyTo(guidBytes, 0);
        return new Guid(guidBytes);
    }
}
Sequential GUID vs Random GUID: Правило SQL Server clustered index — нові рядки вставляються у кінець листа B-tree. Random GUID (Guid.NewGuid()) вставляє «посередині» → фрагментація → дефрагментація → просідання продуктивності при великих таблицях. Sequential GUID вирішує цю проблему, зберігаючи унікальність.

Default Values: значення за замовчуванням

EF Core надає три способи вказати значення за замовчуванням для стовпця. Важливо розуміти різницю між ними.

HasDefaultValue: константа у C#

// Константне значення за замовчуванням, задане у C#
// EF Core генерує DEFAULT у DDL
builder.Property(e => e.IsActive)
       .HasDefaultValue(true);

builder.Property(e => e.Priority)
       .HasDefaultValue(0);

builder.Property(e => e.Currency)
       .HasDefaultValue("USD")
       .HasMaxLength(3)
       .IsUnicode(false);

Генерований DDL:

[IsActive] BIT NOT NULL DEFAULT 1,
[Priority] INT NOT NULL DEFAULT 0,
[Currency] CHAR(3) NOT NULL DEFAULT 'USD'

HasDefaultValue генерує SQL DEFAULT constraint. Якщо EF Core не передає значення для стовпця при INSERT (тобто значення не було явно задане) — база використовує це DEFAULT.

HasDefaultValueSql: SQL-вираз

Коли потрібно, щоб значення обчислювалось базою даних — наприклад, поточний час:

// SQL-функції для timestamp
builder.Property(e => e.CreatedAt)
       .HasDefaultValueSql("GETUTCDATE()"); // SQL Server

builder.Property(e => e.CreatedAt)
       .HasDefaultValueSql("NOW() AT TIME ZONE 'UTC'"); // PostgreSQL

builder.Property(e => e.CreatedAt)
       .HasDefaultValueSql("CURRENT_TIMESTAMP"); // SQLite / portable

// NEWID() для GUID
builder.Property(e => e.ExternalId)
       .HasDefaultValueSql("NEWID()"); // SQL Server

// Вираз-обчислення
builder.Property(e => e.FullName)
       .HasDefaultValueSql("'Unknown'");
HasDefaultValue vs HasDefaultValueSql: HasDefaultValue(someObject) серіалізує .NET значення у SQL literal. HasDefaultValueSql("GETUTCDATE()") передає рядок дослівно як SQL-вираз. Не плутайте: HasDefaultValue("GETUTCDATE()") запише рядок "GETUTCDATE()" у базу, а не поточну дату.

Взаємодія Default Value з Change Tracker

EF Core не встановлює значення за замовчуванням для C#-властивостей при матеріалізації нового об'єкту. HasDefaultValue — це про DDL і SQL INSERT, не про C# ініціалізацію. Тому:

var product = new Product(); // product.IsActive = false (default для bool у C#!)

context.Products.Add(product);
await context.SaveChangesAsync();
// EF Core бачить IsActive = false (задано явно, не "відсутнє")
// Тому INSERT включає: IsActive = 0 — DEFAULT SQL не спрацьовує!

Щоб SQL DEFAULT спрацював — або задайте значення за замовчуванням у C#-класі:

public class Product
{
    public bool IsActive { get; set; } = true; // C# default matches SQL DEFAULT
}

Або використайте ValueGeneratedOnAdd без HasDefaultValue — тоді EF Core знає, що значення генерується БД і не передає його в INSERT.


Computed Columns: обчислювані стовпці

Computed Column (обчислюваний стовпець) — стовпець, значення якого автоматично обчислюється базою даних на основі інших стовпців. EF Core підтримує два типи через HasComputedColumnSql.

Stored vs Virtual Computed Columns

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public decimal Salary { get; set; }
    public decimal BonusPercent { get; set; }

    // Обчислюване поле — лише для читання в C#
    public string FullName { get; set; } = string.Empty;
    public decimal TotalCompensation { get; set; }
}
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        builder.HasKey(e => e.Id);

        builder.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.LastName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.Salary).HasPrecision(12, 2);
        builder.Property(e => e.BonusPercent).HasPrecision(5, 2);

        // VIRTUAL computed column: обчислюється при КОЖНОМУ зверненні до рядка
        // stored: false (default) = virtual
        builder.Property(e => e.FullName)
               .HasComputedColumnSql("[LastName] + ', ' + [FirstName]", stored: false);

        // STORED computed column: обчислюється при INSERT/UPDATE і зберігається фізично
        // stored: true = stored/persisted
        builder.Property(e => e.TotalCompensation)
               .HasComputedColumnSql("[Salary] * (1 + [BonusPercent] / 100.0)", stored: true)
               .HasPrecision(14, 2);
    }
}

Генерований DDL (SQL Server):

CREATE TABLE [Employees] (
    [Id]                INT            NOT NULL IDENTITY,
    [FirstName]         NVARCHAR(100)  NOT NULL,
    [LastName]          NVARCHAR(100)  NOT NULL,
    [Salary]            DECIMAL(12, 2) NOT NULL,
    [BonusPercent]      DECIMAL(5, 2)  NOT NULL,

    -- Virtual: обчислюється при кожному читанні, не зберігається
    [FullName] AS ([LastName] + ', ' + [FirstName]),

    -- Stored: обчислюється при INSERT/UPDATE, фізично зберігається
    [TotalCompensation] AS ([Salary] * (1 + [BonusPercent] / 100.0)) PERSISTED,

    CONSTRAINT [PK_Employees] PRIMARY KEY ([Id])
);

Ключова різниця між Virtual і Stored:

Virtual (stored: false)Stored/Persisted (stored: true)
Коли обчислюєтьсяПри кожному SELECTПри INSERT/UPDATE
Зберігається фізичноНіТак
Займає місце0 байтТак
Може входити до індексуНі (SQL Server)Так
PostgreSQL підтримкаGENERATED ALWAYS AS ...GENERATED ALWAYS AS ... STORED
Якщо потрібно індексувати обчислюваний стовпець — обов'язково stored: true. SQL Server не дозволяє індексувати virtual computed columns. PostgreSQL та SQLite мають власні обмеження — перевіряйте документацію провайдера.

Computed Columns у C#: доступ лише для читання

Важлива особливість: EF Core не намагається записати значення обчислюваного стовпця при INSERT/UPDATE, оскільки HasComputedColumnSql автоматично встановлює ValueGeneratedOnAddOrUpdate. Але властивість у C#-класі залишається доступною для читання при матеріалізації запиту.

Щоб зробити це явним у design — використовуйте private set:

public class Employee
{
    // ...
    public string FullName { get; private set; } = string.Empty; // read-only у C#
    public decimal TotalCompensation { get; private set; }
}

Shadow Properties: властивості без поля у Entity

Shadow Property (тіньова властивість) — стовпець у базі даних, для якого немає відповідної C#-властивості у класі сутності. EF Core зберігає такі властивості у внутрішньому сховищі Change Tracker, не у CLR-об'єкті.

Навіщо Shadow Properties?

Головна мотивація — розділення відповідальностей. Якщо CreatedAt, UpdatedAt, DeletedAt — це інфраструктурні поля, що не є частиною бізнес-логіки, навіщо засмічувати ними доменні класи?

// Без Shadow Properties: аудитові поля у доменному класі (проблема)
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    // Ці поля — не бізнес-логіка, а інфраструктура
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public bool IsDeleted { get; set; }
}
// Зі Shadow Properties: чистий доменний клас
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    // Жодних аудитових полів! Вони — у Shadow Properties
}

Визначення Shadow Properties

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(p => p.Id);

        builder.Property(p => p.Name).IsRequired().HasMaxLength(200);
        builder.Property(p => p.Price).HasPrecision(14, 2);

        // Оголошуємо Shadow Properties через рядкові назви
        builder.Property<DateTime>("CreatedAt")
               .HasDefaultValueSql("GETUTCDATE()")
               .ValueGeneratedOnAdd();

        builder.Property<DateTime?>("UpdatedAt")
               .ValueGeneratedOnAddOrUpdate();

        builder.Property<string?>("CreatedBy")
               .HasMaxLength(200);

        builder.Property<bool>("IsDeleted")
               .HasDefaultValue(false);
    }
}

Читання та запис Shadow Properties

Оскільки Shadow Property не є C#-властивістю, доступ до неї відбувається через EF.Property<T>(entity, "PropertyName") у LINQ або через Entry:

// Читання у LINQ-запиті
var recentProducts = await context.Products
    .Where(p => EF.Property<DateTime>(p, "CreatedAt") > DateTime.UtcNow.AddDays(-7))
    .OrderByDescending(p => EF.Property<DateTime>(p, "CreatedAt"))
    .ToListAsync();

// Читання через Entry
var entry = context.Entry(product);
var createdAt = entry.Property<DateTime>("CreatedAt").CurrentValue;
var isDeleted = entry.Property<bool>("IsDeleted").CurrentValue;

// Запис через Entry (для аудиту)
entry.Property("CreatedBy").CurrentValue = currentUser.Name;
entry.Property("UpdatedAt").CurrentValue = DateTime.UtcNow;

Автоматичне заповнення Shadow Properties через Interceptor

Найчистіший підхід — заповнювати Shadow Properties у SaveChangesInterceptor:

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public AuditInterceptor(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        AuditEntities(eventData.Context!);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        AuditEntities(eventData.Context!);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void AuditEntities(DbContext context)
    {
        var now = DateTime.UtcNow;
        var userName = _currentUser.Username ?? "system";

        foreach (var entry in context.ChangeTracker.Entries())
        {
            // Тільки якщо entity має ці Shadow Properties
            if (entry.Metadata.FindProperty("CreatedAt") is not null)
            {
                if (entry.State == EntityState.Added)
                {
                    entry.Property("CreatedAt").CurrentValue = now;
                    entry.Property("CreatedBy").CurrentValue = userName;
                }

                if (entry.State is EntityState.Added or EntityState.Modified)
                {
                    entry.Property("UpdatedAt").CurrentValue = now;
                }
            }
        }
    }
}

Shadow Foreign Keys: FK без явної властивості

EF Core автоматично створює Shadow Properties для Foreign Keys, якщо FK-властивість відсутня у C#-класі. Це особливо поширено у навігаційних властивостях:

public class Comment
{
    public int Id { get; set; }
    public string Text { get; set; } = string.Empty;

    // Немає явного BlogPostId у класі
    public BlogPost Post { get; set; } = null!;
}

EF Core автоматично додає Shadow Property "BlogPostId" як FK до BlogPost. Ви можете побачити її через ChangeTracker.DebugView або в міграціях.


Backing Fields: поля замість властивостей

Backing Field (резервне поле) — приватне поле класу, яке EF Core читає та записує безпосередньо, оминаючи публічний getter/setter. Це дозволяє реалізувати інкапсуляцію доменної логіки.

Навіщо Backing Fields?

У чистій доменній моделі (DDD) сутності часто мають бізнес-логіку у setter'ах або взагалі не надають публічних setter'ів:

public class BankAccount
{
    public int Id { get; private set; }

    // Публічний getter, private setter: баланс не можна присвоїти напряму
    public decimal Balance { get; private set; }

    // Бізнес-метод: касова операція з валідацією
    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new DomainException("Deposit amount must be positive");
        Balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= 0) throw new DomainException("Withdrawal amount must be positive");
        if (amount > Balance) throw new DomainException("Insufficient funds");
        Balance -= amount;
    }
}

private set вже підтримується EF Core — він читає та записує через рефлексію, навіть якщо setter приватний. Але що якщо getter або setter містять логіку?

public class Order
{
    // Приватне поле — EF Core читатиме/писатиме сюди напряму
    private decimal _totalAmount;

    // Getter з логікою (наприклад, форматування або ліниве обчислення)
    public decimal TotalAmount
    {
        get => Math.Round(_totalAmount, 2); // округлення при читанні
        private set => _totalAmount = value;
    }

    private readonly List<OrderItem> _items = new();

    // Колекція: тільки через IReadOnlyCollection — не можна Set ззовні
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public void AddItem(OrderItem item)
    {
        _items.Add(item);
        RecalculateTotal();
    }

    private void RecalculateTotal()
    {
        _totalAmount = _items.Sum(i => i.Quantity * i.UnitPrice);
    }
}

Конфігурація Backing Fields

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        // EF Core читає _totalAmount (поле), а не TotalAmount (property)
        builder.Property(o => o.TotalAmount)
               .HasField("_totalAmount")
               .HasPrecision(14, 2);

        // Для колекцій: UsePropertyAccessMode вказує де шукати
        builder.HasMany(o => o.Items)
               .WithOne()
               .HasForeignKey("OrderId");

        // Вказуємо, що navigations читаються через поле _items
        builder.Navigation(o => o.Items)
               .HasField("_items")
               .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

PropertyAccessMode: стратегії доступу

EF Core підтримує кілька режимів доступу до властивостей і полів:

// PropertyAccessMode визначає, як EF Core читає/пише значення

// Field: завжди через поле (ігнорує property)
builder.Property(e => e.Balance)
       .HasField("_balance")
       .UsePropertyAccessMode(PropertyAccessMode.Field);

// Property: завжди через property (getter/setter)
builder.Property(e => e.Balance)
       .UsePropertyAccessMode(PropertyAccessMode.Property);

// FieldDuringConstruction (default для navigations):
// при конструкторній ін'єкції — через поле, у інших випадках — через property
builder.Property(e => e.Balance)
       .UsePropertyAccessMode(PropertyAccessMode.FieldDuringConstruction);

// PreferField: спочатку шукає поле, якщо немає — property
// PreferProperty: навпаки

Field-only properties: властивості без C#-property

Крайній випадок: властивість існує лише у базі даних (Shadow Property) + лише як поле у C# (без публічного property):

public class AuditedProduct
{
    private DateTime _deletedAt; // Приватне поле без property

    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}
// Маппінг поля без property
builder.Property<DateTime>("_deletedAt")
       .HasField("_deletedAt")
       .HasColumnName("DeletedAt")
       .UsePropertyAccessMode(PropertyAccessMode.Field);

Temporal Tables: вбудована підтримка SQL Server

Temporal Tables (темпоральні таблиці або System-Versioned Tables) — функція SQL Server 2016+, що автоматично зберігає повну історію змін для кожного рядка. Для кожної зміни система записує, яке значення було актуальним і в який проміжок часу.

EF Core 6 додав native підтримку System-Versioned Temporal Tables.

Як це працює: архітектура

Temporal Table складається з двох таблиць:

  • Основна таблиця: містить поточний стан рядків
  • History таблиця: містить всі попередні версії рядків

SQL Server автоматично переміщує рядки до History таблиці при update/delete, додаючи часові мітки ValidFrom та ValidTo.

Конфігурація Temporal Table

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Category { get; set; } = string.Empty;
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).IsRequired().HasMaxLength(200);
        builder.Property(p => p.Price).HasPrecision(12, 2);

        // Вмикаємо System-Versioned Temporal Table
        builder.ToTable("Products", tb => tb.IsTemporal(temporal =>
        {
            // Назва history таблиці (за замовчуванням: ProductsHistory)
            temporal.UseHistoryTable("ProductPriceHistory");

            // Назви стовпців для часових меж (за замовчуванням: PeriodStart/PeriodEnd)
            temporal.HasPeriodStart("ValidFrom");
            temporal.HasPeriodEnd("ValidTo");
        }));
    }
}

Генерований DDL:

CREATE TABLE [Products] (
    [Id]        INT            NOT NULL IDENTITY,
    [Name]      NVARCHAR(200)  NOT NULL,
    [Price]     DECIMAL(12, 2) NOT NULL,
    [Category]  NVARCHAR(MAX)  NOT NULL,
    [ValidFrom] DATETIME2      GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo]   DATETIME2      GENERATED ALWAYS AS ROW END   NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    PERIOD FOR SYSTEM_TIME ([ValidFrom], [ValidTo])
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [dbo].[ProductPriceHistory]));

Temporal Queries у EF Core

// TemporalAsOf: стан таблиці у конкретний момент часу
// "Яка ціна Product #5 була місяць тому?"
var priceLastMonth = await context.Products
    .TemporalAsOf(DateTime.UtcNow.AddMonths(-1))
    .Where(p => p.Id == 5)
    .Select(p => p.Price)
    .FirstOrDefaultAsync();

// TemporalAll: всі версії всіх рядків (включаючи historical)
var allVersions = await context.Products
    .TemporalAll()
    .Where(p => p.Id == 5)
    .Select(p => new {
        p.Price,
        ValidFrom = EF.Property<DateTime>(p, "ValidFrom"),
        ValidTo   = EF.Property<DateTime>(p, "ValidTo")
    })
    .OrderBy(v => v.ValidFrom)
    .ToListAsync();

// TemporalBetween: рядки, що були актуальні в проміжок часу
var changesThisMonth = await context.Products
    .TemporalBetween(
        DateTime.UtcNow.AddMonths(-1),
        DateTime.UtcNow)
    .Where(p => p.Id == 5)
    .ToListAsync();

// TemporalFromTo: рядки зі start <= ValidFrom < end
var specificRange = await context.Products
    .TemporalFromTo(
        new DateTime(2024, 1, 1),
        new DateTime(2024, 6, 30))
    .ToListAsync();

// TemporalContainedIn: рядки, що повністю знаходяться у діапазоні
var fullyContained = await context.Products
    .TemporalContainedIn(
        new DateTime(2024, 1, 1),
        new DateTime(2024, 12, 31))
    .ToListAsync();

Відновлення даних через Temporal Tables

Одне з потужних застосувань — відновлення випадково видалених або змінених даних:

// Знаходимо стан продукту до помилкового оновлення
var productBeforeError = await context.Products
    .TemporalAsOf(errorOccurredAt.AddSeconds(-1))
    .Where(p => p.Id == productId)
    .FirstOrDefaultAsync();

if (productBeforeError is not null)
{
    // Створюємо новий SaveChanges для відновлення
    context.Entry(productBeforeError).State = EntityState.Modified;
    await context.SaveChangesAsync();
}
Обмеження Temporal Tables: підтримуються лише SQL Server провайдером у EF Core. PostgreSQL має власний підхід через розширення temporal_tables або temporal_range, але EF Core його не підтримує нативно. Для інших СУБД потрібна ручна реалізація через Shadow Properties, Interceptors та окремі history-таблиці.

Практичні завдання (Частина 2)

Рівень 1 — Базовий

Завдання 1.1: Value Comparer для списку

Є Article з public List<string> Tags { get; set; }, що зберігається як JSON через HasConversion. Напишіть ValueComparer<List<string>>, який:

  • Порівнює вміст (не посилання): ["a","b"] == ["a","b"], але != ["b","a"]
  • Робить deep copy через new List<string>(v)

Переконайтеся, що article.Tags.Add("new-tag") тепер виявляється Change Tracker.

Завдання 1.2: Shadow Properties для Audit

Додайте Shadow Properties CreatedAt (DateTime, NOT NULL, DEFAULT GETUTCDATE()) і UpdatedAt (DateTime?, nullable) до entity BlogPost без модифікації класу BlogPost. Напишіть запит, що вибирає лише пости, оновлені за останні 24 години, використовуючи EF.Property<DateTime?>.

Завдання 1.3: Computed Column

Для Invoice з полями SubTotal, TaxRate (у відсотках), DiscountAmount — додайте computed columns:

  • TaxAmount = SubTotal * TaxRate / 100 (virtual)
  • GrandTotal = SubTotal + TaxAmount - DiscountAmount (stored)

Рівень 2 — Логіка

Завдання 2.1: Backing Field для колекції

Реалізуйте ShoppingCart з приватним полем private readonly List<CartItem> _items. Публічний доступ лише через Items як IReadOnlyCollection<CartItem> і методи AddItem(CartItem), RemoveItem(int cartItemId), Clear(). Налаштуйте EF Core, щоб він читав/писав через поле _items, а не через IReadOnlyCollection.

Завдання 2.2: ValueGenerator для SlugGenerator

Реалізуйте SlugGenerator : ValueGenerator<string>, що при INSERT автоматично генерує URL-slug з поля Title (перетворює "Hello World! Це тест""hello-world-tse-test"). Slug має бути унікальним. Як вирішити проблему унікальності без запиту до БД у генераторі?

Завдання 2.3: Default Values у різних контекстах

Для SupportTicket налаштуйте:

  • Status = "Open" через HasDefaultValue
  • CreatedAt = поточна дата через HasDefaultValueSql
  • Priority = 3 через ініціалізатор у C#-класі

Поясніть: в якому порядку спрацьовують ці три механізми? Що станеться, якщо C#-ініціалізатор встановлює значення відмінне від SQL DEFAULT?

Рівень 3 — Архітектура

Завдання 3.1: Повна аудитна система через Shadow Properties

Реалізуйте базовий клас конфігурації AuditableEntityConfiguration<T> (де T — будь-який entity), що автоматично додає Shadow Properties: CreatedAt, UpdatedAt, CreatedBy, UpdatedBy, IsDeleted. Напишіть AuditSaveChangesInterceptor, що заповнює ці поля. Подбайте про те, щоб IsDeleted = true не видаляло рядок, а лише позначало його (soft delete).

Завдання 3.2: Portable Temporal History без SQL Server

Реалізуйте власне версіонування для PostgreSQL без нативних Temporal Tables:

  • Основна таблиця Documents
  • History таблиця DocumentHistory з додатковими полями ValidFrom, ValidTo
  • SaveChangesInterceptor, що при UPDATE/DELETE копіює поточний рядок у history
  • Метод розширення AsOfAsync(DateTime pointInTime) для отримання стану документу у минулому

Підсумок

Конфігурація властивостей в EF Core — це набагато більше, ніж просто HasMaxLength і IsRequired. Це потужна система, що дозволяє:

  • Value Converters — трансформувати значення між C# та SQL: enum → string, Strongly Typed IDs, JSON-серіалізація, шифрування. Ключовий принцип: конвертер каже «як перетворити», компаратор — «як порівняти».
  • Value Comparers — забезпечити правильний Change Tracking для складних типів. Без comparer для посилальних типів — зміни невидимі для EF Core.
  • Value Generators — автоматично заповнювати поля при INSERT: sequential GUIDs, номери документів, будь-яка логіка генерації.
  • Default ValuesHasDefaultValue для C#-констант, HasDefaultValueSql для SQL-виразів. Пам'ятайте: вони про DDL, не про C#-ініціалізацію.
  • Computed Columns — делегувати обчислення базі, гарантуючи консистентність без додаткового коду.
  • Shadow Properties — тримати інфраструктурні поля за межами доменних класів. Ідеальне рішення для аудиту та soft delete.
  • Backing Fields — реалізувати DDD-стиль з інкапсульованими колекціями та захищеними записами.
  • Temporal Tables — отримати повну історію змін «з коробки» для SQL Server.

Ці інструменти разом дозволяють побудувати гнучку, безпечну та підтримувану модель даних, де база даних залишається строгою та семантично точною, а C#-код — чистим і виразним.


Додаткові ресурси