До 2012 року зберігання JSON у реляційних базах вважалося поганою практикою. «Якщо є нереляційні дані — використовуй NoSQL». Але реальний світ виявився складнішим: у PostgreSQL 9.4 з'явився тип jsonb, у SQL Server 2016 — функції JSON_VALUE/JSON_QUERY. Сьогодні підтримка JSON у реляційних СУБД — це first-class feature, а не хак.
Чому ж виникає потреба зберігати JSON у реляційній базі? Розглянемо три реальних сценарії.
Сценарій 1: Налаштування продукту. Інтернет-магазин продає одяг, електроніку та меблі. Одяг має Size, Color, Material. Електроніка — ProcessorSpeed, RAM, StorageCapacity. Меблі — Dimensions, WeightCapacity, Assembling. Якщо нормалізувати — потрібна таблиця ProductAttributes з 50+ стовпцями, де 90% завжди NULL. Або EAV (Entity-Attribute-Value) патерн — але він є антипатерном через відсутність типізації та жахливу продуктивність складних запитів. Або окремі таблиці для кожного типу продукту — але їх може бути сотні. JSON вирішує: Attributes зберігається як jsonb зі схемою, специфічною для кожного типу продукту.
Сценарій 2: Audit log / Events. Кожна дія у системі генерує подію: UserLoggedIn, OrderPlaced, PaymentFailed. Кожна подія має специфічний payload. Зберігати всі типи подій у нормалізованій схемі — дуже складно через різний набір полів. JSON дозволяє зберігати гетерогенні payload в одній таблиці.
Сценарій 3: Документо-орієнтовані частини даних. CMS: стаття має структурований контент (draftjs/ProseMirror JSON), що не відповідає простій реляційній схемі. Зберігання у JSON дозволяє читати і зберігати без парсингу на рівні бази.
Ключова відмінність від Value Converter (серіалізація у рядок):
У попередній статті ми розглядали HasConversion з JsonSerializer — збереження об'єкту як JSON-рядка. Різниця критична:
| Value Converter (JSON string) | Нативні JSON Columns | |
|---|---|---|
| SQL тип | nvarchar(max) / text | jsonb / json |
| LINQ-запити | ❌ Неможливо (opaque string) | ✅ Транслюються у SQL |
| Індексування | ❌ Неможливо | ✅ Часткові індекси на JSON-поля |
| SQL-функції | Вручну через Raw SQL | Через LINQ або Raw SQL |
| Валідація схеми | Немає | На рівні БД (PostgreSQL check) |
| Change Tracking | Потрібен Value Comparer | Вбудований |
EF Core 7 ввів нативну підтримку JSON Columns через ToJson(). Це — тема цієї статті.
EF Core реалізує JSON Columns через вже знайомий механізм Owned Types (OwnsOne / OwnsMany). Різниця лише в одному методі — ToJson(). Без нього Owned Type зберігається як стовпці у таблиці. З ToJson() — весь граф об'єктів серіалізується у один JSON-стовпець.
Без ToJson(): З ToJson():
Customers Customers
├── Id ├── Id
├── Name ├── Name
├── ShippingStreet ├── ShippingAddress (jsonb)
├── ShippingCity │ └── {"street":"...", "city":"..."}
├── ShippingCountry └── ...
└── ShippingPostalCode
Це означає: всі концепції з попередньої статті (OwnsOne, OwnsMany, вкладені типи) залишаються незмінними — просто додається ToJson(), і EF Core перемикається на JSON-зберігання.
SQL Server 2016+
nvarchar(max) з JSON. Функції JSON_VALUE, JSON_QUERY, JSON_MODIFY. EF Core транслює LINQ у ці функції.PostgreSQL 12+
jsonb (бінарний JSON з індексуванням) або json. Оператори ->, ->>, @>. Підтримка GIN-індексів.SQLite
TEXT. JSON-функції доступні з SQLite 3.38+. Обмежені можливості запитів.MySQL 8.0+
JSON_EXTRACT, JSON_CONTAINS.Найпростіший випадок: один вкладений об'єкт → один JSON-стовпець.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
// Цей об'єкт буде збережено як JSON у стовпці Metadata
public ProductMetadata Metadata { get; set; } = new();
}
public class ProductMetadata
{
public string? Brand { get; set; }
public string? Sku { get; set; }
public double? WeightKg { get; set; }
public string? CountryOfOrigin { get; set; }
public List<string> Tags { get; set; } = new();
public bool IsDigital { 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(200);
builder.Property(p => p.Price).HasPrecision(12, 2);
// OwnsOne + ToJson: ProductMetadata → JSON стовпець "Metadata"
builder.OwnsOne(p => p.Metadata, meta =>
{
meta.ToJson(); // ← ключова відмінність від звичайного OwnsOne!
meta.Property(m => m.Brand).HasMaxLength(100);
meta.Property(m => m.Sku).HasMaxLength(50);
// Tags: List<string> всередині JSON — не потребує окремої конфігурації
});
}
}
Генерований DDL (SQL Server):
CREATE TABLE [Products] (
[Id] INT NOT NULL IDENTITY,
[Name] NVARCHAR(200) NOT NULL,
[Price] DECIMAL(12, 2) NOT NULL,
[Metadata] NVARCHAR(MAX) NULL, -- JSON стовпець
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
PostgreSQL:
CREATE TABLE "Products" (
"Id" SERIAL NOT NULL,
"Name" VARCHAR(200) NOT NULL,
"Price" NUMERIC(12, 2) NOT NULL,
"Metadata" JSONB NULL, -- бінарний JSONB
PRIMARY KEY ("Id")
);
Збережений JSON у стовпці Metadata:
{
"brand": "Apple",
"sku": "MBP-M3-14",
"weightKg": 1.55,
"countryOfOrigin": "China",
"tags": ["laptop", "apple", "m3"],
"isDigital": false
}
EF Core автоматично обирає camelCase для ключів JSON (за конвенцією). Це можна змінити через конфігурацію серіалізатора.
// Створення: ніяких відмінностей від звичайного OwnsOne
var product = new Product
{
Name = "MacBook Pro 14\"",
Price = 89990m,
Metadata = new ProductMetadata
{
Brand = "Apple",
Sku = "MBP-M3-14",
WeightKg = 1.55,
CountryOfOrigin = "China",
Tags = new List<string> { "laptop", "apple", "m3" },
IsDigital = false
}
};
context.Products.Add(product);
await context.SaveChangesAsync();
// INSERT INTO Products (Name, Price, Metadata) VALUES (
// 'MacBook Pro 14"', 89990,
// '{"brand":"Apple","sku":"MBP-M3-14","weightKg":1.55,...}'
// )
// Читання: Metadata завантажується і десеріалізується автоматично
var product = await context.Products.FindAsync(productId);
Console.WriteLine(product!.Metadata.Brand); // Apple
Console.WriteLine(product.Metadata.Tags[0]); // laptop
// Оновлення: змінюємо поля всередині JSON
product.Metadata.WeightKg = 1.6;
product.Metadata.Tags.Add("m3-pro");
await context.SaveChangesAsync();
// UPDATE Products SET Metadata = '{"brand":"Apple","weightKg":1.6,...}'
// WHERE Id = @id
// (весь JSON перезаписується, не тільки змінені поля)
HasConversion з JSON-рядком, тут не потрібен ValueComparer — EF Core «розуміє» структуру і порівнює по полях.Якщо потрібно зберегти колекцію об'єктів у JSON-масиві одного стовпця — OwnsMany з ToJson().
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public DateTime PlacedAt { get; set; }
// Колекція → JSON-масив у одному стовпці
public ICollection<OrderTag> Tags { get; set; } = new List<OrderTag>();
public ICollection<OrderNote> Notes { get; set; } = new List<OrderNote>();
}
public class OrderTag
{
public string Value { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty; // "priority", "region", тощо
}
public class OrderNote
{
public string Text { get; set; } = string.Empty;
public string AuthorName { get; set; } = string.Empty;
public DateTime AddedAt { get; set; }
public bool IsInternal { get; set; }
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber).IsRequired().HasMaxLength(30);
// OwnsMany + ToJson: масив тегів → JSON стовпець "Tags"
builder.OwnsMany(o => o.Tags, tag =>
{
tag.ToJson(); // весь List<OrderTag> → JSON array
tag.Property(t => t.Value).HasMaxLength(50);
tag.Property(t => t.Category).HasMaxLength(50);
});
// OwnsMany + ToJson: масив нотаток → JSON стовпець "Notes"
builder.OwnsMany(o => o.Notes, note =>
{
note.ToJson();
note.Property(n => n.Text).HasMaxLength(2000);
note.Property(n => n.AuthorName).HasMaxLength(100);
});
}
}
Генерований DDL:
CREATE TABLE [Orders] (
[Id] INT NOT NULL IDENTITY,
[OrderNumber] VARCHAR(30) NOT NULL,
[PlacedAt] DATETIME2 NOT NULL,
[Tags] NVARCHAR(MAX) NULL, -- JSON масив тегів
[Notes] NVARCHAR(MAX) NULL, -- JSON масив нотаток
CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
);
Збережені JSON-значення:
-- Tags стовпець:
[
{"value": "urgent", "category": "priority"},
{"value": "kyiv", "category": "region"}
]
-- Notes стовпець:
[
{
"text": "Клієнт просив доставку до 18:00",
"authorName": "Менеджер Іванова",
"addedAt": "2024-01-15T10:30:00Z",
"isInternal": false
}
]
// Робота з OwnsMany + ToJson
var order = await context.Orders
.Include(o => o.Tags)
.Include(o => o.Notes)
.FirstAsync(o => o.Id == orderId);
// Додати тег
order.Tags.Add(new OrderTag { Value = "express", Category = "delivery" });
// Видалити нотатку
var noteToRemove = order.Notes.FirstOrDefault(n => n.IsInternal);
if (noteToRemove is not null) order.Notes.Remove(noteToRemove);
await context.SaveChangesAsync();
// UPDATE Orders SET Tags='[...]', Notes='[...]' WHERE Id=@id
Найпотужніший сценарій — складні вкладені структури у одному JSON-стовпці. Наприклад, форма замовлення з рядками, де кожен рядок має власний опис:
public class SurveyResponse
{
public int Id { get; set; }
public string RespondentEmail { get; set; } = string.Empty;
public DateTime SubmittedAt { get; set; }
// Вся структура відповідей → один JSON стовпець "Answers"
public ICollection<QuestionAnswer> Answers { get; set; } = new List<QuestionAnswer>();
}
public class QuestionAnswer
{
public string QuestionId { get; set; } = string.Empty;
public string QuestionText { get; set; } = string.Empty;
public string AnswerType { get; set; } = string.Empty; // "text", "rating", "choice"
public string? TextValue { get; set; }
public int? RatingValue { get; set; }
public SelectedChoice? Choice { get; set; } // вкладений об'єкт
}
public class SelectedChoice
{
public string OptionId { get; set; } = string.Empty;
public string OptionLabel { get; set; } = string.Empty;
}
public class SurveyResponseConfiguration : IEntityTypeConfiguration<SurveyResponse>
{
public void Configure(EntityTypeBuilder<SurveyResponse> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.RespondentEmail).IsRequired().HasMaxLength(320);
builder.OwnsMany(s => s.Answers, answer =>
{
answer.ToJson("Answers"); // явна назва колонки
answer.Property(a => a.QuestionId).HasMaxLength(50);
answer.Property(a => a.QuestionText).HasMaxLength(500);
answer.Property(a => a.AnswerType).HasMaxLength(20);
answer.Property(a => a.TextValue).HasMaxLength(2000);
// Вкладений OwnsOne всередині OwnsMany + ToJson
answer.OwnsOne(a => a.Choice, choice =>
{
// Не треба ToJson() — батько вже ToJson(), все вкладається у той самий JSON
choice.Property(c => c.OptionId).HasMaxLength(50);
choice.Property(c => c.OptionLabel).HasMaxLength(200);
});
});
}
}
Збережений JSON:
[
{
"questionId": "q1",
"questionText": "Як ви оцінюєте наш сервіс?",
"answerType": "rating",
"textValue": null,
"ratingValue": 5,
"choice": null
},
{
"questionId": "q2",
"questionText": "Яким способом ви дізналися про нас?",
"answerType": "choice",
"textValue": null,
"ratingValue": null,
"choice": {
"optionId": "opt-3",
"optionLabel": "Через соціальні мережі"
}
}
]
Все це в одному рядку таблиці SurveyResponses — немає окремих таблиць для QuestionAnswers чи SelectedChoices.
Головна перевага нативних JSON Columns над HasConversion — LINQ-запити безпосередньо до полів всередині JSON, що транслюються у SQL.
// Знайти всі Macbook від Apple
var appleProducts = await context.Products
.Where(p => p.Metadata.Brand == "Apple")
.ToListAsync();
SQL Server:
SELECT [p].[Id], [p].[Name], [p].[Price], [p].[Metadata]
FROM [Products] AS [p]
WHERE JSON_VALUE([p].[Metadata], '$.brand') = 'Apple'
PostgreSQL:
SELECT p."Id", p."Name", p."Price", p."Metadata"
FROM "Products" AS p
WHERE p."Metadata" ->> 'brand' = 'Apple'
// Знайти замовлення з тегом "urgent"
var urgentOrders = await context.Orders
.Where(o => o.Tags.Any(t => t.Value == "urgent"))
.ToListAsync();
SQL Server:
SELECT [o].[Id], [o].[OrderNumber], [o].[PlacedAt]
FROM [Orders] AS [o]
WHERE EXISTS (
SELECT 1
FROM OPENJSON([o].[Tags]) WITH ([value] NVARCHAR(50) '$.value') AS [t]
WHERE [t].[value] = 'urgent'
)
PostgreSQL:
SELECT o."Id", o."OrderNumber", o."PlacedAt"
FROM "Orders" AS o
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(o."Tags") AS t(element)
WHERE t.element ->> 'value' = 'urgent'
)
// Вибрати тільки назву та бренд
var productBrands = await context.Products
.Where(p => p.Metadata.IsDigital == false)
.Select(p => new { p.Name, p.Metadata.Brand, p.Metadata.WeightKg })
.ToListAsync();
SQL Server:
SELECT [p].[Name],
JSON_VALUE([p].[Metadata], '$.brand') AS [Brand],
CAST(JSON_VALUE([p].[Metadata], '$.weightKg') AS float) AS [WeightKg]
FROM [Products] AS [p]
WHERE CAST(JSON_VALUE([p].[Metadata], '$.isDigital') AS bit) = CAST(0 AS bit)
// Сортування по JSON-полю
var byWeight = await context.Products
.OrderBy(p => p.Metadata.WeightKg)
.ToListAsync();
// Фільтрація по кількості елементів у JSON-масиві
var withManyTags = await context.Products
.Where(p => p.Metadata.Tags.Count > 3)
.ToListAsync();
InvalidOperationException або дані будуть завантажені у пам'ять (client evaluation). Завжди перевіряйте генерований SQL через логування. Складні операції (вкладені агрегації, GroupBy по JSON-полю) можуть не транслюватися.// Знайти продукти без бренду
var unbranded = await context.Products
.Where(p => p.Metadata.Brand == null)
.ToListAsync();
// Знайти відповіді з вибором (Choice не null)
var withChoice = await context.SurveyResponses
.Where(s => s.Answers.Any(a => a.Choice != null))
.ToListAsync();
Завдання 1.1: Перший JSON Column
Є BlogPost (Id, Title, PublishedAt). Додайте SeoData з полями: MetaTitle (string?), MetaDescription (string?), Keywords (List<string>), CanonicalUrl (string?). Налаштуйте OwnsOne + ToJson() так, щоб весь SEO-блок зберігався у стовпці SeoData. Напишіть CRUD: створення поста з SEO, додавання keyword, пошук постів за keyword.
Завдання 1.2: OwnsMany + ToJson для тегів
Recipe (Id, Name, CookingTimeMinutes) має колекцію Ingredient: name (string), amount (decimal), unit (string — "g", "ml", "pcs"). Налаштуйте OwnsMany + ToJson(). Напишіть запит: знайти всі рецепти, що містять інгредієнт "борошно" (пошук по name).
Завдання 1.3: Порівняння HasConversion vs ToJson
Реалізуйте той самий UserSettings (Theme, Language, NotificationEmails List<string>) двома способами:
HasConversion (JSON-рядок у nvarchar(max))OwnsOne + ToJson()Для кожного: чи можна зробити .Where(u => u.Settings.Theme == "dark")? Що генерується у SQL?
Завдання 2.1: Складна JSON-структура
JobPosting (Id, Title, CompanyName) має Requirements як JSON:
{
"experienceYears": 3,
"skills": ["C#", "EF Core", "PostgreSQL"],
"education": { "level": "bachelor", "field": "computer science" },
"languages": [{"lang": "Ukrainian", "level": "native"}, {"lang": "English", "level": "b2"}]
}
Реалізуйте C#-класи та конфігурацію. Напишіть запити:
Завдання 2.2: LINQ vs Raw SQL для JSON
Для Product з Metadata (Brand, Tags, WeightKg) напишіть три варіанти запиту «продукти з тегом "sale" і вагою < 1 кг»:
FromSqlRaw для SQL Server (JSON_VALUE, OPENJSON)FromSqlRaw для PostgreSQL (jsonb операції)Порівняйте складність і переносимість.
Завдання 3.1: JSON-орієнтована подієва система
Реалізуйте DomainEvent (Id, AggregateId, AggregateType, EventType, OccurredAt) з Payload як JSON Column. Payload — поліморфний: OrderPlacedPayload (OrderNumber, CustomerId, TotalAmount), PaymentReceivedPayload (Amount, Currency, PaymentMethod), etc.
Проблема: EF Core не підтримує поліморфний JSON нативно. Знайдіть рішення: можливо HasConversion + ручна десеріалізація по EventType? Або окремі nullable JSON стовпці? Оцініть компроміси.
У першій частині ми заклали фундамент роботи з JSON Columns:
HasConversion — нативна підтримка LINQ-запитів.ToJson(): одна лінія, що перетворює Owned Type з inline-стовпців у JSON-стовпець. Вся конфігурація (MaxLength, Precision) зберігається, але впливає на валідацію, не на DDL.OwnsOne + ToJson: скалярний JSON-об'єкт у одному стовпці. Завантажується разом з власником.OwnsMany + ToJson: JSON-масив об'єктів у одному стовпці.OwnsMany → OwnsOne всередині — всі рівні у тому ж JSON..Where(p => p.Metadata.Brand == "Apple") транслюється у JSON_VALUE (SQL Server) або ->> (PostgreSQL). Але не всі операції траслюються — перевіряйте SQL.У другій частині розглянемо: Value Comparers для JSON, індексування JSON-полів, відмінності між провайдерами, обмеження JSON Columns, стратегія еволюції JSON-схеми та матриця «JSON vs нормалізована таблиця».
Складні типи — Complex Types, Keyless Entities, Порівняння (Частина 2)
Complex Types (EF Core 8+) як нові Value Objects без ідентичності, Keyless Entity Types для Views і Raw SQL, практичне DDD-моделювання та матриця вибору між Owned Types, Complex Types і Value Converters.
JSON Columns — Value Comparers, Індекси, Провайдери (Частина 2)
Value Comparers для JSON Columns, індексування JSON-полів, відмінності PostgreSQL JSONB vs SQL Server JSON vs SQLite, обмеження JSON Columns в EF Core, стратегії версіонування JSON-схеми та матриця вибору «JSON vs нормалізована таблиця».