Cascade Types (пример на Hibernate и Spring Boot)

В этой статье рассмотрим самые популярные каскадные операции на примере отношения OneToMany (хотя их возможно использовать также в OneToOne и ManyToMany).

Модель OneToMany

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

OneToMany и ManyToOne

Топик является родительской сущностью в том смысле, что комментарий сам по себе не существует, а всегда относится к топику. Его жизненный цикл привязан к топику. При удалении топика надо удалять из базы и его комментарии, а при сохранении топика — сохранять и его комментарии. Тут то и уместны каскадные операции — то есть те, которые распространяются и на дочернюю сущность.

Каскадные операции указываются над ссылкой на дочернее отношение, у нас это 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.

 

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *