Requirements:
JBoss Developer Studio 5
JBoss AS 7.1.1.Final
Seam 3.1.0.Final Validation Module (for dependency injection in validators)
Seam 3.1.0.Final Solder Module (needed by Validation Module)
de.hashcode:jsr303-validators:1.1 (for some utility methods in the validator)

Annotation

package org.lucaster.validator;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Constraint(validatedBy = { UniqueKeysValidator.class })
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueKeys {

	String[] columnNames();

	String message() default "Values are not unique";

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

	Class<? extends Payload>[] payload() default {};

	@Target({ ElementType.TYPE })
	@Retention(RetentionPolicy.RUNTIME)
	@Documented
	@interface List {
		UniqueKeys[] value();
	}
}

Validator

package org.lucaster.validator;

import static de.hashcode.validation.ReflectionUtils.getIdField;
import static de.hashcode.validation.ReflectionUtils.getPropertyValue;

import java.beans.PropertyDescriptor;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.ConstraintValidatorContext.ConstraintViolationBuilder;
import javax.validation.ConstraintValidatorContext.ConstraintViolationBuilder.NodeBuilderDefinedContext;

public class UniqueKeysValidator implements	ConstraintValidator<UniqueKeys, Serializable> {

	@Inject private EntityManager entityManager;

	private UniqueKeys constraintAnnotation;

	private String[] columnNames;

	public UniqueKeysValidator() {}

	@Override
	public void initialize(final UniqueKeys constraintAnnotation) {
		this.columnNames = constraintAnnotation.columnNames();
		this.constraintAnnotation = constraintAnnotation;
	}

	@Override
	public boolean isValid(Serializable target,	ConstraintValidatorContext context) {

		final Class<?> entityClass = target.getClass();

		final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();

		final CriteriaQuery<Object> criteriaQuery = criteriaBuilder.createQuery();

		final Root<?> root = criteriaQuery.from(entityClass);

		List<Predicate> predicates = new ArrayList<Predicate>(columnNames.length);

		try {

			for (int i = 0; i < columnNames.length; i++) {

				String propertyName = columnNames[i];
				PropertyDescriptor desc = new PropertyDescriptor(propertyName, entityClass);
				Method readMethod = desc.getReadMethod();
				Object propertyValue = readMethod.invoke(target);

				Predicate predicate = criteriaBuilder.equal(root.get(propertyName), propertyValue);

				predicates.add(predicate);
			}

			Field idField = getIdField(entityClass);
			String idProperty = idField.getName();
			Object idValue = getPropertyValue(target, idProperty);

			if (idValue != null) {
				Predicate idNotEqualsPredicate = criteriaBuilder.notEqual(root.get(idProperty), idValue);
				predicates.add(idNotEqualsPredicate);
			}

		} catch (Exception e) {
			e.printStackTrace();
		}

		criteriaQuery.select(root).where(predicates.toArray(new Predicate[predicates.size()]));

		TypedQuery<Object> typedQuery = entityManager.createQuery(criteriaQuery);

		List<Object> resultSet = typedQuery.getResultList();

		if (!resultSet.isEmpty()) {

			// This string will contain all column names separated by a comma. Example: "title,author,editor"
			String names = "";
			for (String columnName : columnNames) {
				names += columnName;
				names += ",";
			}
			names = names.substring(0, names.length() - 1);

			ConstraintViolationBuilder cvb = context.buildConstraintViolationWithTemplate(constraintAnnotation.message());
			NodeBuilderDefinedContext nbdc = cvb.addNode(names);
			ConstraintValidatorContext cvc = nbdc.addConstraintViolation();
			cvc.disableDefaultConstraintViolation();
			return false;
		}

		return true;
	}
}

Usage in entity bean

package org.lucaster.model;

import javax.persistence.Column;
import javax.persistence.Entity;

import org.hibernate.validator.constraints.NotEmpty;
import org.lucaster.validator.UniqueKeys;

@Entity
@UniqueKeys(columnNames = { "title", "author" }) // multiple column constraint
// @UniqueKeys(columnNames = { "title" }) // single column constraint
// @UniqueKeys.List(value = { @UniqueKeys(columnNames = { "title", "author" }), @UniqueKeys(columnNames = { "pages" }) }) // multiple constraints
public class Book extends Model {

	private static final long serialVersionUID = 8519973666976987438L;

	@NotEmpty @Column(unique = true) private String title;
	private String author;
	private int pages;

	@Override
	public String toString() {
		return title + ", " + author + ", " + pages;
	}

	public String getTitle() {
		return title;
	}

	public void setTitle(String title) {
		this.title = title;
	}

	public String getAuthor() {
		return author;
	}

	public void setAuthor(String author) {
		this.author = author;
	}

