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

Що таке orphan removal

Механізм видалення «сиріт» (orphanRemoval = true) у JPA та Hibernate — це декларативне правило керування життєвим циклом залежних сутностей у відносинах композиції (@OneToMany т...

Механізм видалення «сиріт» (orphanRemoval = true) у JPA та Hibernate — це декларативне правило керування життєвим циклом залежних сутностей у відносинах композиції (@OneToMany та @OneToOne). Коли дочірній об’єкт видаляється з батьківської колекції або посилання на нього обнуляється (null), Hibernate автоматично переводить таку сутність у стан Removed і генерує команду SQL DELETE під час синхронізації з базою даних.


🟢 Junior Level

Що таке «сирота» (Orphan) і навіщо потрібен orphanRemoval

У реляційних базах даних розрив зв’язку між батьком і дитиною (наприклад, видалення елемента зі списку в Java) за замовчуванням лише встановлює зовнішній ключ parent_id у значення NULL:

-- Без orphanRemoval: запис залишається в БД "осиротілим" (Orphan)
UPDATE order_items SET order_id = NULL WHERE id = 5;

Якщо колонка order_id має обмеження NOT NULL, таке оновлення завершиться помилкою цілісності. Якщо обмеження немає — таблиця засмічується покинутими рядками, які не належать жодному замовленню.

Параметр orphanRemoval = true наказує Hibernate не обнуляти зовнішній ключ, а фізично видаляти рядок із БД:

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Вмикаємо автоматичне видалення осиротілих записів:
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();
}
Order order = entityManager.find(Order.class, 1L);

// Просто видаляємо елемент із колекції в пам'яті:
order.getItems().remove(0);

// При коміті транзакції Hibernate автоматично виконає:
// DELETE FROM order_items WHERE id = ?;

Порівняння: CascadeType.REMOVE vs orphanRemoval = true

Головна концептуальна відмінність, яку перевіряють на співбесідах:

Сценарій CascadeType.REMOVE orphanRemoval = true
Видалення батька: entityManager.remove(order) ✅ Дочірні записи видаляються з БД ✅ Дочірні записи видаляються з БД
Видалення з колекції: order.getItems().remove(item) ❌ Запис у БД залишається (зовнішній ключ стає NULL) ✅ Запис у БД фізично видаляється (DELETE)
Очищення колекції: order.getItems().clear() ❌ Жоден рядок із БД не видалиться ✅ Усі дочірні записи видаляться з БД

🟡 Middle Level

Підтримувані типи зв’язків

Специфікація JPA дозволяє використання orphanRemoval = true лише для відносин із монопольним володінням:

  1. @OneToMany: Колекції дочірніх сутностей (замовлення -> позиції, користувач -> адреси).
  2. @OneToOne: Одиночні залежні сутності (користувач -> профіль, замовлення -> деталі доставки).
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private UserProfile profile;
    
    // Видалення профілю з БД:
    user.setProfile(null); // При flush згенерує DELETE FROM user_profiles
    
  3. Заборонено для @ManyToOne та @ManyToMany: У цих відносинах сутності є спільними (Shared Entities), і видалення посилання не означає знищення самого об’єкта.

Пастка переприсвоєння колекції: was no longer referenced

Одна з найчастіших і найнеприємніших помилок початківців:

Order order = entityManager.find(Order.class, 1L);

// ❌ ГРУБА ПОМИЛКА: Заміна екземпляра колекції!
order.setItems(new ArrayList<>(newItems));
entityManager.flush();

Результат виконання: Hibernate викине фатальний виняток: org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance.

Чому це відбувається:

Hibernate огортає вихідну колекцію у свій власний проксі-клас PersistentBag / PersistentSet, прив’язаний до сесії. Якщо перетерти посилання новою колекцією (new ArrayList), Hibernate втрачає можливість відстежувати різницю між старими та новими елементами за знімком CollectionEntry.

Як робити правильно:

// ✅ ПРАВИЛЬНО: Модифікувати існуючу колекцію за місцем
order.getItems().clear();          // Позначає старі елементи на видалення
order.getItems().addAll(newItems); // Додає нові елементи

🔴 Senior Level

Місце OrphanRemovalAction у черзі дій Hibernate (ActionQueue)

Усередині ядра Hibernate (SessionImpl) видалення сиріт обробляється особливим чином:

                    ПОРЯДОК ВИКОНАННЯ В ACTIONQUEUE
                    
  1. OrphanRemovalAction      <── СПРАЦЬОВУЄ ПЕРШИМ!
  2. EntityInsertAction
  3. EntityUpdateAction
  4. CollectionRemoveAction
  5. CollectionUpdateAction
  6. EntityDeleteAction       <── Звичайний remove() спрацьовує наприкінці!

Архітектурна перевага:

Звичайний CascadeType.REMOVE ставить операцію EntityDeleteAction у самий кінець черги (після всіх INSERT). Через це заміна елемента з унікальним обмеженням (UNIQUE) падала з помилкою дублювання ключа.

Оскільки OrphanRemovalAction виконується на першому місці:

  1. Спочатку видаляється старий елемент: DELETE FROM items WHERE code = 'ITEM_1';
  2. Потім вставляється новий елемент: INSERT INTO items (code) VALUES ('ITEM_1');
  3. Унікальне обмеження не порушується!

Синхронізація двонапрямлених зв’язків (Helper Methods)

У двонапрямленому зв’язку @OneToMany(mappedBy = "order") власником зовнішнього ключа є дочірня сутність OrderItem (колонка order_id).

