Terje Andersen
Terje Andersen

Reputation: 429

JOIN FETCH query not fetching lazy collection in Spring DataJpaTest

Background

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.

Entities and Repository

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);

The test

@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);
    }
}

Test results

What I am seeing is that getPersons() returns:

@DataJpaTest
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@SpringBootTest

Conclusion / question

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

Answers (2)

Patrice Blanchardie
Patrice Blanchardie

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

Krzysztof Zając
Krzysztof Zając

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

Related Questions