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.
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