Ef Core

LINQ-запити в EF Core (Частина 1)

Глибокий розбір LINQ в EF Core — трансляція у SQL, IQueryable vs IEnumerable, Server vs Client Evaluation, проєкції Select, GroupBy, підзапити, складні умови фільтрації. Анатомія LINQ-виразу та типові помилки.

LINQ-запити в EF Core

Як EF Core перетворює LINQ у SQL

Одна з найпотужніших рис EF Core — здатність перетворювати C# LINQ-вирази у SQL-запити. Розробник пише звичний C# код, а база даних виконує оптимізований SQL. Але щоб правильно використовувати цю магію, потрібно зрозуміти механізм «під капотом».

IQueryable vs IEnumerable: ключова різниця

Перше і найважливіше, що потрібно знати: методи 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#-пам'яті, а не у базі.

Expression Tree: що будує LINQ

// Цей вираз:
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 vs Client Evaluation

Server Evaluation — підрахунок відбувається у БД (ідеально). Client Evaluation — дані спочатку завантажуються у C#-пам'ять, потім фільтруються/трансформуються там (дорого, іноді небезпечно).

Автоматична Client Evaluation у EF Core 3+

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

AsEnumerable() для контрольованого переходу

Якщо частина логіки справді не може виконатися в 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();

Ключ: мінімізуйте кількість рядків, що завантажуються у пам'ять. Виконуйте якомога більше у БД.


Базові операції: Where, OrderBy, Skip/Take

Where: фільтрація

// Проста умова
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())

OrderBy: сортування

// Просте сортування
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();

Skip/Take: пагінація

// Класична пагінація: сторінка 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)
};
Keyset Pagination (cursor-based): Для великих таблиць SKIP + OFFSET стає повільним (БД читає і пропускає n рядків). Краща альтернатива — keyset pagination: фільтруємо за останнім значенням попередньої сторінки.
// WHERE CreatedAt < @lastSeenDate AND Id < @lastSeenId
// ORDER BY CreatedAt DESC, Id DESC
// TAKE pageSize

Select: проєкція

Проєкція — один з найважливіших інструментів оптимізації. Замість SELECT * ми читаємо лише потрібні стовпці.

Проєкція у DTO

// БЕЗ проєкції: завантажується весь 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 у пам'ять.

Проєкція у вкладені DTO

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: агрегація

GroupBy — одна з найскладніших операцій для правильного використання в EF Core. Важливо знати, що транслюється, а що ні.

Базовий GroupBy

// Кількість продуктів за категорією
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

HAVING через Where після GroupBy

// Категорії з більш ніж 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

GroupBy з кількома ключами

// Продажі по місяцях і статусах
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 і Client Evaluation: Якщо після .GroupBy() ви звертаєтесь до повних об'єктів у групі (g.ToList()) — EF Core часто переходить до client evaluation. Завжди агрегуйте у .Select() замість отримання груп як колекцій.

Any, All, Count, Exists

// 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

First, Single, Find: отримання одного запису

// 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 vs FirstOrDefault: важлива різниця

// 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

Підзапити та умовна логіка

EXISTS (Any у підзапиті)

// Категорії, що мають хоча б один активний продукт
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
//      )

Correlated subquery у Select

// Кожна категорія з кількістю активних продуктів
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 через умовний оператор 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 — Базовий

Завдання 1.1: IQueryable vs IEnumerable

Напишіть два варіанти запиту «10 найдорожчих активних продуктів категорії Electronics»:

  1. Через IQueryable (все у SQL)
  2. Through 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) що в одній транзакції виконує:

  1. CountAsync() для загальної кількості
  2. Skip().Take().ToListAsync() для поточної сторінки

Поверніть також TotalPages, HasPreviousPage, HasNextPage.

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

Завдання 2.1: Складна проєкція

OrderOrderDto де:

  • 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) напишіть звіт:

  • TOP 10 клієнтів за сумою замовлень за останній рік
  • Для кожного: кількість замовлень, загальна сума, середня сума, останнє замовлення

Один LINQ-запит, без підзапитів у C#.

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

Завдання 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 && IsActive
  • PriceRangeSpecification(min, max)Price >= min && Price <= max
  • CategorySpecification(ids)ids.Contains(CategoryId)

Метод репозиторію: GetAsync(Specification<Product> spec).


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

У першій частині розклали фундамент LINQ у EF Core:

  • IQueryable<T> vs IEnumerable<T>: ключова різниця — запит будується лазово і матеріалізується один раз. Ранній ToListAsync() → все до пам'яті.
  • Expression Tree: LINQ-вирази → Expression Tree → SQL Translator → SQL. Не кожен C# вираз транслюється.
  • Server vs Client Evaluation: EF Core 3+ кидає виняток при нетрансльовних виразах — безпечніша поведінка.
  • Where, OrderBy, Skip/Take: базові операції з трансляцією і ідіоматичними патернами.
  • Select (проєкція): уникаємо SELECT *, читаємо лише потрібні стовпці. Вкладені навігаційні властивості у Select → JOIN без явного Include.
  • GroupBy: GROUP BY + HAVING, агрегаційні функції, множинні ключі.
  • Any/All/Count/Find: семантика, різниця Find (Identity Map) vs FirstOrDefault (завжди SQL).
  • Підзапити: вкладений Select, correlated subquery, CASE WHEN через тернарний оператор.

У другій частиніEF.Functions, FromSqlRaw, складні JOIN, Union/Intersect/Except, window functions через Raw SQL, типові помилки та tips для оптимізації LINQ.