У попередній статті ми бачили, що 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 застосунків.
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 продовжує ланцюжок від попереднього 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 у одному запиті
// Завантажити і 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.
// 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
Коли включаємо кілька колекцій через 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 — режим, при якому 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 глобально для всього 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();
| Сценарій | Рекомендація |
|---|---|
| Одна колекція Include | Single Query (Join ефективніший) |
| Reference Include (M:1) | Single Query (немає Cartesian) |
| 2+ колекції Include | Split Query |
| Велика колекція (100+ рядків) | Split Query |
| Невелика колекція (5-10 рядків) | Залежить — перевіряйте план |
| Include + ThenInclude (один ланцюг) | Single Query (один JOIN-шлях) |
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: якщо на 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 з колекцією 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 не окремий метод для пов'язаних — він частина основного запиту. 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: Граф Include
Для Entity BlogPost (Id, Title, AuthorId) → Author (Id, Name) → і колекція Comments (Id, PostId, Text, AuthorId) → де кожен Comment теж має Author:
AsSplitQuery() — скільки окремих запитів?Завдання 1.2: Cartesian Explosion
Для Product з колекціями Reviews (10+ записів) і Tags (5+ записів):
AsSplitQuery — скільки рядків у SQL?AsSplitQuery — скільки запитів?Console.WriteLine($"Reviews: {p.Reviews.Count}, Tags: {p.Tags.Count}") — результат однаковий?Завдання 1.3: Filtered Include
Для Order з OrderLineItem (деякі IsDeleted = true):
li => !li.IsDeleted && li.Quantity > 2 — що змінюється у SQL?IgnoreQueryFilters() — чи повертаються soft-deleted LineItems?Завдання 2.1: Оптимальна стратегія Include
Для API endpoint GET /api/orders/{id} потрібно повернути OrderDetailDto:
Реалізуйте два варіанти і порівняйте SQL:
Include/ThenInclude + маппінг у DTOSelect проєкцію (без 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.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).
Перша частина розкрила Eager Loading:
Include: JOIN навігаційної властивості (reference або collection) у основний запит.ThenInclude: продовжує ланцюжок від попереднього Include — вглиб графа.AsSplitQuery().Include(o => o.LineItems.Where(...)).У другій частині — Lazy Loading (усі ризики), Explicit Loading, порівняльна таблиця трьох стратегій і practical guide коли що обирати.
LINQ-запити в EF Core (Частина 2)
EF.Functions, складні JOIN, Union/Intersect/Except, AsNoTracking, Distinct, raw SQL у LINQ, типові помилки N+1 та LazyLoading у LINQ, best practices для продуктивних запитів.
Завантаження Пов'язаних Даних (Частина 2)
Lazy Loading — усі ризики, проксі і ILazyLoader, N+1 детектування. Explicit Loading для точкового контролю. Порівняльна таблиця трьох стратегій. Practical guide і architectural best practices.