Це продовження статті «JSON Columns: Складні дані у JSON». Читайте послідовно.
У статті про конфігурацію властивостей ми детально розбирали Value Comparers — пари функцій порівняння і клонування, що дозволяють EF Core Change Tracker правильно виявляти зміни у складних типах. Виникає питання: чи потрібен Value Comparer для JSON Columns?
Відповідь залежить від того, чи є зміни структурними (замінили весь об'єкт) або мутаційними (змінили поле всередині існуючого об'єкту).
// Сценарій 1: заміна всього об'єкту — Change Tracker бачить зміну
var product = await context.Products.FindAsync(1);
product!.Metadata = new ProductMetadata { Brand = "Samsung", Tags = ["phone"] };
// EF Core порівнює посилання: old != new → Modified ✓
// Сценарій 2: мутація поля всередині — EF Core 7+ з ToJson() ВІДСТЕЖУЄ!
product.Metadata.Brand = "Samsung";
product.Metadata.Tags.Add("flagship");
// EF Core 7+: правильно виявляє як Modified ✓
// (на відміну від HasConversion, де потрібен ValueComparer)
EF Core 7+ з ToJson() має вбудований механізм відстеження змін усередині JSON-структури. Це принципова відмінність від HasConversion із JSON-рядком — там потрібен ручний ValueComparer.
Чому так? Тому що з ToJson() EF Core знає структуру об'єкту — це не opaque string. Change Tracker створює snapshot з усіх властивостей Owned Type і порівнює їх по-одному. Це більш гранулярно і правильно.
Якщо ви вбудовуєте JSON у JSON через HasConversion (рядок JSON всередині JSON Column) — тут вже потрібен Comparer:
public class ProductMetadata
{
public string? Brand { get; set; }
// Цей List<string> всередині JSON Column — EF Core відстежує нативно
public List<string> Tags { get; set; } = new();
// Але якщо додати ще один рядок через HasConversion (JSON у JSON):
public Dictionary<string, object> CustomAttributes { get; set; } = new();
// Для цього потрібен ValueComparer, якщо використовувати HasConversion для нього
}
Для List<string> та інших простих колекцій всередині ToJson() — EF Core 7+ справляється сам. Для складних вкладених obj через HasConversion — потрібна явна реєстрація Comparer.
Одна з ключових переваг jsonb у PostgreSQL над звичайним JSON-рядком — можливість індексування. SQL Server також підтримує обчислювані стовпці на базі JSON для індексування. SQLite підтримує обмежено.
GIN (Generalized Inverted Index) у PostgreSQL — тип індексу, оптимізований для пошуку по масивах і JSON-структурах. Він дозволяє ефективно шукати «чи містить JSON конкретний ключ або значення».
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.OwnsOne(p => p.Metadata, meta =>
{
meta.ToJson();
meta.Property(m => m.Brand).HasMaxLength(100);
});
// GIN-індекс для PostgreSQL — через HasIndex з оператором (Npgsql-специфічно)
// Пошук "@>" (jsonb contains) буде використовувати цей індекс
builder.HasIndex(p => p.Metadata)
.HasMethod("gin");
// PostgreSQL: CREATE INDEX ... USING gin ("Metadata")
}
}
-- PostgreSQL: GIN індекс дозволяє ефективний пошук
CREATE INDEX IX_Products_Metadata ON "Products" USING gin ("Metadata");
-- Запит з @> (contains): використовує GIN-індекс
SELECT * FROM "Products"
WHERE "Metadata" @> '{"brand": "Apple"}';
-- jsonb_array_elements теж використовує GIN при правильному запиті
SELECT * FROM "Products"
WHERE "Metadata" -> 'tags' @> '["laptop"]';
Для часто фільтрованих JSON-полів ефективніший часткові або functional індекси:
-- Functional index: індекс на конкретному JSON-полі
CREATE INDEX IX_Products_Brand
ON "Products" (("Metadata" ->> 'brand'));
-- Після цього EF Core запит транслюється ефективно:
-- WHERE "Metadata" ->> 'brand' = 'Apple' → використовує індекс
EF Core поки не підтримує functional індекси на JSON-поля через Fluent API — їх потрібно додавати через migrationBuilder.Sql() у міграції:
public partial class AddProductBrandIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Functional index на JSON-поле (PostgreSQL)
migrationBuilder.Sql(@"
CREATE INDEX IX_Products_Metadata_Brand
ON ""Products"" ((""Metadata"" ->> 'brand'))
WHERE ""Metadata"" IS NOT NULL;
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DROP INDEX IF EXISTS IX_Products_Metadata_Brand;");
}
}
SQL Server не підтримує індекси напряму на NVARCHAR(MAX). Обхідний шлях — обчислюваний стовпець (Persisted Computed Column) на базі JSON_VALUE:
-- Computed column витягує значення з JSON і зберігає окремо
ALTER TABLE [Products]
ADD [_Metadata_Brand] AS JSON_VALUE([Metadata], '$.brand') PERSISTED;
-- Тепер можна індексувати computed column
CREATE INDEX IX_Products_MetadataBrand
ON [Products] ([_Metadata_Brand]);
Через EF Core Fluent API — знову через migrationBuilder.Sql():
migrationBuilder.Sql(@"
ALTER TABLE [Products]
ADD [_Metadata_Brand] AS JSON_VALUE([Metadata], '$.brand') PERSISTED;
CREATE INDEX IX_Products_MetadataBrand
ON [Products] ([_Metadata_Brand]);
");
Brand), і таблиця велика — додайте індекс. Без індексу кожен .Where(p => p.Metadata.Brand == "Apple") — повне сканування таблиці з JSON_VALUE на кожному рядку.Нативна підтримка JSON у EF Core є, але провайдери реалізують її по-різному. Розуміння відмінностей критичне для переносимого коду.
PostgreSQL jsonb — найбільш повна реалізація для роботи з JSON у реляційній базі. EF Core Npgsql провайдер транслює LINQ у jsonb-оператори PostgreSQL.
Тип стовпця: jsonb (за замовчуванням для Npgsql) або json
// Специфічні для PostgreSQL конфігурації:
builder.OwnsOne(p => p.Metadata, meta =>
{
meta.ToJson();
// Явно: jsonb замість json
// meta.ToJson("Metadata"); // назва стовпця
});
Що підтримується:
-> та ->> оператори@> (contains), <@ (contained by)jsonb_array_elements для роботи з масивамиjsonb_path_query (JSONPath)jsonb_set для оновлення окремих полівТрансляція LINQ у PostgreSQL:
// C#
.Where(p => p.Metadata.Brand == "Apple")
// SQL: WHERE "Metadata" ->> 'brand' = 'Apple'
.Where(p => p.Metadata.Tags.Count > 3)
// SQL: WHERE jsonb_array_length("Metadata" -> 'tags') > 3
.Where(p => p.Metadata.Tags.Any(t => t == "laptop"))
// SQL: WHERE "Metadata" -> 'tags' @> '["laptop"]'
SQL Server зберігає JSON як NVARCHAR(MAX) — це звичайний рядок з JSON-функціями поверх.
Тип стовпця: nvarchar(max)
Що підтримується:
JSON_VALUE(column, '$.path') — скалярне значенняJSON_QUERY(column, '$.path') — JSON-фрагментOPENJSON(column) — розгортання JSON у рядкиJSON_MODIFY() — часткове оновленняISJSON() — валідаціяТрансляція LINQ у SQL Server:
// C#
.Where(p => p.Metadata.Brand == "Apple")
// SQL: WHERE JSON_VALUE([Metadata], '$.brand') = 'Apple'
.Where(p => p.Metadata.Tags.Any(t => t == "laptop"))
// SQL: WHERE EXISTS (SELECT 1 FROM OPENJSON([Metadata], '$.tags') WHERE [value] = 'laptop')
Обмеження SQL Server:
nvarchar(max) напряму → потрібні computed columnsOPENJSON на великих масивах може бути повільнимSQLite підтримує JSON через вбудоване розширення (з версії 3.38.0, яке включено у більшість дистрибутивів).
Тип стовпця: TEXT
Функції: json_extract(), json_each(), json_array_length()
Трансляція LINQ у SQLite:
// C#
.Where(p => p.Metadata.Brand == "Apple")
// SQL: WHERE json_extract("Metadata", '$.brand') = 'Apple'
Обмеження: SQLite JSON-підтримка слабша за PostgreSQL. Складні операції можуть не транслюватися або виконуватися у C#.
| Можливість | PostgreSQL | SQL Server | SQLite |
|---|---|---|---|
| SQL тип | jsonb | nvarchar(max) | TEXT |
| Індексування | GIN, functional | Computed columns | Немає |
| Часткове оновлення | jsonb_set | JSON_MODIFY | Немає в EF |
| JSONPath | jsonb_path_query | Немає нативно | json_each |
| Продуктивність | Висока (бінарний) | Середня (string) | Низька для складних |
| LINQ-трансляція | Повна | Хороша | Базова |
Незважаючи на потужність нативних JSON Columns, є важливі обмеження, яких потрібно бути свідомим.
EF Core при зміні JSON-стовпця перезаписує весь стовпець, а не лише змінені поля:
product.Metadata.Brand = "Samsung"; // змінили тільки Brand
await context.SaveChangesAsync();
// SQL: UPDATE Products SET Metadata='{"brand":"Samsung","sku":"...","tags":[...]}'
// Весь JSON перезаписується, не тільки brand!
Це нормально для невеликих JSON-об'єктів. Але якщо JSON великий (наприклад, 10KB з великим масивом) — кожне дрібне оновлення генерує великий UPDATE. Для часткового оновлення потрібен JSON_MODIFY (SQL Server) або jsonb_set (PostgreSQL) через Raw SQL.
JSON-поля не можна використовувати як FK або join clause у SQL. Якщо потрібен JOIN — нормалізуйте поле у окремий стовпець.
EF Core очікує фіксовану C#-структуру для JSON Column. Якщо JSON може мати різну схему залежно від типу запису — необхідно або:
HasConversion з ручною десеріалізацієюList<string> всередині Owned Type зберігається як JSON-масив рядків. Але у Fluent API немає HasMaxLength для елементів масиву — EF Core не може валідувати або маппити їх индивідуально.
Owned Type або зберігається inline (стовпці у таблиці), або в окремій таблиці (ToTable), або як JSON (ToJson). Не можна комбінувати ToJson і ToTable для одного Owned Type.
Одна з переваг JSON — гнучкість схеми. Але це ж є потенційною проблемою: старі рядки мають стару схему, нові — нову. Як EF Core обробляє десеріалізацію старих даних?
Найпростіший підхід: нові поля — nullable. EF Core десеріалізує null для відсутніх полів:
// Версія 1:
public class ProductMetadata
{
public string? Brand { get; set; }
public List<string> Tags { get; set; } = new();
}
// Версія 2: додали нові поля як nullable
public class ProductMetadata
{
public string? Brand { get; set; }
public List<string> Tags { get; set; } = new();
// Нові поля — nullable, старі рядки матимуть null
public string? Manufacturer { get; set; } // НОВИЙ
public int? WarrantyMonths { get; set; } // НОВИЙ
public double? WeightKg { get; set; } // НОВИЙ
}
Старі рядки при десеріалізації: Manufacturer = null, WarrantyMonths = null — все коректно.
public class ProductMetadata
{
public string? Brand { get; set; }
public List<string> Tags { get; set; } = new();
// Нове обов'язкове поле з дефолтом: старі рядки отримають дефолт при десеріалізації
public bool IsAvailable { get; set; } = true; // якщо немає в JSON → true
public string Currency { get; set; } = "UAH"; // якщо немає в JSON → UAH
}
Коли System.Text.Json десеріалізує і не знаходить поля — залишає значення, що було у ініціалізаторі.
Для суттєвих змін схеми — явне версіонування:
public class ProductMetadata
{
public int SchemaVersion { get; set; } = 2; // Версія схеми
public string? Brand { get; set; }
public List<string> Tags { get; set; } = new();
// Поля версії 2+
public double? WeightKg { get; set; }
public string? Manufacturer { get; set; }
// Поля версії 3+ (nullable для зворотної сумісності)
public List<string> Certifications { get; set; } = new();
}
// Сервісний шар: міграція при читанні
public class ProductService
{
public async Task<Product> GetProductAsync(int id)
{
var product = await context.Products.FindAsync(id);
// Мігруємо схему якщо потрібно
if (product!.Metadata.SchemaVersion < 2)
{
product.Metadata.SchemaVersion = 2;
// Популюємо нові поля з інших джерел або дефолтами
product.Metadata.Manufacturer ??= product.Metadata.Brand;
await context.SaveChangesAsync();
}
return product;
}
}
Для масової міграції даних при зміні схеми — SQL-скрипт у міграції:
public partial class MigrateProductMetadataV2 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// PostgreSQL: оновлення всіх рядків, додаємо нове поле з дефолтом
migrationBuilder.Sql(@"
UPDATE ""Products""
SET ""Metadata"" = ""Metadata"" || '{""schemaVersion"": 2, ""isAvailable"": true}'::jsonb
WHERE ""Metadata"" IS NOT NULL
AND NOT (""Metadata"" ? 'schemaVersion');
");
// SQL Server: через JSON_MODIFY
migrationBuilder.Sql(@"
UPDATE [Products]
SET [Metadata] = JSON_MODIFY(JSON_MODIFY([Metadata],
'$.schemaVersion', 2),
'$.isAvailable', 1)
WHERE [Metadata] IS NOT NULL
AND JSON_VALUE([Metadata], '$.schemaVersion') IS NULL;
");
}
}
Найчастіше питання: «Коли використовувати JSON Column, а коли нормалізувати у окрему таблицю?»
| Критерій | JSON Column | Нормалізована таблиця |
|---|---|---|
| Схема фіксована | Ні / Так | Так |
| Часта фільтрація | Обмежено (без індексу — повільно) | Так (інд. за замовчуванням) |
| JOIN з іншими таблицями | Ні | Так |
| Загальна кількість полів | 5-20 | Будь-яка |
| Поля кожного рядка різні | Так | Погано (NULL explosion) |
| Потрібна ACID для кожного поля | Ні (весь JSON атомарний) | Так |
| Великий масив (1000+ елементів) | Погано | Краще |
| Частота зміни схеми | Висока (легко) | Низька (міграції) |
| Читання всього об'єкту разом | Ефективно | Потребує JOIN |
| Агрегація по полях | Складно | Природно |
Використовуйте JSON Column, якщо:
UserSettings, ProductOptions, FeatureFlags — читається та записується як єдиний блок, не потрібна фільтрація по окремих полях або вона рідкісна.Використовуйте нормалізовану таблицю, якщо:
GROUP BY, SUM, COUNT по полях — SQL значно ефективніший для реляційних даних.OwnsMany у окрему таблицю значно кращий за JSON-масив. Пошук, сортування, pagination по елементах масиву — PostgreSQL добре справляється, але реляційна таблиця ефективніша.public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string CategorySlug { get; set; } = string.Empty;
// JSON Column: специфікації конкретної категорії
public ProductSpecifications? Specifications { get; set; }
// JSON Column: SEO-дані (завжди разом з продуктом)
public ProductSeoData Seo { get; set; } = new();
}
public class ProductSpecifications
{
// Загальні поля для всіх категорій
public double? WeightKg { get; set; }
public string? Color { get; set; }
// Специфіки для електроніки
public int? RamGb { get; set; }
public int? StorageGb { get; set; }
public string? Processor { get; set; }
public int? BatteryMah { get; set; }
// Специфіки для одягу
public string? Size { get; set; }
public string? Material { get; set; }
public string? Gender { get; set; } // "male", "female", "unisex"
// Специфіки для меблів
public double? WidthCm { get; set; }
public double? HeightCm { get; set; }
public double? DepthCm { get; set; }
public int? MaxWeightCapacityKg { get; set; }
// Довільні теги для будь-якої категорії
public List<string> Features { get; set; } = new();
}
public class ProductSeoData
{
public string? MetaTitle { get; set; }
public string? MetaDescription { get; set; }
public List<string> Keywords { get; set; } = new();
public string? CanonicalUrl { get; set; }
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).IsRequired().HasMaxLength(300);
builder.Property(p => p.Price).HasPrecision(12, 2).IsRequired();
builder.Property(p => p.CategorySlug).IsRequired().HasMaxLength(100).IsUnicode(false);
// Specifications: nullable (нові продукти можуть не мати)
builder.OwnsOne(p => p.Specifications, spec =>
{
spec.ToJson();
spec.Property(s => s.Color).HasMaxLength(50);
spec.Property(s => s.Processor).HasMaxLength(100);
spec.Property(s => s.Size).HasMaxLength(20);
spec.Property(s => s.Material).HasMaxLength(100);
spec.Property(s => s.Gender).HasMaxLength(10);
});
// Seo: обов'язкова (завжди є)
builder.OwnsOne(p => p.Seo, seo =>
{
seo.ToJson();
seo.Property(s => s.MetaTitle).HasMaxLength(160);
seo.Property(s => s.MetaDescription).HasMaxLength(320);
seo.Property(s => s.CanonicalUrl).HasMaxLength(500);
});
// Індекс по CategorySlug (нормалізований — в окремому стовпці)
builder.HasIndex(p => p.CategorySlug);
}
}
Запити з JSON Columns:
// Ноутбуки з RAM >= 16GB
var powerLaptops = await context.Products
.Where(p => p.CategorySlug == "laptops"
&& p.Specifications != null
&& p.Specifications.RamGb >= 16)
.OrderBy(p => p.Price)
.ToListAsync();
// Одяг для жінок
var womenClothing = await context.Products
.Where(p => p.CategorySlug == "clothing"
&& p.Specifications != null
&& p.Specifications.Gender == "female")
.ToListAsync();
// Продукти з feature "water-resistant"
var waterResistant = await context.Products
.Where(p => p.Specifications != null
&& p.Specifications.Features.Any(f => f == "water-resistant"))
.ToListAsync();
// SEO-дані: продукти без Meta Description
var missingSeo = await context.Products
.Where(p => p.Seo.MetaDescription == null)
.Select(p => new { p.Id, p.Name, p.CategorySlug })
.ToListAsync();
Завдання 1.1: Часткове оновлення через Raw SQL
Для Product з JSON Column Metadata реалізуйте метод UpdateBrandAsync(int productId, string newBrand), що оновлює тільки поле brand у JSON без перезапису всього стовпця. Для PostgreSQL: через jsonb_set, для SQL Server: через JSON_MODIFY. Чому це важливо при великих JSON?
Завдання 1.2: Індекс для PostgreSQL
У міграції додайте functional index на поле brand в Metadata для таблиці Products. Перевірте за допомогою логування SQL, чи використовується індекс при запиті .Where(p => p.Metadata.Brand == "Apple"). Підказка: EXPLAIN ANALYZE у PostgreSQL.
Завдання 1.3: Еволюція схеми
У ProductMetadata потрібно додати нове обов'язкове поле IsAvailableOnline (bool, дефолт true). Напишіть:
Завдання 2.1: Порівняння продуктивності
Є таблиця Articles з Tags як OwnsMany + ToJson() (JSON-масив у стовпці) і таблиця ArticleTags (нормалізована). Порівняйте продуктивність запиту «знайти статті з тегом "news"»:
.Where(a => a.Tags.Any(t => t == "news")).Where(a => a.ArticleTags.Any(t => t.Name == "news"))Намалюйте план запиту (EXPLAIN ANALYZE) для обох. Що швидше при 10K статей і 5 тегів кожна? При 1M статей?
Завдання 2.2: JSON Column для audit log
Реалізуйте Keyless Entity AuditEvent (Timestamp, EntityType, EntityId, Action, ActorId) з Data як JSON Column, що містить специфічний для кожного Action payload:
Create: всі поля нового записуUpdate: {before: {}, after: {}} — diffDelete: всі поля видаленого записуРеалізуйте AuditInterceptor у SaveChangesInterceptor, що записує події в AuditEvents при кожному збереженні.
Завдання 3.1: Гібридна архітектура Product Catalog
Реалізуйте ProductCatalog з гібридним підходом:
ProductTags (OwnsMany без ToJson())Обгрунтуйте це рішення: чому теги нормалізовані, а Specs — JSON? Які запити виграють від такого поділу?
Реалізуйте:
Ця стаття завершила огляд JSON Columns в EF Core:
ToJson() Change Tracking вбудований — EF Core знає структуру і відстежує зміни поле за полем. Не потрібен ручний ValueComparer.HasIndex(...).HasMethod("gin")), computed column для SQL Server. Прямі functional індекси на JSON-поля через migrationBuilder.Sql().jsonb найпотужніший (GIN, @>, JSONPath). SQL Server nvarchar(max) з JSON-функціями — добрий, але складніший для індексування. SQLite — базова підтримка.JOIN.SchemaVersion → SQL data migration у Up().Наступна стаття — Успадкування: TPH, TPT, TPC (стаття 12) — розкриє три стратегії маппінгу ієрархій класів на реляційні таблиці.
JSON Columns — Складні дані у JSON (Частина 1)
Навіщо зберігати JSON у реляційній БД, нативні JSON Columns в EF Core 7+ через ToJson(), маппінг Owned Types як JSON, LINQ-запити до JSON-властивостей та їх трансляція у SQL для PostgreSQL JSONB і SQL Server JSON.
Успадкування — Абстрактні класи та TPH (Частина 1)
Наслідування в EF Core — від простого абстрактного базового класу до Table-Per-Hierarchy (TPH). Discriminator column, конфігурація, продуктивність, NULL-стовпці та антипатерни.