ScalaQuery: Un DSL para acceso a base de datos en Scala

La manera más básica o primitiva de interactuar con una base de datos en Java es por medio de JDBC. Esto por supuesto se puede hacer también en Scala, Groovy o cualquier otro lenguaje para la JVM, pero al usar JDBC se tiene que programar en estilo Java por la manera en que fue diseñado.

En Scala existe una alternativa interesante: ScalaQuery. Lo que esta biblioteca nos permite hacer es realizar queries con código Scala, con un margen de error mucho menor, ya que se aprovecha el tipado estático del lenguaje de una forma que incluso en Java no se hace con JDBC.

La mejor manera de ilustrar su uso es con un ejemplo sencillo. Supongamos que tenemos una tabla, definida así en SQL:

CREATE TABLE ejemplo(
  clave     SERIAL PRIMARY KEY, --autoincrementada
  nombre    VARCHAR(80) NOT NULL,
  apellidos VARCHAR(80) NULL,
  fecha_nac DATE NOT NULL,
  ultimo_acceso TIMESTAMP NULL,
  entero    INT NULL,
  saldo     NUMERIC(10,4) NOT NULL
)

Esta tabla tiene columnas con los tipos más comunes, algunas aceptan nulos, otras no. En ScalaQuery lo primero que hay que hacer para poder realizar operaciones sobre esta tabla, es definirla, como un objeto. Y tenemos que ponerle los tipos de cada columna. Si están familiarizados con Hibernate, verán que esto tiene cierta similitud, pero a la vez es muy distinto:

//El objeto usa generics
//Al final se pone el nombre externo de la tabla
object Ejemplo extends Table[(Int, String, String, Date, Option[Timestamp], Option[Int], Double)]("ejemplo") {
  //Cada columna va como un método que llama a column con sus opciones
  def clave        = column[Int]("clave", O PrimaryKey)
  def nombre       = column[String]("nombre")
  def apellidos    = column[String]("apellidos")
  def fechaNac     = column[Date]("fecha_nac")
  def ultimoAcceso = column[Option[Timestamp]]("ultimo_acceso")
  def entero       = column[Option[Int]]("entero")
  def saldo        = column[Double]("saldo")
  //Y al final tenemos que definir una proyección de todas las columnas
  def * = clave ~ nombre ~ apellidos ~ fechaNac ~ ultimoAcceso ~ entero ~ saldo
}

Algunos puntos importantes de esta definición:

  • Por la manera en que la clase Table está definida, el compilador arroja un error si el método * no incluye todas las columnas que se definieron en la declaración de la tabla (los tipos de la tupla al principio de la definición).
  • Del mismo modo, si definimos una columna con un tipo distinto que en la declaración de la tabla, la proyección de * no tendrá los mismos tipos y el compilador arroja una error.
  • Las columnas que aceptan nulos son de tipo Option[tipo] en vez de ser directamente del tipo de la columna. De esta manera se pueden manejar los valores nulos de manera más idiomática en el código.
  • La columna que es llave primaria lo tiene indicado con una opción, esto le sirve a ScalaQuery cuando queremos hacer un UPDATE.

Cabe mencionar que ScalaQuery no es un ORM, al menos no del tipo de Hibernate. Cuando hagamos un query, obtendremos tuplas; no podemos indicar que queremos instancias de cierta clase.

Queries compilados

Una vez que tenemos la definición de la tabla, es donde viene la parte tan útil de ScalaQuery: Las consultas que hagamos en código, si compilan, podemos estar seguros que funcionarán, siempre y cuando la definición de la tabla en Scala corresponda con la tabla en la base de datos. Esto es muy bueno porque si bien al principio podemos tardarnos más de lo normal en hacer una consulta, por la curva de aprendizaje, el beneficio a mediano y largo plazo es que tendremos menos problemas en tiempo de ejecución por tipos de datos mal mapeados (ClassCastException), manejo de nulos (NullPointerException), consultas desactualizadas, etc.

La consulta más simple, SELECT * FROM ejemplo, se puede hacer de varias formas en Scala:

val q1 = Query(Ejemplo)
val q2 = for { r <- Ejemplo } yield r.*
//y esto es SELECT count(*) FROM ejemplo
val c1 = Query(Ejemplo.count)

