domingo, 4 de noviembre de 2012

Trampas y trucos de GORM - Parte 2

En la primera parte hemos visto una introducción a algunas de las particularidades relacionadas con la persistencia de instancias con GORM. Ahora vamos a atacar a la forma en que se manejan las relaciones, con especial énfasis en hasMany y belongsTo

Gorm tiene, de base, muy pocos elementos para definir las relaciones entre clases de dominio, pero son suficientes para satisfacer la mayoria de las necesidades. Cuando doy (N.del.T: Peter Ledbrook, obviamente :-)) cursos sobre Grails siempre me sorprende el pequeño número de diapositivas (slides) dedicado a las relaciones. Como puedes imaginar, esta aparente simplicidad esconde algunas trampas que pueden pillar desprevenidos a los mas inocentes.

Comencemos con la relación mas básica, la de muchos-a-uno.

Relación de muchos-a-uno


Imagina que tenemos las siguientes clases de dominio:

class Lugar {
  String ciudad
}

class Autor {
  String nombre
  Lugar lugar
}

Si vemos la clase Autor , ya podemos ir suponiendo que la clase Libro no puede estar muy lejos. Es cierto, tendremos una clase Libro mas adelante, pero de momento vamos a concentrarnos solo en las dos clases de dominio que tenemos arriba y la relacion muchos-a-uno de Lugar

Parece sencillo, ¿verdad?. Y lo es. Simplemente asigna la propiedad lugar a una instancia de la clase Lugar y ya tienes enlazado un autor a un lugar. Pero veamos lo que pasa cuando ejecutamos el siguiente código en la consola Grails:

def a = new Autor(nombre: "Haruki Murakami", lugar: new Lugar(ciudad: "Tokio"))
a.save()


Nos salta una excepcion. Si buscas la causa última del porqué ha saltado esta excepcion, veras el mensaje: "not-null property references a null or transient value: Autor.lugar". ¿Que ha pasado?

Supongo que unas palabras sobre "transient value" (valor transitorio) serán bienvenidas en este punto. Una instancia transitoria es aquella que no esta asociada a una sesion Hibernate. Como puedes ver en el código de arriba, estamos asignando  Autor.lugar un valor recien creado, no uno recuperado desde la base de datos. Por eso, esa instancia es transitoria. El arreglo mas evidente es hacer la instancia de Lugar persistente guardándola:



def l = new Lugar(ciudad: "Tokio")
l.save()

def a = new Autor(nombre: "Haruki Murakami", lugar: l)
a.save()


Pero, si tenemos que persistir las instancias antes de poder usarlas de esta forma, ¿como puede ser que hayas visto tantos ejemplos muy parecidos a nuestro primer código, donde hemos creado sobre la marcha una nueva instancia de Lugar?. La respuesta es que en este tipo de situaciones, normalmente encontrarás una propiedad belongsTo en alguna de las clases de dominio:

Actualizaciones en cascada con belongsTo

Cuando vayas a tratar con relaciones en Hibernate, necesitas tener un buen conocimiento de lo que significan las operaciones en cascada. Y esto se aplica tambien a GORM.  Estas operaciones determinan que tipo de acciones, cuando se aplican a una instancia de clase de dominio, tambien se propagan a las relaciones de esa instancia. Por ejemplo, en el modelo anterior, ¿se guarda el Lugar del autor cuando se guarda el autor?. ¿Se borra el lugar cuando borramos al autor?. ¿Que pasa si borramos el lugar? ¿Se borra tambien el autor relacionado?

El guardado y borrado son las dos acciones conectadas en cascada mas comunes, y en realidad son las únicas que necesitas comprender. Si vuelves atras a la seccion anterior te darás cuenta de que la instancia de Lugar no se guarda con autor porque la operación en cascada no esta funcionando en la relacion de Autor hacia Lugar.  Si cambiamos la clase Lugar a:

class Lugar {
    String ciudad
 
    static belongsTo = Autor 
}

encontramos que la excepción desaparece y que la instancia de Lugar es guardada junto con la de autor. La linea belongsTo asegura este guardado en cascada de Autor a Lugar. Tal como indica la documentación, esta propagación en cascada funciona para los borrados tambien, así que si borras un autor, su lugar asociado tambien se borra. Pero no a la inversa: guardar o borrar un lugar no guarda o borra al autor.

