В этой статье рассмотрим, почему в отношении 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.
Спасибо за статью!