Reputation: 429
Hi! I am attempting to write a test that checks that a JOIN FETCH query fetches a lazy collection properly. I'm trying this in a simple Spring Boot 2.1.7 project with h2 has datasource, and spring-boot-starter-data-jpa loaded. Test is with Junit4 and assertJ, not that I think that this matters.
When I'm using @DataJpaTest
, the collection returns empty here, as opposed to e.g. @SpringBootTest
, and I fail to understand why.
I have two simple entities, Classroom
and Person
. A classroom can contain multiple persons. This is defined in the classroom class by:
@OneToMany(mappedBy = "classroom", fetch = FetchType.LAZY)
private Set<Person> persons = new HashSet<>();
and in the person class:
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "classroom_id")
private Classroom classroom;
In the ClassRoomRepository
I have defined a method that should eagerly fetch the persons in a classroom:
@Query("SELECT DISTINCT c FROM Classroom c JOIN FETCH c.persons WHERE c.id = :classroomId")
Classroom getClassRoom(@Param("classroomId") Long classRoomId);
@RunWith(SpringRunner.class)
@DataJpaTest
public class ClassroomTest{
@Autowired
private PersonRepository personRepository;
@Autowired
private ClassRoomRepository classRoomRepository;
@Test
public void lazyCollectionTest() {
Classroom classroom = new Classroom();
classRoomRepository.save(classroom);
Person person = new Person(classroom);
personRepository.save(person);
assertThat(classRoomRepository.getClassRoom(classroom.getId()).getPersons()).hasSize(1);
}
}
What I am seeing is that getPersons()
returns:
@DataJpaTest
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@SpringBootTest
I know that @DataJpaTest
runs each test in a transaction with rollback at the end. But why would this prevent this join fetch query to return the data?
Upvotes: 4
Views: 1995
Reputation: 1351
This is the expected behavior when bi-directional associations are not synchronized.
1 transaction for the whole test: 1 persistence context
When using @DataJpaTest
alone, the test method executes with 1 transaction, 1 persistence context, 1 first-level cache, because @Transactional
is applied on all test methods.
In this case, the first classroom
and the one returned by classRoomRepository.getClassRoom(classroom.getId())
are the same instance because Hibernate uses its first-level cache to return the classroom instance, which was constructed with an empty Set, and ignores the ResultSet record from your query.
It can be verified:
Classroom classroom = new Classroom(); // constructs the Classroom with an empty Set
classRoomRepository.save(classroom);
Classroom classroom2 = classRoomRepository.getClassRoom(classroom.getId());
System.out.println("same? " + (classroom==classroom2));
// output: same? true
// and classroom2.persons is empty :)
The fix: bi-directional association synchronization
As Hibernate ignores your query result, the @OneToMany
is still empty after the query.
In other words, you "forgot" to add the person in Classroom.persons.
You'll have to manually synchronize your bi-directional association in the setters and adders (or any method that manipulates these associations, including your constructor), or use Hibernate bytecode enhancement with enableAssociationManagement (magic, but use carefully).
Let's write a (fluent) Classroom.addPerson
adder that adds a Person in this Classroom, and updates the Person:
public Classroom addPerson(Person person) {
this.persons.add(person);
person.setClassroom(this);
return this;
}
Note that you should also add a Classroom.removePerson
method, that sets Person.classroom
to null
after removing the person from the Set.
Then rewrite your test to make it pass:
Classroom classroom = new Classroom();
classRoomRepository.save(classroom);
Person person = new Person();
classroom.addPerson(person);
personRepository.save(person);
In this case, you manually added the person to the set and kept the other side of the association in sync, which is a natural way of doing things.
But if you want to stick with your Person(Classroom classroom)
constructor:
public Person(Classroom classroom) {
classroom.addPerson(this); // add this person to the classroom
}
If you want to be able to manipulate this association in both ways, you could also use a Person.setClassroom
setter but it's a bit heavy:
public Person setClassroom(Classroom classroom) {
this.classroom = classroom;
if(classroom != null)
this.classroom.getPersons().add(this);
else
this.classroom.getPersons().remove(this);
return this;
}
You manually kept both sides of the association in sync, so you're not relying on Hibernate fetching the collection.
Your test will pass, and I added a check to ensure that the association is in sync:
Classroom classroom = new Classroom();
classRoomRepository.save(classroom);
Person person = new Person(classroom);
// check that the classroom contains the person
Assertions.assertThat(classroom.getPersons().contains(person)).isTrue();
personRepository.save(person);
Assertions.assertThat(classRoomRepository.getClassRoom(classroom.getId())
.getPersons()).hasSize(1);
But keep in mind that the call to classRoomRepository.getClassRoom(classroom.getId())
is useless, as Hibernate ignores the result if it's already present in the persistence context.
You should only use your first classroom
instance.
Multiple transactions: multiple persistence contexts
When you added @Transactional(propagation = Propagation.NOT_SUPPORTED)
, you chose not to use a transaction, so 3 transactions, 3 persistence contexts and 3 first-level caches are used (one for the first save, one for the second, and one for the query).
Same for @SpringBootTest
which does not use @Transactional
at all.
In these 2 cases, you're manipulating different instances, So Hibernates uses the ResultSet record from the query to provide your classroom with fetched persons, as expected.
System.out.println("same? " + (classroom==classroom2));
// output: same? false
For more information check this article, and Vlad's answer to Edison's question: https://vladmihalcea.com/jpa-hibernate-first-level-cache/
If there’s already a managed entity with the same id, then the ResultSet record is ignored.
You can also check https://vladmihalcea.com/jpa-hibernate-synchronize-bidirectional-entity-associations/ about bi-directional association synchronization.
If you're interested by Hibernate bytecode enhancement and magic bi-directional association synchronization, read https://docs.jboss.org/hibernate/orm/current/topical/html_single/bytecode/BytecodeEnhancement.html
Upvotes: 5
Reputation: 21
It doesn't prevent join fetch from getting right set of data, it just prevents from getting any records saved within the same transaction because they haven't been stored yet.
To overcome the issue, simply inject EntityManager
instance and then call:
em.flush():
em.close();
Before assertion.
Upvotes: 0