6

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:

  • 0 if test class annotated with
@DataJpaTest
  • 1 if test class annotated with:
@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
  • 1 if test class annotated with:
@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?

2
  • I was running into the exact same problem. Your solutions are also working for me, thank you. Commented Nov 26, 2019 at 9:36
  • Solution with @Transactional(propagation = Propagation.NOT_SUPPORTED) also worked for me. But I don't know the exact reason Commented Jan 24 at 9:28

2 Answers 2

6

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

Sign up to request clarification or add additional context in comments.

Comments

2

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.

2 Comments

Doing this in a @DataJpaTest will produce the same result with Hibernate: an empty Set. It's not because "they haven't been stored yet", but because they are already present in the persistence context and the corresponding first-level cache. The flush is already automatically done when executing the query, as you can see 2 INSERTs before the SELECT, so the record is "stored" in this transaction. The result is simply ignored. Using em.clear() could make the test pass, but it's not recommended in this case and you still have to keep bi-directional associations in sync ;)
Not directly related to the question, but may be helpful for someone: for unidirectional relation a similar problem occurs and in such case this seems to be sufficient.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.