Hibernate @Check to ensure only single occurrence of a particular value in a column

Viewed 922

I am implementing a table for storing user roles having columns user_id and role. The business requirement is that there should be a constraint that only one record must exist with the value "ROLE_ROOT" for column role. There is no limit to the number of records for any other value in the role column.

For example:

Valid:

role         |user_id|
-------------|-------|
ROLE_ROOT    |      3|
ROLE_CUSTOMER|      5|
ROLE_CUSTOMER|      9|

Invalid:

role         |user_id|
-------------|-------|
ROLE_ROOT    |      3|
ROLE_ROOT    |      4|
ROLE_CUSTOMER|      5|
ROLE_CUSTOMER|      9|

The below scenario must not occur at all when persisting data.

I had at first thought about using a trigger on the table to check this constraint before any insert, but I have been asked not to implement triggers or any database specific feature and use Hibernate only.

This leaves only (to the best of my knowledge) the @Check annotation in Hibernate. But I am unable to determine the constraint to set because checks cannot have aggregate functions. Is there any way to use a Hibernate @Check annotation to achieve this? The only other way is to implement this manually but I wanted to make sure that this could be achieved on as low a level as possible given the constraints I have to work with.

2 Answers

@Check wont help you in this case, since it's not meant to query the database as part of validation before persisting the entity. Like other hibernate validator annotations, it is meant for basic constraint checking.

Thus only way is to perform the DB check either before persisting or as part of a Custom annotation. However, since this will be expensive to check DB everytime, you can avoid it by having an in-memory cache (if there is only 1 instance of your application deployed) or a distributed cache (if multiple instances).

Example using custom annotation and in-memory cache:

(Spring Boot Version: 2.3.1.RELEASE, Hibernate validator: 5.2.4.Final) (If you seek to use in-memory cache, remember to invalidate it, when record with role ROLE_ROOT is deleted)

  1. Entity class - (auto increment id)
@Entity
@Table(name = "my_table")
@CheckRole
public class MyEntity {

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

  @Column(name = "role")
  private String role;

  @Column(name = "user_id")
  private int userId;

  public int getId() {
    return id;
  }

  public String getRole() {
    return role;
  }

  public void setRole(String role) {
    this.role = role;
  }

  public int getUserId() {
    return userId;
  }

  public void setUserId(int userId) {
    this.userId = userId;
  }

}
  1. Repository -
@Repository
public interface MyTableRepository extends JpaRepository<MyEntity, Integer> {

  @Transactional(propagation = Propagation.NOT_SUPPORTED)
  @Query(value = "SELECT CASE WHEN COUNT(e) > 0 THEN true ELSE false END FROM MyEntity e WHERE e.role = :roleName")
  Boolean checkIfRoleExists(@Param("roleName") String roleName);
}

  1. Annotation-
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = RoleValidator.class)
public @interface CheckRole {

  String message() default "Cannot have duplicate entry for Role: ROLE_ROOT";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};
}
  1. Constraint Validator Impl (Used In-memory Caffeine cache) -
public class RoleValidator implements ConstraintValidator<CheckRole, MyEntity > {

  private static final String ROLE_TO_VALIDATE = "ROLE_ROOT";
  private LoadingCache<String, Boolean> myCache;

  public RoleValidator(MyTableRepository repository) {

    myCache = Caffeine.newBuilder()
        .maximumSize(1)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .refreshAfterWrite(1, TimeUnit.MINUTES)
        .build(repository::checkIfRoleExists);

  }

  @Override
  public void initialize(CheckRole constraintAnnotation) {
  }

  @Override
  public boolean isValid(MyEntity entity, ConstraintValidatorContext context) {
    String roleValue = entity.getRole();
    if (roleValue.equals(ROLE_TO_VALIDATE)) {
      boolean isValid = !myCache.get(ROLE_TO_VALIDATE);
      if (!isValid) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("Cannot have duplicate entry for Role: " + ROLE_TO_VALIDATE)
            .addConstraintViolation();
      }
      return isValid;
    } else {
      return true;
    }
  }

}
  1. Hibernate properties customizer -
@Component
public class ValidatorAddingCustomizer implements HibernatePropertiesCustomizer {

  private final ObjectProvider<Validator> provider;

  @Autowired
  public ValidatorAddingCustomizer(ObjectProvider<Validator> provider) {
    this.provider = provider;
  }

  @Override
  public void customize(Map<String, Object> hibernateProperties) {
    Validator validator = provider.getIfUnique();
    if (validator != null) {
      hibernateProperties.put("javax.persistence.validation.factory", validator);
    }
  }
}
  1. Validation in action -
@RestController
public class MyController {

  @Autowired
  private MyTableRepository repository;

  @GetMapping("/hello")
  public void hello() {
    MyEntity myEntity = new MyEntity();
    myEntity.setRole("ROLE_ROOT");
    myEntity.setUserId(3);

    repository.save(myEntity); //saves successfully

    MyEntity myEntity2 = new MyEntity();
    myEntity2.setRole("ROLE_ROOT");
    myEntity2.setUserId(4);

    repository.save(myEntity2); //Throws Constraint Violation Exception
  }
}

@Check is only a way to set a SQL check constraint on an entity :

Arbitrary SQL CHECK constraints which can be defined at the class, property or collection level.

That is :

a type of integrity constraint in SQL which specifies a requirement that must be met by each row in a database table The constraint must be a predicate. It can refer to a single column, or multiple columns of the table.

And the relevant part :

Common restrictions

Most database management systems restrict check constraints to a single row, with access to constants and deterministic functions, but not to data in other tables, or to data invisible to the current transaction because of transaction isolation.

So it cannot very probably apply to your case because your check for a row relies on the content of other rows.

I think that you fall into business rule cases where you should ensure yourself that within your workflows that constraint is not violated.
You have so 2 ways :

  • to handle specific cases where the role column may be updated
  • to have a more cross-cut approach by implementing JPA preUpdate and PreInsert listeners.

At last, nothing can prevent your application code to perform a native query, even with JPA. But well, code review, unit tests and integration tests are also there to fill the gap as much as possible.

Related