springhibernatejpanotnull

Spring JPA DataIntegrityViolationException because NotNull column is set to null


I do have an entity OptionGroup with relationships to other entities. One of the relationships is making trouble: An OptionGroup has an owner (User). When I delete an OptionGroup, for some reason, the JPA provider hibernate is trying to set the owner_id of the OptionGroup to null which violates to the NotNull constraint defined for the owner field. I have no clue why hibernate is doing this, but I can see that it is doing this in the log:

2022-08-30 20:17:53.008 DEBUG 17488 --- [nio-8081-exec-1] org.hibernate.SQL                        : update option_group set description=?, option_group_name=?, owner_id=? where id=?
2022-08-30 20:17:53.008 TRACE 17488 --- [nio-8081-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [null]
2022-08-30 20:17:53.008 TRACE 17488 --- [nio-8081-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [Männliche Vornamen]
2022-08-30 20:17:53.008 TRACE 17488 --- [nio-8081-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [null]
2022-08-30 20:17:53.008 TRACE 17488 --- [nio-8081-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [20001]
2022-08-30 20:17:53.012  WARN 17488 --- [nio-8081-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 23502
2022-08-30 20:17:53.012 ERROR 17488 --- [nio-8081-exec-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : ERROR: NULL value in column »owner_id« of relation »option_group« violates Not-Null-Constraint

If I would have defined cascade delete on the owner field I could imagine that hibernate might delete the owner first, set the owner in the OptionGroup to null and then delete the OptionGroup - although it does not make much sense to first the the owner to null and then delete the OptionGroup...

Do you have any idea why hibernate is setting owner_id to null? Btw. if I remove the NotNull constraint the behavior is as expected: the OptionGroup is deleted and the User (owner) remains.

This is the OptionGroupClass:

@Entity
@Table(name = "option_group"/*, uniqueConstraints = {
        @UniqueConstraint(columnNames = {  "owner_id", "option_group_name" }) }*/)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class OptionGroup {

    /**
     * Id of the Option Group. Generated by the database
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * Name of the Option Group. Unique in the context of a user.
     */
    @NotBlank(message = "Option Group name is mandatory")
    @Column(name = "option_group_name")
    private String optionGroupName;

    /**
     * Description for the Option Group
     */
    private String description;

    /**
     * User that is the owner of the Option Group.
     */
    @NotNull(message = "Owner cannot be null")
    @ManyToOne(fetch = FetchType.LAZY, cascade={CascadeType.PERSIST})
    @JoinColumn(name = "ownerId")
    private User owner;

    /**
     * List of options that belong to the Option Group.
     */
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "optionGroup", orphanRemoval = true)
    @NotEmpty(message = "Options cannot be empty")
    private List<Option> options;

    /**
     * List of invitations that belong to the Option Group.
     */
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "optionGroup", orphanRemoval = true)
    private List<Invitation> invitations;

    @Override
    public int hashCode() {
        return Objects.hash(description, id, optionGroupName,
                options == null ? null : options.stream().map(option -> option.getId()).toList(), owner,
                invitations == null ? null : invitations.stream().map(invitation -> invitation.getId()).toList());
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        OptionGroup other = (OptionGroup) obj;
        return Objects.equals(description, other.description) && Objects.equals(id, other.id)
                && Objects.equals(optionGroupName, other.optionGroupName)
                && Objects.equals(options == null ? null : options.stream().map(option -> option.getId()).toList(),
                        other.options == null ? null : other.options.stream().map(option -> option.getId()).toList())
                && Objects.equals(owner, other.owner)
                && Objects.equals(
                        invitations == null ? null
                                : invitations.stream().map(invitation -> invitation.getId()).toList(),
                        other.invitations == null ? null
                                : other.invitations.stream().map(invitation -> invitation.getId()).toList());
    }

    @Override
    public String toString() {
        return "OptionGroup [id=" + id + ", optionGroupName=" + optionGroupName + ", description=" + description
                + ", owner=" + owner + ", options="
                + (options == null ? null : options.stream().map(option -> option.getId()).toList()) + ", invitations="
                + (invitations == null ? null : invitations.stream().map(invitation -> invitation.getId()).toList())
                + "]";
    }
}

As you can see the cascade of owner is limited to persist. f a OptionGroup is created, the owner User is created as well. But if an OptionGroup is deleted the owner User should not be deleted.

This is the User class:

/**
 * Entity that represents a user
 * 
 * Primary key: id
 */
@Entity
@Table(name = "usert", uniqueConstraints = {
        @UniqueConstraint(columnNames = { "email"}) })
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class User {
    /**
     * Id of the User. Generated by the database
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    /**
     * Email address of the invitee.
     */
    @Email(message = "Email is not valid", regexp = "(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])")
    private String email;

    /**
     * Option Groups of which the user is the owner.
     */
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "owner", orphanRemoval = true)
    private List<OptionGroup> ownedOptionGroups;

    /**
     * Invitations of the user.
     */
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "invitee", orphanRemoval = true)
    private List<Invitation> invitations;

}

And this is the class that triggers the delete

/**
 * Service related to Option Groups.
 */
@Service
@Transactional
@AllArgsConstructor
public class OptionGroupService {

    /**
     * Repository used to access Option Groups.
     */
    @Autowired
    private OptionGroupRepository optionGroupRepository;

    /**
     * Deletes the Option Group with the given id.
     * 
     * @param id Id of the Option Group to delete.
     * @throws ObjectWithNameDoesNotExistException
     * @throws ObjectWithIdDoesNotExistException
     */
    public void deleteOptionGroupById(Long id) throws ObjectWithIdDoesNotExistException {
        if (optionGroupRepository.existsById(id)) {
            optionGroupRepository.deleteById(id);
        } else {
            throw new ObjectWithIdDoesNotExistException("Option Group", id);
        }
    }

}

And the repository

public interface OptionGroupRepository extends JpaRepository<OptionGroup, Long> {}

Appreciate your help on this. Thanks.


Solution

  • The root cause was an extensive use of cascades in both, parent and child entities which led to a chain of cascades: by saving an Option Group an Invitation was saved by which again an Option Group was saved. After cleaning this up it works.

    I recommend reading: Hibernate - how to use cascade in relations correctly