Ef Core

Завантаження Пов'язаних Даних (Частина 1)

Eager Loading через Include і ThenInclude в EF Core — стратегії завантаження навігаційних властивостей, Filtered Include, Split Queries для оптимізації картезіанського вибуху, АuditableIncludes і типові помилки.

Завантаження Пов'язаних Даних

Три стратегії, одна мета

У попередній статті ми бачили, що EF Core може автоматично включати навігаційні властивості у SQL через проєкцію Select. Але часто потрібен інший підхід — завантажити повний граф об'єктів з реляційними зв'язками. Для цього EF Core підтримує три стратегії:

Eager Loading (жадібне завантаження) — дані завантажуються разом з основним запитом через Include і ThenInclude. SQL виконується одразу зі всіма JOIN або кількома запитами (Split Query). Це найпоширеніша і найбезпечніша стратегія.

Lazy Loading (ліниве завантаження) — навігаційні властивості завантажуються на вимогу, коли до них звертаються вперше. Відбувається автоматично, але непомітно генерує додаткові SQL. Найнебезпечніша стратегія — легко отримати N+1.

Explicit Loading (явне завантаження) — навігаційні властивості завантажуються вручну через context.Entry().Reference().LoadAsync(). Повний контроль, але більше коду.

Розуміння різниці між цими стратегіями — ключ до уникнення N+1 проблеми і написання ефективних EF Core застосунків.


Eager Loading: Include і ThenInclude

Базовий Include

public class Order
{
    public int Id { get; set; }
    public string OrderNumber { get; set; } = string.Empty;
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
    public ICollection<OrderLineItem> LineItems { get; set; } = new List<OrderLineItem>();
}

public class OrderLineItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public Order Order { get; set; } = null!;
    public Product Product { get; set; } = null!;
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}
// Include: завантажити пов'язану сутність разом з основною
var orders = await context.Orders
    .Include(o => o.Customer)  // ← JOIN до Customers
    .ToListAsync();
// SQL: SELECT o.*, c.*
//      FROM Orders o
//      LEFT JOIN Customers c ON c.Id = o.CustomerId
// Include для колекції
var orders = await context.Orders
    .Include(o => o.LineItems) // ← JOIN до OrderLineItems
    .ToListAsync();
// SQL: SELECT o.*, li.*
//      FROM Orders o
//      LEFT JOIN OrderLineItems li ON li.OrderId = o.Id

ThenInclude: вглиб графа об'єктів

ThenInclude продовжує ланцюжок від попереднього Include:

// Два рівні: Order → LineItems → Product
var orders = await context.Orders
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .ToListAsync();
// SQL (спрощено):
// SELECT o.*, li.*, p.*
// FROM Orders o
// LEFT JOIN OrderLineItems li ON li.OrderId = o.Id
// LEFT JOIN Products p ON p.Id = li.ProductId

// Три рівні: Order → LineItems → Product → Category
var ordersDeep = await context.Orders
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
            .ThenInclude(p => p.Category)
    .ToListAsync();
// Три JOIN у одному запиті

Кілька Include на одному рівні

// Завантажити і Customer, і LineItems (і ще більше)
var fullOrders = await context.Orders
    .Include(o => o.Customer)          // JOIN Customers
    .Include(o => o.LineItems)         // JOIN OrderLineItems
        .ThenInclude(li => li.Product) // JOIN Products
    .Include(o => o.Invoice)           // JOIN Invoices
    .ToListAsync();

EF Core генерує один SQL запит з усіма JOIN за замовчуванням (Single Query). Але кілька Include з колекціями можуть спричинити Cartesian Explosion — детальніше у розділі про Split Queries.

Include для Reference та Collection: різниця

// Reference Include (1:1 або M:1): завжди LEFT JOIN
.Include(o => o.Customer) // Customer — reference navigation
// SQL: LEFT JOIN Customers c ON c.Id = o.CustomerId

// Collection Include (1:N): може бути окремий SELECT (залежно від стратегії)
.Include(o => o.LineItems) // LineItems — collection navigation

Cartesian Explosion і Split Queries

Проблема: Cartesian Explosion

Коли включаємо кілька колекцій через Include, EF Core за замовчуванням виконує один SQL з усіма JOIN. Але результат містить декартовий добуток:

// Проблема: Order з 3 LineItems і 2 Tags
var orders = await context.Orders
    .Include(o => o.LineItems) // 3 рядки
    .Include(o => o.Tags)      // 2 рядки
    .ToListAsync();

Без Split Query генерується:

SELECT o.*, li.*, t.*
FROM Orders o
LEFT JOIN OrderLineItems li ON li.OrderId = o.Id
LEFT JOIN OrderTags t ON t.OrderId = o.Id

Результат — декартовий добуток: 3 × 2 = 6 рядків для одного замовлення. Кожний LineItem дублюється для кожного Tag і навпаки. EF Core правильно дедублікує в C#, але по мережі передається в 6 разів більше даних.

