OOP Course
Сьогодні

Lab 14

LINQ

Where, Select, GroupBy, Join, агрегати

Лабораторна робота №14. LINQ

Мета

Зрозуміти різницю між імперативним і декларативним стилем роботи з колекціями. Навчитись виражати запити до даних через LINQ-оператори: .Where, .Select, .GroupBy, .Join, .OrderBy, .Any, .Sum, .Count, .Max, .Take, .Distinct — і розуміти коли і чому кожен з них доречний.

Гілка

feature/linq

Чому виник LINQ

Відкрийте src/Managers/AnalyticsManager.cs. Знайдіть метод ComputeDoctorStats. Він рахує статистику по кожному лікарю: скільки прийомів, яка виручка, коли останній прийом. Подивіться на код — скільки рядків займає ця логіка? Що саме вона робить?

Тепер прочитайте той самий алгоритм словами:

"Для кожного лікаря — знайди всі його прийоми, порахуй їх кількість, склади вартість, знайди найпізнішу дату."

Одне речення. А у коді — десятки рядків із вкладеними циклами і тимчасовими змінними.

LINQ (Language Integrated Query) — це набір методів що дозволяє писати запити до колекцій так само лаконічно, як ви їх описуєте словами. Це не нова мова — це бібліотека методів розширення над IEnumerable<T>, яку ви вже знаєте з Lab 10.

Головна відмінність від циклів:

Цикл (imperative) LINQ (declarative)
Описує як перебирати Описує що отримати
Тимчасові змінні-лічильники Результат — вираз
Важко читати з першого погляду Читається майже як речення

Завдання 1. Рефакторинг `AnalyticsManager` ⭐⭐

Що зараз

Відкрийте src/Managers/AnalyticsManager.cs і уважно прочитайте обидва методи. Спробуйте відповісти:

  • Яка роль змінних-лічильників (count, revenue, lastDate)?
  • Що відбувається якщо лікар не має жодного прийому?
  • Що означає умова if (a.DoctorId == d.Id)?

Ідея рефакторингу

Кожен for-цикл по прийомах всередині циклу по лікарях — це по суті фільтрація (Where) і агрегація (Count, Sum, Max). LINQ дозволяє виразити це в один вираз.

Для кожного лікаря ми хочемо:

  1. Відібрати його прийоми — .Where(a => a.DoctorId == d.Id)
  2. Порахувати кількість — .Count()
  3. Скласти виручку — .Sum(a => a.GetCost())
  4. Знайти останній прийом — .Max(a => a.ScheduledAt)

Всіх лікарів разом — трансформувати через .Select().

Важлива деталь

Перед тим як викликати .Max(), перевірте чи є хоч один прийом через .Any(). Якщо прийомів немає — .Max() кине виняток на порожній послідовності. У такому разі повертайте DateTime.MinValue як "немає дати".

Що потрібно зробити

Перепишіть обидва методи — ComputeDoctorStats і ComputePatientStats — замінивши for-цикли на LINQ-ланцюг. Сигнатури методів і тип результату не змінюються.

Після переписування запустіть програму, відкрийте пункт "8. Аналітика" і переконайтесь що всі сортування дають ті самі результати що й раніше.

Ключові питання

  • Чому .Select() всередині містить блок { var own = ...; return ...; } а не просто вираз?
  • Чи змінюється поведінка якщо прибрати Appointment[] appointments = _appointments.GetAll() на початку і звертатись до _appointments.GetAll() кожного разу всередині .Select()? Чому це погано?

Завдання 2. DTO `SpecialityReport` ⭐

Навіщо окремий клас

Звіт по спеціальностях повертатиме кілька числових полів для кожної спеціальності одночасно: кількість лікарів, кількість прийомів, загальна виручка. Можна було б повертати tuple — але іменований клас читається краще і дозволяє додати ToString().

Що потрібно зробити

Створіть src/Models/SpecialityReport.cs.

Клас повинен містити чотири readonly-властивості: Speciality (тип Speciality з enum), DoctorCount, AppointmentCount, TotalRevenue. Всі заповнюються через конструктор.

Реалізуйте ToString() так, щоб результат читався в одному рядку і містив усі чотири значення. Подивіться на DoctorStats.ToString() як на зразок форматування.


Завдання 3. `ReportManager` — нові звіти через LINQ ⭐⭐⭐

Чому новий клас, а не додавати в `AnalyticsManager`

AnalyticsManager вже має чітку відповідальність — статистика по лікарях і пацієнтах для сортування. Нові звіти мають іншу природу: групування по спеціальностях, пошук топ-N, місячна виручка. Це окремий модуль звітності.

Створіть src/Managers/ReportManager.cs. Конструктор приймає ті самі три залежності що й AnalyticsManager.


Метод `GetSpecialityStats()` — GroupBy ⭐⭐

Задача: для кожної спеціальності показати скільки лікарів з цією спеціальністю є у клініці, скільки прийомів вони провели разом і яка загальна виручка.

Що таке GroupBy. Уявіть що у вас список лікарів і ви раскладаєте їх у стопки за спеціальністю. .GroupBy(d => d.Speciality) повертає колекцію груп — кожна група має Key (спеціальність) і елементи (лікарі з цією спеціальністю).

Алгоритм:

  1. Згрупувати лікарів за Speciality
  2. Для кожної групи: взяти ID всіх лікарів групи, знайти в масиві прийомів ті що належать цим лікарям
  3. Зібрати SpecialityReport з g.Key, g.Count(), кількістю та сумою прийомів
  4. Відсортувати за виручкою спадно

