Anormálně funkcionální parsování dat z databáze

Na jednom projektu jsem použil Anorm, vrstvu pro přístup k relační databázi, která se jednoduše integruje s Play! frameworkem. Co znamená "Anorm"? "Anorm is Not an Object Relational Mapper" (rekurzivní akronym).

Anorm nám tedy nenabídne klasické ORM, ale výsledky databázového dotazu lze zato parsovat pomocí flexibilního Parser API, které je nám schopné kýžený objekt naplněný daty vrátit. V tomto článku se prakticky podíváme na to, jak parsery Anormu vhodně znovupoužitelně definovat, skládat dohromady a aplikovat.

Celkem se mi líbí základní myšlenky frameworku:

  • SQL je už samo nejlepším DSL pro přístup k relačním databázím, není potřeba vynalézat něco nového (co pak budeme chtít na SQL stejně překládat).
  • Používat přímo samotné JDBC API je poměrně nepříjemným zážitkem, který jde ovšem radikálně vylepšit nabídnutím lepšího API.
  • Typově bezpečná DSL umožňující ověřit správnost dotazů vůči meta-modelu databáze, který udržujeme v aplikaci (viz. JPA/Hibernate), poskytují iluzi bezpečí jen do té doby, dokud dotazy nezačnou za běhu aplikace selhávat díky změnám definice dat ve skutečné databázi. Anorm taková DSL/metamodely nepoužívá. Považuje je za chybný/zbytečný krok.
  • ORM frameworky jsou efektivní pro triviální případy. Ve složitějších aplikacích začínají být neintuitivní a mnoho programátorů s nimi bojuje takovým způsobem, že jejich přínosy jsou pak sporné.

Jednoduchý setup frameworku a příklad delete a insert dotazu (který pro nový záznam vrací databází vygenerované id) můžete najít v dokumentaci. Pojďme se rovnou podívat na komplexnější příklad použití parserů.

Z databáze budeme parsovat objednávky (z tabulky orders), ke každé objednávce existuje informace o tom, jaký uživatel objednávku vytvořil a jaký uživatel objednávku naposledy modifikoval (dva záznamy z téže databázové tabulky user), a informace o tom, na jaké prodejně byla objednávka uskutečněna (tabulka store). Bude se tedy jednat o join tří tabulek, přičemž tabulku user budeme v dotazu joinovat rovnou dvakrát.

Databázové tabulky

Parsery můžeme definovat přímo v komponentě repozitáře/DAO, pro přehlednost a snadnou znovupoužitelnost je ale raději zadefinujeme v samostatných Scala objektech.

Nejdříve zadefinujeme parser pro načtení prodejny:

object StoreParser {
  import anorm._
  import anorm.SqlParser._
  import scala.language.postfixOps

  val TableName = "store"

  // RowParser for store
  val simple = {
    get[Int](s"${TableName}.store_id") ~
    get[String](s"${TableName}.name") map {
      case storeId ~ name => Store(storeId, name)
    }
  }
}

Parser get[Int](s"${TableName}.store_id") (typu RowParser[Int]) je zde složen s parserem get[String](s"${TableName}.name") (typu RowParser[String]) pomocí operátoru ~ do výsledného parseru typu RowParser[~[Int,String]]. Pomocí map a pattern matchingu (case storeId ~ name) pak transformujeme tento parser vracející jednotlivé sloupce řádku result setu přímo na parser vracející instanci třídy Store (což je case class o dvou fieldech).

Výsledný parser můžeme samostatně použít např. v databázovém dotazu, který nám vrátí prodejnu s daným id:

import anorm._
import play.api.db.DB
import play.api.Play.current
import scala.language.postfixOps

def findStoreById(storeId: Long): Option[Store] = {
  DB.withConnection { implicit conn =>
    SQL("""
      SELECT * FROM store WHERE store_id = {storeId}
    """)
    // filling in parameter of SQL statement
    .on("storeId" -> storeId)
    .as(StoreParser.simple *).headOption
  }
}

Za povšimnutí stojí to, že jakmile parser nadefinujeme, samotné SQL dotazy se už dají tvořit poměrně jednoduše a elegantně.

Pomocí postfixového operátoru * (metody * na RowParseru) získáme z RowParseru ResultSetParser (viz. StoreParser.simple *), který naparsuje tolik řádků, kolik se jich nachází v  result setu - výsledku databázového dotazu. Metodou headOption si pak z výsledku vezmeme jen první záznam, který může a nemusí existovat (získáme výsledný typ Option[Store]). Pokud bychom nepoužili headOption, vraceli bychom List[Store].

Pro zjednodušení ve zde uváděném kódu nebudeme vracet Future[Option[Store]], ale jen přímo Option[Store], jinak se však v Play! frameworku s Futures často (a přirozeně) pracuje.

Podobně vytvoříme parser pro uživatele z tabulky user. Nejdříve si ale vytvoříme pomocnou metodu, která nám usnadní pozdější joinování objednávek s uživateli - v jednom SQL dotazu budeme potřebovat načíst záznam uživatele, který vytvořil objednávku, a druhý záznam s uživatelem, který objednávku naposledy modifikoval.

Pomocí prefixu pro alias sloupce budeme chtít ve výsledku databázového dotazu odlišit sloupce pro uživatele-tvůrce objednávky od sloupců pro uživatele, který objednávku naposledy modifikoval. Aliasem mám na mysli vlastní pojmenování sloupce, které se v SQL SELECTu uvádí za klíčovým slovem AS. Ve výsledku dotazu nechceme mít např. dva stejně pojmenované sloupce user.login, ale odlišitelné sloupce created_by_login a modified_by_login, kde created_by a modified_by jsou prefixy aliasů, které si budeme moci dle potřeby určit.