При реальних даних (100 LineItems × 20 Tags = 2000 рядків для одного замовлення) — продуктивність катастрофічна.

Split Queries: рішення

Split Queries — режим, при якому EF Core виконує окремий SQL-запит для кожної колекції Include:

// Split Query: окремий запит для кожної колекції
var orders = await context.Orders
    .Include(o => o.LineItems)
    .Include(o => o.Tags)
    .AsSplitQuery()  // ← увімкнути Split Query
    .ToListAsync();

Генеровані запити:

-- Запит 1: основні замовлення
SELECT o.Id, o.OrderNumber, o.CustomerId, ...
FROM Orders o;

-- Запит 2: рядки для завантажених замовлень
SELECT li.Id, li.OrderId, li.ProductId, li.Quantity, li.UnitPrice
FROM OrderLineItems li
WHERE li.OrderId IN (1, 2, 3, ...);  -- IDs з першого запиту

-- Запит 3: теги для завантажених замовлень
SELECT t.Id, t.OrderId, t.Name
FROM OrderTags t
WHERE t.OrderId IN (1, 2, 3, ...);

Три запити замість одного з 6x multiplication — значно ефективніше при великих колекціях.

Глобальний Split Query

// Налаштувати Split Query глобально для всього DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(connectionString,
        sqlOptions => sqlOptions.UseQuerySplittingBehavior(
            QuerySplittingBehavior.SplitQuery));
}

// або через DI:
services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString,
        sqlOptions => sqlOptions.UseQuerySplittingBehavior(
            QuerySplittingBehavior.SplitQuery));
});

Коли Split Query глобальний — можна повернутись до Single Query для конкретного запиту:

var singleQueryResult = await context.Orders
    .Include(o => o.Customer)   // reference — JOIN (без cartesian проблеми)
    .AsSingleQuery()            // ← для цього конкретного запиту — Single Query
    .ToListAsync();

Коли Split Query, коли Single Query

СценарійРекомендація
Одна колекція IncludeSingle Query (Join ефективніший)
Reference Include (M:1)Single Query (немає Cartesian)
2+ колекції IncludeSplit Query
Велика колекція (100+ рядків)Split Query
Невелика колекція (5-10 рядків)Залежить — перевіряйте план
Include + ThenInclude (один ланцюг)Single Query (один JOIN-шлях)
Split Query і транзакційність: Split Query виконує кілька SQL-команд. Якщо між ними хтось змінить дані (concurrent write) — результати можуть бути неконсистентними. Якщо це критично — оберніть у транзакцію або використовуйте Single Query.

Filtered Include: умова на навігаційній властивості

Filtered Include (EF Core 5+) дозволяє додати умову до Include — завантажити лише підмножину пов'язаних записів:

// Завантажити тільки активні рядки замовлення
var orders = await context.Orders
    .Include(o => o.LineItems.Where(li => !li.IsDeleted)) // фільтр!
    .ToListAsync();
// SQL: SELECT o.*, li.*
//      FROM Orders o
//      LEFT JOIN OrderLineItems li ON li.OrderId = o.Id AND li.IsDeleted = 0

// Завантажити не більше 5 відгуків на продукт (сортовані)
var products = await context.Products
    .Include(p => p.Reviews
        .Where(r => r.IsApproved)
        .OrderByDescending(r => r.Rating)
        .Take(5))
    .ToListAsync();
// SQL: ... з підзапитом або LATERAL JOIN (PostgreSQL) залежно від провайдера

Filtered Include і Global Query Filters

Filtered Include взаємодіє з Global Query Filters: якщо на OrderLineItem є GQF !IsDeleted — він і так застосовується до Include. Filtered Include дозволяє додати додаткові умови поверх GQF або обійти GQF через IgnoreQueryFilters:

// GQF на OrderLineItem: WHERE IsDeleted = 0 (автоматично)
// Filtered Include: WHERE IsDeleted = 0 AND IsGift = true
var ordersWithGiftItems = await context.Orders
    .Include(o => o.LineItems.Where(li => li.IsGift))
    .ToListAsync();
// li.IsDeleted = 0 (від GQF) AND li.IsGift = 1 (від Filtered Include)

Автоматична дедублікація при Include

Важлива деталь: при Include з колекцією EF Core автоматично дедублікує результати. Якщо одне замовлення має 3 рядки — у SQL поверне 3 рядки з однаковими полями Order, але EF Core поверне один об'єкт Order з колекцією з 3 елементів:

var orders = await context.Orders
    .Include(o => o.LineItems)
    .ToListAsync();

// SQL: 3 рядки для замовлення з 3 LineItems:
// | OrderId | OrderNumber | LineItemId | Qty |
// |---------|-------------|------------|-----|
// |       1 | ORD-001     |          1 |   2 |
// |       1 | ORD-001     |          2 |   1 |
// |       1 | ORD-001     |          3 |   3 |

