Одна з найпотужніших рис EF Core — здатність перетворювати C# LINQ-вирази у SQL-запити. Розробник пише звичний C# код, а база даних виконує оптимізований SQL. Але щоб правильно використовувати цю магію, потрібно зрозуміти механізм «під капотом».
Перше і найважливіше, що потрібно знати: методи LINQ поводяться по-різному залежно від того, застосовуються вони до IQueryable<T> чи IEnumerable<T>.
// context.Products → IQueryable<Product>
// Кожен LINQ-метод БУДУЄ вираз, не виконує його
IQueryable<Product> query = context.Products
.Where(p => p.Price > 1000) // додає WHERE до expression tree
.OrderBy(p => p.Name) // додає ORDER BY до expression tree
.Take(10); // додає TOP 10 до expression tree
// ← ЖОДЕН SQL ЩЕ НЕ ВИКОНАНО!
// Матеріалізація: тут виконується реальний SQL SELECT
List<Product> products = await query.ToListAsync();
// SQL: SELECT TOP 10 * FROM Products WHERE Price > 1000 ORDER BY Name
// НЕБЕЗПЕЧНО: перетворення на IEnumerable занадто рано
IEnumerable<Product> allProducts = await context.Products.ToListAsync(); // ← все в пам'ять
var expensive = allProducts.Where(p => p.Price > 1000).Take(10);
// SQL: SELECT * FROM Products (ВСІ записи!)
// Фільтр Where і Take виконуються в C#, а не в SQL
IQueryable<T> — ленивий (lazy) запит. LINQ-методи будують Expression Tree (дерево виразів), яке EF Core перекладає у SQL лише при матеріалізації (.ToList(), .FirstOrDefault(), .Count(), await foreach).
IEnumerable<T> — вже матеріалізована колекція у пам'яті. LINQ-методи виконуються у C#-пам'яті, а не у базі.
// Цей вираз:
var query = context.Products
.Where(p => p.Price > 1000 && p.IsActive)
.Select(p => new { p.Id, p.Name, p.Price });
// Зберігається як Expression Tree (спрощено):
// MethodCallExpression(Select,
// MethodCallExpression(Where,
// DbSet<Product>,
// Lambda(p => And(
// GreaterThan(Property(p, "Price"), Constant(1000)),
// Property(p, "IsActive")))),
// Lambda(p => new { p.Id, p.Name, p.Price }))
// EF Core's QueryTranslator читає це дерево і генерує:
// SELECT p.Id, p.Name, p.Price
// FROM Products p
// WHERE p.Price > 1000 AND p.IsActive = 1
Кожен LINQ-метод (Where, Select, OrderBy, GroupBy, Join) відповідає SQL-оператору. EF Core «знає» як перекласти їх. Але не всі C# вирази транслюються — про це далі.
Server Evaluation — підрахунок відбувається у БД (ідеально). Client Evaluation — дані спочатку завантажуються у C#-пам'ять, потім фільтруються/трансформуються там (дорого, іноді небезпечно).
До EF Core 3 деякі вирази автоматично «проваливалися» у client evaluation без явного попередження. З EF Core 3 — якщо вираз не може транслюватися у SQL → InvalidOperationException. Це краща поведінка: краще помилка, ніж неявне завантаження мільйону рядків.
// ПОМИЛКА: власна C#-функція не транслюється в SQL
static bool IsExpensiveProduct(Product p) => p.Price > 10000;
var expensive = await context.Products
.Where(p => IsExpensiveProduct(p)) // ← InvalidOperationException!
.ToListAsync(); // EF Core не знає, як перекласти IsExpensiveProduct у SQL
// РІШЕННЯ: розгорнути логіку безпосередньо
var expensive = await context.Products
.Where(p => p.Price > 10000) // ← транслюється у WHERE Price > 10000
.ToListAsync();
Якщо частина логіки справді не може виконатися в SQL — явно перейдіть до IEnumerable у правильному місці:
// Частина 1: SQL-фільтрація (у БД)
var fromDb = await context.Products
.Where(p => p.CategoryId == categoryId && p.IsActive)
.Select(p => new { p.Id, p.Name, p.Price, p.Tags }) // Tags — JSON стовпець
.ToListAsync(); // ← матеріалізація, перехід до C# пам'яті
// Частина 2: C#-логіка яку не можна транслювати
var result = fromDb
.Where(p => MyComplexFilter(p.Tags)) // C# логіка — ОК, бо вже ToList
.OrderBy(p => MyCustomSort(p.Name)) // C# сортування
.ToList();
Ключ: мінімізуйте кількість рядків, що завантажуються у пам'ять. Виконуйте якомога більше у БД.
// Проста умова
var products = await context.Products
.Where(p => p.Price >= 1000 && p.Price <= 50000)
.ToListAsync();
// WHERE Price >= 1000 AND Price <= 50000
// Contains (IN оператор)
var categoryIds = new[] { 1, 3, 7 };
var byCategories = await context.Products
.Where(p => categoryIds.Contains(p.CategoryId))
.ToListAsync();
// WHERE CategoryId IN (1, 3, 7)
// String методи
var searchTerm = "laptop";
var byName = await context.Products
.Where(p => p.Name.Contains(searchTerm))
.ToListAsync();
// WHERE Name LIKE '%laptop%'
var byPrefix = await context.Products
.Where(p => p.Name.StartsWith("Mac"))
.ToListAsync();
// WHERE Name LIKE 'Mac%'
// EF.Functions — провайдерні функції
var byNameCI = await context.Products
.Where(p => EF.Functions.Like(p.Name, "%laptop%"))
.ToListAsync();
// WHERE Name LIKE '%laptop%' (без LOWER())
// Просте сортування
var byPrice = await context.Products
.OrderBy(p => p.Price)
.ToListAsync();
// ORDER BY Price ASC
// Сортування за кількома полями
var multiSort = await context.Products
.OrderBy(p => p.CategoryId)
.ThenByDescending(p => p.Price)
.ThenBy(p => p.Name)
.ToListAsync();
// ORDER BY CategoryId ASC, Price DESC, Name ASC
// Динамічне сортування (за параметром)
string sortField = "price"; // з HTTP параметра
bool ascending = true;
IQueryable<Product> query = context.Products;
query = sortField switch
{
"name" => ascending ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name),
"price" => ascending ? query.OrderBy(p => p.Price) : query.OrderByDescending(p => p.Price),
_ => query.OrderBy(p => p.Id)
};
var sorted = await query.ToListAsync();
// Класична пагінація: сторінка N, розмір pageSize
int page = 2;
int pageSize = 20;
var paged = await context.Products
.Where(p => p.IsActive)
.OrderBy(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// SELECT ... FROM Products WHERE IsActive=1
// ORDER BY CreatedAt SKIP 20 FETCH NEXT 20 ROWS ONLY (SQL Server)
// або LIMIT 20 OFFSET 20 (PostgreSQL)
// Загальна кількість для пагінації (окремий запит)
var total = await context.Products
.Where(p => p.IsActive)
.CountAsync();
var result = new PagedResult<Product>
{
Items = paged,
TotalCount = total,
Page = page,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling((double)total / pageSize)
};
SKIP + OFFSET стає повільним (БД читає і пропускає n рядків). Краща альтернатива — keyset pagination: фільтруємо за останнім значенням попередньої сторінки.// WHERE CreatedAt < @lastSeenDate AND Id < @lastSeenId
// ORDER BY CreatedAt DESC, Id DESC
// TAKE pageSize
Проєкція — один з найважливіших інструментів оптимізації. Замість SELECT * ми читаємо лише потрібні стовпці.
// БЕЗ проєкції: завантажується весь Entity з усіма стовпцями
var products = await context.Products.ToListAsync();
// SELECT Id, Name, Price, Sku, Description, CreatedAt, UpdatedAt, TenantId, IsDeleted, ...
// З проєкцією: лише потрібні поля
var productDtos = await context.Products
.Where(p => p.IsActive)
.Select(p => new ProductListDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();
// SELECT Id, Name, Price FROM Products WHERE IsActive = 1
По-справжньому потужна риса: проєкція автоматично додає потрібні JOIN без явного Include:
var productsWithCategory = await context.Products
.Where(p => p.IsActive)
.Select(p => new
{
p.Id,
p.Name,
p.Price,
CategoryName = p.Category.Name, // ← JOIN до Categories
ReviewCount = p.Reviews.Count(), // ← JOIN + COUNT
AverageRating = p.Reviews.Average(r => r.Rating) // ← AVG
})
.ToListAsync();
// SQL: SELECT p.Id, p.Name, p.Price,
// c.Name AS CategoryName,
// (SELECT COUNT(*) FROM Reviews WHERE ProductId = p.Id) AS ReviewCount,
// (SELECT AVG(Rating) FROM Reviews WHERE ProductId = p.Id) AS AverageRating
// FROM Products p
// LEFT JOIN Categories c ON c.Id = p.CategoryId
// WHERE p.IsActive = 1
Це ефективніше за Include(p => p.Reviews) коли потрібна лише агрегація — не завантажуємо всі рядки Reviews у пам'ять.
var orderDtos = await context.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderDto
{
Id = o.Id,
OrderNumber = o.OrderNumber,
PlacedAt = o.PlacedAt,
Status = o.Status.ToString(),
CustomerName = o.Customer.FullName, // JOIN
// Вкладена колекція — теж транслюється!
LineItems = o.LineItems.Select(li => new OrderLineItemDto
{
ProductName = li.Product.Name, // вкладений JOIN
Quantity = li.Quantity,
UnitPrice = li.UnitPrice,
SubTotal = li.Quantity * li.UnitPrice
}).ToList()
})
.OrderByDescending(o => o.PlacedAt)
.ToListAsync();
EF Core транслює вкладений Select у підзапит або JOIN залежно від контексту.
GroupBy — одна з найскладніших операцій для правильного використання в EF Core. Важливо знати, що транслюється, а що ні.
// Кількість продуктів за категорією
var countsByCategory = await context.Products
.GroupBy(p => p.CategoryId)
.Select(g => new
{
CategoryId = g.Key,
Count = g.Count(),
AvgPrice = g.Average(p => p.Price),
MaxPrice = g.Max(p => p.Price),
MinPrice = g.Min(p => p.Price)
})
.ToListAsync();
// SQL: SELECT CategoryId, COUNT(*), AVG(Price), MAX(Price), MIN(Price)
// FROM Products
// GROUP BY CategoryId
// З Join для отримання назви категорії
var withNames = await context.Products
.GroupBy(p => new { p.CategoryId, CategoryName = p.Category.Name })
.Select(g => new
{
g.Key.CategoryId,
g.Key.CategoryName,
Count = g.Count(),
AvgPrice = g.Average(p => p.Price)
})
.OrderByDescending(x => x.Count)
.ToListAsync();
// SQL: SELECT p.CategoryId, c.Name, COUNT(*), AVG(p.Price)
// FROM Products p
// JOIN Categories c ON c.Id = p.CategoryId
// GROUP BY p.CategoryId, c.Name
// ORDER BY COUNT(*) DESC
// Категорії з більш ніж 10 продуктами
var popularCategories = await context.Products
.GroupBy(p => p.CategoryId)
.Where(g => g.Count() > 10) // ← транслюється у HAVING
.Select(g => new { CategoryId = g.Key, ProductCount = g.Count() })
.ToListAsync();
// SQL: SELECT CategoryId, COUNT(*)
// FROM Products
// GROUP BY CategoryId
// HAVING COUNT(*) > 10
// Продажі по місяцях і статусах
var salesByMonthAndStatus = await context.Orders
.Where(o => o.PlacedAt.Year == 2024)
.GroupBy(o => new
{
Month = o.PlacedAt.Month,
Status = o.Status
})
.Select(g => new
{
g.Key.Month,
g.Key.Status,
Count = g.Count(),
TotalRevenue = g.Sum(o => o.TotalAmount)
})
.OrderBy(x => x.Month)
.ThenBy(x => x.Status)
.ToListAsync();
// SQL: SELECT MONTH(PlacedAt), Status, COUNT(*), SUM(TotalAmount)
// FROM Orders WHERE YEAR(PlacedAt) = 2024
// GROUP BY MONTH(PlacedAt), Status
// ORDER BY MONTH(PlacedAt), Status
.GroupBy() ви звертаєтесь до повних об'єктів у групі (g.ToList()) — EF Core часто переходить до client evaluation. Завжди агрегуйте у .Select() замість отримання груп як колекцій.// Any: чи є хоч один рядок (EXISTS у SQL)
bool hasExpensive = await context.Products
.AnyAsync(p => p.Price > 50000);
// SQL: SELECT CASE WHEN EXISTS(SELECT 1 FROM Products WHERE Price > 50000)
// THEN 1 ELSE 0 END
// Count: кількість
int totalProducts = await context.Products
.CountAsync(p => p.IsActive);
// SQL: SELECT COUNT(*) FROM Products WHERE IsActive = 1
// LongCount для великих таблиць
long bigCount = await context.Orders.LongCountAsync();
// SQL: SELECT COUNT_BIG(*) FROM Orders
// All: всі рядки відповідають умові (NOT EXISTS + NOT)
bool allInStock = await context.Products
.AllAsync(p => p.Stock > 0);
// SQL: SELECT CASE WHEN NOT EXISTS(
// SELECT 1 FROM Products WHERE NOT (Stock > 0)) THEN 1 ELSE 0 END
// Sum, Average, Min, Max
decimal totalRevenue = await context.Orders
.Where(o => o.Status == OrderStatus.Delivered)
.SumAsync(o => o.TotalAmount);
// SQL: SELECT SUM(TotalAmount) FROM Orders WHERE Status = 'Delivered'
decimal avgOrderValue = await context.Orders
.AverageAsync(o => o.TotalAmount);
// SELECT AVG(TotalAmount) FROM Orders
// FirstOrDefault: перший або null
var product = await context.Products
.Where(p => p.CategoryId == 5)
.OrderByDescending(p => p.Price)
.FirstOrDefaultAsync();
// SELECT TOP 1 ... FROM Products WHERE CategoryId = 5 ORDER BY Price DESC
// First: перший або Exception якщо немає
var requiredProduct = await context.Products
.FirstAsync(p => p.Sku == "LAPTOP-001");
// SELECT TOP 1 ... WHERE Sku = 'LAPTOP-001'
// InvalidOperationException якщо не знайдено
// SingleOrDefault: очікується ОДИН запис (Exception якщо більше одного)
var uniqueProduct = await context.Products
.SingleOrDefaultAsync(p => p.Sku == "LAPTOP-001");
// SELECT ... WHERE Sku = 'LAPTOP-001'
// Exception якщо знайдено > 1 запис — корисно для перевірки унікальності
// Find: пошук по PK (використовує кеш Identity Map)
var byId = await context.Products.FindAsync(productId);
// Якщо рядок вже у Change Tracker → немає SQL-запиту!
// Інакше: SELECT ... WHERE Id = @id
// FIND: перевіряє Identity Map (кеш Change Tracker) ДО запиту до БД
var product = await context.Products.FindAsync(42);
// 1. Перевіряє context.ChangeTracker — є product з Id=42?
// 2. Якщо є → повертає з кешу без SQL
// 3. Якщо немає → SELECT WHERE Id = 42
// FIRSTORDEFAULT: ЗАВЖДИ виконує SQL (якщо немає перехоплення GQF тощо)
var product2 = await context.Products.FirstOrDefaultAsync(p => p.Id == 42);
// ЗАВЖДИ виконує SELECT WHERE Id = 42
// Практичне наслідування:
// У сервісі, що читає і одразу змінює — Find краще (одне звернення до БД)
// У ситуації де хочемо свіжі дані (не з кешу) — FirstOrDefault або AsNoTracking
// Категорії, що мають хоча б один активний продукт
var categoriesWithProducts = await context.Categories
.Where(c => c.Products.Any(p => p.IsActive))
.ToListAsync();
// SQL: SELECT ... FROM Categories c
// WHERE EXISTS (
// SELECT 1 FROM Products p
// WHERE p.CategoryId = c.Id AND p.IsActive = 1
// )
// Кожна категорія з кількістю активних продуктів
var categoriesWithCount = await context.Categories
.Select(c => new
{
c.Id,
c.Name,
ActiveProductCount = c.Products.Count(p => p.IsActive),
LatestProduct = c.Products
.OrderByDescending(p => p.CreatedAt)
.Select(p => p.Name)
.FirstOrDefault()
})
.ToListAsync();
// SQL: SELECT c.Id, c.Name,
// (SELECT COUNT(*) FROM Products WHERE CategoryId=c.Id AND IsActive=1),
// (SELECT TOP 1 Name FROM Products WHERE CategoryId=c.Id ORDER BY CreatedAt DESC)
// FROM Categories c
// CASE WHEN через тернарний оператор
var productSummaries = await context.Products
.Select(p => new
{
p.Id,
p.Name,
PriceCategory = p.Price < 1000 ? "Budget" :
p.Price < 10000 ? "Mid-range" :
"Premium"
})
.ToListAsync();
// SQL: SELECT Id, Name,
// CASE WHEN Price < 1000 THEN 'Budget'
// WHEN Price < 10000 THEN 'Mid-range'
// ELSE 'Premium' END AS PriceCategory
// FROM Products
Завдання 1.1: IQueryable vs IEnumerable
Напишіть два варіанти запиту «10 найдорожчих активних продуктів категорії Electronics»:
IQueryable (все у SQL)ToListAsync() першим, потім LINQ в пам'ятіПорівняння: використайте LogTo(Console.WriteLine) і перевірте, який SQL генерується. Яка різниця у кількості переданих даних?
Завдання 1.2: Динамічна фільтрація
Реалізуйте ProductSearchQuery (Name?, MinPrice?, MaxPrice?, CategoryId?, IsActive?, SortBy?, SortAsc?) і метод ApplyFilters(IQueryable<Product> query) що будує запит умовно:
if (filter.Name is not null)
query = query.Where(p => p.Name.Contains(filter.Name));
if (filter.MinPrice.HasValue)
query = query.Where(p => p.Price >= filter.MinPrice.Value);
// ...
Завдання 1.3: Пагінація з метаданими
Реалізуйте PaginatedResult<T> і generic метод ToPagedResultAsync<T>(IQueryable<T> query, int page, int pageSize) що в одній транзакції виконує:
CountAsync() для загальної кількостіSkip().Take().ToListAsync() для поточної сторінкиПоверніть також TotalPages, HasPreviousPage, HasNextPage.
Завдання 2.1: Складна проєкція
Order → OrderDto де:
TotalAmount — сума з рядків (не з поля)LineItemsSummary — рядки без видалених (li.IsDeleted = false)HasDiscount — bool (чи є хоч один рядок з Discount > 0)StatusLabel — CASE WHEN: Pending/"На очікуванні", Processing/"В обробці", Delivered/"Доставлено"Реалізуйте через Select без Include — все має бути у SQL.
Завдання 2.2: Складний GroupBy
Для Order (CustomerId, Status, PlacedAt, TotalAmount) напишіть звіт:
Один LINQ-запит, без підзапитів у C#.
Завдання 3.1: Специфікаційний патерн
Реалізуйте Specification<T> pattern:
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity) => ToExpression().Compile()(entity);
public Specification<T> And(Specification<T> other) =>
new AndSpecification<T>(this, other);
public Specification<T> Or(Specification<T> other) =>
new OrSpecification<T>(this, other);
}
Реалізуйте:
ActiveProductSpecification → !IsDeleted && IsActivePriceRangeSpecification(min, max) → Price >= min && Price <= maxCategorySpecification(ids) → ids.Contains(CategoryId)Метод репозиторію: GetAsync(Specification<Product> spec).
У першій частині розклали фундамент LINQ у EF Core:
IQueryable<T> vs IEnumerable<T>: ключова різниця — запит будується лазово і матеріалізується один раз. Ранній ToListAsync() → все до пам'яті.SELECT *, читаємо лише потрібні стовпці. Вкладені навігаційні властивості у Select → JOIN без явного Include.GROUP BY + HAVING, агрегаційні функції, множинні ключі.Find (Identity Map) vs FirstOrDefault (завжди SQL).Select, correlated subquery, CASE WHEN через тернарний оператор.У другій частині — EF.Functions, FromSqlRaw, складні JOIN, Union/Intersect/Except, window functions через Raw SQL, типові помилки та tips для оптимізації LINQ.
Global Query Filters — Підводні камені та Інтеграція (Частина 2)
Підводні камені Global Query Filters — Include і JOIN з фільтрованими навігаційними властивостями, GQF у TPH/TPC ієрархіях, фільтри і оголошення міграцій, тестування GQF, інтеграція у реальний проєкт.
LINQ-запити в EF Core (Частина 2)
EF.Functions, складні JOIN, Union/Intersect/Except, AsNoTracking, Distinct, raw SQL у LINQ, типові помилки N+1 та LazyLoading у LINQ, best practices для продуктивних запитів.