Ef Core

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 нормалізована таблиця».

JSON Columns: Value Comparers, Індекси, Провайдери

Це продовження статті «JSON Columns: Складні дані у JSON». Читайте послідовно.


Value Comparers для JSON Columns

У статті про конфігурацію властивостей ми детально розбирали Value Comparers — пари функцій порівняння і клонування, що дозволяють EF Core Change Tracker правильно виявляти зміни у складних типах. Виникає питання: чи потрібен Value Comparer для JSON Columns?

Відповідь залежить від того, чи є зміни структурними (замінили весь об'єкт) або мутаційними (змінили поле всередині існуючого об'єкту).

Поведінка Change Tracker з ToJson

// Сценарій 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 і порівнює їх по-одному. Це більш гранулярно і правильно.

Коли все-таки потрібен ValueComparer

Якщо ви вбудовуєте 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.


Індексування JSON-полів

Одна з ключових переваг jsonb у PostgreSQL над звичайним JSON-рядком — можливість індексування. SQL Server також підтримує обчислювані стовпці на базі JSON для індексування. SQLite підтримує обмежено.

PostgreSQL: GIN-індекс для повного пошуку

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"]';

PostgreSQL: часткові індекси на конкретному JSON-полі

Для часто фільтрованих 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: обчислювані стовпці для індексування JSON

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]);
");
Рекомендація для продуктивності: Якщо ви регулярно фільтруєте по конкретному JSON-полю (наприклад, Brand), і таблиця велика — додайте індекс. Без індексу кожен .Where(p => p.Metadata.Brand == "Apple") — повне сканування таблиці з JSON_VALUE на кожному рядку.

Відмінності між провайдерами

Нативна підтримка JSON у EF Core є, але провайдери реалізують її по-різному. Розуміння відмінностей критичне для переносимого коду.

PostgreSQL (Npgsql)

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 для роботи з масивами
  • GIN індекси
  • 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

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 columns
  • OPENJSON на великих масивах може бути повільним
  • SQL Server 2016+ обов'язково для JSON-функцій

SQLite

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#.

Порівняльна таблиця провайдерів

МожливістьPostgreSQLSQL ServerSQLite
SQL типjsonbnvarchar(max)TEXT
ІндексуванняGIN, functionalComputed columnsНемає
Часткове оновленняjsonb_setJSON_MODIFYНемає в EF
JSONPathjsonb_path_queryНемає нативноjson_each
ПродуктивністьВисока (бінарний)Середня (string)Низька для складних
LINQ-трансляціяПовнаХорошаБазова

Обмеження JSON Columns в EF Core

Незважаючи на потужність нативних JSON Columns, є важливі обмеження, яких потрібно бути свідомим.

1. Часткове оновлення не підтримується

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.

2. Неможливо JOIN по JSON-полю

JSON-поля не можна використовувати як FK або join clause у SQL. Якщо потрібен JOIN — нормалізуйте поле у окремий стовпець.

3. Поліморфні JSON (різні схеми) не підтримуються нативно

EF Core очікує фіксовану C#-структуру для JSON Column. Якщо JSON може мати різну схему залежно від типу запису — необхідно або:

  • Використати спільний базовий клас з усіма можливими полями (частина будуть null)
  • Зберегти як HasConversion з ручною десеріалізацією

4. Scalars у масивах не підтримуються напряму

List<string> всередині Owned Type зберігається як JSON-масив рядків. Але у Fluent API немає HasMaxLength для елементів масиву — EF Core не може валідувати або маппити їх индивідуально.

5. ToJson() не можна поєднати з ToTable() у тому самому Owned Type

Owned Type або зберігається inline (стовпці у таблиці), або в окремій таблиці (ToTable), або як JSON (ToJson). Не можна комбінувати ToJson і ToTable для одного Owned Type.


Еволюція JSON-схеми: як версіонувати

Одна з переваг JSON — гнучкість схеми. Але це ж є потенційною проблемою: старі рядки мають стару схему, нові — нову. Як EF Core обробляє десеріалізацію старих даних?

Стратегія 1: Nullable нові поля

Найпростіший підхід: нові поля — 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 — все коректно.

Стратегія 2: Дефолтні значення через ініціалізатор

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 десеріалізує і не знаходить поля — залишає значення, що було у ініціалізаторі.

Стратегія 3: Версіонування через поле SchemaVersion

Для суттєвих змін схеми — явне версіонування:

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;
    }
}

Стратегія 4: Data Migration через SQL

Для масової міграції даних при зміні схеми — 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 vs нормалізована таблиця: матриця рішень

Найчастіше питання: «Коли використовувати JSON Column, а коли нормалізувати у окрему таблицю?»

Матриця вибору