¿Cual belongsTo?


Un tema que a menudo confunde a la gente inicialmente es que belongsTo admite dos sintaxis diferentes. La forma que hemos usado arriba solamente define la cascada entre dos clases, mientras que la alternativa tambien añade la correspondecia de vuelta (backreference), convirtiendo la relacion en bidirecional:

class Lugar {
    String ciudad
 
    static belongsTo = [ autor: Autor ]
}



En este caso, la propiedad autor se añade a Lugar al mismo tiempo que se define la relacion de cascada. Una ventaja añadida es que con esta notación puedes tambien definir multples relaciones de cascada. Un detalle que puedes notar si usas esta última sintaxis es que cuando guardas un nuevo Autor con su Lugar, Grails automaticamente asigna la propiedad de Lugar del Autor a la instacia de Autor. En otras palabras, la referencia (backreference) es inicializada sin que tengas que hacerlo explicitamente.

Antes de seguir adelante con colecciones, me gustaria comentar una última cosa sobre la relacion de muchos-a-uno. A veces se piensa que añadiendo la referencia inversa (backreference) tal como hemos hecho convierte la relacion en uno-a-uno. Lo cierto es que técnicamente no lo es a no ser que añadamos una  constraint  de unicidad en uno de los extremos de la relación. Por ejemplo:

class Autor {
    String nombre
    Lugar lugar
 
    static constraints = {
        lugar(unique: true)
    }
}


Por supuesto, la relación de arriba no tiene ningun sentido en el caso de la relación entre un Autor y un Lugar en este caso concreto, pero creo que sirve para entender como como se define una relación de uno-a-uno.

La relación de muchos-a-uno es bastante directa una vez comprendes como funciona belongsTo. Por otro lado, las relaciones que involucran colecciones, pueden darte algunas sorpresas desagradables si no estas acostumbrado a Hibernate.

Colecciones (uno-a-muchos / muchos-a-muchos)

Las colecciones son la forma natural de modelar las relaciones de uno-a-muchos en un lenguaje orientado a objetos, y GORM hace que su uso sea realmente sencillo teniendo en cuenta lo que pasa detras de las cámaras. En cualquier caso, no cabe duda de que es una de las areas donde las diferencias entre un lenguaje orientado a objetos y las bases de datos relaciones muestras su peor cara. Para empezar, tienes que recordar que los datos que ves en la memoria pueden ser diferentes de los que hay fisicamente en la base de datos.

Colecciones de objetos de dominio contra registros en Base de Datos


Cuando tienes una coleccion de objetos de una clase de dominio estas tratando con objetos en memoria.  Y esto quiere decir que puedes manejarlos de la misma forma que cualquier otra coleccion de objetos. Puedes iterarlos y puedes modificarlos. En algún momento probablemente querras persistirlos a base de datos, lo que puedes hacer salvando los objetos de la coleccion. Volveremos a esto pronto, pero antes déjame mostrarte alguna de las sutilezas asociadas con esta desconexión que existe entre tu coleccion de objetos y los datos reales. Para hacerlo, vamos a introducir la clase Libro:


class Libro {
    String titulo
 
    static constraints = {
        titulo(blank: false)
    }
}
 
class Autor {
    String nombre
    Lugar lugar
 
    static hasMany = [ libros: Libro ]
}

Estamos creando una relación unidireccional (Libro no tiene una referecia (backreference) a Autor) de uno-a-muchos, donde un autor tiene cero o mas libros. Ahora supongamos que ejecutamos el siguiente código en la consola Grails (maravillosa herrerramienta para experimentar con GORM):


def a = new Autor(nombre: "Haruki Murakami", lugar: new Lugar(ciudad: "Tokio"))
a.save(flush: true)
 
a.addToLibros(titulo: "Tokio Blues")
a.addToLibros(titulo: "Sputnik mi amor")
 
println a.libros*.titulo
println Libro.list()*.titulo


La salida probablemente será algo parecido a:

[Sputnik mi amor, Tokio Blues]
[]

Vaya. Parece que podemos pintar la coleccion de libros pero no están aún en la base de datos. Incluso podemos incluir a.save() despues del segundo a.addToLibros() sin que tenga efecto aparentemente. ¿Recuerdas en el artículo anterior que decíamos que llamar a save()no garantizaba la persistencia inmediata de los datos?. Aquí tenemos un ejemplo de ello. Si quieres ver los libros en tu consulta tendras que forzar la escritura con un flush explícito:


