dimanche 26 juin 2011

Scala: persistance avec MyBatis

Mon exploration de la persistance avec Scala m'amène aujourd'hui vers MyBatis (anciennement iBatis).

Le projet est le même que dans l'article consacré à OrBroker. Aussi, la table et son initialisation sont reprises de ce projet.

J'ai juste légèrement modifié la classe entité où mon id est passé de Option[Long] à Option[Int].

Là voici
package entities

class Book (var id:Option[Int], var title:String, var author:String, val isbn:String){
     def this(title:String, author:String, isbn:String) = this(None,title,author,isbn)
     override def toString = title+" de "+author+ " (ISBN: "+isbn+", id: "+id+")"
}

Pour la justification du Option, voir l'article précédent.


Problèmes

L'utilisation d'un Option[Int] pour l'id nécessite un travail supplémentaire. A noter que ce travail aurait de toute façon été nécessaire si l'id avec été un Int (Scala). Par défaut, MyBatis aurait voulu un int (Java) ou un java.lang.Integer.

Je décide d'utiliser un TypeHandler pour transformer mon int en Option[Int].

Le TypeHandler

package typehandlers

import org.apache.ibatis.`type`.{TypeHandler,JdbcType}
import java.sql._

import entities._

class OptionalIntTypeHandler extends TypeHandler{
    override def setParameter (ps : PreparedStatement,i : Int, parameter : Object, jdbcType : JdbcType) : Unit = {
        parameter match {
            case Some(j:Int) => ps.setInt(i,  j)
            case _ => ps.setNull(i,Types.INTEGER)
        }
    }

    override def getResult (rs : ResultSet, columnName : String) : Object = {
        Some(rs.getInt(columnName)).asInstanceOf[Object]
    }

    override def getResult (cs : CallableStatement, columnIndex : Int) : Object = {
        Some(cs.getInt(columnIndex)).asInstanceOf[Object]
    }
}

Mise en place

Il s'agit d'un projet sbt (voir cet article pour la configuration).

Le fichier build est le suivant:

import sbt._
import de.element34.sbteclipsify._

class IBatisBuild(info: ProjectInfo) extends DefaultProject(info) with Eclipsify{
    val ibatis = "org.mybatis" % "mybatis" % "3.0.1"
    val postgres = "postgresql" % "postgresql" % "8.4-701.jdbc4"
}

Fichiers de configurations

Configuration de MyBatis

Dans src/main/resources/ibatis, un fichier SqlMapConfig.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
    PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-config.dtd">  
<configuration>
   <typeHandlers>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.Some"/>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.Option"/>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.None"/>
   </typeHandlers>
   <environments default="development">
       <environment id="development">
           <transactionManager type="JDBC"/>
           <dataSource type="POOLED">
               <property name="driver" value="org.postgresql.Driver"/>
               <property name="url" value="jdbc:postgresql://localhost:5432/formation"/>
               <property name="username" value="formation"/>
               <property name="password" value="formation"/>
           </dataSource>
       </environment>
    </environments>
    <mappers>
       <mapper resource="ibatis/BOOK_SqlMap.xml"/>
    </mappers>
</configuration>

Difficultés rencontrées
La configuration du typehandler m'a causé quelques soucis. Ce qui me semblait logique, c'était de le configurer sur le type sclala.Option et qu'il serait utilisé si le type de la variable était Option (ce qui est effectivement le cas), mais aussi pour les types dérivés (scala.Some). Et ça, apparemment, ce n'est pas le cas.

