Отношение ManyToMany в Hibernate и Spring

В этой статье рассмотрим, почему в отношении ManyToMany оптимальнее использовать для коллекций Set, а не List.

Модель

Рассмотрим пример: у автора может быть несколько книг, а у книги — несколько авторов. Схема в базе состоит из трех таблиц:

Схема в базе
Схема в базе

Отношение в классах двунаправленное: то есть и у автора есть ссылка на коллекцию книг, и у книги на коллекцию авторов. Причем автор Author является главной стороной (его ссылки приоритетнее).

Сначала будем изучать поведение List (потом Set), поэтому в классе Author используется коллекция List.

Класс Author:

@NoArgsConstructor
@Data
@Entity
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String name;
    public Author(String name){
        this.name=name;
    }
    @ManyToMany (cascade = {
        CascadeType.PERSIST,
                CascadeType.MERGE
    })
    @JoinTable(name = "author_book",
            joinColumns = @JoinColumn(name = "author_id"),
            inverseJoinColumns = @JoinColumn(name = "book_id")
    )
    private List<Book> books=new ArrayList<>();

    public void addBook(Book book){
        this.books.add(book);
        book.getAuthors().add(this);
    }

    public void removeBook(Book book){
        this.books.remove(book);
        book.getAuthors().remove(this);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;

        if (!(o instanceof Author)) return false;

        return id != null && id.equals(((Author) o).getId());
    }

    @Override
    public int hashCode() {
        return 31;
    }
}

Как говорилось, в двунаправленные отношения надо добавлять методы синхронизации (addBook() и removeBook()) для поддержания согласованности ссылок. Чтобы не было ситуации, когда у автора есть такая-то книга в коллекции, а у книги нет в коллекции этого автора.

Book является неглавной стороной, поэтому коллекция авторов аннотирована mappedBy:

@NoArgsConstructor
@Data
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String name;

    @ManyToMany(mappedBy = "books")
    private Set<Author> authors=new HashSet<>();

    public Book(String name){
        this.name=name;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;

        if (!(o instanceof Author)) return false;

        return id != null && id.equals(((Book) o).getId());
    }

    @Override
    public int hashCode() {
        return 31;
    }
}

Теперь перейдем к демонстрации неоптимального поведения, которое наблюдается при использовании List.

Удаление элемента коллекции из List

Для этого напишем тест. До его выполнения добавим в базу двух авторов и две книги (книга b1 принадлежит обоим авторам):

@DataJpaTest
@Commit
public class ManyToManyTest {
    @Autowired
    private AuthorRepository authorRepository;
    @Autowired
    private BookRepository bookRepository;


    @BeforeEach
    public void booksShouldBeAdded() {
        Author author1 = new Author("a1");

        Book b1 = new Book("b1");
        Book b2 = new Book("b2");
        author1.addBook(b1);
        author1.addBook(b2);

        authorRepository.save(author1);

        Author author2 = new Author("a2");
        author2.addBook(b1);
        authorRepository.save(author2);

        Assertions.assertEquals(2, authorRepository.count());
        Assertions.assertEquals(2, bookRepository.count());
    }

    @Test
    @DisplayName("отсоединение книги от автора")
    public void whenDeleteAuthorFromBook_thenOneDeleteStatement() {
        Author author = authorRepository.findByName("a1");
        Book book = bookRepository.findByName("b1");
        author.removeBook(book);
    }
}

А в самом тесте просто удалим книгу b1 из коллекции автора a1 (именно из автора, а не из базы вообще. В базе b1 остается (b1 принадлежит еще a2). То есть обновиться должна средняя таблица author_book: из нее должна быть удалена строка b1a1.

В консоли отображаются следующие SQL-операторы:

Hibernate: delete from author_book where author_id=?
Hibernate: insert into author_book (author_id, book_id) values (?, ?)

То есть сначала удаляются все записи для автора, а потом обратно вставляются те, что удалять не надо.

И это неоптимально: как говорилось выше, достаточно одного delete.

И его  можно получить, если сменить List на Set.

Удаление элемента коллекции из Set

Заменим в классе Book коллекцию List на Set:

@JoinTable(name = "author_book",
        joinColumns = @JoinColumn(name = "author_id"),
        inverseJoinColumns = @JoinColumn(name = "book_id")
)
private Set books=new HashSet();

Выполним тот же тест и получим один оператор delete:

Hibernate: delete from author_book where author_id=? and book_id=?

Итоги

Таким образом, в отношениях ManyToMany оптимальнее использовать Set, а не List. Так выполняется меньше SQL-операторов.

Исходный код примера есть на GitHub.

Отношение ManyToMany в Hibernate и Spring: 1 комментарий

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

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