Lab 20
EF Core: запити
IQueryable, pagination, DTO projections
Лаба 20 — EF Core: IQueryable, пагінація, проєкції
Мета
Навчитися будувати запити EF Core так, щоб фільтрація, сортування і вибір полів виконувались у SQL, а не в пам'яті: розуміти відкладене виконання IQueryable<T>, робити пагінацію, проєкції в DTO і глобальні фільтри (м'яке видалення).
Контекст
Клініка зростає: 10 000 пацієнтів, 50 000 записів. context.Patients.ToList() завантажує всі рядки в пам'ять — секунди очікування і десятки мегабайт. Три типові помилки продуктивності:
- Завантажити все і відфільтрувати в C# — замість
WHEREу SQL. - Показати список із 1000 елементів — замість сторінок по 20.
- Завантажити повний об'єкт (15 полів), коли потрібні 3, —
SELECT *замістьSELECT Id, Name, Phone.
Усі три вирішуються одним принципом: EF будує SQL-запит поступово і виконує його лише в момент матеріалізації.
Структура проєкту на початку лаби
Це результат Лаби 19 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 19)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/
│ ├── Patient.cs
│ └── … ще 16 файлів без змін
├── Managers/ (13 файлів)
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/
│ ├── ClinicDbContext.cs
│ ├── DbSeeder.cs
│ └── ClinicRepository.cs
└── Migrations/ (7 файлів — генерує EF)Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
Ключові поняття
IQueryable<T> — опис запиту, а не колекція. SQL виконується лише при матеріалізації:
| Операція | Виконує SQL? |
|---|---|
context.Patients, .Where(...), .OrderBy(...), .Skip(...), .Take(...) |
ні — лише нарощують запит |
.ToList(), .Count(), .FirstOrDefault(), foreach |
так |
ToList() посеред ланцюжка — решта операцій виконується вже в C#, над усіма завантаженими рядками.
Пагінація — Skip((page - 1) * pageSize).Take(pageSize); EF генерує OFFSET … FETCH NEXT …. Без OrderBy порядок рядків у БД не гарантований — сторінки будуть непередбачуваними. Загальну кількість для «Показано 1–20 з 347» дає окремий Count() до Skip/Take.
Проєкція — Select(p => new Dto(...)): EF вибирає з БД лише потрібні стовпці, а кількість записів пацієнта рахує підзапитом COUNT(*). DTO (Data Transfer Object) — простий тип лише з даними, без логіки; зручно оголошувати як record.
Глобальний фільтр — HasQueryFilter(p => !p.IsDeleted) у OnModelCreating: EF додає WHERE IsDeleted = 0 до кожного запиту до таблиці, включно з Include. Обійти фільтр (для адміністративних запитів) — IgnoreQueryFilters().
Що нового дозволено (і тільки воно)
IQueryable<T>як тип результату методу;Skip/Takeдля пагінації;recordдля DTO, проєкції черезSelect;HasQueryFilter,IgnoreQueryFilters;- логування SQL через
LogTo(тимчасово, для спостереження).
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-20Коміт — на кожне завдання (Lab20 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.
Як користуватися підказками
Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.
Задача 1. `ClinicQueryService`: відкладене виконання ⭐⭐
Умова
Покажіть різницю між фільтром, що виконується в SQL, і фільтром, що виконується в пам'яті, і дайте викликаючому коду змогу самому дописувати умови до запиту.
Що реалізувати:
- Клас
ClinicQueryServiceуClinicApp/Data/з конструктором(ClinicDbContext context). - Метод
DemoQueryableVsEnumerable(string filter): той самий пошук за прізвищем двома способами — умова доToList()і умова післяToList(); повертає обидва результати. - Метод
QueryPatients(): повертаєIQueryable<Patient>(лише читання), до якого викликаючий код може додатиWhere/OrderByдо виконання. - Тимчасово увімкнути в
OnConfiguringлогування SQL (LogTo) і порівняти, які запити генерують два способи. Перед комітом логування прибрати.
Специфікація
| Метод | Повертає |
|---|---|
DemoQueryableVsEnumerable(string filter) |
(List<Patient> InSql, List<Patient> InMemory) |
QueryPatients() |
IQueryable<Patient> без відстеження змін |
Приклад
var seniors = queryService.QueryPatients()
.Where(p => p.DateOfBirth.Year < 1960) // додається до того самого SQL-запиту
.OrderBy(p => p.LastName)
.ToList();У лозі SQL перший спосіб DemoQueryableVsEnumerable містить WHERE … LIKE …, другий — SELECT без умови.
Підказки
IQueryable«стає даними» на першій матеріалізації —ToList(),Count(),foreach.- Для запитів лише на читання —
AsNoTracking()(Лаба 18). LogTo(Console.WriteLine, LogLevel.Information)показує кожен SQL-запит, який EF відправляє в БД.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
QueryPatients() |
QueryGuests() |
QueryCustomers() |
QueryStudents() |
QueryClients() |
QueryReaders() |
QueryMembers() |
Коміт
git add ClinicApp/Data/ClinicQueryService.cs
git commit -m "Lab20 Task01"Задача 2. Пагінація ⭐⭐
Умова
Замість повного списку віддавайте дані сторінками разом із загальною кількістю — для інтерфейсу виду «Показано 1–20 з 347».
Що реалізувати:
- У
ClinicQueryServiceметодGetPatientsPaged: необов'язковий пошук за прізвищем, сортування, сторінка. - Метод
GetAppointmentsPagedз необов'язковими фільтрами за статусом і пацієнтом. - В обох — загальна кількість рахується до
Skip/Take, а сортування застосовується перед ними.
Специфікація
| Метод | Параметри | Повертає |
|---|---|---|
GetPatientsPaged |
int page, int pageSize, string? search = null |
(List<Patient> Items, int TotalCount), сортування за прізвищем |
GetAppointmentsPaged |
int page, int pageSize, AppointmentStatus? status = null, int? patientId = null |
(List<Appointment> Items, int TotalCount), сортування за датою |
Необов'язковий фільтр додається до запиту, лише якщо його задано.
Приклад
var (items, total) = queryService.GetPatientsPaged(page: 2, pageSize: 20);
Console.WriteLine($"Показано {items.Count} з {total}");Підказки
- Нарощуйте запит у змінній:
query = query.Where(...)— це не виконує SQL, лише додає умову. query.Count()— окремийSELECT COUNT(*)у БД;query.ToList().Count— завантаження всіх рядків заради одного числа.- Номер сторінки рахується з 1: пропустити треба
(page - 1) * pageSizeрядків.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
GetPatientsPaged / GetAppointmentsPaged |
GetGuestsPaged / GetBookingsPaged |
GetCustomersPaged / GetReservationsPaged |
GetStudentsPaged / GetEnrollmentsPaged |
GetClientsPaged / GetRentalsPaged |
GetReadersPaged / GetLoansPaged |
GetMembersPaged / GetSessionsPaged |
Коміт
git add ClinicApp/Data/ClinicQueryService.cs
git commit -m "Lab20 Task02"Задача 3. Проєкції в DTO ⭐⭐
Умова
Замість повних об'єктів вибирайте з БД лише потрібні поля — через проєкцію Select у DTO.
Що реалізувати:
- Створити в
ClinicApp/Models/дваrecord-и зі специфікації. - У
ClinicQueryServiceметодGetPatientSummaries()— проєкція пацієнтів уPatientSummaryDto, кількість записів — черезp.Appointments.Count. - Метод
GetAppointmentSummaries()— проєкція записів уAppointmentSummaryDtoз іменами пацієнта й лікаря.
Специфікація
| DTO | Поля |
|---|---|
PatientSummaryDto |
Id, FullName, Age, Phone, BloodType (рядок), AppointmentCount |
AppointmentSummaryDto |
Id, PatientName, DoctorName, Speciality, Date, Status, Cost |
| Метод | Повертає |
|---|---|
GetPatientSummaries() |
List<PatientSummaryDto> |
GetAppointmentSummaries() |
List<AppointmentSummaryDto> |
Приклад
-- що генерує EF для GetPatientSummaries
SELECT p.Id, p.FirstName + N' ' + p.LastName, …,
(SELECT COUNT(*) FROM Appointments AS a WHERE p.Id = a.PatientId)
FROM Patients AS pПідказки
recordз позиційними параметрами — коротке оголошення незмінного типу даних:public record PatientSummaryDto(int Id, string FullName, …);.- Вік у проєкції достатньо порахувати як різницю років — EF перекладе це в SQL.
- Імена пацієнта й лікаря беруться через навігаційні властивості прямо в
Select—Includeне потрібен. - Не все перекладається в SQL. Що EF не може перекласти в останньому
Select(наприклад,GetCost()), він обчислює на клієнті вже після завантаження — перевірте це в лозі SQL.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
PatientSummaryDto / AppointmentSummaryDto |
GuestSummaryDto / BookingSummaryDto |
CustomerSummaryDto / ReservationSummaryDto |
StudentSummaryDto / EnrollmentSummaryDto |
ClientSummaryDto / RentalSummaryDto |
ReaderSummaryDto / LoanSummaryDto |
MemberSummaryDto / SessionSummaryDto |
Коміт
git add ClinicApp/Models/PatientSummaryDto.cs ClinicApp/Models/AppointmentSummaryDto.cs ClinicApp/Data/ClinicQueryService.cs
git commit -m "Lab20 Task03"Задача 4. М'яке видалення і глобальний фільтр ⭐⭐⭐
Умова
Замість фізичного видалення пацієнта (Remove) позначайте його видаленим, а запити хай автоматично не бачать таких пацієнтів.
Що реалізувати:
- У
Patientдодати властивістьIsDeleted(private set) і методSoftDelete(). - У
OnModelCreatingдодати дляPatientглобальний фільтр «не видалений». - У
ClinicQueryServiceдодати методиSoftDeletePatient(int id)іGetDeletedPatients(). - Створити і застосувати міграцію
AddPatientSoftDelete.
Специфікація
dotnet ef migrations add AddPatientSoftDelete --project ClinicApp
dotnet ef database update --project ClinicApp| Член | Опис |
|---|---|
Patient.IsDeleted |
bool, private set |
Patient.SoftDelete() |
позначає пацієнта видаленим |
ClinicQueryService.SoftDeletePatient(int id) |
знаходить пацієнта, SoftDelete(), SaveChanges(); bool — чи знайдено |
ClinicQueryService.GetDeletedPatients() |
List<Patient> — лише видалені, в обхід фільтра |
Приклад
SoftDeletePatient(3) → True
context.Patients.Count() → 4 (пацієнт #3 не видно)
IgnoreQueryFilters().Count() → 5
GetDeletedPatients() → [3] Максим БойкоПідказки
- Після фільтра кожен запит до
Patients(іIncludeпацієнта) додаєWHERE IsDeleted = 0— писати умову вручну більше не треба. - Видаленого пацієнта не знайде звичайний пошук — у
SoftDeletePatientшукайте зIgnoreQueryFilters(), щоб коректно обробити повторне видалення. - Під час міграції EF попередить, що
Patientз фільтром — обов'язковий кінець зв'язку зAppointment: записи видаленого пацієнта приInclude(a => a.Patient)матимутьPatient == null. Варіанти — такий самий фільтр дляAppointment, необов'язковий зв'язок або задокументоване обмеження. Оберіть і поясніть коментарем.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Patient.IsDeleted |
Guest.IsDeleted |
Customer.IsDeleted |
Student.IsDeleted |
Client.IsDeleted |
Reader.IsDeleted |
Member.IsDeleted |
Коміт
git add ClinicApp/Models/Patient.cs ClinicApp/Data/ClinicDbContext.cs ClinicApp/Data/ClinicQueryService.cs ClinicApp/Migrations/
git commit -m "Lab20 Task04"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-20 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/
│ ├── Patient.cs ✏ Т4
│ ├── AppointmentSummaryDto.cs 🆕 Т3
│ ├── PatientSummaryDto.cs 🆕 Т3
│ └── … ще 16 файлів без змін
├── Managers/ (13 файлів)
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/
│ ├── ClinicDbContext.cs ✏ Т4
│ ├── DbSeeder.cs
│ ├── ClinicRepository.cs
│ └── ClinicQueryService.cs 🆕 Т1 ✏ Т2 Т3 Т4
└── Migrations/ (9 файлів — генерує EF) ✏ Т4Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 19.
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
-
DemoQueryableVsEnumerableповертає однакові списки, але в лозі SQL лише перший спосіб міститьWHERE … LIKE -
GetPatientsPaged(2, 2)повертає третього й четвертого пацієнтів за прізвищем і правильну загальну кількість -
GetPatientSummaries()у лозі SQL не вибирає непотрібних стовпців (Email,RowVersion) - Після
SoftDeletePatientпацієнт зникає зі звичайних запитів і з'являється вGetDeletedPatients() - У
OnConfiguringне лишилосьLogTo
Питання для самоперевірки
- Дерево виразів чи делегат.
IQueryableпрацює з деревами виразів,IEnumerable— з делегатамиFunc<T, bool>. Чому EF не може перекласти в SQL будь-якийFunc? - Keyset-пагінація.
Skip/Takeпри сотнях тисяч рядків дорогий. Альтернатива —WHERE Id > @lastId ORDER BY Id. Коли варто на неї переходити? - DTO чи ViewModel. DTO — для передачі даних між шарами, ViewModel — для відображення. Чи є між ними різниця у вашому проєкті?
recordчиclassдля DTO.recordсам генеруєEquals,GetHashCode,ToString. Чи потрібні вони DTO? Коли кращеclass?- М'яке видалення й унікальність. Якби номер ліцензії лікаря мав унікальний індекс, а лікаря видалили м'яко — новий лікар з тим самим номером не додався б. Як це вирішити?
- Матеріалізація посеред ланцюжка. Чому цей код компілюється, але є проблемою продуктивності?
var result = context.Patients .AsNoTracking() .ToList() .GroupBy(p => p.BloodType) .Select(g => new { g.Key, Count = g.Count() }) .ToList();
Статус гілки
Після всіх 4 завдань (кожне — окремий коміт Lab20 TaskNN на гілці Lab-20):
git push -u origin Lab-20
git checkout main
git merge --no-ff Lab-20 -m "Merge Lab-20: EF Core Queries"
git pushНаступна лаба:
git checkout main→git checkout -b Lab-21.