💾 Розділ 16 · Питання #30

Які типи наслідування підтримує JPA

Об'єктно-орієнтована парадигма Java підтримує поліморфізм і наслідування класів, тоді як реляційна модель баз даних оперує пласкими таблицями та зовнішніми ключами (проблема Obj...

Об’єктно-орієнтована парадигма Java підтримує поліморфізм і наслідування класів, тоді як реляційна модель баз даних оперує пласкими таблицями та зовнішніми ключами (проблема Object-Relational Impedance Mismatch). Специфікація JPA визначає три фундаментальні стратегії відображення ієрархій наслідування на таблиці СУБД (SINGLE_TABLE, JOINED, TABLE_PER_CLASS), а також шаблон перевикористання спільних властивостей @MappedSuperclass.


🟢 Junior Level

3 стратегії наслідування JPA

Стратегія задається анотацією @Inheritance(strategy = InheritanceType...) на кореневому класі ієрархії:

               3 СТРАТЕГІЇ ВІДОБРАЖЕННЯ ІЄРАРХІЇ В БАЗУ ДАНИХ
               
   1. SINGLE_TABLE (За замовчуванням) 2. JOINED                     3. TABLE_PER_CLASS
      ┌─────────────────────────┐       ┌──────────────────────┐      ┌──────────────────────┐
      │     payments            │       │   payments (Спільна) │      │    card_payments     │
      ├─────────────────────────┤       ├──────────────────────┤      ├──────────────────────┤
      │ id, dtype, amount,      │       │ id, amount           │      │ id, amount, card_num │
      │ card_num, cash_date     │       └──────────┬───────────┘      └──────────────────────┘
      └─────────────────────────┘                  │ PK/FK                    ┌──────────────────────┐
      (Усі класи в 1 таблиці)           ┌──────────┴───────────┐      │    cash_payments     │
                                        │    card_payments     │      ├──────────────────────┤
                                        ├──────────────────────┤      │ id, amount, cash_date│
                                        │ id (FK), card_num    │      └──────────────────────┘
                                        └──────────────────────┘      (Ізольовані таблиці)

Базовий приклад налаштування:

// Кореневий клас ієрархії:
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private BigDecimal amount;
}

// Підклас 1: Оплата карткою
@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
    private String cardNumber;
}

// Підклас 2: Оплата готівкою
@Entity
@DiscriminatorValue("CASH")
public class CashPayment extends Payment {
    private String cashierName;
}

🟡 Middle Level

Детальний аналіз стратегій та їхніх компромісів

1. InheritanceType.SINGLE_TABLE (Одна таблиця на всю ієрархію)

Усі поля всіх класів ієрархії зберігаються в одній фізичній таблиці. Для розрізнення типів використовується колонка-дискримінатор (DTYPE за замовчуванням).

  • Плюси:
    • Максимальна продуктивність на читання та запис (відсутні JOIN та UNION).
    • Поліморфні запити виконуються блискавично: SELECT * FROM payments.
  • Мінуси:
    • Розрідженість даних (велика кількість колонок зі значенням NULL).
    • Неможливість додати обмеження NOT NULL у СУБД на поля підкласів (колонка card_number зобов’язана бути NULL, інакше не можна буде вставити CashPayment).

2. InheritanceType.JOINED (Нормалізована таблиця на кожен клас)

Кореневий клас відображається в базову таблицю зі спільними полями, а кожен підклас — в окрему таблицю зі своїми специфічними полями, пов’язану з базовою таблицею за первинним/зовнішнім ключем.

  • Плюси:
    • Сувора нормалізація схеми (3NF).
    • Поля підкласів можуть мати обмеження цілісності NOT NULL.
  • Мінуси:
    • Читання вимагає виконання LEFT OUTER JOIN з усіма дочірніми таблицями ієрархії.
    • Вставка вимагає двох послідовних SQL INSERT (спочатку в базову таблицю, потім у дочірню).

3. InheritanceType.TABLE_PER_CLASS (Таблиця на кожен конкретний клас)

