Lab 19
EF Core: Advanced
OwnsOne, TPH, RowVersion, concurrency
Лаба 19 — EF Core: TPH для абстрактної ієрархії, Owned Entity, Concurrency
Мета
Зберегти в БД абстрактну ієрархію медичних записів (TPH з різними наборами полів), вбудувати в таблицю пацієнтів залежний об'єкт (Owned Entity) і захистити дані від одночасного редагування (concurrency token).
Контекст
Після Лаби 18 таблиця Appointments зберігає ієрархію підтипів через TPH. Але медичні записи (діагноз, аналіз, рецепт) ще не в БД — і ця ієрархія складніша: MedicalRecord абстрактний, а підтипи мають зовсім різні набори полів.
Крім того, пацієнту потрібна контактна особа на випадок надзвичайної ситуації. Це не окрема сутність, а частина пацієнта (ім'я, телефон, ким доводиться) — окрема таблиця для неї надлишкова, досить кількох стовпців у Patients.
І ще: якщо два адміністратори одночасно редагують картку пацієнта, без захисту від паралельного доступу останній запис мовчки перезапише перший.
Структура проєкту на початку лаби
Це результат Лаби 18 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 18)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/
│ ├── Patient.cs
│ ├── Diagnosis.cs
│ ├── LabResult.cs
│ ├── MedicalRecord.cs
│ ├── Prescription.cs
│ └── … ще 11 файлів без змін
├── Managers/ (13 файлів)
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/
│ ├── ClinicDbContext.cs
│ ├── DbSeeder.cs
│ └── ClinicRepository.cs
└── Migrations/ (5 файлів — генерує EF)Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
Ключові поняття
- TPH для абстрактного класу.
new MedicalRecord()неможливий — EF створює лише конкретні підтипи. Дискримінатор описується для базового типу; поля підтипів у рядках інших підтипів —NULL, тому вони налаштовуються як необов'язкові. - Owned Entity (
OwnsOne). Залежний об'єкт без власногоIdі таблиці: його поля стають стовпцями таблиці власника (EC_Name,EC_Phone,EC_RelationshipуPatients). На відміну відValueConverter(один рядок, якWorkSchedule), кожне поле — окремий стовпець рідного SQL-типу, по якому можна шукати. - Concurrency token (
RowVersion). SQL Server автоматично змінює стовпецьrowversionпри кожномуUPDATE. EF додає доUPDATEумовуWHERE RowVersion = @старе_значення: якщо запис устиг змінити хтось інший, рядок не знайдеться — і EF кинеDbUpdateConcurrencyException.
Що нового дозволено (і тільки воно)
- TPH для абстрактного базового класу;
OwnsOne(Owned Entity);IsRowVersion()іDbUpdateConcurrencyException.
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-19Коміт — на кожне завдання (Lab19 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.
Як користуватися підказками
Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.
Задача 1. Ієрархія `MedicalRecord`: підготовка до EF ⭐⭐
Умова
Підготуйте абстрактну ієрархію медичних записів (Лаба 06) до роботи з EF: властивості мають отримувати значення з БД, а EF — створювати об'єкти підтипів.
Що реалізувати:
- У
MedicalRecordзмінитиId,PatientId,DoctorId,Dateна{ get; private set; }. - Додати в
MedicalRecordprotectedконструктор без параметрів, що задаєDate = DateTime.Today. - Додати в
MedicalRecordнавігаційну властивістьPatient. - Додати
protectedконструктори без параметрів уDiagnosis,LabResult,Prescription.
Специфікація
| Клас | Зміни |
|---|---|
MedicalRecord |
Id, PatientId, DoctorId, Date — private set; protected MedicalRecord(); public Patient? Patient { get; set; } |
Diagnosis, LabResult, Prescription |
protected конструктор без параметрів |
Приклад
var records = context.MedicalRecords.Where(r => r.PatientId == 1).ToList();
// у списку — Diagnosis, LabResult, Prescription: EF створив об'єкти потрібних підтипівПідказки
Date = DateTime.Todayу конструкторі без параметрів — безпечне значення до того, як EF запише справжнє.- EF викликає
protectedконструктор через рефлексію; конструктор без параметрів потрібен кожному конкретному підтипу. - Завантажуючи запис, EF заповнює властивості через сеттери — а сеттери підтипів валідують значення (Лаба 05). Подумайте, чи небезпечно це для даних, що вже лежать у БД.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
MedicalRecord → Diagnosis / LabResult / Prescription |
ServiceRecord → … |
OrderRecord → … |
AcademicRecord → … |
VehicleRecord → … |
LoanRecord → … |
FitnessRecord → … |
Коміт
git add ClinicApp/Models/MedicalRecord.cs ClinicApp/Models/Diagnosis.cs ClinicApp/Models/LabResult.cs ClinicApp/Models/Prescription.cs
git commit -m "Lab19 Task01"Задача 2. Таблиця `MedicalRecords` (TPH) у Fluent API ⭐⭐⭐
Умова
Опишіть таблицю медичних записів: два зовнішні ключі, дискримінатор і необов'язкові поля підтипів.
Що реалізувати:
- Додати в
ClinicDbContextтаблицюDbSet<MedicalRecord> MedicalRecords. - Налаштувати
MedicalRecordуOnModelCreating: таблиця, ключ, зв'язки, дискримінатор (специфікація нижче). - Налаштувати поля кожного підтипу окремо — усі необов'язкові.
Специфікація
| Стовпець | Налаштування |
|---|---|
Id |
PK, IDENTITY; значення з _nextId ігнорується (як у Лабі 17) |
PatientId |
FK → Patients, каскадне видалення |
DoctorId |
FK → Doctors, Restrict |
Date |
дата |
Notes |
до 500 символів |
RecordType |
дискримінатор: Diagnosis / LabResult / Prescription |
DiagnosisCode (до 20), Description, IsChronic |
лише для Diagnosis, nullable |
TestName, Value, Unit, ReferenceRange, IsNormal |
лише для LabResult, nullable |
MedicationName, Dosage, DurationDays, Instructions |
лише для Prescription, nullable |
Приклад
MedicalRecords
Id | PatientId | RecordType | DiagnosisCode | TestName | MedicationName
1 | 1 | Diagnosis | I10 | NULL | NULL
2 | 1 | LabResult | NULL | Холестерин | NULL
3 | 1 | Prescription | NULL | NULL | ЛізиноприлПідказки
- Два каскади до однієї таблиці SQL Server не дозволяє — як і в Лабі 18: пацієнт — каскад, лікар —
Restrict. - Абстрактний базовий тип не має власного значення дискримінатора — значення задаються лише для конкретних підтипів.
IsRequired(false)явно позначає стовпець як nullable: без нього EF може вимагатиNOT NULLдля полів, яких в інших підтипах просто немає.- Значення полів підтипів зберігаються в приватних полях (
_diagnosisCode,_testName…) за властивостями — EF працює через властивості.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
MedicalRecords (RecordType) |
ServiceRecords |
OrderRecords |
AcademicRecords |
VehicleRecords |
LoanRecords |
FitnessRecords |
Коміт
git add ClinicApp/Data/ClinicDbContext.cs
git commit -m "Lab19 Task02"Задача 3. Контактна особа як Owned Entity ⭐⭐
Умова
Додайте пацієнту контактну особу на випадок надзвичайної ситуації. Вона не має власного Id і таблиці — існує лише як частина пацієнта.
Що реалізувати:
- Створити клас
EmergencyContactуClinicApp/Models/з трьома властивостями зі специфікації. - Додати в
Patientнеобов'язкову властивістьEmergencyContact. - Налаштувати її в
OnModelCreatingчерезOwnsOne— три стовпці в таблиціPatients.
Специфікація
Властивість EmergencyContact |
Стовпець у Patients |
Довжина |
|---|---|---|
Name — ім'я контактної особи |
EC_Name |
100 |
Phone — телефон |
EC_Phone |
20 |
Relationship — ким доводиться (дружина, мати, брат…) |
EC_Relationship |
50 |
Член Patient |
|
|---|---|
public EmergencyContact? EmergencyContact { get; set; } |
необов'язкова: без контакту стовпці EC_* — NULL |
Приклад
Patients
Id | FirstName | … | EC_Name | EC_Phone | EC_Relationship
1 | Іван | … | Олена Петренко | 0671112233 | дружина
2 | Олена | … | NULL | NULL | NULLПідказки
OwnsOneвбудовує поля залежного об'єкта в таблицю власника: немаєJOIN, FK чи окремої таблиці.- Назви стовпців задаються через
HasColumnName. - Порівняйте з
WorkSchedule(Лаба 17): один рядок проти трьох окремих стовпців — по яких зручніше шукати?
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
EmergencyContact |
EmergencyContact гостя |
ContactPerson клієнта |
Guardian студента |
EmergencyContact клієнта |
ContactPerson читача |
EmergencyContact учасника |
Коміт
git add ClinicApp/Models/EmergencyContact.cs ClinicApp/Models/Patient.cs ClinicApp/Data/ClinicDbContext.cs
git commit -m "Lab19 Task03"Задача 4. `RowVersion`, міграція і медичні записи в сідері ⭐⭐⭐
Умова
Захистіть картку пацієнта від одночасного редагування, застосуйте всі зміни схеми цієї лаби однією міграцією і додайте медичні записи в сідер.
Що реалізувати:
- Додати в
PatientвластивістьRowVersionі налаштувати її як concurrency token (IsRowVersion()). - Створити і застосувати міграцію
AddMedicalRecordsAndOwnedEntities(команди нижче). - Додати в
DbSeederкрокSeedMedicalRecords(післяSeedAppointments): хронічний і звичайний діагнози, аналіз у нормі й поза нормою, активний рецепт — на реальніIdз БД; ідемпотентно. - Додати в
Program.csстатичну функціюDemoConcurrencyConflict()і викликати її один раз на старті: два окремі контексти завантажують того самого пацієнта, перший зберігає зміну, другий отримуєDbUpdateConcurrencyException; виведіть, що сталося.
Специфікація
dotnet ef migrations add AddMedicalRecordsAndOwnedEntities --project ClinicApp
dotnet ef database update --project ClinicAppЧлен Patient |
Налаштування |
|---|---|
public byte[]? RowVersion { get; private set; } |
IsRowVersion() → тип rowversion, оновлюється SQL Server автоматично |
Крок DemoConcurrencyConflict() |
Очікувано |
|---|---|
| контекст А і контекст Б завантажують пацієнта #1 | обидва бачать однаковий RowVersion |
| А змінює телефон і зберігає | успіх, RowVersion у БД змінився |
| Б змінює ім'я і зберігає | DbUpdateConcurrencyException |
Приклад
== Конфлікт паралельного редагування ==
Сесія А: зміни збережено.
Сесія Б: DbUpdateConcurrencyException — запис уже змінено іншим користувачем.Підказки
IsRowVersion()поєднує три налаштування: типrowversion, участь уWHEREприUPDATEі автоматичну генерацію значення сервером.- Два «користувачі» — це два окремі екземпляри
DbContext: в одному контексті EF відстежує один об'єкт і конфлікту не буде. - Перехопіть виняток у
try/catch; у реальній програмі тут або перезавантажують дані, або повідомляють користувача. - Порядок у сідері: пацієнти → лікарі → записи → медичні записи.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Patient.RowVersion |
Guest.RowVersion |
Customer.RowVersion |
Student.RowVersion |
Client.RowVersion |
Reader.RowVersion |
Member.RowVersion |
Коміт
git add ClinicApp/Models/Patient.cs ClinicApp/Data/ClinicDbContext.cs ClinicApp/Data/DbSeeder.cs ClinicApp/Migrations/ ClinicApp/Program.cs
git commit -m "Lab19 Task04"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-19 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs ✏ Т4
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/
│ ├── Patient.cs ✏ Т3 Т4
│ ├── Diagnosis.cs ✏ Т1
│ ├── LabResult.cs ✏ Т1
│ ├── MedicalRecord.cs ✏ Т1
│ ├── Prescription.cs ✏ Т1
│ ├── EmergencyContact.cs 🆕 Т3
│ └── … ще 11 файлів без змін
├── Managers/ (13 файлів)
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/
│ ├── ClinicDbContext.cs ✏ Т2 Т3 Т4
│ ├── DbSeeder.cs ✏ Т4
│ └── ClinicRepository.cs
└── Migrations/ (7 файлів — генерує EF) ✏ Т4Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 18.
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
- У БД є таблиця
MedicalRecordsзі стовпцемRecordTypeі nullable-стовпцями підтипів - У таблиці
Patientsє стовпціEC_Name,EC_Phone,EC_RelationshipіRowVersion - Після сідера в БД медичні записи всіх трьох типів
- На старті
DemoConcurrencyConflict()показує успіх першої сесії іDbUpdateConcurrencyExceptionдругої - Повторний запуск не дублює дані
Питання для самоперевірки
- Nullable-стовпці TPH.
DiagnosisCode,TestName,MedicationName— nullable. Це порушення першої нормальної форми чи прийнятний компроміс? - TPT як альтернатива. Окремі таблиці для підтипів — без
NULL, але зJOINпри завантаженніMedicalRecord[]. Де краще TPH, де TPT? - Value Object.
EmergencyContact— value object у термінах DDD. Чим value object відрізняється від сутності (entity)? - Оптимістичне чи песимістичне блокування. Чим
RowVersionвідрізняється від блокування рядка на час редагування? - Що робити в
catch (DbUpdateConcurrencyException)— перезавантажити дані чи повідомити користувача? Від чого залежить вибір? - Валідація в сеттерах чи обмеження БД. Сеттер
DiagnosisCodeне пропускає порожній рядок, а стовпець у БД — nullable. Хто відповідає за якість даних?
Статус гілки
Після всіх 4 завдань (кожне — окремий коміт Lab19 TaskNN на гілці Lab-19):
git push -u origin Lab-19
git checkout main
git merge --no-ff Lab-19 -m "Merge Lab-19: EF Core Advanced"
git pushНаступна лаба:
git checkout main→git checkout -b Lab-20.