Lab 19
EF Core: Advanced
OwnsOne, TPH, RowVersion, concurrency
Лабораторна робота 19 — EF Core: TPH глибоко, Owned Entity, Concurrency
Проблема
Після Lab 18 таблиця Appointments вже зберігає ієрархію підтипів через TPH. Але MedicalRecord (діагноз, аналіз, рецепт) ще не в базі даних — і ця ієрархія складніша: MedicalRecord — абстрактний клас, а підтипи мають принципово різні набори полів.
Крім того, Patient зростає: йому потрібен контактний номер на випадок надзвичайної ситуації — але це не окрема сутність, а частина пацієнта (ім'я, телефон, роль). Зберігати це в окремій таблиці надлишково — краще кілька додаткових стовпців у Patients.
І ще: якщо два адміністратори одночасно редагують картку пацієнта — хто "переможе"? Без захисту від паралельного доступу останній запис мовчки перезапише перший.
Ключові концепції
TPH для абстрактної ієрархії
В Lab 18 TPH застосовувався до Appointment — конкретного класу. Тепер ієрархія MedicalRecord — абстрактна: не можна створити new MedicalRecord().
EF Core підтримує TPH з abstract базовими класами — дискримінатор описується для базового типу, а всі concrete підтипи реєструються окремо.
Складність: поля підтипів Diagnosis, LabResult, Prescription — принципово різні. В одній таблиці вони є NULL для несумісних підтипів. Флюент API описує ці поля як IsRequired(false).
Owned Entity
Проблема: EmergencyContact — не незалежна сутність; вона існує тільки як частина Patient. Але у неї три поля (Name, Phone, Relationship).
ValueConverter (WorkSchedule) — серіалізує в один рядок. Для EmergencyContact це незручно — три поля трьох різних типів.
Рішення — Owned Entity (OwnsOne):
Patient → EC_Name, EC_Phone, EC_Relationship (стовпці у таблиці Patients)EF Core вбудовує стовпці EmergencyContact прямо в таблицю власника. Немає JOIN, немає FK, немає окремої таблиці.
modelBuilder.Entity<Patient>().OwnsOne(p => p.EmergencyContact, ec =>
{
ec.Property(e => e.Name).HasColumnName("EC_Name").HasMaxLength(100);
...
});Важлива деталь: OwnsOne дозволяє null (пацієнт без контакту). В БД стовпці просто NULL.
Concurrency Token
Проблема паралельного доступу:
- Адміністратор А завантажує Patient (RowVersion =
[1,2,3,4]) - Адміністратор Б завантажує той самий Patient
- Адміністратор Б зберігає зміни → RowVersion стає
[1,2,3,5] - Адміністратор А намагається зберегти — його версія
[1,2,3,4]вже застаріла!
IsRowVersion() — EF додає до UPDATE:
UPDATE Patients SET ... WHERE Id = @id AND RowVersion = @original_versionЯкщо за час між SELECT і UPDATE хтось вже змінив запис — WHERE не знаходить рядка → 0 рядків оновлено → EF кидає DbUpdateConcurrencyException.
SQL Server автоматично оновлює rowversion (тип timestamp) при кожному UPDATE.
Завдання
Завдання 1. MedicalRecord — EF Core сумісність
Задача: підготувати абстрактну ієрархію для EF Core.
Проблема: абстрактний клас з readonly властивостями
MedicalRecord — абстрактний. EF Core ніколи не створює його безпосередньо, лише конкретні підтипи. Але shared конфігурація для всіх підтипів (Id, PatientId, DoctorId, Date) — в базовому класі.
public int Id { get; } — потрібен private set для EF. Аналогічно для PatientId, DoctorId, Date.
Protected ctor для EF:
protected MedicalRecord() { Date = DateTime.Today; }Чому Date = DateTime.Today? Щоб властивість мала безпечне значення після EF-побудови, ще до того як EF встановить реальне значення через setter.
Підкласи:
Diagnosis: додатиprotected Diagnosis() { }LabResult: додатиprotected LabResult() { }Prescription: додатиprotected Prescription() { }
Перевірте: чи потрібні зміни в приватних полях підкласів (_diagnosisCode, _testName, etc.)? EF викликає setter при завантаженні — а setter валідує. Чи небезпечно це?
Navigation property:
Додайте до MedicalRecord:
public Patient? Patient { get; set; } // navigationКлючові питання:
- Чому EF Core може використовувати
protectedконструктор? Він же неpublic. - Чи потрібен parameterless ctor для підкласів, якщо базовий
protected MedicalRecord()вже є?
Завдання 2. Fluent API для MedicalRecord TPH
Задача: описати TPH ієрархію MedicalRecord з правильними FK та nullable стовпцями.
Структура таблиці MedicalRecords:
Id — PK, IDENTITY
PatientId — FK на Patients, Cascade
DoctorId — FK на Doctors, Restrict
Date — datetime2
Notes — nvarchar(500)
RecordType — дискримінатор: "Diagnosis" / "LabResult" / "Prescription"
DiagnosisCode — nullable, тільки для Diagnosis
Description — nullable, тільки для Diagnosis
IsChronic — nullable bit, тільки для Diagnosis
TestName — nullable, тільки для LabResult
Value — nullable float, тільки для LabResult
Unit — nullable, тільки для LabResult
ReferenceRange— nullable, тільки для LabResult
IsNormal — nullable bit, тільки для LabResult
MedicationName— nullable, тільки для Prescription
Dosage — nullable, тільки для Prescription
DurationDays — nullable int, тільки для Prescription
Instructions — nullable, тільки для PrescriptionTwo Cascade Paths:
Знову проблема двох каскадних шляхів. MedicalRecord має два FK: PatientId і DoctorId. Обидва не можуть бути Cascade.
Рішення: Patient → MedicalRecords: Cascade, Doctor → MedicalRecords: Restrict.
Конфігурація підтипів:
Після головної конфігурації — окремо для кожного підтипу:
modelBuilder.Entity<Diagnosis>(entity => {
entity.Property(d => d.DiagnosisCode).HasMaxLength(20).IsRequired(false);
...
});IsRequired(false) явно позначає стовпець як nullable — без нього EF може вимагати NOT NULL.
Ключові питання:
- Що означає
HasDiscriminatorдля абстрактного базового класу? Чи потрібноHasValue<MedicalRecord>("Base")? - Чому всі поля підтипів в одній таблиці є nullable?
Завдання 3. Owned Entity: EmergencyContact
Задача: додати до пацієнта контактну особу як Owned Entity.
Створіть клас EmergencyContact:
Name — ім'я контактної особи
Phone — телефон
Relationship — хто вона для пацієнта (Дружина / Мати / Брат...)Цей клас:
- не має
Id - не має власної таблиці
- існує тільки як частина Patient
Власник (Patient):
public EmergencyContact? EmergencyContact { get; set; }? — контакт не обов'язковий. Якщо null — в БД стовпці EC_* рівні NULL.
Fluent API:
modelBuilder.Entity<Patient>().OwnsOne(p => p.EmergencyContact, ec =>
{
ec.Property(e => e.Name).HasColumnName("EC_Name").HasMaxLength(100);
ec.Property(e => e.Phone).HasColumnName("EC_Phone").HasMaxLength(20);
ec.Property(e => e.Relationship).HasColumnName("EC_Relationship").HasMaxLength(50);
});Перевірте після міграції: чи є нові стовпці EC_* у таблиці Patients?
Відмінності OwnsOne vs ValueConverter:
ValueConverter<TModel, TProvider> |
OwnsOne |
|
|---|---|---|
| Стовпців | 1 | N (по одному на поле) |
| Пошук | WHERE WorkSchedule = '8-17' |
WHERE EC_Name LIKE '%...%' |
| Типи | Один серіалізований | Рідні SQL типи |
| Приклад | WorkSchedule → "8-17" | EmergencyContact → 3 стовпці |
Ключові питання:
- Чи можна зробити
OwnsMany(колекцію Owned Entity)? Що EF робить з таблицею? - Що відбудеться якщо встановити
patient.EmergencyContact = nullі зберегти?
Завдання 4. Concurrency Token та DbSeeder
Задача: додати захист від паралельного редагування та наповнити БД медичними записами.
RowVersion:
Додайте до Patient:
public byte[]? RowVersion { get; private set; }Fluent API:
modelBuilder.Entity<Patient>()
.Property(p => p.RowVersion)
.IsRowVersion();IsRowVersion() — це поєднання трьох налаштувань:
- Тип
timestamp/rowversionу SQL Server IsConcurrencyToken = true— EF включає у WHERE при UPDATEValueGeneratedOnAddOrUpdate = true— SQL Server оновлює автоматично
Демонстрація конфліктів:
Напишіть метод (або тест) що симулює конфлікт:
1. Завантажити patient через context1
2. Завантажити той самий patient через context2
3. context1.SaveChanges() — успішно
4. context2.SaveChanges() — DbUpdateConcurrencyExceptionЗверніть увагу: потрібні два окремих DbContext екземпляри для симуляції двох сесій.
DbSeeder — медичні записи:
Додайте SeedMedicalRecords(context):
Diagnosis: принаймні один хронічний і один звичайнийLabResult: один аналіз в нормі, один поза нормоюPrescription: один активний рецепт
Важливо: медичні записи потребують реальних PatientId і DoctorId з БД. Порядок виклику: SeedPatients → SeedDoctors → SeedAppointments → SeedMedicalRecords.
Ключові питання:
- Що таке Optimistic Concurrency (оптимістичне блокування)? Чим відрізняється від Pessimistic (блокування рядка)?
- Коли використовувати RowVersion, а коли —
[ConcurrencyCheck]на окремому полі? - Що зробити у
catch (DbUpdateConcurrencyException)? Перезавантажити дані чи інформувати користувача?
Рефлексійні питання
TPH nullable fields. У таблиці MedicalRecords стовпці
DiagnosisCode,TestName,MedicationName— nullable. Це "порушення" 1НФ (перша нормальна форма)? Чи це прийнятний компроміс?TPT як альтернатива. Table Per Type: Diagnosis в
Diagnoses, LabResult вLabResultsтощо — немає NULL. Але при завантаженніMedicalRecord[]— JOIN для кожного підтипу. Де TPH краще? Де TPT?Owned Entity і агрегати. EmergencyContact — це Value Object у термінах DDD (Domain-Driven Design). Що означає "value object"? Як воно відрізняється від Entity?
RowVersion і репліка. Якщо БД реплікована (primary + replica),
rowversionне синхронізується між серверами. Як вирішити проблему конкурентного доступу в такому середовищі?Soft delete. Замість видалення запису — встановити
IsDeleted = true. Як це реалізувати в EF Core так, щоб всі запити автоматично фільтрували видалені записи?Validation in setters vs DB constraints. Setter
DiagnosisCodeвалідує не-порожній рядок. А в БД — nullable column. Хто відповідає за якість даних — C# код чи схема БД? Або обоє?