Edited at

Database.Esqueleto入門

More than 3 years have passed since last update.

みんなこんにちは!liquid_amberです。業務ではC++開発をしています。ただC++こわいのと、gcc 4.4のC++0xの実装が古すぎて辛い。あとRustとPureScriptは気になるけどほとんど触ってない。なので、今日はHaskellのSQLを楽に書ける言語内DSLであるEsqueletoの話をします。


TL;DR


  • HaskellでDBアクセスにはpersistentつかえ


    • って言うけどRDBMS特化してないじゃん, SQL書けるの?



  • RDBMSアクセスにはpersistent+esqueleto使え


    • SQL直書き気分で文字列とか悩まされずに書ける



  • サンプルソースコードはGistに上げたので見てくれ


経緯

こういうものを片手間に作っているんですが、Haskellで書いています。何かって言うとRDBMS使う小規模なウェブアプリ、すごい普通のウェブアプリなんですが、HaskellでWebAppの開発に必要なN個のこと - maoeのブログとか見ても分かる通り最近のHaskell web framework三国時代はSnap, Happstack, Yesodになってる。で、Yesod陣営を使おう、Web frontendの部分に関してはYesodは重量級なんでscotty使おうかなーって思ってたんですが、ググってみるとapiaryってのが良さげだったのでこっちを使うことに(apiary.ioってサイトとは全く関係ない)。そうなるとフロントは決まったなってことでバックエンドでDB触る話が今日のお題です。

そもそもWeb frameworkとDB触る層関係ないだろッて思われるかもしれませんが、Haskellではproducer/consumer型のI/Oライブラリが乱立していて、それぞれを率いるのがWeb frameworkの開発者という状態。それで、Yesodの開発者はconduitというI/Oライブラリ上に


  • HTTPサーバ warp

  • webアプリのI/F wai (WSGI的なもの)

  • DBライブラリpersistent

などなどいろいろ作っていて、今日紹介するのはその最後の部分、僕の使うapiaryはwaiに準拠したフレームワークなのでYesod陣営ということになってこのスタックに載ってくる。


esqueleto入門


テーブルを定義しよう

persistentというのはDBアクセスを型安全にしてくれるライブラリです、だいたいORMみたいなもんです。バックエンドとして色々選べます(sqlite, MongoDB, MySQL, PostgreSQL, …)。で、TemplateHaskellを使ってメタメタしくしてくれるのがポイント。次みたいなコードを書くとデータ型定義とかテーブル定義とか色々してくれるらしい。

{- たくさんの言語拡張, 詳しくはgistみて欲しい -}

import Data.Persistent.TH

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
mail Text -- フィールド定義
UniqueMail mail
deriving Show

Handle
personId PersonId
service Text
name Text
created UTCTime
UniqueHandle personId service name created
deriving Show

Uri
handleId HandleId
uri Text
label Text
UniqueUri handleId uri label
deriving Show
|]

これを書くとTemplateHaskellの御加護で勝手にPerson, Handle, Uriというデータ型が宣言されて便利。更に、例えばPersonに対応する主キーの型がPersonIdという型で定義されていたり、UniqueXXXって書いておけばuniqueキー制約をつけられたりします。因みにDBからPersonを取得するとEntity Personっていう型のEntity{entityVal = Person(..), entityKey = PersonId(..)}みたいなものになって出てくるのは要注意です。


普通のCRUD

これでモデルの定義ができたんで、ひと通りデータのCRUDができる。

import qualified Data.Text as T

main = do
runSqlite ":memory:" $ do -- 以下をsqliteで実行
time <- liftIO getCurrentTime -- 時刻取得
runMigration migrateAll -- テーブルのmigration

-- データの追加, insertするとPersonId型の主キーが取れる
johnId <- insert $ Person "john@example.com"
janeId <- insert $ Person "jane@example.com"
johnWId <- insert $ Person "john_watson@example.com"

-- 以下relationを作るために上で得たIDを入れている
johnExampleId <- insert $ Handle johnId "example" "John Doe" time
janeExampleId <- insert $ Handle janeId "example" "Jane Doe" time
insert $ Handle johnId "irc" "john_doe" time
johnAnotherId <- insert $ Handle johnWId "another" "John Watson" time

insert $ Uri johnExampleId "calendar" "http://example.com/calendar/john"
insert $ Uri johnExampleId "address book" "http://example.com/address/john"
insert $ Uri johnAnotherId "irc" "http://example.com/calendar/john_watson"

liftIO $ putStrLn "use normal ==. operator"
johnHandles <- selectList [HandleName ==. "John Doe"] [LimitTo 1]
forM_ johnHandles $ \johnHandle -> do
johnURIs <- selectList [UriHandleId ==. entityKey johnHandle] []
liftIO $ print (johnURIs :: [Entity Uri])