Підказка: щоб відфільтрувати прийоми для групи лікарів, отримайте їхні ID у масив і використовуйте .Contains().


Метод `FindBusiestDoctorName()` — OrderByDescending + FirstOrDefault ⭐

Задача: знайти лікаря з найбільшою кількістю записів і повернути його ім'я.

Підхід: відсортувати лікарів за кількістю їхніх прийомів спадно і взяти першого. .FirstOrDefault() повертає null якщо список порожній — тому тип повернення string?.

Подумайте: .Count(a => a.DoctorId == d.Id) всередині .OrderByDescending() — це ефективно? Для невеликої системи — так. Що можна зробити щоб уникнути повторного перебору?


Метод `GetPatientsWithMultipleVisits(int minVisits)` — GroupBy + Join ⭐⭐

Задача: знайти пацієнтів що мають щонайменше minVisits записів і повернути їхні імена.

Що таке Join. Якщо у вас є дві колекції — groups (згруповані прийоми) і patients (список пацієнтів) — .Join() дозволяє їх з'єднати за спільним ключем, як JOIN у SQL.

Алгоритм:

  1. Згрупувати прийоми за PatientId
  2. Залишити тільки групи де кількість >= minVisits
  3. З'єднати з масивом пацієнтів: ключ з групи — g.Key (PatientId), ключ з пацієнта — p.Id
  4. Результат — p.FullName

Метод `GetTopEarners(int n)` — OrderByDescending + Take ⭐

Задача: топ-N лікарів за виручкою.

.Take(n) — обрізає послідовність до перших N елементів. Ставиться після сортування.

Логіка формування DoctorStats для кожного лікаря — така сама як у AnalyticsManager після рефакторингу в Завданні 1. Не копіюйте код — подумайте чи є спосіб уникнути дублювання. (Якщо ні — скопіюйте, але усвідомте проблему.)


Метод `HasAnyUrgentAppointments()` — Any ⭐

Задача: чи є в системі хоч один терміновий прийом?

.Any(predicate) — повертає true як тільки знаходить перший елемент що відповідає умові. Це ефективніше ніж .Count() > 0 — не перебирає всю колекцію.

Перевірте тип через is: a is UrgentAppointment.


Метод `GetActiveSpecialities()` — Distinct + OrderBy ⭐

Задача: список унікальних спеціальностей лікарів що є у клініці.

.Distinct() — прибирає дублікати. Якщо є три кардіологи — Cardiology з'явиться один раз. Для enum-значень порівняння відбувається за значенням — жодних додаткових налаштувань не потрібно.


Метод `GetMonthlyRevenue()` — GroupBy з анонімним типом ⭐⭐

Задача: виручка клініки по кожному місяцю.

Анонімний тип у GroupBy. Щоб згрупувати одночасно за роком і місяцем, ключем GroupBy стає анонімний об'єкт:

.GroupBy(a => new { a.ScheduledAt.Year, a.ScheduledAt.Month })

Потім g.Key.Year і g.Key.Month.

Value tuple як тип повернення. Замість окремого DTO поверніть кортеж:

IEnumerable<(int Year, int Month, decimal Total)>

У Select формуєте: (g.Key.Year, g.Key.Month, g.Sum(a => a.GetCost())).

Відсортуйте за роком, потім за місяцем — .OrderBy(...).ThenBy(...).


Завдання 4. Підключення до `Clinic` і `Program` ⭐

Clinic.cs

ReportManager — новий компонент системи. За аналогією з AnalyticsManager додайте публічну readonly-властивість і ініціалізуйте її в конструкторі. Передайте ті самі три менеджери: Appointments, Doctors, Patients.

Program.cs

Додайте пункт 11 у головне меню. Реалізуйте ReportsMenu(Clinic clinic) — підменю з 7 пунктами по одному на кожен метод ReportManager.

Зверніть увагу на пункт "3. Пацієнти з кількома візитами": запитайте у користувача мінімальну кількість візитів перед викликом методу.

Зверніть увагу на пункт "4. Топ-3 лікарів": передайте константу 3 у GetTopEarners(3) і виводьте з нумерацією.


Перевірка

dotnet run --project src

Перевірте у розділі 11. Звіти:

  • Пункт 1: показує спеціальності з кількістю лікарів і виручкою, відсортовані за виручкою
  • Пункт 2: виводить ім'я лікаря що має найбільше записів
  • Пункт 3 при введенні 1: всі пацієнти що мають хоч один запис
  • Пункт 4: три лікарі відсортовані за виручкою спадно
  • Пункт 5: повідомлення про наявність або відсутність термінових записів
  • Пункт 7: рядки виду 2026/05 — 1350.00 грн

Також перевірте що пункт 8. Аналітика (з Lab 10) продовжує працювати коректно після рефакторингу AnalyticsManager.


Питання для самоперевірки

  1. Чим .Where() відрізняється від .Select()? Що кожен з них повертає?
  2. Чому .Max() на порожній послідовності кидає виняток, а .FirstOrDefault() — ні?
  3. Що таке g.Key у .GroupBy()? Якого типу він у GetSpecialityStats і у GetMonthlyRevenue?
  4. .Any() і .Count() > 0 дають однаковий результат. В чому різниця з точки зору продуктивності?
  5. .Take(n) стоїть після .OrderByDescending(). Що станеться якщо поміняти їх місцями?
  6. У GetMonthlyRevenue ключ GroupBy — анонімний тип new { Year, Month }. Як C# порівнює два такі об'єкти на рівність?
Розроблено Tomka Yurii · © 2026 ·