Hasta aquí, en q1, q2 y c1 tenemos los queries ya compilados como objetos listos para usarse, pero aún no se ejecuta nada. Podemos pedirles el SQL que van a generar, invocando selectStatement, pero para ejecutar uno de estos queries, necesitamos tener una sesión o una transacción abierta, y ejecutarlos ahí dentro. Para ello utilizamos un objeto Database, al cual le pedimos una sesión y le pasamos una función que recibe la sesión como parámetro y ejecutamos nuestras consultas. Esto es muy fácil con un closure. El Database lo podemos crear a partir de un DataSource de JDBC.

Hay varias maneras de ejecutar un query, dependiendo de lo que queremos hacer con los resultados.

val datasource = //puede venir de JDBC, incluso ser un pool de conexiones
val db = Database.forDataSource(datasource)
db withSession { session:Session =>
  //Aquí ya podemos ejecutar los queries
  println("Tabla tiene " + c1.first()(session) + " registros")
  //Así podemos imprimir cada tupla
  asterisco1.foreach { println(_) }(session)
}
//Y así podemos quedarnos con la lista para usarla después con la sesión ya cerrada
//el "db withSession" devuelve lo que devuelva el closure
val lista = db withSession { session:Session => asterisco1.list()(session) }

A fin de cuentas, estamos haciendo acceso a base de datos con programación funcional.

Queries parametrizados

Al no estar escribiendo SQL y concatenándolo a mano, nos evitamos problemas como inyecciones de SQL. Las consultas con condiciones se definen de manera idiomática con el for de Scala, donde podemos poner al final las columnas que queremos devolver:

val qp = for { rowId <- Parameters[Int]
  r <- Ejemplo if r.clave === rowId
} yield r.nombre ~ r.apellidos ~ r.fechaNac

Nótese que estamos usando un operador especial ===, en vez de simplemente ==. Este operador está definido en las columnas de la tabla, para definir la equivalencia pero no realizarla en el momento en que se define sino hasta después cuando se ejecute el query. Lo anterior sería equivalente a usar un PreparedStatement con el SQL SELECT nombre, apellidos, fecha_nac FROM ejemplo WHERE clave=?. Como estamos buscando sobre la llave primaria, sabemos que podemos obtener un solo registro, como máximo; puede que no haya registro con la clave solicitada. Por lo tanto lo mejor será usar el método firstOption del query; hay un first pero si no se encuentra al menos un registro, arroja una excepción, mientras que firstOption devuelve un Option con la tupla si la encuentra:

db withSession { session =>
  qp.firstOption(100)(session) match {
    case Some((nombre, apellidos, nacimiento)) =>
      //Código que se ejecuta si se encuentra la tupla
    case None =>
      //Código que se ejecuta si no se encuentra la tupla
  }
}

Así que podemos utilizar el pattern matching de Scala de manera normal.

Modificar datos

ScalaQuery no solamente sirve para hacer consultas. Los datos que se obtienen también se pueden actualizar, utilizando el mismo query. Si quisiéramos convertir a mayúsculas los datos de la tupla que encontramos con este último query, podemos poner este código dentro del caso donde sí la encontramos (en el Some):

  qp.mutate(100){
    _.row = (nombre.toUpperCase, apellidos.toUpperCase, nacimiento)
  }(session)

Para las inserciones, se invoca directamente el objeto que representa la tabla, pasando los datos a insertar. Hay una variante que permite insertar varias tuplas al mismo tiempo, usando el batch insert de JDBC.

Ejemplo.insert(100, "Enrique", "Zamudio", new Date(), None, Some(5), 5.4)
//O para insertar varios
Ejemplo.insertAll(
  (501, "Juan", "Pérez", new Date(), new Timestamp(), None, 1.2),
  (502, "Pablo", "González", new Date(), None, Some(1), 3.5),
  (5034, "Pedro", "Perez", new Date(), new Timestamp(), Some(2), 4.5)
)

Transacciones

Para cuando se requieren hacer varias operaciones en una sola transacción de base de datos, simplemente hay que utilizar withTransaction en vez de withSession:

