Agregando grupos de validación en Java con Hibernate Validator

En este artículo describiremos cómo hacer grupos de validación con Hibernate Validator. La documentación de Hibernate Validator, aunque completa, es bastante escueta y dice solamente lo justo, no pone ni un ejemplito de más para hacer que te quede más claro.

Hibernate Validator

Hibernate Validator permite expresar validaciones para el modelo de datos mediante Annotations, lo cual permite “descentralizar” la validación, en que esta se puede expresar de manera tal de hacerla automática (sin requerir código específico que indique dónde o cuándo validar, realizándola al momento de grabar la entidad o al recibir la entidad en el Controller).

Escenario

Tenemos el siguiente escenario: un sistema de un instituto de enseñanza de idioma inglés recibe una inscripción para cursos individuales o grupales. El formulario necesita hacer validaciones en una secuencia determinada (validar primero datos de persona, luego datos de la inscripción), y además, seleccionar las validaciones que correspondan solamente al tipo de inscripción seleccionado: por ejemplo, para una inscripción individual es obligatorio que la persona introduzca los días y horas que prefiere, mientras que para inscripción grupal, los cursos ya están armados y es obligatorio informar en qué curso inscribir a la persona.

De este modo, se pueden crear validaciones para ambos tipos de inscripción y dejar que Hibernate escoja la secuencia apropiada al momento de grabar la entidad o de recibir el formulario de inscripción.

Componentes

Esta solución tiene los siguientes componentes:

  • Proveedor de la secuencia de validaciones a realizar. Esta clase es la que informa a Hibernate Validator cuál es la secuencia de validaciones.
  • Validaciones en las entidades del modelo de datos, que tienen que estar clasificadas de acuerdo al grupo de validaciones que correspondan.
  • Anotaciones para disparar los procesos de validación, las que hacen que todo suceda, tanto en el modelo de datos como en los Controllers.

Modelo de datos

A continuación presentamos el modelo de datos.

Clase Persona: acá se observa que las validaciones no tienen un grupo, es decir, se van a hacer en todos los casos.

package edumanage.model;

import javax.persistence.*;
import javax.validation.constraints.*;

import org.hibernate.envers.*;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.format.annotation.DateTimeFormat;

import edumanage.model.validacion.EdadMinima;

import java.util.*;

@Entity
@Table(name="personas")
@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
public class Persona 
{
    @Id
    @Column(name="id")
    @GeneratedValue
    private Integer id;

    @Column(name="nombre")
    @NotEmpty
    private String nombre;

    @Column(name="apellido")
    @NotEmpty
    private String apellido;

    @Column(name="email")
    @NotEmpty
    private String email;
    
    @Column(name="direccion")
    private String direccion;
    
    @Column(name="telefono")
    private String telefono;
    
    @Column(name="fecha_nacimiento")
    @EdadMinima(valor = 3)
    @DateTimeFormat(pattern="yyyy-MM-dd")
    private Date fecha_nacimiento;
        
    @OneToMany(mappedBy="persona")
    private Set<Inscripcion> inscripciones;
    
