Home > OS >  Fetch join with @Transactional doesnt load their relational entities
Fetch join with @Transactional doesnt load their relational entities

Time:04-02

I have 2 entities, Team and Member, which are related by 1:N.

// Team.java
@Getter
@NoArgsConstructor
@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

}
// Member.java
@Getter
@NoArgsConstructor
@Entity
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    public Member(String name) {
        this.name = name;
    }

    public Member(String name, Team team) {
        this.name = name;
        this.team = team;
    }
}

I'm testing fetch joining, for example, find a team and their members by team id. The test code is like this,

// TeamRepository.java
    public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query(value =
            "select distinct t from Team t "  
            "join fetch t.members "  
            "where t.id = :id")
    Optional<Team> findByIdWithAllMembers(Long id);
}
// Test.java
    @Transactional
    @Test
    void transactionalFetchJoin() {
        System.out.println("save team");
        Team team = new Team();
        Team saved = teamRepository.save(team);

        System.out.println("save members");
        for (int i = 0; i < 10; i  ) {
            Member member = new Member("name"   String.valueOf(i), team);
            memberRepository.save(member);
        }

        System.out.println("teamRepository.findByIdWithAllMembers(saved.getId())");
        Team t = teamRepository.findByIdWithAllMembers(saved.getId())
                .orElseThrow(() -> new RuntimeException("ㅠㅠ"));

        assertThat(t.getMembers().size()).isEqualTo(0); // <-- no members are loaded
    }

    @Test
    void nonTransactionalFetchJoin() {
        System.out.println("save team");
        Team team = new Team();
        Team saved = teamRepository.save(team);

        System.out.println("save members");
        for (int i = 0; i < 10; i  ) {
            Member member = new Member("name"   String.valueOf(i), team);
            memberRepository.save(member);
        }

        System.out.println("teamRepository.findByIdWithAllMembers(saved.getId())");
        Team t = teamRepository.findByIdWithAllMembers(saved.getId())
                .orElseThrow(() -> new RuntimeException("ㅠㅠ"));

        assertThat(t.getMembers().size()).isEqualTo(10); // <-- 10 members are loaded
    }

These two test methods have the same logic but the only difference is @Transactional or not. Also, two test methods are passed successfully.

I found that 'nonTransactionalFetchJoin()' loaded team with 10 member objects, but 'transactionalFetchJoin()' didn't.

Also, I observed that 2 test methods generate the same JPQL/SQL queries for all JPA methods, including save().

Especially, the findByIdWithAllMembers() method generates query like,

    /* select
        distinct t 
    from
        Team t 
    join
        fetch t.members 
    where
        t.id = :id */ select
            distinct team0_.team_id as team_id1_1_0_,
            members1_.member_id as member_i1_0_1_,
            members1_.name as name2_0_1_,
            members1_.team_id as team_id3_0_1_,
            members1_.team_id as team_id3_0_0__,
            members1_.member_id as member_i1_0_0__ 
        from
            team team0_ 
        inner join
            member members1_ 
                on team0_.team_id=members1_.team_id 
        where
            team0_.team_id=?

The only difference is that, in case of transactionalFetchJoin(), o.h.type.descriptor.sql.BasicExtractor extracts just Team.id and Member.id, while nonTransactionalFetchJoin() extracts whole fields of Team and Member.

// transactionalFetchJoin
    2022-03-31 13:39:19.842 TRACE 4725 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([team_id1_1_0_] : [BIGINT]) - [1]
    2022-03-31 13:39:19.842 TRACE 4725 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([member_i1_0_1_] : [BIGINT]) - [1]
// nonTransactionalFetchJoin
    2022-03-31 13:39:19.933 TRACE 4725 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([team_id1_1_0_] : [BIGINT]) - [2]
    2022-03-31 13:39:19.934 TRACE 4725 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([member_i1_0_1_] : [BIGINT]) - [21]
    2022-03-31 13:39:19.935 TRACE 4725 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_1_] : [VARCHAR]) - [name0]

Why does this difference occur?

Thanks.

CodePudding user response:

Welcome to the club of developers screwed by the JPAs 1st level cache.

JPA holds on to entities in the 1st level cache. When you load or persist an entity it will be added to the 1st level cache until the end of the transaction/session/persistence context. And whenever it gives you an entity from any kind of load operation it will check if that entity exists in the cache and return that to you.

This means in the example with @Transactional annotation you don't actually load the entity. You effectively just load the ids and then use them to look up that entity in the cache. But the entity in the cache doesn't know about the added team members. => your team doesn't have any members.

With out explicit transactions the transactions span just one call to the repository. After each call the 1st level cache gets discarded and your load operation actually creates a new Team instance from the data loaded from the database.

There are a couple of things you should fix.

  1. Either get rid of the bidirectional relationship all together and make it a one directional one only. Or if really want to have it ensure in your code to make both sides match. I.e if you set Member.team, you should als add the Member to Team.members. Of course the same is true for removing members. This will avoid seeing inconsistent data in your application.

  2. Make sure you go through the proper JPA life cycle in your tests. My preferred approach to do this is to use TransactionTemplate to wrap the different steps of your test in separate transactions.

  3. If you want to double check that eager loading works properly you should put the loading of the root entity (team in this case) in a separate transaction and then after closing that transaction try to access the eager loaded properties outside the transaction. This way you'll get a LazyLoadingException if the referenced entities weren't loaded eagerly as intended.

  • Related