В этой статье рассмотрим самые популярные каскадные операции на примере отношения OneToMany (хотя их возможно использовать также в OneToOne и ManyToMany).
Модель OneToMany
Рассмотрим пример топика с комментариями. Они находится в отношении OneToMany: у одного топика может быть много комментариев, а каждый комментарий, в свою очередь, относится ровно к одному топику. В базе данных для них создаются две таблицы:

Топик является родительской сущностью в том смысле, что комментарий сам по себе не существует, а всегда относится к топику. Его жизненный цикл привязан к топику. При удалении топика надо удалять из базы и его комментарии, а при сохранении топика — сохранять и его комментарии. Тут то и уместны каскадные операции — то есть те, которые распространяются и на дочернюю сущность.
Каскадные операции указываются над ссылкой на дочернее отношение, у нас это comments:
@Data
@NoArgsConstructor
@Entity
public class Topic {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private long id;
private String title;
public Topic(String title) {
this.title = title;
}
@OneToMany(mappedBy = "topic", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public void addComment(Comment comment){
this.comments.add(comment);
comment.setTopic(this);
}
public void removeComment(Comment comment){
this.comments.remove(comment);
comment.setTopic(null);
}
}
Класс Comment:
@NoArgsConstructor
@Data
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private long id;
private String text;
@ManyToOne(fetch = FetchType.LAZY)
private Topic topic;
public Comment(String text){
this.text=text;
}
}
CascadeType.ALL
Есть несколько типов каскадных операций, мы рассмотрим три основные: PERSIST, MERGE и REMOVE. CascadeType.ALL означает, что необходимо выполнять каскадно сразу все операции:
- CascadeType.PERSIST
- CascadeType.MERGE
- CascadeType.REMOVE
- CascadeType.REFRESH
- CascadeType.DETACH
Обратите внимание, что у нас именно это и указано.
CascadeType.PERSIST
Рассмотрим каскадную операцию PERSIST. Поскольку мы используем Spring, то необязательно вызывать напрямую метод EntityManager persist(). Он вызывается, когда мы выполняем метод save() репозитория SimpleJPARepository. Выглядит метод так:
SimpleJPARepository save()
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
Как видно выше, для новых сущностей вызывается persist(), а для уже существующих сущностей (у которых id!=null) вызывается метод merge().
Но сначала протестируем persist(), который вызывается для новых сущностей. Для этого создадим новый топик с id=null и добавим к нему два комментария:
@Test
@DisplayName("добавление топика с комментариями")
public void whenAddTopic_commentsShouldBeAdded() {
Topic topic = new Topic("Topic1");
Comment comment1 = new Comment("comment1");
Comment comment2 = new Comment("comment2");
topic.addComment(comment1);
topic.addComment(comment2);
topicRepository.save(topic);
Assertions.assertEquals(2, topicRepository.count());
Assertions.assertEquals(5, commentRepository.count());
}
Числа в Assertions выше приведены с учетом уже существующих данных в базе (см. исходный код либо скрипт ниже).
После добавления комментариев сохраним топик с помощью вышеприведенного метода save() репозитория. Внутри вызовется persist(), и эта операция выполнится каскадно, то есть для комментариев она выполнится тоже.
Генерируются три SQL insert, один для топика и два для комментариев:
insert into topic (title, id) values (?, ?) insert into comment (text, topic_id, id) values (?, ?, ?) insert into comment (text, topic_id, id) values (?, ?, ?)
В базе будут сохранены комментарии, хотя persist() выполнялся только для топика. В этом и смысл каскада.
CascadeType.MERGE
Теперь выполним каскадно merge(), для этого отредактируем существующий топик и его комментарий. Как уже сказано, некоторые данные в базу мы добавляем при старте приложения и тестов (см. data.sql):
insert into topic (id, title) values (-1,'title1'); insert into comment (id, text, topic_id) values (-4, 'text1', -1); insert into comment (id, text, topic_id) values (-5, 'text2', -1); insert into comment (id, text, topic_id) values (-6, 'text3', -1);
Отредактируем в тесте комментарий с id=-1 и топик с id=-4, а merge() выполним только для топика (снова через метод save() репозитория).
@Test
@DisplayName("редактирование топика с комментариями")
public void whenMergeTopic_commentsShouldBeMerged() {
Topic topic = topicRepository.getOne(-1l);
topic.setTitle("Updated Title");
Comment comment = commentRepository.getOne(-4l);
comment.setText("Updated Text");
topicRepository.save(topic);
Assertions.assertEquals(3, commentRepository.count());
}
Но также отредактирован будет и комментарий, что видно в консоли:
update topic set title=? where id=? update comment set text=?, topic_id=? where id=?
CascadeType.REMOVE
Наконец, удалим топик из репозитория и убедимся, что для комментариев также выполняются SQL команды delete:
@Test
@DisplayName("удаление топика с комментариями")
public void whenDeleteTopic_commentsShouldBeDeleted() {
Topic topic = topicRepository.getOne(-1l);
topicRepository.delete(topic);
Assertions.assertEquals(0, commentRepository.count());
}
Генерируемые delete:
Hibernate: delete from comment where id=? Hibernate: delete from comment where id=? Hibernate: delete from comment where id=? Hibernate: delete from topic where id=?
orphanRemoval vs CascadeType.REMOVE
О том, чем отличается каскадное удаление от orphanRemoval подробно написано тут. Если кратко, orphanRemoval удаляет комментарий из базы при удалении комментария из топика.
@Test
@DisplayName("если orphanRomoval=true, то при удалении комментария из топика он удаляется из базы")
public void givenOrphanRomovalTrue_whenRemoveCommentFromTopic_thenItRemovedFromDatabase() {
Topic topic = topicRepository.getOne(-1l);
topic.removeComment(topic.getComments().get(0));
Assertions.assertEquals(2, commentRepository.count());
}
SQL:
delete from comment where id=?
В тесте выше методом removeComment() мы обнулили ссылку на топик из комментария, а также удалили комментарий из коллекции комментариев топика. Несмотря на то, что сам комментарий мы не удаляли (через commentRepository.remove()), благодаря настройке orphanRemoval=true он удаляется из базы. Это происходит в тот момент, когда в конце транзакции (тестового метода) изменения отслеживаемых сущностей (у нас это topic) синхронизируются с базой.
Почему не надо использовать CascadeType.REMOVE в отношениях ManyToMany
Наконец, обратите внимание на опасность, которую таит CascadeType.REMOVE в отношении ManyToMany.
Допустим, у нас есть авторы и книги в отношении ManyToMany, и книга b принадлежит авторам a1 и a2. А над коллекцией книг стоит CascadeType.REMOVE. Тогда при удалении автора a1 из базы книга b будет удалена из базы несмотря на то, что она принадлежит также и a2 (внешний ключ от b к a2 тоже будет предварительно удален).
А если еще и в Book поставить над коллекцией авторов CascadeType.REMOVE, то при удалении нашего a1 будут удалены и другие авторы.
В общем CascadeType.REMOVE в отношениях ManyToMany в большинстве случаев нежелателен и опасен.
Итоги
Мы рассмотрели самые популярные случаи использования каскадных операций. Исходный код есть на GitHub.