    @ManyToOne
    @JoinColumn(name="estado_id")
    private PersonaEstado estado;
    @Column(name="baja")
    private int baja;
    @Column(name="fecha_baja")
    private Date fecha_baja;
    @Column(name="created",columnDefinition="TIMESTAMP DEFAULT CURRENT_TIMESTAMP", insertable = true, updatable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private Date created;
    @Column(name="modified")
    private Date modified;
    
    public String getEmail() {
            return email;
    }
    public String getTelefono() {
            return telefono;
    }
    public void setEmail(String email) {
            this.email = email;
    }
    public void setTelefono(String telefono) {
            this.telefono = telefono;
    }
    public String getNombre() {
            return nombre;
    }
    public String getApellido() {
            return apellido;
    }
    public void setNombre(String nombre) {
            this.nombre = nombre;
    }
    public void setApellido(String apellido) {
            this.apellido = apellido;
    }
    public Integer getId() {
            return id;
    }
    public void setId(Integer id) {
            this.id = id;
    }
    public String getDireccion()
    {
    	return this.direccion;
    }
    public Date getFecha_nacimiento()
    {
        return fecha_nacimiento;
    }
    public Date setFecha_nacimiento(Date f)
    {
        fecha_nacimiento=f;
    }
    public void setDireccion(String d)
    {
    	this.direccion=d;
    }
	public PersonaEstado getEstado() {
		return estado;
	}
	public void setEstado(PersonaEstado estado) {
		this.estado = estado;
	}
}

Clase ModeloFormInscripcion: esta es la clase que usa el formulario para registrar las entidades. El formulario, por javascript (mostrado más abajo) muestra solamente los campos necesarios de acuerdo al tipo de inscripción elegida, por lo tanto, aquellos campos no competados quedarían nulos, lo cual motiva a hacer esta validación selectiva.

Aquí es donde se observan una de las peculiaridades del sistema de Hibernate Validator. En una annotation al comienzo de la clase se define qué clase se va a usar para proveer la secuencia de la validación. Además, los 3 componentes del formulario, Persona, InscripcionGrupal e InscripcionIndividual, llevan cada uno una annotation Valid para indicarle a Hibernate Validator que al recorrer esta clase tiene que ir recursivamente a fijarse a las clases anotadas, para continuar ahí las validaciones. Si no tuviera esas anotaciones, la validación no se haría.

@GroupSequenceProvider(ModeloFormInscripcionSequenceProvider.class)
public class ModeloFormInscripcion
{
	@Valid
	private Persona persona;
	@Valid
	private InscripcionGrupal inscripcionGrupal;
	@Valid
	private InscripcionIndividual inscripcionIndividual;
	
	@AssertTrue
	private boolean aceptoTerminos;
	
	public ModeloFormInscripcion()
	{
		// Por ahora nada...
	}
	public ModeloFormInscripcion(Persona p,InscripcionGrupal g, InscripcionIndividual i)
	{
		setPersona(p);
		setInscripcionGrupal(g);
		setInscripcionIndividual(i);
	}
	public Inscripcion getInscripcion()
	{
		// tiene que devolver ambos tipos de inscripciones. Eso lo logramos
		// preguntando el valor del campo tipo de inscripcion. 
		if(inscripcionGrupal.getCurso()!=null && inscripcionGrupal.getCurso().getTipo_curso().getDescripcion().equals("Grupal"))
			return inscripcionGrupal;
		else
			return inscripcionIndividual;
	}
	/**
	 * @return the persona
	 */
	public Persona getPersona() 
	{
		return persona;
	}
	/**
	 * @param persona the persona to set
	 */
	public void setPersona(Persona persona) 
	{
		this.persona = persona;
	}
	/**
	 * @return the inscripcionGrupal
	 */
	public InscripcionGrupal getInscripcionGrupal() 
	{
		return inscripcionGrupal;
	}
	/**
	 * @param inscripcionGrupal the inscripcionGrupal to set
	 */
	public void setInscripcionGrupal(InscripcionGrupal inscripcionGrupal) 
	{
		this.inscripcionGrupal = inscripcionGrupal;
	}
	/**
	 * @return the inscripcionIndividual
	 */
	public InscripcionIndividual getInscripcionIndividual() 
	{
		return inscripcionIndividual;
	}
	/**
	 * @param inscripcionIndividual the inscripcionIndividual to set
	 */
	public void setInscripcionIndividual(InscripcionIndividual inscripcionIndividual) 
	{
		this.inscripcionIndividual = inscripcionIndividual;
	}
	public boolean isAceptoTerminos() {
		return aceptoTerminos;
	}
	public void setAceptoTerminos(boolean aceptoTerminos) {
		this.aceptoTerminos = aceptoTerminos;
	}
}

Clase InscripcionIndividual: aquí se observan otra de las particularidades: los campos obligatorios, que se pide que sean “NotNull”, tienen marcado a qué grupo de validación pertenecen. Ese grupo se expresa mediante una interfaz.

@Entity
@DiscriminatorValue("Individual")
@Audited
public class InscripcionIndividual extends Inscripcion 
{
	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;
	private int clases_semanales;
	private String horas_clase;
	@NotEmpty(groups=InscripcionIndividualChecks.class)
	private String dias_preferidos;
	private String dias_alternativos;
	@NotEmpty(groups=InscripcionIndividualChecks.class)
	private String horario_preferido;
	private String horario_alternativo;
	private String sexo_profesor;
	private String preferencia_idioma;
	private String materia;
	private String cual_examen;
	private String otra_orientacion;
	@ManyToOne
	@JoinColumn(name="idioma_estudiar_id")
	@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
	private IdiomaEstudiar idioma_estudiar;
	@ManyToOne
	@JoinColumn(name="nivel_id")
	@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
	private Nivel nivel;
	@ManyToOne
	@JoinColumn(name="orientacion_id")
	@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
	private Orientacion orientacion;
	@ManyToOne
	@JoinColumn(name="profesor_posible_id")
	@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
	private Profesor profesorPosible;
	@ManyToOne
	@JoinColumn(name="sucursal_id")
	@Audited(targetAuditMode=RelationTargetAuditMode.NOT_AUDITED)
	private Sucursal sucursal;
	