// EF Core: один Order об'єкт, LineItems.Count = 3
Console.WriteLine(orders.Count);           // 1 (один Order)
Console.WriteLine(orders[0].LineItems.Count); // 3 (три LineItem)

Дедублікація відбувається через Identity Map у Change Tracker — по PK значенню. Саме тому PK обов'язковий для всіх entity.


Include з умовами на основний запит

Include не окремий метод для пов'язаних — він частина основного запиту. Where до Include — умова на основний entity:

// Where застосовується до Order, Include завантажує LineItems
var recentBigOrders = await context.Orders
    .Where(o => o.PlacedAt >= DateTime.UtcNow.AddDays(-30))  // WHERE на Order
    .Where(o => o.TotalAmount > 10000)                         // WHERE на Order
    .Include(o => o.LineItems)                                  // JOIN LineItems
        .ThenInclude(li => li.Product)                         // JOIN Products
    .Include(o => o.Customer)                                   // JOIN Customers
    .OrderByDescending(o => o.TotalAmount)
    .ToListAsync();

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

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

Завдання 1.1: Граф Include

Для Entity BlogPost (Id, Title, AuthorId) → Author (Id, Name) → і колекція Comments (Id, PostId, Text, AuthorId) → де кожен Comment теж має Author:

  1. Завантажте пост з автором і коментарями (і авторами коментарів)
  2. Перевірте SQL через логування — скільки JOIN?
  3. Спробуйте AsSplitQuery() — скільки окремих запитів?

Завдання 1.2: Cartesian Explosion

Для Product з колекціями Reviews (10+ записів) і Tags (5+ записів):

  1. Завантажте без AsSplitQuery — скільки рядків у SQL?
  2. Завантажте з AsSplitQuery — скільки запитів?
  3. Console.WriteLine($"Reviews: {p.Reviews.Count}, Tags: {p.Tags.Count}") — результат однаковий?

Завдання 1.3: Filtered Include

Для Order з OrderLineItem (деякі IsDeleted = true):

  1. Include без фільтра — скільки LineItems пропадає через GQF?
  2. Include з explicit filter li => !li.IsDeleted && li.Quantity > 2 — що змінюється у SQL?
  3. Include з IgnoreQueryFilters() — чи повертаються soft-deleted LineItems?

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

Завдання 2.1: Оптимальна стратегія Include

Для API endpoint GET /api/orders/{id} потрібно повернути OrderDetailDto:

  • Order базові поля
  • Customer (Name, Email)
  • LineItems (кожен: ProductName, Qty, UnitPrice, CategoryName)
  • Invoice (якщо є)

Реалізуйте два варіанти і порівняйте SQL:

  1. Через Include/ThenInclude + маппінг у DTO
  2. Через Select проєкцію (без Include)

Який варіант ефективніший? У якому випадку кожен?

Завдання 2.2: Include у Split Query транзакції

using var transaction = await context.Database.BeginTransactionAsync();
try
{
    var order = await context.Orders
        .Include(o => o.LineItems)
        .Include(o => o.Tags)
        .AsSplitQuery()                // ← Split Query
        .FirstAsync(o => o.Id == id);

    // Змінюємо і зберігаємо
    order.Status = OrderStatus.Processing;
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}

Чому транзакція важлива для Split Query? Що станеться без неї?

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

Завдання 3.1: Include Strategy selector

Реалізуйте IIncludeStrategy<T> де різні стратегії визначають що завантажувати:

public interface IIncludeStrategy<T>
{
    IQueryable<T> Apply(IQueryable<T> query);
}

public class OrderWithItemsStrategy : IIncludeStrategy<Order>
{
    public IQueryable<Order> Apply(IQueryable<Order> query) =>
        query.Include(o => o.LineItems).ThenInclude(li => li.Product)
             .Include(o => o.Customer)
             .AsSplitQuery();
}

public class OrderSummaryStrategy : IIncludeStrategy<Order>
{
    public IQueryable<Order> Apply(IQueryable<Order> query) =>
        query.Include(o => o.Customer); // Мінімально — тільки Customer
}

Репозиторій: GetOrderAsync(int id, IIncludeStrategy<Order> strategy).


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

Перша частина розкрила Eager Loading:

  • Include: JOIN навігаційної властивості (reference або collection) у основний запит.
  • ThenInclude: продовжує ланцюжок від попереднього Include — вглиб графа.
  • Кілька Include: кілька колекцій → Cartesian Explosion. Вирішується через AsSplitQuery().
  • Split Query: кілька SQL замість одного великого. Увага на транзакційність при concurrent writes.
  • Filtered Include: умова безпосередньо на навігаційній властивості — Include(o => o.LineItems.Where(...)).
  • Дедублікація: EF Core автоматично через Identity Map збирає правильну колекцію з JOIN-результату.

У другій частині — Lazy Loading (усі ризики), Explicit Loading, порівняльна таблиця трьох стратегій і practical guide коли що обирати.