Якщо при видаленні сироти зі списку батька не обнулити посилання на стороні дочірньої сутності:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    // Обов'язковий defensive-метод бізнес-логіки:
    public void removeItem(OrderItem item) {
        this.items.remove(item);
        item.setOrder(null); // КРИТИЧНО для узгодженості в пам'яті!
    }
}

Якщо забути про item.setOrder(null), то доки сутність item утримується в пам’яті поточного потоку, вона зберігає бите посилання на батька, що може призвести до непередбачуваних помилок при повторному зверненні до неї в межах транзакції.


4 Tricky Questions

1. Що станеться, якщо в колекції з orphanRemoval = true видалити елемент order.getItems().remove(item), а потім у цьому ж методі викликати entityManager.persist(item)?

Відповідь:

  1. При виклику order.getItems().remove(item) об’єкт позначається як сирота для планування OrphanRemovalAction.
  2. Проте явний виклик entityManager.persist(item) повідомляє контексту персистентності, що цей Java-об’єкт повинен залишатися в статусі Status.MANAGED.
  3. Hibernate виявляє конфлікт намірів: сутність одночасно позначена як видалена сирота і як керована для вставки/збереження.
  4. Залежно від діалекту та версії Hibernate це призведе або до скасування видалення і повторного збереження, або до викидання ObjectDeletedException: deleted object would be re-saved by cascade.
  5. Правило: Ніколи не викликати persist() на об’єктах, виключених із колекцій з orphanRemoval.

2. Чи працюватиме orphanRemoval = true для однонапрямленого зв’язку @OneToMany (без mappedBy, з @JoinColumn)?

Відповідь:

  1. Так, працюватиме!
  2. В однонапрямленому зв’язку @OneToMany @JoinColumn(name = "order_id") батьківська сутність повністю керує зовнішнім ключем.
  3. При видаленні елемента з колекції order.getItems().remove(0) Hibernate під час фази flush() згенерує команду DELETE FROM order_items WHERE id = ?.
  4. Проте однонапрямлені @OneToMany породжують неефективний SQL (зайві UPDATE зовнішнього ключа перед INSERT), тому двонапрямлений зв’язок із mappedBy залишається найкращою практикою.

3. Чим відрізняється поведінка orphanRemoval = true при видаленні батька від анотації Hibernate @OnDelete(action = OnDeleteAction.CASCADE)?

Відповідь:

  1. При orphanRemoval = true Hibernate завантажує всі дочірні сутності в пам’ять JVM (L1 кеш), виконує їхні життєві колбеки (@PreRemove) та генерує $N$ окремих запитів DELETE FROM child WHERE id = ?.
  2. При @OnDelete(action = OnDeleteAction.CASCADE) Hibernate делегує видалення зовнішньому ключу СУБД. У базу йде всього один запит DELETE FROM parent WHERE id = ?, а рушій бази даних миттєво стирає дітей на диску без їх вичитування в пам’ять Java.

4. Припустимо, ми викликали order.getItems().clear(), але транзакція завершилася з помилкою і відкотилася (ROLLBACK). Чи відновиться колекція в Java-об’єкті?

Відповідь:

  1. У базі даних усе залишиться в початковому стані: відкат транзакції скасує будь-які відправлені команди SQL DELETE.
  2. Проте в оперативній пам’яті Java-об’єкт order залишиться з порожньою колекцією (order.getItems().isEmpty() == true), оскільки відкат транзакції в СУБД не відкочує мутації полів Java-об’єктів у купі JVM.
  3. Щоб повернути актуальний стан у пам’ять, необхідно заново запросити об’єкт із БД у новій сесії або викликати entityManager.refresh(order).

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

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

«Параметр orphanRemoval = true у JPA активує автоматичне видалення дочірніх сутностей із бази даних при їх видаленні з колекції батька (order.getItems().remove(item)) або при обнуленні посилання @OneToOne. На відміну від CascadeType.REMOVE, який видаляє дітей тільки при видаленні самого батька, orphanRemoval захищає базу від накопичення покинутих записів без зовнішнього ключа. Усередині ядра Hibernate OrphanRemovalAction виконується на самому початку черги дій ActionQueue, що запобігає колізіям унікальних обмежень. Критичний нюанс: не можна перетирати колекцію новим екземпляром (setItems(new ArrayList)), її можна тільки модифікувати через clear() і addAll(), інакше Hibernate викине виняток».

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

  • Кількість непередбачених SQL DELETE: раптовий сплеск видалень може свідчити про випадковий виклик clear() на колекції з orphanRemoval.
  • Foreign Key Constraint Violations: помилки зовнішніх ключів при розриві зв’язків без увімкненого orphanRemoval.

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

  • ❌ «orphanRemoval і CascadeType.REMOVE — це абсолютно одне й те саме» — Груба помилка. REMOVE спрацьовує тільки при видаленні батька; orphanRemoval — при видаленні з колекції.
  • ❌ «Я можу повісити orphanRemoval = true на зв’язок @ManyToOne» — Заборонено JPA, має сенс тільки для композицій із монопольним володінням (@OneToMany, @OneToOne).
  • ❌ «Щоб оновити колекцію, я просто викликаю entity.setItems(newItems)» — Призведе до HibernateException: A collection was no longer referenced.
  • ❌ «orphanRemoval видаляє запис із бази прямо в момент виклику list.remove()» — Ні, видалення відбувається під час фази flush().

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