КритерійJSON ColumnНормалізована таблиця
Схема фіксованаНі / ТакТак
Часта фільтраціяОбмежено (без індексу — повільно)Так (інд. за замовчуванням)
JOIN з іншими таблицямиНіТак
Загальна кількість полів5-20Будь-яка
Поля кожного рядка різніТакПогано (NULL explosion)
Потрібна ACID для кожного поляНі (весь JSON атомарний)Так
Великий масив (1000+ елементів)ПоганоКраще
Частота зміни схемиВисока (легко)Низька (міграції)
Читання всього об'єкту разомЕфективноПотребує JOIN
Агрегація по поляхСкладноПриродно

Конкретні рекомендації

Використовуйте JSON Column, якщо:

  1. Metadata/settings объект: UserSettings, ProductOptions, FeatureFlags — читається та записується як єдиний блок, не потрібна фільтрація по окремих полях або вона рідкісна.
  2. Динамічні атрибути: Продукти різних категорій мають різні специфікації. JSON дозволяє зберегти все без NULL-explosion і складних EAV-схем.
  3. Audit log payload: Кожна подія має специфічний payload. Корпус при читанні завжди одного типу.
  4. Документи малого розміру: Конфігурації, JSON-відповіді, формати даних < 10KB.

Використовуйте нормалізовану таблицю, якщо:

  1. Часта агрегація: GROUP BY, SUM, COUNT по полях — SQL значно ефективніший для реляційних даних.
  2. JOIN-и: Якщо поля у JSON використовуються для зв'язку з іншими таблицями — це FK, і вони мають бути у нормалізованому вигляді.
  3. Великі колекції: 100+ елементів — OwnsMany у окрему таблицю значно кращий за JSON-масив. Пошук, сортування, pagination по елементах масиву — PostgreSQL добре справляється, але реляційна таблиця ефективніша.
  4. Жорстка схема: Якщо кожен рядок має однаковий набір полів — JSON не дає переваг, тільки складніші запити.
  5. Референційна цілісність: Якщо значення у JSON є FK до іншої таблиці — реляційна база не може перевірити це. Нормалізація гарантує консистентність на рівні бази.

Практика: реальна архітектура з JSON Columns

Product Catalog з JSON Attributes

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();

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

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

Завдання 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). Напишіть:

  1. Зміну C#-класу
  2. Нову міграцію з SQL-скриптом для заповнення існуючих рядків (PostgreSQL або SQL Server)
  3. Переконайтеся, що старі JSON без цього поля десеріалізуються коректно

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

Завдання 2.1: Порівняння продуктивності

Є таблиця Articles з Tags як OwnsMany + ToJson() (JSON-масив у стовпці) і таблиця ArticleTags (нормалізована). Порівняйте продуктивність запиту «знайти статті з тегом "news"»:

  • JSON: .Where(a => a.Tags.Any(t => t == "news"))
  • Reляційна: .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: {}} — diff
  • Delete: всі поля видаленого запису

Реалізуйте AuditInterceptor у SaveChangesInterceptor, що записує події в AuditEvents при кожному збереженні.

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

Завдання 3.1: Гібридна архітектура Product Catalog

Реалізуйте ProductCatalog з гібридним підходом:

  • Скалярні атрибути (Name, Price, CategoryId) — нормалізовані колонки з індексами
  • Специфічні атрибути категорії (Specs) — JSON Column
  • Теги — нормалізована таблиця ProductTags (OwnsMany без ToJson())
  • SEO — JSON Column (читається рідко)

Обгрунтуйте це рішення: чому теги нормалізовані, а Specs — JSON? Які запити виграють від такого поділу?

Реалізуйте:

  • API-метод пошуку продуктів: по CategoryId + JSON-фільтри + повнотекстовий пошук по Name
  • API-метод редагування специфікацій продукту (часткове оновлення JSON)

Підсумок

Ця стаття завершила огляд JSON Columns в EF Core:

  • Value Comparers: з ToJson() Change Tracking вбудований — EF Core знає структуру і відстежує зміни поле за полем. Не потрібен ручний ValueComparer.
  • Індексування: GIN-індекс для PostgreSQL (через HasIndex(...).HasMethod("gin")), computed column для SQL Server. Прямі functional індекси на JSON-поля через migrationBuilder.Sql().
  • Провайдери: PostgreSQL jsonb найпотужніший (GIN, @>, JSONPath). SQL Server nvarchar(max) з JSON-функціями — добрий, але складніший для індексування. SQLite — базова підтримка.
  • Обмеження: часткове оновлення не підтримується EF Core — весь JSON перезаписується. Поліморфний JSON вимагає workaround. Немає реляційних JOIN.
  • Еволюція схеми: nullable нові поля → дефолти у ініціалізаторі → явне SchemaVersion → SQL data migration у Up().
  • Матриця вибору: JSON — для metadata, динамічних атрибутів, малих документів, різних схем. Нормалізація — для агрегацій, JOIN, великих колекцій, референційної цілісності.

Наступна стаття — Успадкування: TPH, TPT, TPC (стаття 12) — розкриє три стратегії маппінгу ієрархій класів на реляційні таблиці.


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