Lab 20
EF Core: запити
IQueryable, pagination, DTO projections
Лабораторна робота 20 — EF Core: IQueryable, Pagination, Projections
Проблема
Клініка зростає: 10 000 пацієнтів, 50 000 записів. Запит context.Patients.ToList() — завантажує всі 10 000 рядків у пам'ять, займає 3 секунди і 50 МБ.
Три типові помилки продуктивності:
- Завантажити всі записи і потім відфільтрувати в C# — замість
WHEREв SQL - Показати список з 1000 елементів замість сторінок по 20
- Завантажити повний об'єкт (15 полів) коли потрібні тільки 3 —
SELECT *замістьSELECT id, name, phone
Усі три вирішуються зрозумінням одного принципу: EF Core будує SQL-запит поступово до моменту матеріалізації.
Ключові концепції
IQueryable<T> — відкладене виконання
IQueryable<T> — це не колекція. Це опис запиту (Expression Tree), який EF перетворить в SQL. SQL виконується тільки при матеріалізації:
| Операція | Чи виконується SQL? |
|---|---|
context.Patients |
❌ ні |
.Where(p => p.LastName == "Коваль") |
❌ ні |
.OrderBy(p => p.LastName) |
❌ ні |
.Skip(20).Take(10) |
❌ ні |
.ToList() |
✅ тут SQL виконується |
.Count() |
✅ тут SQL виконується |
.FirstOrDefault() |
✅ тут SQL виконується |
foreach |
✅ тут SQL виконується |
Якщо написати .ToList() між операціями — решта фільтрів виконається в C#, а не SQL:
// ❌ ПРОБЛЕМА: завантажує ВСЕ, потім фільтрує в пам'яті
context.Patients.ToList().Where(p => p.Age > 18)
// ✅ ПРАВИЛЬНО: фільтрує в SQL — завантажує тільки результат
context.Patients.Where(p => p.DateOfBirth < cutoff).ToList().Skip().Take() — пагінація
Стандартна пагінація: сторінка 1 = перші 20, сторінка 2 = наступні 20...
Skip((page - 1) * pageSize).Take(pageSize)EF генерує SQL Server-синтаксис:
ORDER BY LastName
OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLYВажливо: .Skip()/.Take() без .OrderBy() — результат непередбачуваний (порядок у БД не гарантований).
Рахуємо загальну кількість: перед Skip/Take зробіть int total = query.Count() — окремий SELECT COUNT(*).
Проєкції — Select(new DTO)
Замість SELECT * — вибрати тільки потрібні стовпці:
context.Patients.Select(p => new PatientSummaryDto(
p.Id,
p.FirstName + " " + p.LastName, // обчислення в SQL
p.Appointments.Count // COUNT subquery
)).ToList()EF генерує:
SELECT p.Id, p.FirstName + ' ' + p.LastName AS FullName,
(SELECT COUNT(*) FROM Appointments WHERE PatientId = p.Id) AS AppointmentCount
FROM PatientsНе завантажуються: Email, RowVersion, MedicalRecords, navigation collections.
DTO (Data Transfer Object) — простий клас/record без логіки, тільки дані:
public record PatientSummaryDto(int Id, string FullName, int Age, ...);Global Query Filter — автоматичний фільтр
HasQueryFilter(p => !p.IsDeleted) в OnModelCreating — EF додає WHERE IsDeleted = 0 до кожного запиту до Patients, включно з .Include().
Переваги: не потрібно щоразу писати .Where(p => !p.IsDeleted).
Обійти фільтр: .IgnoreQueryFilters() — для адміністративних запитів.
Попередження EF Core: якщо Patient з фільтром — required кінець зв'язку з Appointment — EF попереджає про можливий неконсистентний стан. Це навмисна педагогічна ситуація.
Завдання
Завдання 1. IQueryable<T> — демонстрація відкладеного виконання
Задача: написати методи що демонструють різницю між IQueryable (фільтр у SQL) і IEnumerable (фільтр в C#).
Напишіть клас ClinicQueryService у src/Data/ з методом:
DemoQueryableVsEnumerable(string filter) → (efficient, inefficient) спискиПерший варіант: .Where(p => p.LastName.Contains(filter)).ToList() — SQL WHERE.
Другий варіант: .ToList().Where(p => ...) — в пам'яті.
Щоб побачити різницю, включіть логування EF Core:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);Тимчасово додайте в OnConfiguring і зверніть увагу: перший варіант генерує SQL з LIKE, другий — без фільтру.
Поверніть з методу IQueryable<Patient>:
public IQueryable<Patient> QueryPatients()
=> _context.Patients.AsNoTracking();Викликаючий код може додати умови після виклику:
var seniors = queryService.QueryPatients()
.Where(p => p.DateOfBirth.Year < 1960) // додається до того ж запиту!
.OrderBy(p => p.LastName)
.ToList();Ключові питання:
- Коли саме
IQueryableперетворюється наIEnumerable(перестає бути описом і стає даними)? - Чому
.AsNoTracking()важливий для read-only запитів у ClinicQueryService?
Завдання 2. Пагінація
Задача: реалізувати GetPatientsPaged і GetAppointmentsPaged з підтримкою сортування і фільтрів.
Структура результату:
(List<T> Items, int TotalCount)TotalCount — для UI: "Показано 1-20 з 347".
Алгоритм GetPatientsPaged:
- Порахувати
total = query.Count()до Skip/Take - Застосувати OrderBy (обов'язково!)
.Skip((page-1) * pageSize).Take(pageSize).ToList()
Alгоритм GetAppointmentsPaged з необов'язковими фільтрами:
Nullable параметри (AppointmentStatus? status, int? patientId) дозволяють динамічно додавати умови:
if (status.HasValue)
query = query.Where(a => a.Status == status.Value);Кожне query = query.Where(...) НЕ виконує SQL — це лише нарощування expression tree.
Ключові питання:
- Чому
query.Count()після умов — правильно, аquery.ToList().Count— ні? - Що означає "без ORDER BY результат непередбачуваний"? Чи не завжди записи в тому ж порядку?
Завдання 3. Проєкції та DTO
Задача: замість завантаження повних об'єктів — вибирати тільки потрібні поля через Select(new DTO).
Створіть:
PatientSummaryDto— id, FullName, вік, телефон, тип крові, кількість записівAppointmentSummaryDto— id, ім'я пацієнта, ім'я лікаря, спеціальність, дата, статус, вартість
Використайте C# record замість class:
public record PatientSummaryDto(int Id, string FullName, int Age, ...);record — це незмінний (immutable) тип даних, ідеальний для DTO: не має стану, не має логіки.
Реалізуйте GetPatientSummaries:
context.Patients.Select(p => new PatientSummaryDto(
p.Id,
p.FirstName + " " + p.LastName,
// Age — обчислення з DateOfBirth (EF може перетворити це в SQL)
DateTime.Today.Year - p.DateOfBirth.Year,
p.Phone,
p.BloodType.ToString(),
p.Appointments.Count // → COUNT subquery
))Увага: деякі C# методи EF не може перекласти у SQL (a.GetType().Name — не можна). В такому разі EF або кидає виняток, або матеріалізує дані спочатку а потім виконує в пам'яті. Що відбувається у вашій реалізації?
Ключові питання:
- Чим
recordвідрізняється відclassу C#? - Що буде якщо написати
.Select(p => new { p.Id, p.FirstName })— анонімний тип vs DTO?
Завдання 4. Soft Delete та Global Query Filter
Задача: реалізувати "м'яке видалення" і Global Query Filter.
Soft Delete патерн:
Замість context.Patients.Remove(patient) → patient.SoftDelete() + SaveChanges().
Додайте до Patient:
public bool IsDeleted { get; private set; }
public void SoftDelete() { IsDeleted = true; }Global Query Filter в DbContext:
entity.HasQueryFilter(p => !p.IsDeleted);Після цього кожен запит до Patients автоматично включає WHERE IsDeleted = 0.
Тест що все працює:
SoftDeletePatient(id)— позначити як видаленогоcontext.Patients.ToList()— пацієнта НЕ видно (фільтр автоматичний).IgnoreQueryFilters().ToList()— пацієнт видноGetDeletedPatients()— тільки видалені
Попередження EF Core при міграції: EF видасть попередження: "Patient з QueryFilter є required кінцем зв'язку з Appointment".
Це означає: якщо пацієнта "видалено" (IsDeleted=true), запити до Appointments при .Include(a => a.Patient) можуть повернути a.Patient == null — хоча патч не видаляв рядок.
Прочитайте попередження і подумайте: як обійти цю проблему? Варіанти:
- Додати такий же фільтр до Appointment
- Зробити navigation property optional у FK конфігурації
- Прийняти обмеження і задокументувати поведінку
Ключові питання:
- Як відновити м'яко-видаленого пацієнта (set IsDeleted = false)?
- Чи потрібна міграція при додаванні
HasQueryFilterабо тільки при зміні схеми?
Рефлексійні питання
Expression Tree vs Delegate.
IQueryableвикористовує Expression Trees для трансляції в SQL.IEnumerableвикористовуєFunc<T, bool>делегати (виконуються в C#). Чому EF Core не може перетворити будь-який Func в SQL?Cursor-based pagination.
.Skip().Take()— "offset pagination". При великих обсягах (100 000+ записів) OFFSET дорогий. Альтернатива — keyset pagination:WHERE Id > @lastId ORDER BY Id TAKE 20. Коли варто переходити на keyset?DTO vs ViewModel. DTO — для передачі даних між шарами. ViewModel — для відображення в UI (може містити обчислення для відображення). Чи є різниця між ними у вашому проєкті?
record vs class для DTO. C# record автоматично генерує:
Equals(),GetHashCode(),ToString(), деструктуризацію. Чи потрібні ці методи для DTO? Чи є сценарії деclassкраще?Soft Delete і унікальні індекси. У Doctors є унікальний індекс
UX_Doctors_License. Якщо лікаря "видалити" soft delete, а потім додати нового з тим же номером ліцензії — унікальний індекс не дозволить. Як вирішити?Matеріалізація в середині chain. Де помилка?
var result = context.Patients
.AsNoTracking()
.ToList() // матеріалізація тут!
.GroupBy(p => p.BloodType)
.Select(g => new { g.Key, Count = g.Count() })
.ToList();Чому цей код правильно компілюється, але є проблемою продуктивності?