db withTransaction { session =>
  //Todo lo que se haga aquí será ejecutado dentro de una transacción de base de datos
  //Si este código arroja una excepción, se da rollback automáticamente.
  //También se puede dar rollback manual
  session.rollback()
}

//También se pueden manejar transacciones dentro de sesiones abiertas
db withSession { session =>
  //El código que se ejecuta aquí no está en una transacción
  session.withTransaction {
    //Esto va dentro de una transacción
  }
  //Esto tampoco se ejecuta en transacción
  //ya se hizo commit para cuando se ejecuta esto
}

Funcionalidad adicional

Esto es apenas una probadita de lo que puede hacer ScalaQuery. Además de todo esto, hay sintaxis para joins, para crear las tablas directamente desde ScalaQuery (para lo cual hay que definir varias cosas adicionales en cada columna, como su longitud, default, etc), etc.

Esta biblioteca sigue evolucionando; al momento de escribir esto, la versión es 0.9.5. El mayor problema que he tenido para utilizarla es que la documentación es algo escueta y no está muy actualizada, por ejemplo el API publicado es para la versión 0.9.4, no hay de la 0.9.5; las guías de uso llevan bastante tiempo sin actualizarse y algunas cosas ya no son como dice la documentación porque ha habido cambios de sintaxis y cosas así.

En fin, esta es una opción más para manejar el acceso a bases de datos en Scala. Otra opción es Squeryl, que tiene una filosofía similar de aprovechar el tipado estático para construir queries que se validan mayormente en la fase de compilación, sin embargo parece ser un ORM más en forma, lo cual tiene sus propias ventajas y desventajas.

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de Shadonwk

Genial Ezamudio, este tipo de

Genial Ezamudio, este tipo de lecturas hacen que uno se interese cada vez mas en el lenguaje..

Gracias por el tiempo.

Imagen de bferro

Buen post

Buen post Enrique. Sería bueno el mismo ejemplo con Squeryl y entonces que le entremos a una comparación de ambos.

Tengo una duda

Una vez leyendo tweets de @ezamudio y de @domix decían que ScalaQuery tenía un límite en selección de columnas si no mal recuerdo, que porqué eso era cuento con las tuplas de Scala. Quiero saber si ese límite aún existe y es por Scala o si ya no existe y lo corrigieron (ya sea los desarrolladores de Scala o de ScalaQuery). Hay veces que uno ve tablas enormes (que uno no puede rediseñar) y toca hacer "Gigaselects" de varias columnas.

Imagen de ezamudio

Sigue

bferro: Gracias... espero tener tiempo algún día para probar Squeryl, pero les puedo asegurar que no será pronto.

wishmaster: El límite sigue, y pues es cosa de la combinación Scala y ScalaQuery. Scala ofrece 22 clases llamadas Tuple1 a Tuple22, que son colecciones heterogéneas de datos. Simplemente son contenedores de distintos tipos, similares a una tupla de base de datos: Donde veas algo así: Tuple3[Int, String, Date], puedes esperar (o crear y pasar) tuplas con un entero, una cadena y una fecha. Dado que Scala es de tipado estático, hay que definir los tipos que la tupla maneja, y pues para no hacer demasiadas clases, decidieron dejarlo en 22, por lo que puedes tener de 1 a 22 elementos en una tupla. Hay azúcar sintáctica para crear tuplas al vuelo poniendo los valores entre paréntesis, y eso junto con el pattern matching de Scala te permite hacer cosas interesantes. Es por ello que el creador de ScalaQuery decidió usar simplemente tuplas en vez de que tengas que crear tu propia clase y volverlo un ORM más formal, como Squeryl.

Pero entonces... ScalaQuery mapea tuplas de base de datos a tuplas de Scala, y las tuplas de Scala solamente pueden contener hasta 22 elementos. Por lo tanto, con ScalaQuery sólo puedes manejar hasta 22 columnas en tus definiciones de tablas. Mientras no agreguen tuplas con más columnas a Scala, y ScalaQuery siga usando tuplas de Scala, esta limitante no cambiará.

Imagen de bferro

Puedes crear tuplas de más de 22 elementos

Nada de impide crear clases para n tuplas donde n >22.

Imagen de ezamudio

Tendría que ser SQ

Pero tendrían que ser parte de ScalaQuery para que se puedan usar ahí, no?