	public int getPages() {
		return pages;
	}

	public void setPages(int pages) {
		this.pages = pages;
	}
}

Usage in controller – version 1

public boolean persist() {

	try {

		instance = entityManager.merge(instance);
		entityManager.flush();
		id = instance.getId();
		return true;

	}
	// We expect only unique constraint violations
	catch (ConstraintViolationException e) {

		e.printStackTrace();

		Set<ConstraintViolation<?>> violations = e.getConstraintViolations();

		for (Object violationObject : violations.toArray()) {

			@SuppressWarnings("unchecked")
			ConstraintViolation<Book> violation = (ConstraintViolation<Book>) violationObject;

			// Comma separated list of property names
			String names = violation.getPropertyPath().toString();

			// Get an array of single property names
			String[] properties = names.split(",", -1);

			// Add FacesMessage near each input field
			for (String property : properties) {
				FacesUtil.error("bookForm-" + property,	violation.getMessage(), violation.getMessage());
			}

			// Add also a global FacesMessage
			FacesUtil.error(null, violation.getMessage(), "Combination of properties '" + names + "' must be unique");
		}
		return false;
	}
}

Usage in controller – version 2

(omissis)

The page

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
	xmlns:ui="http://java.sun.com/jsf/facelets"
	xmlns:f="http://java.sun.com/jsf/core"
	xmlns:h="http://java.sun.com/jsf/html"
	xmlns:p="http://primefaces.org/ui"
	xmlns:s="http://jboss.org/seam/faces"
	xmlns:pm="http://primefaces.org/mobile">

<h:head>
	<title>Book</title>
	<style type="text/css">
		.ui-widget, .ui-widget .ui-widget {
			font-size: 90% !important;
		}
	</style>
</h:head>

<h:body>

	<p:messages globalOnly="false" autoUpdate="true" showDetail="true" showSummary="true"/>

	<p:fieldset legend="Book #{bookHomeCRUD.idDefined ? bookCRUD.id : ''}">
		<h:form id="bookForm">
			<h:panelGrid columns="3" >
				<p:outputLabel for="title" value="Title"/>
				<p:inputText id="title" value="#{bookCRUD.title}" required="true" type="text">

				</p:inputText>
				<p:message for="title" />
				<p:outputLabel for="author" value="Author"/>
				<p:inputText id="author" value="#{bookCRUD.author}" required="true" type="text"/>
				<p:message for="author" />
				<p:outputLabel for="pages" value="Pages"/>
				<p:inputText id="pages" value="#{bookCRUD.pages}" required="true" type="number">
					<f:convertNumber integerOnly="true" maxFractionDigits="0"/>
				</p:inputText>
				<p:message for="pages" />
			</h:panelGrid>
			<p:commandButton value="Save" action="#{bookHomeCRUD.persist}"  rendered="#{!bookHomeCRUD.idDefined}" ajax="false" >

			</p:commandButton>
			<p:commandButton value="Update" action="#{bookHomeCRUD.update}" rendered="#{bookHomeCRUD.idDefined}" ajax="false" >
				<f:param name="id" value="#{bookCRUD.id}"/>
			</p:commandButton>

		</h:form>
		<h:link value="Create New"/>
	</p:fieldset>

	<p:separator />

	<p:dataTable id="bookList" value="#{bookListCRUD}" var="_book">
		<f:facet name="header">Books</f:facet>
		<p:column headerText="Id">#{_book.id}</p:column>
		<p:column headerText="Title">#{_book.title}</p:column>
		<p:column headerText="Author">#{_book.author}</p:column>
		<p:column headerText="Pages">#{_book.pages}</p:column>
		<p:column>
			<h:form>
				<h:link value="Edit" outcome="bookCRUD.xhtml">
					<f:param name="id" value="#{_book.id}" />
				</h:link>
				<p:spacer width="5"/>
				<p:commandLink value="Remove" action="#{bookHomeCRUD.remove}" ajax="false">
					<f:param name="id" value="#{_book.id}" />
				</p:commandLink>
			</h:form>
		</p:column>
	</p:dataTable>

</h:body>
</html>

Remarks
This approach lets you define single column AND multiple column constraints.
Constraint violations are detected when the EntityManager flushes, during INVOKE_APPLICATION phase, instead of as part of PROCESS_VALIDATIONS phase.
Constraints violations must be handed programmatically to display messages near the interested input fields.
You need to establish a convention between input fields ids and property names. If you change the input fields ids, you also have to change the code. You must also consider the convention used by the validator to communicate the column names (see code).
Usage in controller – version 1: Notice how the property names are extracted, in respect to the convetion used by the validator.
Usage in controller – version 2: It would be very similar to the one in Part 1.

 

Leave a Reply