funkcionálně.cz

Přední český blog o funkcionálním programování, kde se o funkcionálním programování nepíše
««« »»»

Anorm

22. 4. 2013

Pro ETN dev blog jsem napsal článek o nástroji pro práci s databázemi SLICK. Jde o velice nadějnou technologii, která je podporována samotnou Typesafe a je zařazena do jejich vývojového stacku po boku Scaly, webového frameworku Play a (nejen) aktorového frameworku Akka. Takže SLICK má budoucnost jistou.

Ale SLICK není jediný DB tool, který se objevil v dynamickém světě Scaly. Kromě něj je tu ještě Anorm (což je rekurzivní zkratka pro Anorm is Not ORM), který staví na zcela jiných základech a filosofii než SLICK a je standardně přibalen k frameworku Play.

Anorm se na rozdíl od SLICKu nesnaží vytvořit jednotné DSL pro komunikaci se všemi myslitelnými datovými zdroji, ale soustředí se jenom na relační databáze, které hovoří jazykem JDBC driveru (v tom se tedy trochu podobá Plain SQL queries ze SLICKu). Jde o tenkou abstrakci nad JDBC spojením, která mi dává jenom pohodlnější a scalovštější API pro pokládání dotazů a excelentní možnosti parsování výsledků a transformace do doménových objektů. A to je všechno. Dotazy musím psát ručně. To není nutně špatné řešení. Pokud znám SQL, nemusím se učit nic navíc a dokážu využít všechny nestandardní prostředky daného databázového stroje. Na druhou stranu dotazy jsou stringy a tudíž je nemůžu nijak komponovat a skládat jako ve SLICKu.

Všechno se lépe vysvětluje na příkladech, tak jich tady pár ukážu:

implicit val connection: java.sql.Connection = ???

// provede dotaz, vrátí true, pokud proběhne úspěšně
val result: Boolean = SQL("select 1").execute()

// delete/update, vrátí počet ovlivněných řádků
val result: Int = SQL("delete from City where id = 99").executeUpdate()

// insert s ukázkou bindingu parametrů, může vrátit automaticky generované id
SQL("""
  insert into city (name, country)
  values ({name}, {country}
""").on('name -> "Cambridge", 'country -> "New Zealand").executeInsert()

Metody execute, executeUpdate, executeInsert vyžadují java.sql.Connection jako implicitní parametr a proto ho musím mít někde v aktivním scope.

To všechno je pěkné, ale hlavní síla Anormu vynikne až při selectování.

Připravíme si nějaký dotaz (následující kód dotaz nevykoná, jenom ho připraví):