insertで新規作成、selectListで取得できるから(後削除とか更新とかもある)大体全部できるっぽい。やった!と思ったけどここで疑問に思うことでしょう。何が疑問かというと、persistentって上で言ったとおりバックエンドが幾多もあってMongoDBとかも選べるものです。ということは、DBへの仮定は結構弱くて、検索条件ほとんど選べないんじゃないかって言う気がします。上で見たのは「Handleのentityのうちname fieldが"John Doe"と一致するもの」というのを検索してたわけだけど、実際等号不等号、大小比較、属するしない程度しか条件がない。


PersistentでSQL書きたい

不幸ながら、私が今回検索したいのは「"john"を部分文字列に含む〜」要はSQLで言うLIKEがしたいので条件がしょぼいとそういうことはできない。これに対してかなりprimitiveな解法として存在するのがrawSqlというinterface。例えばこれでLIKEを使って"john"を含むHandleのエンティティを検索してみよう。あと、ついでに含まれるHandle毎にUriを都度検索するのだるいし遅そうだからOUTER JOINしよう、なんせSQLなんだから。

    let s = "SELECT ??, ?? FROM handle LEFT OUTER JOIN uri ON handle.id = uri.handle_id WHERE handle.name LIKE '%' || ? || '%'"

johnH2 <- rawSql s [toPersistValue ("john" `asTypeOf` T.empty )]
liftIO $ forM_ (johnH2 :: [(Entity Handle, Maybe (Entity Uri))]) print

なんと、びっくりするくらい抽象度が下がりました。



  • rawSql :: Text -> [PersistValue] -> なんかMonadという型


    • 当たり前ですがSQL間違ってたら実行時エラーになる

    • 第2引数はプレースホルダに与える値なんですが


      • 個数間違っても実行時エラー

      • そもそも文字列を期待している箇所に数値渡しても実行時エラー





  • テーブルの名前とかも唐突に出てくる


    • persistentがPersonのテーブル名はpersonだとか知ってるんだから勝手にしておいて欲しい



  • フィールド名も唐突に出てくる

唯一取り出すフィールド名は列挙せずに??で済んでいることが救いです。とにかくこんな生SQL地獄は抜けてesqueleto使おう。このEsqueletoっていうのはSQLの言語内DSLでこんなかんじで書ける。

select

$ from $ \(entity1 `InnerJoin` entity2 `LeftOuterJoin` ) -> do
on $ 一番最後のjoinon
on $
on $ 最初のjoinon
where_ $ where_
orderBy $ [asc (条件), dsc(条件), ]
limit N -- してみたり
groupBy -- してみたりできる
return (entity1, entity2 ^. Entity2SomeField)

具体例を見てみよう。

import qualified Database.Esqueleto as E

import Database.Esqueleto ((^.), (?.))
-- 続き
johnEs <-
E.select $ E.from $ \(handle `E.LeftOuterJoin` muri) -> do
E.on $ E.just (handle ^. HandleId) E.==. muri ?. UriHandleId
E.where_ $ handle ^. HandleName `E.like` (E.%) E.++. E.val "john" E.++. (E.%)
return (handle, muri)
liftIO $ forM_ johnEs print

ほらやっぱりSQLだ!



  • whereは完全に予約語なのでwhere_になっている


  • onがjoinと順番逆な点に注意。


  • ^.でエンティティのフィールドを参照できる



    • ?.はちょうどCoffeeScriptの?.みたいな感じ、Haskellっぽく言うとMaybeに持ち上げてフィールドを参照できる。




  • E.onの中でE.justとかやってMaybeっぽくしてるのは外部結合だから



    • muriNULLかも知れないので型としてはMaybe (Entity Uri)

    • 実は気にせずE.on handle ^. HandleId E.==. muri ^. UriHandleIdって書いてもコンパイルは通っちゃう

    • そんでもってmuriNULLが来ると例外で落ちるからちょっとつらい




  • E.where_で文字列結合しててSQLインジェクション大丈夫なのか!?


    • とお思いの方に朗報ですがもちろんE.val(Haskellの値を埋め込む関数)自体がプレースホルダで実装されてるので気にしないでいいです



っていう感じで素晴らしいのでぜひ使ってみてください。


まだわかってないこと


  • シャードとかマスタースレーブ構成とかどうすんだろ

  • トランザクションってどうやって扱うんだろう?


蛇足

$ # gistに上げたコードを実行する方法

$ cabal sandbox init
$ cabal install --only-dependencies --enable-tests
$ cabal configure
$ cabal build
$ ./dist/build/adv-cal2014/adv-cal2014