[...]
 
a.addToLibros(titulo: "Tokio Blues")
a.addToLibros(titulo: "Sputnik mi amor")
a.save(flush: true)          // <---- Añadimos esta linea

println a.libros*.titulo
println Libro.list()*.titulo




Ahora si que podemos ver los dos libros, aunque no necesariamente en el mismo orden. Otro síntoma mas de la discrepancia entre los datos en memoria y los datos de la base de datos se puede ver si cambias las sentencias println con:

println a.books*.id

Incluso despues de hacer save() (sin un flush explicito), esto pintará nulls. Sólamente cuando se haga la escritura (flush) de la sesión los hijos en esta relación tendrán los IDs asignados. Este comportamiento es bastante diferente del caso muchos-a-uno que habíamos visto antes, donde no necesitábamos un flush explícito del objeto de la clase Lugar para que persistiera en la base de datos. Es importante que veas la diferencia o te encontrarás con problemas.

Por cierto, como comentario en el caso de que estes siguiendo los ejemplos con la console Grails: date cuenta de que cualquier cosa que salves cuando ejecutas un script en la consola estará aún ahi cuando ejecutes el siguiente script. Los datos solamente se limpiarán cuando reinicies la consola. Tambien, debes saber que la sesion siempre se escribe (flush) cuando finaliza el script.

Vale, volvemos a las colecciones. Los ejemplos anteriores han mostrado un comportamiento interesante del que quiero hablar a continuación. ¿Porque las instancias de Libro han persistido en la base de datos aun cuando no hemos definido un belongsTo en Libro?

Cascada

Como con las otras relaciones, dominar las colecciones significa dominar su comportamiento en cascada. La primera cosa a notar es que la forma de guardar es siempre de padres a hijos, incluso si no se ha especificado la cláusula belongsTo. Asi pues, ¿hay motivo para usar belongsTo?.  Si

Mira los que sucede si ejecutamos este código en la consola despues de que hayamos añadido al autor y sus libros:

def a = Autor.get(1)
a.delete(flush: true)
 
println Autor.list()*.nombre
println Libro.list()*.titulo


La salida será algo como:

[]
[Sputnik mi amor, Tokio Blues]

Asi que tenemos que el Autor ha sido borrado, pero sus libros no. Aqui es donde entra en juego belongsTo: asegurando que los borrados en cascada tambien se reflejen. Simplemente añadiendo una linea  static belongsTo = Autor el código de arriba pintará dos listas vacias para Autor y Libro. Simple, ¿no?.  Vale, en este caso si, pero la diversion no ha hecho mas que comenzar.  Por cierto, ¿te has dado cuenta de como hemos forzado el flush de la sesion en el ejemplo anterior?. Si no lo hubieramos hecho, la orden Autor.list() probablemente habria mostrado el autor que acabábamos de borrar, porque el cambio aún no habia sido persistido en ese punto.

Borrando a los hijos

Borrar algo como la instancia de Autor y dejar a GORM que borre a los hijos automáticamente hemos visto que es directo e inmediato. Pero ¿que pasa si queremos borrar un o mas libros pero no al propio autor?. Podríamos hacer algo como lo siguiente:

def a = Autor.get(1)
a.libros*.delete()


pensando que de esta forma borraremos todos los libros. Pero si lo haces veras que ese código genera una excepción:

