Search code examples
javaspringhibernatejpaspring-data

Persisting an associated detached entity by cascade in Spring Data Jpa. Is it possible?


What if I want to persist a "new" entity with an "old" associated entity in Spring Data Jpa? Let me show you what I mean

  1. Signup request comes in, here's how I handle it
    public Mono<ServerResponse> signUp(ServerRequest request) {
        return request.bodyToMono(UserDto.class)
                .map(userMapper::toUser)
                .map(userService::encodePassword)
                .map(userService::addDefaultRoles)
                .map(userService::save)
                .map(this::toAuthenticatedUpat)
                .map(tokenService::generateTokenFor)
                .transform(jwt -> ServerResponse.status(HttpStatus.CREATED).body(jwt, String.class));
    }

The important parts are addDefaultRoles() and save()

    @Override
    public User save(User user) {
        return userRepository.save(user); // Spring Data's repository
    }

    @Override
    @Transactional // the only @Transactional method in UserServiceImpl
    public User addDefaultRoles(User user) {
        Optional<Role> userRoleOptional = roleService.findByAuthority(Role.USER);
        Role userRole = userRoleOptional.orElse(new Role(Role.USER));
        userRole.addUser(user);
        user.addRole(userRole);
        return user;
    }
// @Entity etc.
public class User implements UserDetails {
// ...
    @ManyToMany(cascade = CascadeType.PERSIST) // note this
    @JoinTable(name = "users_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> authorities;
// @Entity etc.
public class Role implements GrantedAuthority {
    public static final String USER = "user";
    // ...
    @ManyToMany(mappedBy = "authorities")
    private Set<User> users;
  1. Suppose I have a clean slate, and the database doesn't have any records. A first signup request completes fine: userRoleOptional is empty, and both the new user and the new USER role are persisted
// Spring's SimpleJpaRepository

    @Transactional
    @Override
    public <S extends T> S save(S entity) {

        Assert.notNull(entity, "Entity must not be null");

        if (entityInformation.isNew(entity)) { // true
            entityManager.persist(entity); // persist cascaded to role
            return entity;
        } else {
            return entityManager.merge(entity);
        }
    }
  1. Then a second signup request comes in. This time, I have a new user and an old role (USER). entityInformation.isNew(entity) is evaluated to true, again, persist() is invoked and cascaded to the role which is now deemed detached, and this happens
Caused by: org.hibernate.PersistentObjectException: detached entity passed to persist: com.example.tokenservice.data.entity.Role

How do I avoid this?


Solution

  • My conclusion is:

    1. Persisting a detached entity throws no matter what
    2. Spring Data's if check will evaluate to true no matter what (if you persist a new entity)
    3. Cascade persisting of the role won't go through the same check since it will bypass the Spring Data framework
    4. Meaning save()ing a new entity with an associated detached entity will inevitably throw

    Here's a workaround. Remove the cascade...

        @ManyToMany
        @JoinTable(name = "users_roles",
                joinColumns = @JoinColumn(name = "user_id"),
                inverseJoinColumns = @JoinColumn(name = "role_id"))
        private Set<Role> authorities;
    

    ...and persist the role beforehand (if it doesn't exist yet) manually

        @Override
        @Transactional
        public User addDefaultRoles(User user) {
            Optional<Role> userRoleOptional = roleService.findByAuthority(Role.USER);
            Role userRole = userRoleOptional.orElseGet(() -> roleService.save(new Role(Role.USER)));
            userRole.addUser(user);
            user.addRole(userRole);
            return user;
        }