lundi 18 mars 2013

Comparaison d'Anorm et Slick pour l'accès aux bases de données relationnelles en Scala

Anorm et Slick sont 2 librairies Scala qui permettent d'intéragir avec des bases de données relationnelles.
La première fait partie du framework Play (mais peut être utilisée séparément  et propose d'écrire les requêtes en utilisant directement le langage SQL, la deuxième est l'outil officiel de la stack TypeSafe (la société derrière le langage Scala) et propose un DSL (Domain Specific Langage) plus fortement typé, pour générer du SQL.

Nous allons comparer ces 2 outils et voir comment implémenter les cas d'utilisation classiques à travers l' exemple de l'application Computer Database.

Remarque : Slick propose plusieurs modes de fonctionnement dont le mode plain sql qui permet à l'instar d'Anorm d'écrire du "vrai SQL". Ce mode est préconisé lorsque l'on a besoin d'écrire une requête très spécifique difficilement exprimable avec le DSL. Dans cet article nous étudierons uniquement le mode "lifted embedding" qui est plus fréquemment utilisé.

Définition des structures de données

Avec Anorm, on définit 'case class' pour définir notre modèle, ainsi que des fonctions de mapping pour faire correspondre les resultats de requêtes avec des objets Scala :

case class Company(id: Pk[Long] = NotAssigned, name: String)
case class Computer(id: Pk[Long] = NotAssigned, name: String, introduced: Option[Date], discontinued: Option[Date], companyId: Option[Long])


object Company {
    
  /**
   * Parse a Company from a ResultSet
   */
  val simple = {
    get[Pk[Long]]("company.id") ≈
    get[String]("company.name") map {
      case id~name => Company(id, name)
    }
  }
 //méthodes de lecture, insertion, mise à jour dans la base
}


object Computer {
  
  // -- Parsers
  
  /**
   * Parse a Computer from a ResultSet
   */
  val simple = {
    get[Pk[Long]]("computer.id") ~
    get[String]("computer.name") ~
    get[Option[Date]]("computer.introduced") ~
    get[Option[Date]]("computer.discontinued") ~
    get[Option[Long]]("computer.company_id") map {
      case id~name~introduced~discontinued~companyId => Computer(id, name, introduced, discontinued, companyId)
    }
  }

  //méthodes de lecture, insertion, mise à jour dans la base
}
Le symbole '~' permet de représenter la composition des result sets en provenance de la base de données, puis de définir des tuples Scala à partir de ces données.

Avec Slick, en plus des case class, on décrit la structure des tables de la base de données, afin de pouvoir les manipuler à travers le DSL :

case class Company(id: Option[Long], name: String)
case class Computer(id: Option[Long] = None, name: String, introduced: Option[Date]= None, discontinued: Option[Date]= None, companyId: Option[Long]=None)

object Companies extends Table[Company]("COMPANY") {

  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def name = column[String]("name", O.NotNull)
  def * = id.? ~ name <>(Company.apply _, Company.unapply _)
  def autoInc = * returning id
  
  //méthodes de lecture, insertion, mise à jour dans la base
}

object Computers extends Table[Computer]("COMPUTER") {

  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def name = column[String]("name", O.NotNull)
  def introduced = column[Date]("introduced", O.Nullable)
  def discontinued = column[Date]("discontinued", O.Nullable)
  def companyId = column[Long]("companyId", O.Nullable)

  def * = id.? ~ name ~ introduced.? ~ discontinued.? ~ companyId.? <>(Computer.apply _, Computer.unapply _)

  def autoInc = * returning id

//...
}

Récupération d'une liste d'entrées

On va commencer par lister les marques d'ordinateurs (companies). On veut récupérer des tuples (id, name) sous forme de String (cette info sera uniquement utilisée pour l'affichage sur la page web).

Avec Anorm, on écrit notre requête SQL puis on explicite la correspondance avec le modèle en utilisant la fonction map :

def options: Seq[(String,String)] = DB.withConnection { implicit connection =>
    SQL("select * from company order by name").as(Company.simple *).map(c => c.id.toString -> c.name)
  }

Avec Slick on utilise les API de collections et les structures pour itérer sur les résultats de la requête générée :

def options: Seq[(String, String)] = DB.withSession { implicit session =>
      val query = (for {
        company <- Companies
      } yield (company.id, company.name)
        ).sortBy(_._2)
      query.list.map(row => (row._1.toString, row._2))
  }

Récupération d'une liste d'entrées paginées à partir d'un filtre

On complique un peu les choses, en introduisant la pagination, le tri et la recherche par filtres.
Anorm :

def list(page: Int = 0, pageSize: Int = 10, orderBy: Int = 1, filter: String = "%"): Page[(Computer, Option[Company])] = {
    
    val offest = pageSize * page
    
    DB.withConnection { implicit connection =>
      
      val computers = SQL(
        """
          select * from computer 
          left join company on computer.company_id = company.id
          where computer.name like {filter}
          order by {orderBy} nulls last
          limit {pageSize} offset {offset}
        """
      ).on(
        'pageSize -> pageSize, 
        'offset -> offest,
        'filter -> filter,
        'orderBy -> orderBy
      ).as(Computer.withCompany *)

      val totalRows = count(filter)

      //count sera décrit plus bas

      Page(computers, page, offest, totalRows)
      
    }
    
  }

Les paramètres entre accolades sont substitués à l'aide de la fonction 'on'.

Ce code nécessite l'introduction d'une fonction 'Computer.withCompany' pour transformer les résultats du left join en une collection de type Seq[(Computer, Option[Company])] :

val withCompany = Computer.simple ~ (Company.simple ?) map {
  case computer~company => (computer,company)
}