Naimplementujeme si obecně použitelnou metodu getCol (obdobu metody get[T] Anormu, kterou známe už z parsování prodejny), která nám vrátí parser parsující hodnotu databázového sloupce s daným prefixem aliasu, pokud je prefix zadán. Nebo, pokud žádné aliasy nepotřebujeme, jednoduše sloupec s daným názvem, který je pro úplnost prefixován názvem databázové tabulky.

   
import anorm._
import anorm.SqlParser._
                                                                                                                       
/**
 * Vrati parser sloupce na zaklade zadaneho jmena tabulky,
 * volitelneho prefixu nazvu sloupce
 * v SELECT klauzuli SQL dotazu a jmena sloupce v databazi.
 */
def getCol[T](tableName: String, 
  colsAliasPrefix: Option[String],
  columnName: String)(
  implicit extractor: anorm.Column[T]):RowParser[T]=
  colsAliasPrefix match {
    case Some(aliasPrefix) => 
      getAliased[T](aliasPrefix + "_" + columnName)
    case None => get[T](tableName + "." + columnName)
  }

A nyní už vlastní parser pro uživatele:

object UserParser {
  import anorm._
  import anorm.SqlParser._

  val TableName = "user"

  def simple(colsPrefix: Option[String] = None): 
    RowParser[User] = {
    getCol[Long](TableName, colsPrefix, "user_id") ~
    getCol[String](TableName, colsPrefix, "login") map {
      case userId ~ login => User(userId, login)
    }
  }

  def selectClause(tablePrefix: String): String = {
    val colPrefix = tablePrefix + "_"
    s"${tablePrefix}.user_id AS ${colPrefix}user_id, " +
    + "${tablePrefix}.login AS ${colPrefix}login"
  }
}

A konečně výsledek našeho snažení, parser objednávky, který využívá všechny předchozí definované parsery (dosud implementované parsery můžeme použít i samostatně, ale klidně je můžeme kombinovat do složitějších celků).

object OrderParser {
  import anorm._
  import anorm.SqlParser._
  import scala.language.postfixOps

  val TableName = "orders"

  val simple: RowParser[Order] = {
    long(s"${TableName}.order_id") ~
    (StoreParser.simple ?) ~
    (UserParser.simple(Some("cbuser")) ?) ~
    (UserParser.simple(Some("mbuser")) ?) map { 
      case orderId ~ storeOpt ~ createdByUserOpt ~ 
        modifiedByUserOpt =>
        Order(
          orderId = orderId,
          store = storeOpt,
          createdBy = createdByUserOpt,
          modifiedBy = modifiedByUserOpt
        )
    }
  }
}

Parsery komplexních objektů jsou složené do výsledného parseru objednávky operátorem ~. Pomocí postfixového operátoru ? se parser vracející typ T převede na parser vracející Option[T], což je užitečné, pokud joinovaný záznam nemusí vůbec existovat. Parser objednávky můžeme použít k načtení všech objednávek z databáze včetně všech souvisejících dat:

def listOrders: List[Order] = {
  DB.withConnection { implicit conn =>
    val sql = "SELECT orders.*, store.*, " +
      UserParser.selectClause("cbuser") + ", " +
      UserParser.selectClause("mbuser") + " " +
      "FROM " + OrderParser.TableName + " " +
      """LEFT JOIN store ON store.store_id = orders.store_id
      LEFT JOIN user AS cbuser 
      ON cbuser.user_id = orders.created_by
      LEFT JOIN user AS mbuser 
      ON mbuser.user_id = orders.modified_by"""
    SQL(sql).as(OrderParser.simple *)
  }
}

Sestavený SQL dotaz vypadá takto:

SELECT orders.*, store.*, 
cbuser.user_id AS cbuser_user_id, cbuser.login AS cbuser_login, 
mbuser.user_id AS mbuser_user_id, mbuser.login AS mbuser_login 
FROM orders LEFT JOIN store 
ON store.store_id = orders.store_id
LEFT JOIN user AS cbuser 
ON cbuser.user_id = orders.created_by
LEFT JOIN user AS mbuser 
ON mbuser.user_id = orders.modified_by;

Sloupce v SELECT klauzuli uvádíme celým názvem včetně jména tabulky, aby nedošlo ke kolizi jmen s jinou tabulkou, sloupce z tabulky uživatelů pak odlišujeme pro různé role uživatele pomocí prefixů pro aliasy.

Jak se vám líbí takový způsob parsování? Dáváte raději přednost ORM frameworkům, nebo se vám zdá přístup Anormu praktičtější?

Článek obsahuje 2 komentáře

  • Venda

    1
    Díky za článek. Jakožto odpadlík od ORM (aneb proč to dělat jednoduše když to jde složitěji) musím říct jedině Amen! Osobně mám dobré zkušenosti s knihovnou ScalikeJDBC - velmi jednoduché, účelné a podobné Anormu jak tak koukám.
  • Radek Beran

    2
    SQL je jazykem, ve kterém se mně osobně DB dotazy definují srozumitelněji a snáze než přes JPA/Hibernate API, které obaluje samotné SQL ještě složitější strukturou a do aplikace vnáší netriviální práci s DB session, což může být potenciální zdroj problémů. Také si myslím, že při pozdější správě aplikace nebo i při vývoji a testování se člověk úplně nevyhne psaní plain SQL dotazů, takže nakonec skončí s alespoň dvěma způsoby konstrukce DB dotazů (pokud použije nějaké ORM). Čím více způsobů, tím obtížněji je aplikace udržovatelná.

    Za průzkum určitě stojí i další knihovny: Squeryl (typově bezpečná obálka nad SQL pro Scalu) nebo třeba javovská jOOQ.