Кожен підклас мапиться в повністю незалежну таблицю, що містить як свої поля, так і дублюючі колонки базового класу.

  • Плюси: Кожна таблиця самодостатня, немає зайвих NULL і немає JOIN при вибірці конкретного підкласу.
  • Мінуси: Поліморфний запит до базового класу (SELECT p FROM Payment p) генерує важкий UNION ALL по всіх таблицях, що блокує використання індексів.

Порівняльна матриця для архітектора

Критерій SINGLE_TABLE JOINED TABLE_PER_CLASS
Нормалізація БД ❌ Низька (багато NULL) ✅ Висока (3NF) ⚠️ Середня (дублі колонок)
Обмеження NOT NULL ❌ Неможливі для підкласів ✅ Повністю підтримуються ✅ Підтримуються
Швидкість INSERT ⚡ 1 запит 🐢 2+ запити ⚡ 1 запит
Швидкість поліморфного читання ⚡ 1 запит (без JOIN) 🐢 Важкий LEFT JOIN 🚨 Повільний UNION ALL
Колонка дискримінатора Обов’язкова (@DiscriminatorColumn) Опціональна Не використовується
Рекомендація За замовчуванням для 2–4 підкласів Для нормалізованих корпоративних БД Рідко (тільки за відсутності поліморфізму)

🔴 Senior Level

@MappedSuperclass проти поліморфного наслідування

Дуже часта помилка на співбесідах — плутати @MappedSuperclass зі стратегіями наслідування:

// Базовий клас для перевикористання полів аудиту:
@MappedSuperclass
public abstract class BaseAuditableEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @CreationTimestamp
    private Instant createdAt;
}

@Entity
public class User extends BaseAuditableEntity { ... }

@Entity
public class Order extends BaseAuditableEntity { ... }

Фундаментальні відмінності @MappedSuperclass:

  1. Клас із @MappedSuperclass НЕ є сутністю JPA (@Entity).
  2. Для нього немає окремої таблиці в базі даних.
  3. Не підтримує поліморфні запити: Запит SELECT b FROM BaseAuditableEntity b викине помилку компіляції JPQL, оскільки такої сутності для Hibernate не існує.
  4. Інші сутності не можуть посилатися на нього через @ManyToOne BaseAuditableEntity base.

Проблема поліморфних асоціацій при TABLE_PER_CLASS

Якщо сутність Order посилається на абстрактний клас платежу:

@Entity
public class Order {
    @Id
    private Long id;

    @OneToOne
    @JoinColumn(name = "payment_id")
    private Payment payment; // Payment використовує TABLE_PER_CLASS
}

У чому нерозв’язний реляційний глухий кут:

  • Колонка order.payment_id повинна посилатися на первинний ключ.
  • Але таблиці Payment не існує! Є дві ізольовані таблиці: card_payments та cash_payments.
  • Реляційна СУБД не дозволяє створити Foreign Key, який одночасно посилається на дві різні таблиці!
  • Щоб завантажити замовлення, Hibernate змушений опитувати кожну можливу таблицю дочірніх класів.
  • Висновок: Використання @ManyToOne або @OneToOne до базового класу з TABLE_PER_CLASS — небезпечний антипатерн.

4 Tricky Questions

1. Як при стратегії SINGLE_TABLE забезпечити валідацію обов’язковості полів підкласу (наприклад, cardNumber не повинен бути порожнім), якщо на рівні бази даних колонка зобов’язана бути NULLable?

Відповідь:

  1. На рівні СУБД колонка зобов’язана дозволяти NULL, інакше вставка рядків інших підкласів впаде з помилкою.
  2. Спосіб 1 (СУБД Check Constraint): Додати перевірочне обмеження в таблицю БД:
    ALTER TABLE payments ADD CONSTRAINT chk_card_number 
    CHECK (payment_type != 'CARD' OR card_number IS NOT NULL);
    
  3. Спосіб 2 (Bean Validation з групами): Повісити анотацію @NotNull на рівні Java-коду в підкласі CardPayment. Валідатор Hibernate Validator перехопить порожнє значення на фазі PrePersist до відправки запиту в СУБД.

2. Що станеться, якщо виконати поліморфний запит із пагінацією SELECT p FROM Payment p при стратегії TABLE_PER_CLASS?

