Ef Core

JSON Columns — Складні дані у JSON (Частина 1)

Навіщо зберігати JSON у реляційній БД, нативні JSON Columns в EF Core 7+ через ToJson(), маппінг Owned Types як JSON, LINQ-запити до JSON-властивостей та їх трансляція у SQL для PostgreSQL JSONB і SQL Server JSON.

JSON Columns: Складні дані у JSON

Чому JSON у реляційній базі — це не компроміс, а архітектурний вибір

До 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) / textjsonb / json
LINQ-запити❌ Неможливо (opaque string)✅ Транслюються у SQL
Індексування❌ Неможливо✅ Часткові індекси на JSON-поля
SQL-функціїВручну через Raw SQLЧерез LINQ або Raw SQL
Валідація схемиНемаєНа рівні БД (PostgreSQL check)
Change TrackingПотрібен Value ComparerВбудований

EF Core 7 ввів нативну підтримку JSON Columns через ToJson(). Це — тема цієї статті.


Архітектура JSON Columns в EF Core 7+

Концепція: Owned Types + 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 тип. Pomelo провайдер підтримує ToJson(). Функції JSON_EXTRACT, JSON_CONTAINS.

ToJson(): базова конфігурація

OwnsOne + ToJson: об'єкт як JSON-стовпець

Найпростіший випадок: один вкладений об'єкт → один 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 (за конвенцією). Це можна змінити через конфігурацію серіалізатора.

CRUD-операції з JSON Columns

// Створення: ніяких відмінностей від звичайного 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 перезаписується, не тільки змінені поля)
Change Tracking для JSON: EF Core відстежує зміни всередині JSON-об'єкту коректно через вбудований механізм snapshot. На відміну від HasConversion з JSON-рядком, тут не потрібен ValueComparer — EF Core «розуміє» структуру і порівнює по полях.

OwnsMany + ToJson: масив об'єктів як JSON

Якщо потрібно зберегти колекцію об'єктів у 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-структури: OwnsMany → OwnsOne → ToJson

Найпотужніший сценарій — складні вкладені структури у одному 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.


LINQ-запити до JSON-властивостей

Головна перевага нативних JSON Columns над HasConversion — LINQ-запити безпосередньо до полів всередині JSON, що транслюються у SQL.

Фільтрація по скалярному JSON-полю

// Знайти всі 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'
)

Проєкція JSON-полів

// Вибрати тільки назву та бренд
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)

OrderBy та Count по JSON-полях

// Сортування по 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();
Обмеження LINQ-трансляції: Не всі LINQ-операції транслюються у SQL для JSON. Якщо EF Core не може транслювати — виникне InvalidOperationException або дані будуть завантажені у пам'ять (client evaluation). Завжди перевіряйте генерований SQL через логування. Складні операції (вкладені агрегації, GroupBy по JSON-полю) можуть не транслюватися.

Перевірка на null у 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 — Базовий

Завдання 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>) двома способами:

  1. Через HasConversion (JSON-рядок у nvarchar(max))
  2. Через OwnsOne + ToJson()

Для кожного: чи можна зробити .Where(u => u.Settings.Theme == "dark")? Що генерується у SQL?

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

Завдання 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#-класи та конфігурацію. Напишіть запити:

  • Посади з досвідом >= 3 роки
  • Посади зі скілом "EF Core"
  • Посади з рівнем англійської "b2" або вище

Завдання 2.2: LINQ vs Raw SQL для JSON

Для Product з Metadata (Brand, Tags, WeightKg) напишіть три варіанти запиту «продукти з тегом "sale" і вагою < 1 кг»:

  1. Через LINQ (нативний JSON Column)
  2. Через FromSqlRaw для SQL Server (JSON_VALUE, OPENJSON)
  3. Через FromSqlRaw для PostgreSQL (jsonb операції)

Порівняйте складність і переносимість.

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

Завдання 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 стовпці? Оцініть компроміси.


Підсумок частини 1

У першій частині ми заклали фундамент роботи з JSON Columns:

  • Мотивація: JSON у реляційних БД виправданий для: динамічних атрибутів, гетерогенних payload, документо-орієнтованих даних. Перевага над HasConversion — нативна підтримка LINQ-запитів.
  • ToJson(): одна лінія, що перетворює Owned Type з inline-стовпців у JSON-стовпець. Вся конфігурація (MaxLength, Precision) зберігається, але впливає на валідацію, не на DDL.
  • OwnsOne + ToJson: скалярний JSON-об'єкт у одному стовпці. Завантажується разом з власником.
  • OwnsMany + ToJson: JSON-масив об'єктів у одному стовпці.
  • Вкладені структури: OwnsManyOwnsOne всередині — всі рівні у тому ж JSON.
  • LINQ-запити: .Where(p => p.Metadata.Brand == "Apple") транслюється у JSON_VALUE (SQL Server) або ->> (PostgreSQL). Але не всі операції траслюються — перевіряйте SQL.

У другій частині розглянемо: Value Comparers для JSON, індексування JSON-полів, відмінності між провайдерами, обмеження JSON Columns, стратегія еволюції JSON-схеми та матриця «JSON vs нормалізована таблиця».