J'ai donc dû ajouter une ligne pour "scala.Some" et tant que j'y étais (même si ce n'était pas nécessaire ici) pour "scala.None".

Configuration du mapping

Dans src/main/resources/ibatis, un fichier BOOK_SqlMap.xml:

<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">  
<mapper namespace="be.fabrice.BookMapper">
    <resultMap id="bookResult" type="entities.Book">
       <constructor>
           <idArg column="id" javaType="scala.Option"/>
           <arg column="title" javaType="String"/>
           <arg column="author" javaType="String"/>
           <arg column="isbn" javaType="String"/>
       </constructor>
    </resultMap>

    <select id="findBook" parameterType="int" resultMap="bookResult">
       SELECT id, title, author, isbn FROM book WHERE id = #{value}
    </select>

    <select id="findAll" resultMap="bookResult">
       SELECT id, title, author, isbn FROM book
    </select>

    <insert id="insertBook" parameterType="entities.Book" keyProperty="id" useGeneratedKeys="true" >
       insert into book (title,author,isbn) values (#{title},#{author},#{isbn});
    </insert>

    <update id="updateBook" parameterType="entities.Book">
         update book SET title=#{title},author=#{author} WHERE id = #{id};
    </update>
     
    <delete id="deleteBook" parameterType="entities.Book">
         delete from book where id=#{id}
    </delete>
</mapper>

BookDao.scala

Je ne suis pas certain que le pattern est bien exploité. La construction du sqlMap devrait par exemple être unique au niveau de tous les Dao à venir. Néanmoins, c'est un point de départ.

Pas d’interface non plus, juste un objet pour définir un singleton. Pas très propre comme programmation, mais pleinement assumé :-)

La classe est créée dans un sous-répertoire “dao”:

package dao

import org.apache.ibatis.session._
import org.apache.ibatis.io._

import entities.Book

import scala.collection.JavaConversions._

object BookDao {
    val sqlMap = new  SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("ibatis/SqlMapConfig.xml"))

    def find(id:Int):Book =  {
        val session = sqlMap.openSession()
        try{
            session.selectOne("be.fabrice.BookMapper.findBook", id).asInstanceOf[Book]
        }finally {
            session.close
        }
    }

    def findAll():List[Book] = {
        val session = sqlMap.openSession()
        try{
            session.selectList("be.fabrice.BookMapper.findAll").toList.asInstanceOf[List[Book]]
        }finally {
            session.close
        }
    }

    def save(book:Book) = {
        val session = sqlMap.openSession()
        try {
            book.id match {
                case None =>  session.insert("be.fabrice.BookMapper.insertBook",book)
                case _ =>     session.update("be.fabrice.BookMapper.updateBook",book)
            }
            session.commit()
        } finally {
            session.close
        }
    }

    def delete(book:Book) = {
        val session = sqlMap.openSession()
        try {
            session.delete("be.fabrice.BookMapper.deleteBook",book)
            session.commit()
        } finally {
            session.close()
        }
    }
}

Explications


Initialisation


val sqlMap = new  SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("ibatis/SqlMapConfig.xml"))

Comme annoncé ci-dessus, cette ligne devrait être commune à tous les Dao. Elle lit le fichier de configuration décrit plus haut.

Le find

def find(id:Int):Book =  {
    val session = sqlMap.openSession()
    try{
        session.selectOne("be.fabrice.BookMapper.findBook", id).asInstanceOf[Book]
    }finally {
        session.close
    }
}

Chacune des méthodes du Dao commence par ouvrir une session et termine en la fermant. La fermeture est dans le finally pour garantir qu'elle est faite.