Відповідь:

  1. Hibernate згенерує SQL-запит із конструкцією UNION ALL:
    SELECT p.id, p.amount, p.card_num, NULL AS cash_date, 1 AS clazz_ FROM card_payments p
    UNION ALL
    SELECT p.id, p.amount, NULL AS card_num, p.cash_date, 2 AS clazz_ FROM cash_payments p
    LIMIT 10 OFFSET 20;
    
  2. СУБД змушена спочатку виконати повне сканування й об’єднання рядків обох таблиць у тимчасову таблицю в пам’яті, і лише потім застосувати сортування та LIMIT/OFFSET.
  3. Це призводить до колосальної деградації часу відповіді при зростанні обсягів даних.

3. Як перевірити точний конкретний підклас сутності в JPQL-запиті при стратегії JOINED або SINGLE_TABLE?

Відповідь:

  1. У JPQL використовується спеціальна функція TYPE(e):
    List<Payment> cards = entityManager.createQuery(
        "SELECT p FROM Payment p WHERE TYPE(p) = CardPayment", 
        Payment.class
    ).getResultList();
    
  2. Для приведення типу (Downcasting) атрибутів підкласу в умовах WHERE використовується оператор TREAT:
    List<Payment> visa = entityManager.createQuery(
        "SELECT p FROM Payment p WHERE TREAT(p AS CardPayment).cardNumber LIKE '4%'", 
        Payment.class
    ).getResultList();
    

4. Припустимо, ми завантажили Payment через лінивий зв’язок order.getPayment(). Чи спрацює перевірка if (order.getPayment() instanceof CardPayment)?

Відповідь:

  1. Якщо проксі ще не був ініціалізований, Hibernate створює динамічний проксі базового класу: Payment$ByteBuddy$....
  2. Оскільки проксі є нащадком класу Payment, оператор instanceof CardPayment поверне false, навіть якщо реальний рядок у базі даних — це оплата карткою!
  3. Це класична пастка поліморфізму лінивих проксі в JPA.
  4. Рішення: Витягти реальний клас через Hibernate.unproxy(order.getPayment()) або використовувати вбудований метод Hibernate: Hibernate.getClass(order.getPayment()) == CardPayment.class.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«JPA підтримує 3 стратегії наслідування: SINGLE_TABLE (усі класи в одній таблиці з колонкою дискримінатора DTYPE — найшвидша, але вимагає nullable-колонок), JOINED (базова таблиця зі спільними полями + дочірні таблиці зі специфічними — нормалізована, підтримує NOT NULL, але вимагає JOIN), та TABLE_PER_CLASS (незалежні таблиці з дублюванням полів — породжує важкі UNION ALL). @MappedSuperclass не є стратегією наслідування, а слугує шаблоном перевикористання спільних полів аудиту без окремої таблиці й без підтримки поліморфізму. У 80% проєктів найкращим вибором за співвідношенням продуктивність/простота залишається SINGLE_TABLE».

Ключові метрики для Production-моніторингу

  • Тривалість поліморфних запитів: моніторинг виконання запитів до кореневих сутностей із JOINED та TABLE_PER_CLASS.
  • Кількість колонок у SINGLE_TABLE: розростання понад 40–50 колонок сигналізує про необхідність переходу на JOINED або розділення агрегатів.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «@MappedSuperclass — це ще одна стратегія наслідування JPA» — Ні, @MappedSuperclass не створює сутності й не підтримує поліморфні запити.
  • ❌ «SINGLE_TABLE дозволяє вішати обмеження NOT NULL на колонки підкласів у БД» — У СУБД колонка зобов’язана бути nullable для сумісності з іншими нащадками.
  • ❌ «TABLE_PER_CLASS — чудова стратегія для зв’язків @ManyToOne» — СУБД не вміє будувати зовнішні ключі до кількох таблиць одночасно.
  • ❌ «instanceof завжди надійно працює з поліморфними лінивими проксі» — Проксі базового класу не пройде перевірку instanceof SubClass, потрібен Hibernate.unproxy().

Пов’язані теми