This project demonstrates a potential issue with Spring Data JPA or hibernate when implementing
equals
and hashCode
on a field of an associated entity that is fetched with fetch type EAGER
when the associated entity is fetched by a repository method.
The project uses the following entities:
- Library: Represents a library, which can have many books and subsidiaries.
- Book: Represents a book, with a title and genre, and belongs to a library. hashcode and equals are overridden and based on the title field. The title field is not nullable and should always be present and is not changeable.
- Subsidiary: Represents a subsidiary of the library. I needed another one-to-many relationship and that's the only reason this entity exists.
The relationships are:
- Library --1:N--> Books
- Library --1:N--> Subsidiaries
- Entities use
UUID
as primary keys. - Collections are managed with
Set
. - The
Book
entity overridesequals
andhashCode
based on the title. - The test demonstrates that removing an entity is sometimes not possible
When implementing equals
and hashCode
it is sometimes not possible to remove participants via Set#remove
method.
When we get a library entity by calling the LibraryRepository#findById
method we get the object and the books set is
already initialized. We can successfully remove an object from the set:
Library foundLibrary = libraryRepository.findById(libraryId).orElseThrow();
Book bookToRemove = foundLibrary.getBooks().stream()
.filter(book -> "Book Two".equals(book.getTitle()))
.findFirst()
.orElseThrow();
boolean removed = foundLibrary.getBooks().remove(bookToRemove);
Assertions.assertThat(removed).isTrue();
But removing a child is no longer possible when we start at the BookRepository
and call a query method like
findByTitleAndGenre
:
Book bookOne = bookRepository.findByTitleAndGenre("Book One", BIOGRAPHY).get(0);
boolean removedBookOne = bookOne.getLibrary().getBooks().remove(bookOne);
Assertions.assertThat(removedBookOne).isTrue(); // This test fails but it should not
This is actually independent of Spring as the test canSaveAndFindLibraryWithBooksAndSubsidiariesUsingEntityManager
shows where we use the entity manager directly instead of calling the repository method.
When the findByTitleAndGenre
method is called, Hibernate/JPA/Spring data runs a LEFT JOIN
query joining all three
tables.
While creating the objects the Book objects are created using their default constructor.
This means that all fields in the JVM Object are actually null if they are not initialized by default, which the title
field is not.
Before initializing the fields the objects seem to be put in the Set of the books
field in the library
object.
When a book is put into the set it's hashCode
is called. Because title
is null at that point the hashCode would be 0.
It seems as if the fields of the book objects are set after the books have been added to the set.
So when we call Set#remove
later with an Book
object as parameter, the hashCode method is called on the parameter.
Because the title field is set now the result will be the hashCode
of the value of the title
attribute.
That value won't be 0 and therefore the Set#remove
call won't find the object and return false
.
switching the fetch type to LAZY seems to resolve the issue (see branch "fetchtype.lazy") but it may not be the intended use.
- Java
- Spring Boot
- Spring Data JPA
- H2 Database (in-memory)
- AssertJ (for assertions in tests)
- Build the project using Gradle.
- Run the tests to see the entity relationship and collection manipulation in action.