Dans le cas présent, l'objet reçu doit être casté en Book. Par ailleurs, null est renvoyé par cette méthode si aucun livre n'est retrouvé (c'est le résultat de selectOne).

Le findAll

def findAll():List[Book] = {
    val session = sqlMap.openSession()
    try{
        session.selectList("be.fabrice.BookMapper.findAll").toList.asInstanceOf[List[Book]]
    }finally {
        session.close
    }
}

La seule difficulté ici est que le selectList renvoie une java.util.List. Il faut donc la transformer en liste scala.

Le save

def save(book:Book) = {
    val session = sqlMap.openSession()
    try {
        book.id match {
            case None =>  session.insert("be.fabrice.BookMapper.insertBook",book)
            case _ =>     session.update("be.fabrice.BookMapper.updateBook",book)
        }
        session.commit()
    } finally {
        session.close
    }
}

Il s'agit d'insérer les livres non encore persistés et de mettre à jour ceux qui le sont déjà. C'est l'équivalent d'un saveOrUpdate Hibernate.

Pour cela, je teste l'id du livre qui n'existe (Some) que si le livre a été persisté (id auto-généré). Le matcher m'amène soit vers un insert, soit vers un update.

L'update ne pose pas de problèmes, une fois le typehandler correctement configuré. Par contre l'insert m'a fait suer.

En cause, la récupération de l'id auto-généré. Difficile de dire avec le recul quel a été le problème avec toutes les solutions testées. En final, je pense que la bonne configuration du typehandler a éclairci pas mal de choses.

La récupération de l'id est en fait définie dans le code xml:

<insert id="insertBook" parameterType="entities.Book" keyProperty="id" useGeneratedKeys="true" >
       insert into book (title,author,isbn) values (#{title},#{author},#{isbn});
</insert>

Le delete

def delete(book:Book) = {
    val session = sqlMap.openSession()
    try {
        session.delete("be.fabrice.BookMapper.deleteBook",book)
        session.commit()
    } finally {
        session.close()
    }
}

Rien de très compliqué....

Test

Il ne s'agit pas d'un test à proprement parler, mais d'un main qui essaye les différentes options. Il est très semblable à celui de l'article sur OrBroker:

package tests

import entities._
import dao._

object Test {
    def main(args:Array[String]) = {
        println("-------- Recherche d'un livre existant --------")
        println(BookDao.find(1))

        println("-------- Recherche d'un livre inexistant --------")
        println(BookDao.find(4785))

        printAll(BookDao.findAll())

        println("-------- Insertion d'un nouveau livre --------")
        val newBook = new Book("Test","toto","0000000")
        BookDao.save(newBook)
        println("Id du nouveau livre: "+newBook.id)
        printAll(BookDao.findAll)

        println("-------- Edition du nouveau livre --------")
        newBook.title = "Un bouquin"
        BookDao.save(newBook)
        printAll(BookDao.findAll)

        println("-------- Suppression du nouveau livre --------")
        BookDao.delete(newBook)
        printAll(BookDao.findAll)
    }

    def printAll(books:Seq[Book]){
        println("-------- Liste des livres --------")
        books.foreach(println(_))
    }
}

Le résultat de son exécution (à condition que la base de données aient été initialisée comme décrit dans l'article précédent) donne:

-------- Recherche d'un livre existant --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
-------- Recherche d'un livre inexistant --------
null
-------- Liste des livres --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
-------- Insertion d'un nouveau livre --------
Id du nouveau livre: Some(65)
-------- Liste des livres --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
Test de toto (ISBN: 0000000, id: Some(65))
-------- Edition du nouveau livre --------
-------- Liste des livres --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
Un bouquin de toto (ISBN: 0000000, id: Some(65))
-------- Suppression du nouveau livre --------
-------- Liste des livres --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))

Ce qui correspond à ce que l'on souhaite.

Conclusions

Bien sûr nous eûmes des orages...

Je me souviendrai longtemps de la configuration du typehandler (on apprend par l'erreur, n'est-ce pas?). C'est plus la méconnaissance de MyBatis (c'est la première fois que je m'y essaye) qui m'a posé problème.

Sans cela, avec le recul, je dirais que je n'ai pas rencontré de difficultés majeures.

Si je devais choisir entre MyBatis et OrBroker, je choisirais sans hésitation MyBatis. Le code me paraît plus clair. Les librairies sont mieux documentées. Le résultat est satisfaisant, alors que j'avais encore beaucoup de questions avec OrBroker.

Maintenant, il y a peut-être d'autres solutions à explorer...

Aucun commentaire:

Enregistrer un commentaire