Le caractère '?' permet de spécifier que la compagnie est optionnelle.

Slick :

 def list(page: Int = 0, pageSize: Int = 10, orderBy: Int = 1, filter: String = "%"): Page[(Computer, Option[Company])] = {

    val offset = pageSize * page

    DB.withSession { implicit session =>
        val query =
          (for {
            (computer, company) <- Computers leftJoin Companies on (_.companyId === _.id)
            if computer.name.toLowerCase like filter.toLowerCase()
          }
          yield (computer, company.id.?, company.name.?))
            .drop(offset)
            .take(pageSize)

        val totalRows = count(filter)
        val result = query.list.map(row => (row._1, row._2.map(value => Company(Option(value), row._3.get))))

        Page(result, page, offset, totalRows)
    }
  }

Ici on trouve une autre difficulté. On ne peut pas déclarer directement la jointure avec la compagnie comme optionnelle. On doit ruser en disant qu'on peut avoir un id et un nom, puis en utilisant la fonction map sur l'option d'id, on peut recréer notre type Option[Company] et renvoyer le bon résultat.

Note : dans les 2 cas on utilise un type Page créé par nos soins pour gérer la pagination des données :

case class Page[A](items: Seq[A], page: Int, offset: Long, total: Long) {
  lazy val prev = Option(page - 1).filter(_ >= 0)
  lazy val next = Option(page + 1).filter(_ => (offset + items.size) < total)
}

Insertion d'une entrée dans la base de données

Anorm :

def insert(computer: Computer) = {
    DB.withConnection { implicit connection =>
      SQL(
        """
          insert into computer values (
            (select next value for computer_seq), 
            {name}, {introduced}, {discontinued}, {company_id}
          )
        """
      ).on(
        'name -> computer.name,
        'introduced -> computer.introduced,
        'discontinued -> computer.discontinued,
        'company_id -> computer.companyId
      ).executeUpdate()
    }
  }

Slick :

  def insert(computer: Computer) {
   DB.withSession { implicit session =>
      Computers.autoInc.insert(computer)
    }
  }

Note : pour effectuer les opérations au sein d'une transaction, il faut remplacer "DB.withConnection" (Anorm) ou "DB.withSession" (Slick) par "DB.withTransaction". Le bloc sera ensuite transactionnel. Si besoin les paramètres implicites "connection" et "session" proposent une méthode "rollback()".

 

Calcul du nombre d'éléments

Anorm :

def count(filter: String) : Int = {
    DB.withConnection { implicit connection =>
     SQL(
        """
          select count(*) from computer 
          left join company on computer.company_id = company.id
          where computer.name like {filter}
        """
      ).on(
        'filter -> filter
      ).as(scalar[Long].single)
  }

Slick :

def count(filter: String) : Int = DB.withSession { implicit session =>
      Query(Computers.where(_.name.toLowerCase like filter.toLowerCase).length).first
  }

Mise à jour d'une entrée

Anorm :

def update(id: Long, computer: Computer) = {
    DB.withConnection { implicit connection =>
      SQL(
        """
          update computer
          set name = {name}, introduced = {introduced}, discontinued = {discontinued}, company_id = {company_id}
          where id = {id}
        """
      ).on(
        'id -> id,
        'name -> computer.name,
        'introduced -> computer.introduced,
        'discontinued -> computer.discontinued,
        'company_id -> computer.companyId
      ).executeUpdate()
    }
  }

Slick :

def update(id: Long, computer: Computer) {
    DB.withSession { implicit session =>
        val computerToUpdate: Computer = computer.copy(Some(id))
        Query(Computers).where(_.id === id).update(computerToUpdate)
    }
  }

Suppression d'une entrée

Anorm :

def delete(id: Long) = {
    DB.withConnection { implicit connection =>
      SQL("delete from computer where id = {id}").on('id -> id).executeUpdate()
    }
  }

Slick :

def delete(id: Long) {
    DB.withSession { implicit session =>
        Computers.where(_.id === id).mutate(_.delete)
    }
  }

Conclusion

Slick et Anorm ont chacun leurs avantages et inconvénients.
Voici mon avis sur leurs points positifs et négatifs respectifs ;

Anorm

Les + :
  • Pas besoin d'apprendre un nouveau DSL
  • Possibilité de tester directement ses requêtes SQL dans la base de données avant de les coller dans le code
Les - :
  • Code SQL, donc potentiellement spécifique à une base de données en particulier
  • Plus verbeux que Slick 
  • Requêtes non "type safe"

Slick

Les + :
  • DSL typé
  • Plus concis qu'Anorm. Ceci est particulièrement visible sur les opérations d'insertion/mise à jour de données
Les -
  • Plus opaque, il est parfois difficile d'imaginer le code SQL qui se cache derrière le DSL. En cas d'erreur ça complique un peu les choses...
  • La structure du framework incite à utiliser directement un driver spécifique à une base de données (par exemple la classe scala.slick.driver.MySQLDriver) mais le plugin Play-Slick aide à rendre le code plus générique

Pour moi il n'y a pas vraiment de vainqueur, je pense que le choix dépendra surtout du fait que vous vous sentiez plus à l'aise avec SQL ou avec les collections Scala pour parcourir vos données.

Bonus : Ce n'est qu'un pour le moment "proof of concept" mais il existe une version "type safe" d'Anorm, qui utilise les macros Scala lors de la phase de compilation pour renvoyer des résultats typés en fonction de la définition du schéma de la base de données. En plus de proposer un code plus concis, cette version d'Anorm apporterait aussi plus de sécurité au développeur : https://github.com/guillaumebort/anormtyped-demo
Personnellement je suis hyper-séduit par cette solution!

Et vous, laquelle de ces deux librairies préférez vous?