org.springframework.dao.InvalidDataAccessApiUsageException: deleted object would be re-saved by cascade (remove deleted object from associations): [Libro#1]; ...
 at org.springframework.orm.hibernate3.SessionFactoryUtils.convertHibernateAccessException(SessionFactoryUtils.java:657)
 at org.springframework.orm.hibernate3.HibernateAccessor.convertHibernateAccessException(HibernateAccessor.java:412)
 at org.springframework.orm.hibernate3.HibernateTemplate.doExecute(HibernateTemplate.java:411)
 at org.springframework.orm.hibernate3.HibernateTemplate.executeWithNativeSession(HibernateTemplate.java:374)
 at org.springframework.orm.hibernate3.HibernateTemplate.flush(HibernateTemplate.java:881)
 at ConsoleScript7.run(ConsoleScript7:3)
Caused by: org.hibernate.ObjectDeletedException: deleted object would be re-saved by cascade (remove deleted object from associations): [Libro#1]



¡Hey!. ¡Mira que traza tan util!. Efectivamente: el problema es que los libros están aún en memoria en la coleccion del autor, asi que cuando finalice la sesion y los cambios se guarden, volverá a ser creada en base de datos. Recuerda: no solo hay guardados en cascada, sino que tambien las instancias de las clases de dominio se guardan automáticamente a causa del dirty-checking que veíamos en la anotación anterior.

La solución, tal como indica el mensade la excepción, pasa por borrar los libros de la colección:


def a = Autor.get(1)
a.libros.clear()


... aunque en realidad esto tampoco es la solución, porque los libros siguen en la base de datos y simplemente los hemos disociado del autor. Vale, parece que tendremos que borrarlos explicitamente también:


def a = Autor.get(1)
a.libros.each { libro ->
    a.removeFromLibros(libro)
    libro.delete()
}


Oops, ahora tenemos un error de tipo ConcurrentModificationException porque estamos borrando libros de la coleccion del autor mientras estamos iterando sobre esa misma coleccion. Una trampa del propio Java estándar. Podemos solucionarlo creando una copia de la coleccion:


def a = Autor.get(1)
def l = []
l += a.libros
 
l.each { libro ->
    a.removeFromLibros(libro)
    libro.delete()
}


Esto funciona, pero requiere un poco mas de esfuerzo por nuestra parte.

Tambien tenemos que tener cuidado si tenemos una relacion bidireccional. Por ejemplo, si nuestro belongsTo usa la sintáxis: static belongsTo = [ autor: Autor ]. Si eliminamos los libros de la coleccion sin borrarlos realmente, como por ejemplo:

def a = Autor.get(1)
def l = []
l += a.libros
 
l.each { libro ->
    a.removeFromLibros(libro)
}


tendremos un error parecido a esto: "not-null property references a null or transient value: Libro.autor" .  Como veremos ahora, esto sucede porque el libro se ha quedado con la propiedad autor asignada a null, y como resulta que esa propiedad no puede ser nullable salta ese error. Esto es como para volverse loco.  :-)

Pero no temas, que hay solución. Si añadimos esta cláusula mapping en Autor:

static mapping = {
    libros cascade: "all-delete-orphan"
}


entoces cualquier libro que se separe de su autor será automáticamente borrado por GORM. El trozo de código anterior, donde quitábamos todos los libros de la coleccion, ahora funcionará. Y en realidad, si la relacion es unidireccion,  puedes reducir tu código sustancialmente:

def a = Autor.get(1)
a.libros.clear()


Esto quitará todos los libros y los borrará de un plumazo.

La moraleja de esta historia es simple: si usas belongsTo en una coleccion, asigna explicitamente el tipo de cascada a "all-delete-orphan" en el bloque de mappings del padre. De hecho, bien podria ser este el comportamiento por defecto para belongsTo y las relaciones de uno-a-mucho en GORM.

Esto nos lleva a una pregunta interesante. ¿porqué el método clear() funciona en una relación bidireccional?. No estoy 100% seguro, pero creo que es porque los libros mantienen una referencia (backreference) al autor. Para comprender porque esto afectaria al comportamiento de clear(), primero debes darte cuenta de que GORM crea en base de datos estructuras diferentes para relaciones unidireccionales o bidireccionales de uno-a-muchos. Para las relaciones unidireccionales, GORM crea una tabla de unión intermedia, asi que cueando limpias la coleccion de libros, simplemente se borran de esta tabla de unión. Las relaciones bidireccionales se enlazan usando directamente la clave externa (la foreign key) en la tabla hija, como la tabla de libros en nuestro ejemplo. Tal vez con un diagrama quede mas claro:




Cuando limpias la coleccion de libros, la clave externa (foreign keu) estará todavia ahí porque GORM no hay borrado el valor de la propiedad autor. Como si la colecion nunca se hubiera vaciado.

Y esto es práticamente todo sobre colecciones. Para terminar de atar completamente esta seccion me gustaria echarle un vistazo rápido a los métodos addTo*() y removeFrom*()

addTo*() contra <<


En los ejemplos anteriores, hemos usado los métodos dinámicos addTo*() y removeFrom*() que provée GORM. ¿Porqué?. Despues de todo, si hemos dicho que son colecciones estándar de Java, podríamos haber usado un código como este:


def a = Autor.get(1)
a.libros << new Libro(title: "Tokio Blues")


Probablemente si, pero hay algunos beneficios si usamos los métodos de GORM. Echa un vistazo a este código:


def a = new Autor(nombre: "Haruki Murakami", lugar: new Lugar(ciudad: "Tokio"))
a.libros << new Libro(title: "Tokio Blues")
a.save()


No parece haber nada erróneo, ¿verdad?. Pero si ejecutas el código veras que tienes una NullPointerException porque la coleccion de libros no ha sido inicializada. Este comportamiento es diferente al que ves cuando recuperas al autor desde la base de datos, por ejemplo usando get(). En ese caso podemos añadir alegremente libros a la coleccion. Sólo tenemos este problemas porque estamos creando el autor con new(). Si utilizas el método addTo*() no tienes que preocuparte de esto porque esta forma es null-safe  (N. del T.: no se muy bien como traducir null-safe, pero supongo que se entiende que quiere decir que no tienes porqué tener cuidado de que los valores sean nulos ya que el propio método realiza la validación)

Ahora miremos al ejemplo en el que recuperamos el autor usando get() antes de añadir un nuevo libro a la coleccion. Si la relación es bidireccional, nos encontraremos un error "property not-null or transient" porque la propiedad autor en la instancia de libro no ha sido asignada. Si usas los métodos estándar de una coleccion tendras que inicializar las referencias manualmente, pero con el método addTo*() GORM lo hace por ti.

La última característica del método addTo*() es la creación implícita de la clase de dominio correcta. ¿Te has dado cuenta de que en nuestros ejemplos simplemente hemos pasado los valores de inicialización de libro al método en lugar de instanciar explícitamente la clase Libro? Esto es porque el método es capaz de inferir desde la propiedad hasMany que tipo de coleccion contiene. Mola, ¿verdad?

El método removeFrom*() es menos util, pero al menos borra las back references. Por supuesto, esto funciona mejor con la opcion de cascada "all-delete-orphan" tal como hemos visto antes.

El último tipo de relación que veremos es la de muchos-a-muchos.

Relación de muchos-a-muchos

Si quieres, GORM puede manejar tambien las relaciones de muchos-a-muchos, pero hay unas cuantas cosas con la que tienes que tener cuidado:


  • Los borrados no se hacen en cascada. Punto. 
  • Un lado de la relación tiene que tener un belongsTo , aunque normalmente no importa en que lado esté
  • el belongsTo solo afecta afecta la direccion en la que se hace la propagación en cascada al salvar los datos -puesto que no lo hace con los borrados-
  • Siempre se usa una tabla intermedia para la unión, y no puedes añadir mas información en ella. 
Siento ser tan firme sobre los borrados, pero es importante comprender que el comportamiento es bastante diferente de las relaciones muchos-a-uno o uno-a-muchos. También es importante comprender el último punto: un gran número de relaciones muchos-a-muchos tienen mas información asociada. Por ejemplo, un usuario puede tener muchos roles y un rol puede tener muchos usuarios. Pero el usuario puede tener diferentes roles dependiendo de un proyecto, asi que el proyecto está asociada con la propia relación. En estos casos es mejor que manejes la relación tu mismo. 

Resumen


Bueno, probablemente este es el artículo mas largo que he escrito hasta ahora (N.del T.: tambien yo :-)), pero hemos llegado al final. Felicidades. No te preocupes si no has podido digerir todo de una sentada, siempre puedes volver aqui como referencia.

Creo que GORM hace un gran trabajo de abstraccion tratando las relaciones de bases de datos de una forma orientada a objetos, pero como hemos visto no puedes olvidar que al final (normalmente) estas tratando con una base de datos relacional. Armado con la información de esta anotación, pienso que no deberias tener excesivos problemas con lo básico para manejarte con las colecciones.

Puede que no me creas, pero aun no hemos cubierto todo lo que necesitas saber sobre colecciones. Todavia hay algunos puntos interesantes sobre la carga-perezosa, pero eso lo dejamos para el siguiente artículo.




Ultimos enlaces compartidos

Aerotrastornados - Blog Aeronáutico