1. Роль cascade и orphanRemoval
Сам факт связи мы уже проверили: внешний ключ встал куда нужно, а round-trip через БД это подтвердил. Но граф сущностей интересен не только в статике. Нужно ещё понять, что происходит, когда родительская сущность начинает сохранять, удалять и перестраивать дочерние объекты.
Когда вы впервые видите cascade = ... и orphanRemoval = true, хочется отреагировать по-джуниорски честно: «О, ещё два параметра в аннотации. Наверное, надо поставить как в туториале и забыть». Но в реальном приложении эти настройки — не косметика, а поведение, которое решает, останутся ли у вас в базе «осиротевшие» записи, и будет ли сохранение/удаление графа сущностей работать предсказуемо.
На домене ContentHub это ощущается буквально руками. У статьи есть вложения (ArticleAttachment) — метаданные загруженных файлов. Когда редактор удаляет вложение или заменяет его, нам важно, чтобы в таблице вложений не оставались «привидения»: записи про файлы, которых уже нет, или записи, которые больше не принадлежат никакой статье. И наоборот: при сохранении новой статьи с вложениями мы хотим, чтобы вложения действительно сохранились, а не потерялись где-то между persist(article) и реальностью базы данных.
Если сказать одним предложением, то сегодня мы учимся доказывать тестами две вещи: операции над родительской сущностью (Article) корректно распространяются на дочерние (ArticleAttachment), и удаление вложения из коллекции статьи приводит к предсказуемому эффекту в базе, а не только в Java-списке.
2. Cascade vs orphanRemoval: разные механизмы
Оба слова звучат так, будто это про «автоматическое удаление всего подряд», но это разные истории. cascade — это про то, как одна операция EntityManager «протекает» дальше по графу объектов. orphanRemoval — это про судьбу дочерней записи, когда она перестала быть частью коллекции родителя. Путать их — всё равно что путать «вода пошла по трубам» и «кран сняли со стены».
Давайте зафиксируем разницу максимально практично, в терминах «что делаю» → «что происходит».
| Механизм | Что вы делаете в коде | Что делает JPA в ответ | Типичный смысл для ContentHub |
|---|---|---|---|
| cascade = PERSIST | entityManager.persist(article) | JPA автоматически делает persist и для article.getAttachments() | «Создаю статью сразу с вложениями» |
| cascade = REMOVE | entityManager.remove(article) | JPA удаляет и вложения, принадлежащие статье | «Удаляю статью — вложения тоже должны исчезнуть» |
| orphanRemoval = true | article.getAttachments() .remove(x) | JPA удаляет запись ArticleAttachment из БД | «Убрал вложение из статьи — оно не должно остаться в таблице» |
Очень важно поймать интуицию: cascade включается от того, что вы вызываете операцию над родителем (persist/remove/merge/...). orphanRemoval включается от того, что вы меняете состав коллекции у родителя. Механизм похож на «умный уборщик»: увидел, что объект больше не принадлежит родителю — значит, он «orphan», и его надо удалить.
Чтобы закрепить, вот маленькая схема поведения. Да, это почти комикс, но зато мозг запоминает лучше:
flowchart TD
%% Родительская сущность
A[Article] -->|has many| B[ArticleAttachment]
%% Операции EntityManager запускают каскад от родителя к детям
EM[EntityManager] -->|persist Article| A
A -->|cascade persist| B
%% Orphan removal срабатывает, когда вы меняете состав коллекции у родителя
A -->|remove attachment from list| OR[orphanRemoval]
OR -->|DELETE in DB| B
Схема не говорит, что «всегда так надо». Она говорит, что так будет, если вы поставили соответствующие настройки. А наша задача — написать тесты, которые подтверждают это поведение, чтобы потом изменение аннотации не превращалось в тихую регрессию.
3. Mapping в ContentHub: где каскад уместен
Сейчас мы говорим о связи Article → ArticleAttachment, и тут каскад часто действительно полезен. Вложения в нашем домене живут как часть статьи: отдельно они не имеют смысла. Это похоже на «папка и файлы внутри»: файл без папки в нашем сценарии не должен просто болтаться на рабочем столе базы данных.
Пример упрощённого мэппинга (идея важнее деталей полей):
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
class Article {
// Коллекция на inverse side (mappedBy) — отражение связи.
// Реальный внешний ключ обычно лежит на owning side (в ArticleAttachment.article).
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ArticleAttachment> attachments = new ArrayList<>(); // Чтобы не ловить NPE при добавлении вложений
}
Здесь мы используем CascadeType.ALL, потому что это учебный и наглядный вариант. В проде вы часто будете сужать каскад до того, что реально нужно (например, PERSIST и REMOVE), чтобы случайно не «протекало» то, что не должно. Но пока нам важно понять механику и научиться её тестировать.
А вот со связью Article → Category ситуация другая. Категории — справочник, они общие, засеяны миграциями, и одна категория принадлежит множеству статей. Поэтому каскад на категорию — опасная штука. Если вы по привычке поставите cascade = REMOVE на @ManyToOne Category category, то «удалить статью» внезапно может превратиться в «удалить категорию», а дальше… ну, дальше будет весело, но только первые пять минут, пока вы не откроете прод-логи.
В нашем домене это можно сформулировать очень по-человечески: вложение — это «часть статьи», категория — это «справочник для статей». Каскад и orphan removal обычно уместны для «частей», и обычно неуместны для «справочников».
4. Методы addAttachment / removeAttachment как страховка
Когда у вас двунаправленная связь (Article содержит коллекцию вложений, а ArticleAttachment содержит ссылку на статью), появляется неприятная мелочь: JPA хранит внешний ключ на owning side. В нашем случае owning side почти всегда ArticleAttachment.article (то есть @ManyToOne со стороны вложения), потому что именно там находится @JoinColumn и именно там физически находится foreign key в таблице.
Это приводит к классическому багу: разработчик добавил вложение в article.getAttachments().add(attachment), но не установил attachment.setArticle(article). В памяти список выглядит красиво, тесты без flush/clear иногда «проходят», а в базе foreign key не выставлен, и запись либо не вставится, либо вставится с NULL (если вы зачем-то разрешили), либо вылетит constraint violation.
Поэтому на практике мы почти всегда делаем методы-обёртки, которые синхронизируют обе стороны:
public void addAttachment(ArticleAttachment attachment) {
// Добавляем в коллекцию (inverse side)
attachments.add(attachment);
// Обязательно синхронизируем owning side, иначе foreign key в БД не выставится
attachment.setArticle(this);
}
public void removeAttachment(ArticleAttachment attachment) {
// Убираем из коллекции (иначе orphanRemoval может не сработать)
attachments.remove(attachment);
// Разрываем owning side — это ключевой сигнал для JPA, что связь действительно разорвана
attachment.setArticle(null);
}
Выглядит как «лишние два метода», но это как раз тот случай, когда «лишнее» экономит часы расследований. И, кстати, эти методы улучшают тестируемость: в тесте вы делаете article.addAttachment(...) и уверены, что foreign key будет корректным, а orphan removal сработает как ожидается.
Ещё одна важная мысль: orphanRemoval реагирует на то, что дочерняя сущность перестала быть частью коллекции. Но в двунаправленной связи «перестала быть частью» — это не только remove из списка, но и разрыв owning side. Поэтому хороший removeAttachment делает оба действия, чтобы JPA не получила «противоречивую картину мира».
5. Тест на cascade: вложения тоже в БД
Теперь превращаем аннотацию в наблюдаемое поведение. Мы хотим тест, который доказывает: если я создал Article, добавил туда новые (ещё не сохранённые) ArticleAttachment, и затем сохранил только статью, то вложения всё равно попали в базу. Это и есть эффект cascade persist.
Скелет теста в @DataJpaTest обычно выглядит так:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;
@DataJpaTest
class ArticleAttachmentCascadeDataJpaTest {
@Autowired TestEntityManager entityManager; // Удобный EntityManager для тестов (persist/flush/clear)
}
Добавим репозитории, потому что нам нужно удобно проверять состояние БД после clear():
import org.springframework.beans.factory.annotation.Autowired;
@Autowired ArticleRepository articleRepository; // Проверяем удаление/наличие статей при необходимости
@Autowired ArticleAttachmentRepository attachmentRepository; // Проверяем, что вложения реально лежат в таблице
И теперь сам сценарий. Старайтесь держать его максимально «тонким»: одна категория, одна статья, одно вложение. Чем меньше декораций — тем легче понять, почему тест упал.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void persistArticle_shouldPersistAttachments_viaCascade() {
// Категорию сохраняем отдельно: каскад на справочники мы не предполагаем
Category java = entityManager.persist(new Category("java", "Java"));
// Статья ссылается на уже существующую категорию
Article article = new Article("JPA basics", java);
// Добавляем вложение через helper-метод, чтобы owning side (attachment.article) точно заполнился
article.addAttachment(new ArticleAttachment("guide.pdf"));
// persist только родителя — вложение должно сохраниться каскадом
entityManager.persistAndFlush(article);
// Очищаем persistence context, чтобы дальше читать именно из БД, а не из кэша 1-го уровня
entityManager.clear();
// Главное утверждение: запись о вложении реально попала в таблицу
assertThat(attachmentRepository.count()).isEqualTo(1L);
}
Что здесь важно заметить, именно как автору тестов:
Мы сохраняем категорию отдельно, потому что для категории каскад мы не предполагаем. Это нормальная модель: статья ссылается на уже существующую категорию.
Мы добавляем вложение через addAttachment, чтобы owning side (attachment.article) точно заполнился.
Мы используем persistAndFlush, потому что без flush JPA может отложить INSERT до конца транзакции, и вы будете проверять не базу, а планы базы на жизнь.
Мы делаем clear(), чтобы последующие проверки не «подсмотрели» состояние из persistence context. После clear () attachmentRepository.count() точно пойдёт в БД.
Если в мэппинге убрать cascade = PERSIST (или ALL), то в зависимости от того, как устроено сохранение, вы получите либо исключение про transient entity, либо вложение просто не сохранится. И это не «плохой тест» — это тест, который честно показывает, что поведение изменилось.
6. Тест на orphanRemoval: запись исчезла из БД
Orphan removal — это как «жёсткая уборка» базы: если объект больше не часть графа, его удаляют. Но чтобы это не осталось словами, нам нужен тест: создаём статью с двумя вложениями, убираем одно, делаем flush/clear, и проверяем, что в таблице вложений запись исчезла.
Чтобы тест оставался коротким и не превращался в триллер на 80 строк, удобно иметь маленький helper-метод внутри тестового класса, который создаёт статью с вложениями. Это не «скрытая магия», если он честный и прозрачный.
Например, вот очень компактный helper:
private Article persistArticleWithTwoAttachments(Category category) {
// Создаём статью в заданной категории
Article article = new Article("A", category);
// Добавляем 2 вложения, чтобы было что "осиротить" при удалении одного из них
article.addAttachment(new ArticleAttachment("first.txt"));
article.addAttachment(new ArticleAttachment("second.txt"));
// Сохраняем и сразу flush, чтобы вложения гарантированно получили id
return entityManager.persistAndFlush(article);
}
И теперь основной тест. Обратите внимание: нам нужно взять id удаляемого вложения до удаления, потому что после удаления объект в памяти ещё существует, но в базе уже должен исчезнуть.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void removeAttachment_shouldDeleteRow_viaOrphanRemoval() {
// Справочник (категория) живёт отдельно, без каскада
Category java = entityManager.persist(new Category("java", "Java"));
// Создаём статью и 2 вложения (оба должны быть в БД после persistAndFlush)
Article article = persistArticleWithTwoAttachments(java);
// Берём id заранее: после orphanRemoval запись должна исчезнуть из таблицы
Long removedId = article.getAttachments().getFirst().getId();
// Удаляем вложение корректным методом, который разрывает обе стороны связи
article.removeAttachment(article.getAttachments().getFirst());
// Синхронизируем persistence context с БД (иначе DELETE может быть отложен)
entityManager.flush();
// Очищаем контекст, чтобы findById сходил в БД, а не вернул managed-объект
entityManager.clear();
// Проверяем именно базу: строки с removedId больше быть не должно
assertThat(attachmentRepository.findById(removedId)).isEmpty();
}
Смысл теста именно в том, что мы доказываем эффект на уровне базы. Проверка article.getAttachments().size() была бы проверкой Java-коллекции, а не persistence-поведения. Да, нам важно, что коллекция стала меньше. Но для качества data-layer гораздо важнее, чтобы строка в таблице действительно исчезла.
Если убрать orphanRemoval = true, исход уже зависит от того, как именно вы разрываете связь и что позволяет текущий mapping. В нашем варианте removeAttachment() делает attachment.setArticle(null), а article_id мы держим non-nullable, так что на flush вы скорее получите constraint violation, а не «тихо оставшуюся» строку. Если бы FK допускал NULL или owning side вообще не разрывали, запись могла бы остаться в БД как осиротевшая. И вот здесь ценность orphanRemoval особенно хорошо видна: он превращает двусмысленную судьбу дочерней строки в предсказуемое удаление.
И ещё одна практичная ремарка: orphan removal удаляет запись, но не удаляет файл из файловой системы. В ContentHub это будет обязанностью другого слоя (storage/service), а data-layer тест доказывает только корректность DB-метаданных. Это нормально: один тест — один слой, иначе вы получите слоёный пирог из случайностей.
7. Cascade REMOVE при удалении статьи
Удаление вложения из коллекции — это один сценарий. Другой жизненный сценарий: статья целиком удаляется (или, как минимум, в некоторых проектах это может быть так; в ContentHub у нас чаще будет архивирование, но сам механизм каскада важно понимать). Если удалить статью, логично ожидать, что вложения удалятся вместе с ней. Вот где cascade = REMOVE (или ALL) полезен.
Но при этом категорически не хочется, чтобы удаление статьи удалило категорию. Категория — справочник. Поэтому тест на cascade remove хорошо писать так, чтобы он проверял обе стороны: вложения исчезли, категория осталась.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void deleteArticle_shouldDeleteAttachments_butNotCategory() {
// Создаём категорию-справочник (без каскадного удаления со стороны статьи)
Category java = entityManager.persist(new Category("java", "Java"));
// Создаём статью с вложениями
Article article = persistArticleWithTwoAttachments(java);
// Запоминаем id категории, чтобы проверить, что её случайно не удалили
Long categoryId = java.getId();
// Удаляем статью: ожидаем, что вложения уйдут каскадом, а категория останется
articleRepository.delete(article);
// Принудительно отправляем DELETE в БД
entityManager.flush();
// Очищаем кэш, чтобы проверки ниже читали именно базу
entityManager.clear();
// Вложения должны исчезнуть
assertThat(attachmentRepository.count()).isZero();
// Категория должна остаться (иначе где-то включили лишний каскад)
assertThat(entityManager.find(Category.class, categoryId)).isNotNull();
}
Этот тест полезен как «страховка от случайной аннотации». Если кто-то добавит каскад на категорию, тест может внезапно стать красным, и вы поймёте причину до того, как «справочник категорий» неожиданно исчезнет в проде. Это очень приятный класс регрессий: его лучше ловить тестом, чем пользователями.
8. Нюансы: remove из списка и состояние БД
Иногда кажется, что JPA должна «догадаться», чего вы хотели. Но JPA — это не телепат. Она честно смотрит на managed-объекты и их состояние в persistence context, и на то, что именно поменялось. Если вы удалили объект из коллекции, но owning side всё ещё указывает на родителя, JPA может решить, что связь не разорвана. Если вы не сделали flush, JPA может вообще ещё ничего не отправить в базу. Если вы не сделали clear, ваш subsequent findById может вернуть объект из кэша первого уровня.
Из-за этого у data-тестов появляется железная дисциплина: изменили граф — сделали flush(); хотите проверить именно базу — сделали clear(); проверяете через репозиторий или через entityManager.find после очистки. Это не «ритуал», это способ убрать «счастливые случайности» из теста.
Отдельно стоит держать в голове природу @DataJpaTest: тест чаще всего работает внутри одной транзакции, и откатится после завершения. Поэтому удаление в тесте не «навсегда» — и это хорошо. Но flush всё равно обязателен, если вы хотите увидеть constraint violations и реальные DELETE/INSERT в рамках текущего теста.
9. Типичные ошибки при cascade и orphanRemoval
Ошибка №1: поставить CascadeType.ALL «потому что так в примере», а потом удивляться побочным эффектам.
ALL включает не только PERSIST и REMOVE, но и MERGE, и другие операции. В учебном коде это удобно, но в реальном проекте может привести к неожиданным обновлениям дочерних сущностей при save() родителя. Правильная привычка — сначала сформулировать, какое поведение нужно, а потом выбрать минимальный набор каскадов под это поведение.
Ошибка №2: каскадировать REMOVE на справочники и общие сущности.
Для связи Article → Category каскад удаления почти всегда вреден. Категория не «принадлежит» статье, она используется многими статьями. Если включить каскад, то удаление одной статьи теоретически может удалить категорию (или попытаться удалить и получить ошибку из-за foreign key). Тест, который проверяет «категория осталась», — отличная страховка от такого.
Ошибка №3: менять только inverse side связи (коллекцию), забывая про owning side (foreign key).
При mappedBy = "article" коллекция Article.attachments — отражение связи, но внешний ключ хранится в ArticleAttachment.article. Если вы делаете article.getAttachments().add(a), но не делаете a.setArticle(article), то в базе либо не появится связь, либо вылетит constraint violation. Именно поэтому методы addAttachment/removeAttachment — не прихоть, а способ не устраивать себе расследование «почему у меня attachments не привязались».
Ошибка №4: проверять orphan removal по размеру Java-коллекции, не проверяя базу.
attachments.size() может быть 1, потому что вы удалили объект из списка. Но запись в таблице может остаться. В data-layer тесте главное доказательство — это запрос к базе после flush() и clear(): например, attachmentRepository.findById(id) должен вернуть empty.
Ошибка №5: забыть flush и ждать, что удаление «уже произошло».
JPA не обязана немедленно выполнять SQL на каждое ваше действие. Она копит изменения и синхронизирует их с базой на flush или в конце транзакции. Если вы удалили вложение и сразу проверяете репозиторий, тест может дать странные результаты, особенно если persistence context ещё хранит старые объекты. flush делает момент истины чуть ближе и превращает тест в честный.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