viernes, 28 de octubre de 2011

Creando Hibernate Criteria en Grails

   La situación es la siguiente: Tenemos una aplicación grails en la que tenemos que buscar por nombre de usuario. La primera aproximación sería utilizar ilike para buscar en los campos nombre y apellidos y todo funcionaría perfectamente hasta que llegamos a los nombres con tildes. ¿Qué ocurre si el usuario está dado de alta como Iván y nosotros introducimos Ivan, pues que sencillamente no aparecerá.
   Hay varias formas de afrontar el problema incluyendo el full text search con plugins como searchable o elasticsearch, pero si no queremos complicarnos en configurarlos o nuestra situación no requiere de búsquedas complejas, podemos usar otra solución.
   En mi caso estoy utilizando PostgreSQL como base de datos en su versión 9.0. A partir de esta versión se incluye por defecto (sólo es necesario instalarla) la función unaccent que elimina todas las tildes de un campo. Así, la consulta que contruiríamos a mano sería algo como:
select * from user
where unaccent("name") ilike unaccent('%iván%')
Y devolvería cualquier usuario que se llamase: ivan, Iván, iván, IVÁN,...   Todo esto está muy bien, pero que ocurre si ya tenemos el siguiente criteria de grails:
def users = User.createCriteria().list() {
    ilike('name', '%' + value + '%')
}
¿Cómo añadimos esa llamada a la función unaccent?. Vamos a crear nuestro propio Hibernate Criteria.
Editamos el archivo BootStrap.groovy y añadimos lo siguiente:
HibernateCriteriaBuilder.metaClass.unaccent = { String propertyName, Object propertyValue ->

 if (!validateSimpleExpression()) {
  throwRuntimeException(new IllegalArgumentException("Call to [unaccent] with propertyName [" +
    propertyName + "] and other property name [" + otherPropertyName + "] not allowed here."));
 }

 propertyName = calculatePropertyName(propertyName);
 propertyValue = calculatePropertyValue(propertyValue);

 def query = "unaccent(\"${propertyName}\") ilike unaccent('%${propertyValue}%')"
 return addToCriteria(Restrictions.sqlRestriction(query));

 def query = "unaccent(\"${propertyName}\") ilike unaccent(?)"
 def value = "%${propertyValue}%"
 return addToCriteria(Restrictions.sqlRestriction(query.toString(), value.toString(), Hibernate.STRING));
}
[ACTUALIZACIÓN]: He cambiado la forma de generar la consulta para que evitar posibles ataques por inyección de sql.

Lo que estamos haciendo es inyectar al HibernateCriteriaBuilder un método llamado unaccent que recibe como parámetros un string con el nombre de la propiedad y un objeto con el valor que queremos comparar.
Con esto podemos reescribir la consulta anterior de la siguiente manera:
def users = User.createCriteria().list() {
    // Old method
    //ilike('name', '%' + name + '%')
    unaccent('name', value)
}
Si ejecutamos el criteria vemos que la consulta es la siguiente:
select
        this_.id as id19_0_,
        this_.name as name19_0_
     from
        user this_
     where
        unaccent("name") ilike unaccent('%iván%')
Que efectivamente devuelve los mismos registros de antes :-)
   Todo esto se puede mejorar puesto que en función del número de registros que esperemos tener en la tabla, aplicar la función unaccent a la columna obliga a la base de datos a hacer un full scan en toda la tabla. Podríamos crear un índice, usar un campo paralelo para realizar las búsquedas que se mantenga automáticamente con un trigger, controlar este campo desde la aplicación grails con los métodos afterSave() y afterUpdate() de la clase de dominio User,... en fin, unas cuantas alternativas.