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 (před 7 lety) — k47

Pro ETN dev blog jsem napsal článek o ná­stroji pro práci s da­ta­bá­zemi SLICK. Jde o velice na­děj­nou tech­no­lo­gii, která je pod­po­ro­vána sa­mot­nou Ty­pe­safe a je za­řa­zena do jejich vý­vo­jo­vého stacku po boku Scaly, webo­vého fra­meworku Play a (nejen) ak­to­ro­vého fra­meworku Akka. Takže SLICK má bu­douc­nost jistou.

Ale SLICK není jediný DB tool, který se ob­je­vil v dy­na­mic­kém světě Scaly. Kromě něj je tu ještě Anorm (což je re­kur­zivní zkratka pro Anorm is Not ORM), který staví na zcela jiných zá­kla­dech a fi­lo­so­fii než SLICK a je stan­dardně při­ba­len k fra­meworku Play.

Anorm se na rozdíl od SLICKu ne­snaží vy­tvo­řit jed­notné DSL pro ko­mu­ni­kaci se všemi mys­li­tel­nými da­to­vými zdroji, ale sou­středí se jenom na re­lační da­ta­báze, které hovoří ja­zy­kem JDBC dri­veru (v tom se tedy trochu podobá Plain SQL que­ries ze SLICKu). Jde o tenkou abs­trakci nad JDBC spo­je­ním, která mi dává jenom po­ho­dl­nější a sca­lovštější API pro po­klá­dání dotazů a ex­ce­lentní mož­nosti par­so­vání vý­sledků a trans­for­mace do do­mé­no­vých ob­jektů. A to je všechno. Dotazy musím psát ručně. To není nutně špatné řešení. Pokud znám SQL, ne­mu­sím se učit nic navíc a dokážu využít všechny ne­stan­dardní pro­středky daného da­ta­bá­zo­vého stroje. Na druhou stranu dotazy jsou stringy a tudíž je nemůžu nijak kom­po­no­vat a sklá­dat jako ve SLICKu.

Všechno se lépe vy­svět­luje na pří­kla­dech, 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­ža­dují java.sql.Con­nection jako im­pli­citní pa­ra­metr a proto ho musím mít někde v ak­tiv­ním scope.

To všechno je pěkné, ale hlavní síla Anormu vy­nikne až při se­lec­to­vání.

Při­pra­víme si nějaký dotaz (ná­sle­du­jící kód dotaz ne­vy­koná, jenom ho při­praví):

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

Pokud ho chci vy­ko­nat, za­vo­lám na něm metodu apply (která opět chce im­pli­citní Connection).

doSelect.apply

// nebo zkráceně

doSelect()

Ale co je jejím vý­sled­kem? Po­ně­kud ne­u­ži­tečný typ Stream[SqlRow]. Mě by se však hodilo něco jako Seq[User], zkrátka nějaké moje do­mé­nové ob­jekty. Právě tuhle trans­for­maci pro­vede par­so­vání vý­sledků, které můžu udělat přes Stream API, pat­tern matching nebo Parser API.

Stream API

Stream API je za­lo­žena na tom, že objekt RowSqlRow má metodu apply, která z řádku ex­tra­huje slou­pec daného jména a typu.

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

Pat­tern matching

Pat­tern matching můžu dělat proti ob­jektu Row (předek SqlRow). V tomto pří­padě na jmé­nech sloupců ne­zá­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 nej­moc­nější ze tří va­ri­ant. Je složen z kom­bi­ná­torů par­serů (parser com­bi­na­tors, velice po­dobné těm, které jsou při­ba­leny ve stan­dardní knihovně Scaly), díky tomu je možné je různé sklá­dat a kom­bi­no­vat (an­g­licky se tomu říká com­posa­ble, ale zatím jsem ne­na­šel ide­ální český pře­klad).

Par­so­vání se pro­vede me­to­dou as, které pře­dáme parser a im­pli­citní při­po­jení.

// 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 kom­po­zice spo­čívá v tom, že si můžu na­de­fi­no­vat ně­ko­lik slo­ži­tých par­serů, z nichž každý ex­tra­huje data z jedné ta­bulky a pak je zkom­bi­no­vat, 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ě­ja­kou dobou se ob­je­vil pro­to­typ typově zcela bez­peč­ného Anormu, který už během kom­pi­lace kon­t­ro­luje jestli všechny SQL dotazy mají správ­nou syntax a jestli všechny v nich uve­dené sloupce a jejich typy od­po­ví­dají da­ta­bá­zo­vému sché­matu. Kromě toho také vrací plně ty­po­vané vý­sledky (TupleN). Díky tomu ne­mu­sím v apli­kaci du­pli­ko­vat schéma da­ta­báze a jestli od­po­vídá re­a­litě. O tohle všechno se po­sta­rají pří­slušná makra během kom­pi­lace a spousta chyb, které by se pro­je­vily až v run­time se dozvím ještě než pro­gram vůbec spus­tím.

Pozn #2: V článku jsem de­mon­stro­val jenom kom­bi­ná­tor ~, který je nej­čas­tější, ale to ne­zna­mená, že je jediný. Než se k nim do­stanu musím vy­svět­lit jednu věc: V Anorm exis­tují dva typy par­serů. RowParser který par­suje jed­not­livé řádky a ResultSetParser, který zpra­co­vává celé Re­sult­Sety. Dají se je před­sta­vit zjed­no­du­š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: Do­ku­men­taceScala doc

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