みんなこんにちは!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
だとか知ってるんだから勝手にしておいて欲しい
- persistentが
- フィールド名も唐突に出てくる
唯一取り出すフィールド名は列挙せずに??
で済んでいることが救いです。とにかくこんな生SQL地獄は抜けてesqueleto使おう。このEsqueletoっていうのはSQLの言語内DSLでこんなかんじで書ける。
select
$ from $ \(entity1 `InnerJoin` entity2 `LeftOuterJoin` …) -> do
on $ 一番最後のjoinのon
on $ …
on $ 最初のjoinのon
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っぽくしてるのは外部結合だから-
muri
はNULL
かも知れないので型としてはMaybe (Entity Uri)
- 実は気にせず
E.on handle ^. HandleId E.==. muri ^. UriHandleId
って書いてもコンパイルは通っちゃう - そんでもって
muri
にNULL
が来ると例外で落ちるからちょっとつらい
-
-
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