val doSelect = SQL("""
  select id, userName from users
  where id = {userId}
""").on('userId -> 122)

Pokud ho chci vykonat, zavolám na něm metodu apply (která opět chce implicitní Connection).

doSelect.apply

// nebo zkráceně

doSelect()

Ale co je jejím výsledkem? Poněkud neužitečný typ Stream[SqlRow]. Mě by se však hodilo něco jako Seq[User], zkrátka nějaké moje doménové objekty. Právě tuhle transformaci provede parsování výsledků, které můžu udělat přes Stream API, pattern matching nebo Parser API.

Stream API

Stream API je založena na tom, že objekt Row a SqlRow má metodu apply, která z řádku extrahuje sloupec daného jména a typu.

doSelect() map { row =>
  User(
    id   = row[Int]("id"),
    name = row[String]("userName")
  )
}

Pattern matching

Pattern matching můžu dělat proti objektu Row (předek SqlRow). V tomto případě na jménech sloupců nezáleží, jde pouze o jejich pořadí v dotazu.

doSelect() collect {
  case Row(1, _)                  => "admin"
  case Row(id: Int, name: String) => s"user id = $id name = $name"
}

Parser API

Parser API je nejmocnější ze tří variant. Je složen z kombinátorů parserů (parser combinators, velice podobné těm, které jsou přibaleny ve standardní knihovně Scaly), díky tomu je možné je různé skládat a kombinovat (anglicky se tomu říká composable, ale zatím jsem nenašel ideální český překlad).

Parsování se provede metodou as, které předáme parser a implicitní připojení.

// importuje parsery (str, scalar, bool, int, long a další)
import anorm.SqlParser._

// zkonvertuje jeden řádek jedno-sloupcového výsledku na sekvenci longů
SQL("...").as(scalar[Long].single)

// to samé, ale pro 0 - ∞ řádků
SQL("...").as(scalar[Long] *)

// vrátí List[Int ~ String]
SQL("...").as(int("id") ~ str("userName") *)

// flatten zkonvertuje typ ~ na tuple příslušné arity
// dotaz tedy vrátí List[(Int, String)]
SQL("...").as(int("id") ~ str("userName") map flatten *)

// můžeu udělat vlastní map operaci
SQL("...").as(
  int("id") ~ str("name") ~ bool("isAdmin") map {
    case (id ~ name ~ true) => "admin: "+name
    case (_  ~ name ~ _)    => "pleb:"+ name
  } *)

Síla kompozice spočívá v tom, že si můžu nadefinovat několik složitých parserů, z nichž každý extrahuje data z jedné tabulky a pak je zkombinovat, abych vytáhl data z join dotazu.

val userParser: RowParser[User] =
  int("userId") ~ str("userName") map { case id ~ name => User(id, name) }

val articleParser: RowParser[Article] =
  int("artId") ~ date("published") ~ str("text") map { id, date, txt) => Article(id, date, txt) }

val res: List[(User, Article)] = SQL("""
  select *
  from users u
  join articles a
    on a.authorId = u.userId
""").as(userParser ~ articleParser map flatten *)

Pozn #1: před nějakou dobou se objevil prototyp typově zcela bezpečného Anormu, který už během kompilace kontroluje jestli všechny SQL dotazy mají správnou syntax a jestli všechny v nich uvedené sloupce a jejich typy odpovídají databázovému schématu. Kromě toho také vrací plně typované výsledky (TupleN). Díky tomu nemusím v aplikaci duplikovat schéma databáze a jestli odpovídá realitě. O tohle všechno se postarají příslušná makra během kompilace a spousta chyb, které by se projevily až v runtime se dozvím ještě než program vůbec spustím.

Pozn #2: V článku jsem demonstroval jenom kombinátor ~, který je nejčastější, ale to neznamená, že je jediný. Než se k nim dostanu musím vysvětlit jednu věc: V Anorm existují dva typy parserů. RowParser který parsuje jednotlivé řádky a ResultSetParser, který zpracovává celé ResultSety. Dají se je představit zjednodušeně takto:

type RowParser       = (Row => SqlResult[A])
type ResultSetParser = (ResultSet => SqlResult[A])

//RowParser kombinátory:

p ~ q  // uspěje, když uspěje parser p i q
p ~> q // uspěje, když uspějí obě strany, nechá jenom pravou stranu
p <~ q // uspěje, když uspějí obě strany, nechá jenom levou stranu
p | q  // když p neuspěje, zkusí q
p flatMap f // umožňuje parametrizovat druhý parser výsledkem toho prvního
p >> f // to samé co flatMap
p ?    // transformuje parser T na Option[T], pokud p neuspěje, vrátí None
p map f // transformuje výsledek parseru
p collect f // stejné jako map, ale f je PartialFunction

// metody, které z RowParseru udělají ResultSetParser:

p *         // parser přijme žádný nebo více řádků
p +         // parser přijme jeden nebo více řádků
p.single    // parser přijme jenom jeden řádek
p.singleOpt // parser přijme jeden nebo žádný řádek, vrací Option

Pozn #3: Dokumentace a Scala doc

@kaja47, kaja47@k47.cz, deadbeef.k47.cz, starší články