В этой статье рассмотрим самые популярные каскадные операции на примере отношения 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.