Search code examples
javaspringspring-boothibernatejpa

Self Join many to many relationship leads to Stack overflow


I am new to spring boot and Java and I am trying to establish a follower-following relationship by self joining my tb_user table.

However I am getting a stackoverflow error. I tried to solve it but using @JsonIgnore and @JsonIdentityInfo like what many answers suggested but it doesn't seem to work.

Below is my code:

User.java

package com.codaholic.shop.models.security.user;

import com.codaholic.shop.models.Article;
import com.fasterxml.jackson.annotation.*;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Set;
import java.util.UUID;
import java.util.List;
import java.util.Collection;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "tb_user")
public class User implements UserDetails{
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String firstName;
    private String lastName;
    private String email;
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Enumerated(EnumType.STRING)
    @Builder.Default
    private UserStatus status = UserStatus.ACTIVE;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public String getPassword(){
        return password;
    }

    @Override
    public boolean isAccountNonExpired(){
        return true;
    }

    @Override
    public boolean isAccountNonLocked(){
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired(){
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @OneToMany
    private List<Article> articles;


    @ManyToMany(
            cascade={CascadeType.MERGE, CascadeType.DETACH, CascadeType.REFRESH},
            fetch = FetchType.LAZY)
    @JoinTable(
            name = "tb_follower_following",
            joinColumns = @JoinColumn(name="followee_id", referencedColumnName = "id"), // the owning side of the r/s is this user
            inverseJoinColumns = @JoinColumn(name="follower_id", referencedColumnName = "id")
    )
    @JsonIgnore
    private Set<User> followers;


    @ManyToMany(mappedBy="followers")
    @JsonIgnore
    private Set<User> following;

}

UserRepository.java

package com.codaholic.shop.repository;
import com.codaholic.shop.models.security.user.User;
import com.codaholic.shop.models.security.user.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.Param;

import java.util.Optional;
import java.util.UUID;

public interface UserRepository extends JpaRepository<User, UUID> {

    Optional<User> findByEmail(String email);
    Optional<User> findById(UUID id);
    Optional<User> findByIdAndStatus(@Param("id") UUID id, UserStatus status);
}

FollowingService.java

package com.codaholic.shop.services;

import com.codaholic.shop.dto.FollowingResponseDTO;
import com.codaholic.shop.exceptions.ResourceNotFoundException;
import com.codaholic.shop.models.security.user.FollowingRequest;
import com.codaholic.shop.models.security.user.User;
import com.codaholic.shop.models.security.user.UserStatus;
import com.codaholic.shop.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Service
public class FollowingService {

    private final UserRepository userRepository;

    FollowingService(UserRepository userRepository){
        this.userRepository = userRepository;
    }

    public FollowingResponseDTO createFollowing(FollowingRequest request) throws ResourceNotFoundException {
        UUID followerId = request.getFollowerId();
        User follower = userRepository.findByIdAndStatus(followerId, UserStatus.ACTIVE)
            .orElseThrow(
                ()-> new ResourceNotFoundException(String.format("User %s is not found", followerId))
            );
        UUID followingId = request.getFollowingId();

        User followee = userRepository.findByIdAndStatus(followingId, UserStatus.ACTIVE)
            .orElseThrow(
                () -> new ResourceNotFoundException(String.format("User %s is not found", followingId))
            );

        // Next create a new relation by setting their status to ACTIVE
        follower.getFollowing().add(followee);
        followee.getFollowers().add(follower);

        userRepository.save(follower);
        userRepository.save(followee);

        FollowingResponseDTO followingResult = new FollowingResponseDTO();
        followingResult.setFollowerId(followerId);
        followingResult.setFollowingId(followingId);
        followingResult.setSuccess(true);

        return followingResult;

    }

}

FollowingController

package com.codaholic.shop.controllers;

import com.codaholic.shop.dto.FollowingResponseDTO;
import com.codaholic.shop.exceptions.ResourceNotFoundException;
import com.codaholic.shop.models.security.user.FollowingRequest;
import com.codaholic.shop.services.FollowingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/following")
public class FollowingController {
    private final FollowingService followingService;

    @Autowired
    public FollowingController(FollowingService followingService) {
        this.followingService = followingService;
    }

    @PostMapping
    public ResponseEntity<FollowingResponseDTO> createFollowing(@RequestBody FollowingRequest request) throws ResourceNotFoundException {
        FollowingResponseDTO result = followingService.createFollowing(request);
        return ResponseEntity.ok().body(result);

    }
}

Stack trace:

2023-09-21T23:41:46.787+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        u1_0.id,
        u1_0.email,
        u1_0.first_name,
        u1_0.last_name,
        u1_0.password,
        u1_0.role,
        u1_0.status 
    from
        tb_user u1_0 
    where
        u1_0.id=? 
        and u1_0.status=?
Hibernate: 
    select
        u1_0.id,
        u1_0.email,
        u1_0.first_name,
        u1_0.last_name,
        u1_0.password,
        u1_0.role,
        u1_0.status 
    from
        tb_user u1_0 
    where
        u1_0.id=? 
        and u1_0.status=?
2023-09-21T23:41:46.791+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        u1_0.id,
        u1_0.email,
        u1_0.first_name,
        u1_0.last_name,
        u1_0.password,
        u1_0.role,
        u1_0.status 
    from
        tb_user u1_0 
    where
        u1_0.id=? 
        and u1_0.status=?
Hibernate: 
    select
        u1_0.id,
        u1_0.email,
        u1_0.first_name,
        u1_0.last_name,
        u1_0.password,
        u1_0.role,
        u1_0.status 
    from
        tb_user u1_0 
    where
        u1_0.id=? 
        and u1_0.status=?
2023-09-21T23:41:46.794+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        f1_0.follower_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.followee_id 
    where
        f1_0.follower_id=?
Hibernate: 
    select
        f1_0.follower_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.followee_id 
    where
        f1_0.follower_id=?
2023-09-21T23:41:46.796+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        f1_0.followee_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.follower_id 
    where
        f1_0.followee_id=?
Hibernate: 
    select
        f1_0.followee_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.follower_id 
    where
        f1_0.followee_id=?
2023-09-21T23:41:46.798+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        f1_0.follower_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.followee_id 
    where
        f1_0.follower_id=?
Hibernate: 
    select
        f1_0.follower_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.followee_id 
    where
        f1_0.follower_id=?
2023-09-21T23:41:46.800+08:00 DEBUG 75113 --- [nio-3000-exec-2] org.hibernate.SQL                        : 
    select
        f1_0.followee_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.follower_id 
    where
        f1_0.followee_id=?
Hibernate: 
    select
        f1_0.followee_id,
        f1_1.id,
        f1_1.email,
        f1_1.first_name,
        f1_1.last_name,
        f1_1.password,
        f1_1.role,
        f1_1.status 
    from
        tb_follower_following f1_0 
    join
        tb_user f1_1 
            on f1_1.id=f1_0.follower_id 
    where
        f1_0.followee_id=?
2023-09-21T23:41:46.810+08:00 DEBUG 75113 --- [nio-3000-exec-2] o.s.web.servlet.DispatcherServlet        : Failed to complete request: jakarta.servlet.ServletException: Handler dispatch failed: java.lang.StackOverflowError
2023-09-21T23:41:46.811+08:00 ERROR 75113 --- [nio-3000-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed: java.lang.StackOverflowError] with root cause

java.lang.StackOverflowError: null
        at org.hibernate.collection.spi.PersistentSet.hashCode(PersistentSet.java:413) ~[hibernate-core-6.2.7.Final.jar:6.2.7.Final]
        at com.codaholic.shop.models.security.user.User.hashCode(User.java:16) ~[main/:na]
        at java.base/java.util.AbstractSet.hashCode(AbstractSet.java:124) ~[na:na]
        at org.hibernate.collection.spi.PersistentSet.hashCode(PersistentSet.java:413) ~[hibernate-core-6.2.7.Final.jar:6.2.7.Final]
        at com.codaholic.shop.models.security.user.User.hashCode(User.java:16) ~[main/:na]
        at java.base/java.util.AbstractSet.hashCode(AbstractSet.java:124) ~[na:na]
        at org.hibernate.collection.spi.PersistentSet.hashCode(PersistentSet.java:413) ~[hibernate-core-6.2.7.Final.jar:6.2.7.Final]

Edit: As per @RomanC's explanation, I have since removed adding one side of the list and now the stackoverflow is resolved. i.e from

        follower.getFollowing().add(followee);
        followee.getFollowers().add(follower);

        userRepository.save(follower);
        userRepository.save(followee);

to

        follower.getFollowing().add(followee);
        userRepository.save(follower);

Similarly, I also made amendments to remove the mappedBy as suggested since both the follower and following can "own" the relationship.

    @ManyToMany(
            cascade={CascadeType.MERGE, CascadeType.DETACH, CascadeType.REFRESH},
            fetch = FetchType.LAZY)
    @JoinTable(
            name = "tb_follower_following",
            joinColumns = @JoinColumn(name="following_id", referencedColumnName = "id"), // ie. the FK is the follower_id
            inverseJoinColumns = @JoinColumn(name="follower_id", referencedColumnName = "id")
    )
    private Set<User> followers;


    @ManyToMany
    @JoinTable(
            name = "tb_follower_following",
            joinColumns = @JoinColumn(name="follower_id", referencedColumnName = "id"), // ie. the FK is the following_id
            inverseJoinColumns = @JoinColumn(name="following_id", referencedColumnName = "id")
    )
    private Set<User> following;

Also realized that the @JsonIgnore was no longer necessary.


Solution

  • Your following attribute is mapped to followers, end the end they have the same value. But since you do this:

    follower.getFollowing().add(followee);
    followee.getFollowers().add(follower);
    

    You add the followee as a following and the follower as a follower. But the follower already contains a followee. So when you will try to load each, It will try to load the other and so on until StackOverflow.

    According to me, you don't really need to have both following and followers list. According to your design, if a user is your follower, you are also his follower. So you just have to keep one list. But if following and followers lists should be different, you have to remove the mappedBy attribute.