Це продовження статті «Властивості: Типи, Конвертери». Рекомендується читати послідовно.
Розглянемо ситуацію, що регулярно дивує розробників на початку роботи з 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 (компаратор значень).
ValueComparer<T> — клас, що визначає три операції для EF Core:
Без правильного SnapshotExpression EF Core зберігає посилання на оригінальний об'єкт, а не його копію. Коли об'єкт мутується — «оригінал» і «поточне» вказують на один і той самий об'єкт, тому порівняння завжди дає «не змінилось».
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 = ...
Аналогічна проблема існує для 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 Generator (генератор значень) — механізм автоматичного заповнення властивостей перед збереженням. Стандартний приклад — IDENTITY/AUTOINCREMENT для PK. Але Value Generators дозволяють реалізувати власну логіку генерації.
Ці методи не генерують значення самостійно — вони лише інформують 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<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);
}
}
Guid.NewGuid()) вставляє «посередині» → фрагментація → дефрагментація → просідання продуктивності при великих таблицях. Sequential GUID вирішує цю проблему, зберігаючи унікальність.EF Core надає три способи вказати значення за замовчуванням для стовпця. Важливо розуміти різницю між ними.
// Константне значення за замовчуванням, задане у 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.
Коли потрібно, щоб значення обчислювалось базою даних — наприклад, поточний час:
// 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(someObject) серіалізує .NET значення у SQL literal. HasDefaultValueSql("GETUTCDATE()") передає рядок дослівно як SQL-вираз. Не плутайте: HasDefaultValue("GETUTCDATE()") запише рядок "GETUTCDATE()" у базу, а не поточну дату.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 Column (обчислюваний стовпець) — стовпець, значення якого автоматично обчислюється базою даних на основі інших стовпців. EF Core підтримує два типи через HasComputedColumnSql.
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 мають власні обмеження — перевіряйте документацію провайдера.Важлива особливість: 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 Property (тіньова властивість) — стовпець у базі даних, для якого немає відповідної C#-властивості у класі сутності. EF Core зберігає такі властивості у внутрішньому сховищі Change Tracker, не у CLR-об'єкті.
Головна мотивація — розділення відповідальностей. Якщо 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
}
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 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 у 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;
}
}
}
}
}
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 Field (резервне поле) — приватне поле класу, яке EF Core читає та записує безпосередньо, оминаючи публічний getter/setter. Це дозволяє реалізувати інкапсуляцію доменної логіки.
У чистій доменній моделі (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);
}
}
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);
}
}
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: навпаки
Крайній випадок: властивість існує лише у базі даних (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 (темпоральні таблиці або System-Versioned Tables) — функція SQL Server 2016+, що автоматично зберігає повну історію змін для кожного рядка. Для кожної зміни система записує, яке значення було актуальним і в який проміжок часу.
EF Core 6 додав native підтримку System-Versioned Temporal Tables.
Temporal Table складається з двох таблиць:
SQL Server автоматично переміщує рядки до History таблиці при update/delete, додаючи часові мітки ValidFrom та ValidTo.
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]));
// 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();
Одне з потужних застосувань — відновлення випадково видалених або змінених даних:
// Знаходимо стан продукту до помилкового оновлення
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 або temporal_range, але EF Core його не підтримує нативно. Для інших СУБД потрібна ручна реалізація через Shadow Properties, Interceptors та окремі history-таблиці.Завдання 1.1: Value Comparer для списку
Є Article з public List<string> Tags { get; set; }, що зберігається як JSON через HasConversion. Напишіть ValueComparer<List<string>>, який:
["a","b"] == ["a","b"], але != ["b","a"]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.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" через HasDefaultValueCreatedAt = поточна дата через HasDefaultValueSqlPriority = 3 через ініціалізатор у C#-класіПоясніть: в якому порядку спрацьовують ці три механізми? Що станеться, якщо C#-ініціалізатор встановлює значення відмінне від SQL DEFAULT?
Завдання 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:
DocumentsDocumentHistory з додатковими полями ValidFrom, ValidToSaveChangesInterceptor, що при UPDATE/DELETE копіює поточний рядок у historyAsOfAsync(DateTime pointInTime) для отримання стану документу у минуломуКонфігурація властивостей в EF Core — це набагато більше, ніж просто HasMaxLength і IsRequired. Це потужна система, що дозволяє:
HasDefaultValue для C#-констант, HasDefaultValueSql для SQL-виразів. Пам'ятайте: вони про DDL, не про C#-ініціалізацію.Ці інструменти разом дозволяють побудувати гнучку, безпечну та підтримувану модель даних, де база даних залишається строгою та семантично точною, а C#-код — чистим і виразним.
Властивості — Типи, Конвертери, Компаратори (Частина 1)
Глибокий розбір конфігурації властивостей в EF Core — маппінг C# типів на SQL, HasColumnType, HasPrecision, Value Converters (HasConversion), вбудовані конвертери, strongly typed IDs, шифрування та JSON-серіалізація через конвертери.
Складні типи — Owned Types та Complex Types (Частина 1)
Глибокий розбір Owned Types в EF Core — OwnsOne, OwnsMany, вкладені owned types, table splitting, entity splitting. Теорія Value Objects з DDD, практична реалізація агрегатів.