	public InscripcionIndividual()
	{
	}
	/**
	 * @return the clases_semanales
	 */
	public int getClases_semanales() {
		return clases_semanales;
	}
	/**
	 * @param clases_semanales the clases_semanales to set
	 */
	public void setClases_semanales(int clases_semanales) {
		this.clases_semanales = clases_semanales;
	}
	/**
	 * @return the horas_clase
	 */
	public String getHoras_clase() {
		return horas_clase;
	}
	/**
	 * @param horas_clase the horas_clase to set
	 */
	public void setHoras_clase(String horas_clase) {
		this.horas_clase = horas_clase;
	}
	/**
	 * @return the dias_preferidos
	 */
	public String getDias_preferidos() {
		return dias_preferidos;
	}
	/**
	 * @param dias_preferidos the dias_preferidos to set
	 */
	public void setDias_preferidos(String dias_preferidos) {
		this.dias_preferidos = dias_preferidos;
	}
	/**
	 * @return the dias_alternativos
	 */
	public String getDias_alternativos() {
		return dias_alternativos;
	}
	/**
	 * @param dias_alternativos the dias_alternativos to set
	 */
	public void setDias_alternativos(String dias_alternativos) {
		this.dias_alternativos = dias_alternativos;
	}
	/**
	 * @return the horario_preferido
	 */
	public String getHorario_preferido() {
		return horario_preferido;
	}
	/**
	 * @param horario_preferido the horario_preferido to set
	 */
	public void setHorario_preferido(String horario_preferido) {
		this.horario_preferido = horario_preferido;
	}
	/**
	 * @return the horario_alternativo
	 */
	public String getHorario_alternativo() {
		return horario_alternativo;
	}
	/**
	 * @param horario_alternativo the horario_alternativo to set
	 */
	public void setHorario_alternativo(String horario_alternativo) {
		this.horario_alternativo = horario_alternativo;
	}
	/**
	 * @return the sexo_profesor
	 */
	public String getSexo_profesor() {
		return sexo_profesor;
	}
	/**
	 * @param sexo_profesor the sexo_profesor to set
	 */
	public void setSexo_profesor(String sexo_profesor) {
		this.sexo_profesor = sexo_profesor;
	}
	/**
	 * @return the preferencia_idioma
	 */
	public String getPreferencia_idioma() {
		return preferencia_idioma;
	}
	/**
	 * @param preferencia_idioma the preferencia_idioma to set
	 */
	public void setPreferencia_idioma(String preferencia_idioma) {
		this.preferencia_idioma = preferencia_idioma;
	}
	/**
	 * @return the idiomaEstudiar
	 */
	public IdiomaEstudiar getIdioma_estudiar() {
		return idioma_estudiar;
	}
	/**
	 * @param idiomaEstudiar the idiomaEstudiar to set
	 */
	public void setIdioma_estudiar(IdiomaEstudiar idiomaEstudiar) {
		this.idioma_estudiar = idiomaEstudiar;
	}
	/**
	 * @return the nivel
	 */
	public Nivel getNivel() {
		return nivel;
	}
	/**
	 * @param nivel the nivel to set
	 */
	public void setNivel(Nivel nivel) {
		this.nivel = nivel;
	}
	/**
	 * @return the orientacion
	 */
	public Orientacion getOrientacion() {
		return orientacion;
	}
	/**
	 * @param orientacion the orientacion to set
	 */
	public void setOrientacion(Orientacion orientacion) {
		this.orientacion = orientacion;
	}
	/**
	 * @return the profesorPosible
	 */
	public Profesor getProfesorPosible() {
		return profesorPosible;
	}
	/**
	 * @param profesorPosible the profesorPosible to set
	 */
	public void setProfesorPosible(Profesor profesorPosible) {
		this.profesorPosible = profesorPosible;
	}
	public void setConfirmada(int c)
	{
		confirmada=c;
	}
	public int getConfirmada()
	{
		return confirmada;
	}
	/**
	 * @return the sucursal
	 */
	public Sucursal getSucursal() {
		return sucursal;
	}
	/**
	 * @param sucursal the sucursal to set
	 */
	public void setSucursal(Sucursal sucursal) {
		this.sucursal = sucursal;
	}
	/**
	 * @return the materia
	 */
	public String getMateria() {
		return materia;
	}
	/**
	 * @param materia the materia to set
	 */
	public void setMateria(String materia) {
		this.materia = materia;
	}
	/**
	 * @return the cual_examen
	 */
	public String getCual_examen() {
		return cual_examen;
	}
	/**
	 * @param cual_examen the cual_examen to set
	 */
	public void setCual_examen(String cual_examen) {
		this.cual_examen = cual_examen;
	}
	/**
	 * @return the otra_orientacion
	 */
	public String getOtra_orientacion() {
		return otra_orientacion;
	}
	/**
	 * @param otra_orientacion the otra_orientacion to set
	 */
	public void setOtra_orientacion(String otra_orientacion) {
		this.otra_orientacion = otra_orientacion;
	}
	@Override
	public boolean esValida() 
	{
		return true;
	}
}

Clase InscripcionGrupal: acá se observa que no hay validaciones, pero podríamos agregarlas.

@Entity
@DiscriminatorValue("Grupal")
@Audited
public class InscripcionGrupal extends Inscripcion
{
	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;
	@Column(name="lista_espera")
	protected Integer lista_espera;
	/**
	 * Constructor default: pongo algunos valores por defecto al generar una nueva inscripcion
	 * grupal.
	 */
	public InscripcionGrupal()
	{
		this.confirmada=0;
		this.lista_espera=0;
	}
	/**
	 * @return the lista_espera
	 */
	public int getLista_espera() 
	{
		return lista_espera;
	}
	/**
	 * @param lista_espera the lista_espera to set
	 */
	public void setLista_espera(Integer lista_espera) 
	{
		this.lista_espera = lista_espera;
	}
	@Override
	public boolean esValida() 
	{
		return true;
	}
}

Clases de validación

Una vez descripto el modelo de datos que se necesita, mostramos las clases que se necesitan para que Hibernate Validator funcione.

Aquí viene la clase ModeloFormInscripcionSequenceProvider, referenciada por la anotación de la clase ModeloFormInscripcion:

/**
 * Provee la secuencia de chequeos a realizar sobre un formulario de inscripcion.
 * Valida los campos basicos, y despues decide si agrega los chequeos sobre
 * inscripcion individual o grupal dependiendo del tipo de inscripcion.
 * @author daxcurson
 *
 */
public class ModeloFormInscripcionSequenceProvider implements DefaultGroupSequenceProvider<ModeloFormInscripcion>
{
	public List<Class<?>> getValidationGroups(ModeloFormInscripcion car) 
	{
		List<Class<?>> defaultGroupSequence = new ArrayList<Class<?>>();
		defaultGroupSequence.add(ModeloFormInscripcion.class);
		defaultGroupSequence.add(InscripcionChecks.class );

		if(car!=null)
		{
			Inscripcion i=car.getInscripcion();
			if(i.getClass().isAssignableFrom(InscripcionGrupal.class))
				defaultGroupSequence.add(InscripcionGrupalChecks.class);
			if(i.getClass().isAssignableFrom(InscripcionIndividual.class))
				defaultGroupSequence.add(InscripcionIndividualChecks.class);
		}
		return defaultGroupSequence;
	}

}

En esta clase se observa la secuencia de chequeos y cómo se elige. En la secuencia se agrega, en primer lugar, la clase que hay que validar. Luego se agregan los chequeos, dependiendo de la validación que se necesita.

El manual de Hibernate Validator dice, “Debido a que no puede haber una dependencia cíclica en el grupo y la definición de secuencia de grupos, uno no puede agregar Default a la secuencia que redefine lo que es Default para una clase. En vez de eso, se debe agregar la clase en sí”.

Ufff, qué nota difícil! Esto viene por el hecho de que existe una secuencia por defecto para cada clase para chequear las validaciones, que se hace en el momento, que se basa en las validaciones que la clase tiene y que no tiene en cuenta un orden particular para estas. Ahora, yo estoy redefiniendo lo que es por defecto con esta clase. No puedo decirle “hacé lo que es default” porque eso crearía una dependencia cíclica: Hibernate Validator invoca esta clase, que a su vez le diría que invoque a esta clase, que a su vez le indicaría que invoque esta clase…. etcétera. Para eso le tengo que decir por dónde comenzar específicamente, o sea, validá esta clase y a partir de ahí continuá con los grupos que se indican”.

A continuación ilustramos las interfases InscripcionChecks, InscripcionIndividualChecks e InscripcionGrupalChecks que sirven como indicaciones de secuencia.

package edumanage.model.validacion;

public interface InscripcionChecks {

}
package edumanage.model.validacion;

public interface InscripcionGrupalChecks {

}
package edumanage.model.validacion;

public interface InscripcionIndividualChecks {

}

Simples, ¿no? Esto es debido a que solamente sirven para indicar, no representan ninguna funcionalidad a implementar y por lo tanto no necesitan ningún método.

Controller

Bien, pero ¿cómo empieza a dispararse todo esto? Bastante simple. En el método que recibe el contenido del formulario, basta con poner una anotación Valid en los argumentos sobre el objeto del formulario, ModeloFormInscripcion. Si hay errores, estos ya fueron informados en el objeto result, de tipo BindingResult. Este objeto se puede recorrer para buscar los errores y escribirlos en el log, por si hace falta, pero simplemente si ese objeto tiene errores (results.hasErrors() devuelve true), se puede mandar derechito al formulario. Si el formulario tiene los campos apropiados, se pueden marcar específicamente los errores alredededor de cada campo que está mal.

@Controller
class InscripcionesController
{
[... otros metodos ...]
	@RequestMapping(value="/inscribir",method=RequestMethod.POST)
	public ModelAndView procesarFormInscripcion(
			@Valid
			@ModelAttribute("modelo_inscripcion") ModeloFormInscripcion modelo_inscripcion,
			BindingResult result,ModelMap model)
	{
		if(result.hasErrors())
		{
			List<ObjectError> lista_errores=result.getAllErrors();
			Iterator<ObjectError> i=lista_errores.iterator();
			while(i.hasNext())
			{
				log.trace("Error: "+i.next().toString());
			}
			ModelAndView modelo=this.cargarFormInscripcion(modelo_inscripcion);
			// Hay que poner un mensaje de error!
			
			model.addAttribute("message",this.getMessage("errores_inscripcion"));
			return modelo;
		}
		else
		{
			try
			{
				Persona persona=modelo_inscripcion.getPersona();
				Inscripcion inscripcion=modelo_inscripcion.getInscripcion();
				inscripcionService.grabar_inscripcion(inscripcion,persona);
				model.addAttribute("message",this.getMessage("inscripcion_recibida"));
				return new ModelAndView("inscripcion_index");
			}
			catch(Exception e)
			{
				log.error("Error: ",e);
			}
			return this.cargarFormInscripcion(modelo_inscripcion);
		}
	}

}

Proyecto completo

Para observar este sistema en acción, concurrir a este link para bajarlo de GitHub: Sistema Edu-Manage.

Espero que les haya gustado. ¡Saludos!