ScalaでSQLを書くのにSlickで便利にやる話. Slickでは生SQLを補間子(sql"..."
)で書けるけれど, リストが渡せなくてWHERE column IN ($list)
できなかったり, 他にもいくつか不便なところがあったのでなんとかした. 最近になってScalaを書き始めたのでScala力を上げるための練習も兼ねている.
なぜ生SQLか
社内では既にMackerelでSlickを使っていて, liftedな書き方をしているけれど, これはぱっと思いつくだけでも以下のような実運用上の課題があった.
- そもそもどの部分がクエリを表しているのかぱっと見わかりづらい
- 意図せず複雑なクエリになることがある
- 非Scalaエンジニアが読めない
とくに最後のは, たとえばインフラ系のエンジニアが(クエリログを精査した結果などから)やばいクエリの出所を探そうと思ったときに全く手がつけられなくて困るので割と致命的.
liftedな方だと生成されるクエリに構文間違いがなくて型安全で最高だぜ!と言いたいのはわかるけど, 泥くさいパフォーマンスチューニングが必要になってくるDBまわりでそういうの本当に必要なのか?とも思う. かつて型理論を研究していた者にあるまじき物言いに思えるかもしれないけれど, 逆に型でやる気ならもっとつっこんでやってくれないと満足できない. まず生成されるクエリが本当に正しいSQLになるのか(Coqとかで)の証明つきになっていないと何も保証したことにはならないし, パフォーマンスの悪いクエリが生成されそうな場合には静的に検出してコンパイラで警告が出たり, 効率のよいバージョンが機械的に見つかるなら自動変換する, というくらいまでしてくれるならメリットはわかる. それが叶わないならどうせテストちゃんと書いたり小手先のチューニングが必要なんだから生クエリでええやん. 型はただつければいいってもんではなくて, それで何が保証されるのかが重要.
なぜSlickか
正直なところべつにScalikeJDBCでもよかった. ただ社内で既にSlickの採用事例があったので, 合わせられるなら合わせておいた方がノウハウの共有ができて助かる, というくらい.
SlickのSQL補間子は埋め込み方のわからないものは静的にはじく(ScalikeJDBCだとランタイムエラーになる)ようになっていてなかなか頑張っているところはよいとおもった. あまり細かいところまで見てないけれど, ScalikeJDBCだとクエリDSLが推奨されているようだしこっちだとそういう問題もないのではないかとは思うので, やっぱりどっちでもいいかもしれない.
SQL補間子でいく場合, Slickだと問題がいくつかあるものの, それらはちょっといじったらなんとかなるだろう, ということでSlickにした. 実際はちょっとどころではなかったような気もしないでもない...... 結果的によいものができたのでまぁよしとしよう.
作ったもの
Slickの生SQL機能を拡張してべんりにする. Scala 2.11, Slick 3.0.0前提(tsql"..."
には非対応). Maven Centralから取得可能.
補間子に埋め込めるものを増やす
Slickのsql"..."
では, 基本的にすべての埋め込み値($value
)はプリペアドステートメントのプレースホルダ(?
)になってしまう. しかもリストを渡そうが組を渡そうが, 単一の?
になってしまう.
リテラル
埋め込むときにsql"... #$value ..."
の形にすれば, プレースホルダにせずにvalue.toString
をそのまま埋め込める. ただいちいち#
が要るかどうか考えるのは面倒で, 間違いも起きやすい.
プレースホルダにするかどうかは, 埋め込む対象がどういうものなのかによって決まるはずなので, Literal
トレイトをミックスインしていたら#
をつけなくてもプレースホルダにならないようにした. たとえば, テーブル名はプレースホルダにしないので, (Literal
をミックスインした)TableName
というクラスを用意してそのまま展開されるようにした.
リストと組とケースクラス
$list
が単一の?
に展開されると非常に困る. 具体的にはWHERE column IN ($list)
ができない. なのでリストの場合は複数の?
に展開されるようにした. ついでにINSERT
やUPDATE
での展開が楽になるように, 組やケースクラスも複数の?
に展開されるようにした. ただこれらを単純にやるといくつか問題があるので多少の工夫を入れた.
- 空リストをコンパイル時に拒否したい
WHERE column IN ()
になってしまうとぶっ壊れる- テストで見逃しがちなので静的になんとかしたい
- ふつうにやろうとすると
Traversable[]
互換の非空リストがほしくなる- これはけっこう難しいらしい
- (Scalazに
NonEmptyList[]
があるけどTraversable[]
との相互変換は微妙っぽかった)
- 本質的には非空かどうかのチェックをユーザに(静的に)強制できればよい
- リストをそのまま昇格する
NonEmpty[]
というラッパーを定義した- 補間子にはこの型のみを許す
NonEmpty[]
はコンストラクタが制限されていて実際はOption[]
にしか昇格できない- 空リストだと
None
になる
- 空リストだと
Option[]
なので非空リストとして使いたかったらSome[]
かどうか確かめるしかない
(直接get
とかする奴はコードレビューでフルボッコ)多少妥協していて, これに入れると(追記: nonempty:0.2.0以降では元のコレクション型を保存した使い方も可能になっている)Traversable[]
でしか取り出せないのでArrayBuffer
だったのかどうかとかわからなくなる- 補間子に値を渡しているメソッドのインタフェースとして使うべき
- これもライブラリ化: nonempty
- リストをそのまま昇格する
- 組やケースクラスを含む
Product
も複数の?
に展開INSERT INTO table (col1, col2, ...) VALUES $tuple
と書けてべんりINSERT INTO table (col1, col2, ...) VALUES (?, ?, ...), (?, ?, ...), ...
に展開される複数行挿入のINSERT INTO table (col1, col2, ...) VALUES $aListOfTuples
を実現するために本質的に必要
Option[]
は埋め込み禁止None
になったときの扱いをどうするかが厄介- ひとまず静的にはじくことにした
NULL
を入れたかったら明示的にnull
を渡せばよいはず?
クエリを生成したコード位置をクエリそのものに埋め込む
Perlではクエリがどこから発行されたかをSQL中の/* コメント */
として埋め込むのが割と一般的で, これはScalaでも是非やりたい. JDBCの層でなにかやる方法もあるかもしれないけれど, それだとスタックトレースをどれくらい遡ればいいのか自明ではない.
そもそもSQL補間子の返り値はSQLActionBuilder
とかなので, 補間子を使ったメソッド内ではけっきょくクエリは発行されないかもしれない. 問題のあるクエリが見つかったときに追跡したいのは, それがどこで発行されたかというよりは, どこでそんなクエリが組み立てられてしまうかの方だろう. 少なくとも後者が必要になる場面を想定するなら, SQL補間子を使っているところで追跡しておく以外に方法はない.
そういうわけで, 生クエリを任意に書き換えられるしくみと, それを使ってSQL補間子の呼び出し元をコメントとして埋め込む機能をつけた. クエリの書き換えはカスタマイズ可能で, Traversable[query.Translator]
型のimplicit val
を定義すればそれが使われるようになっている.
順序ではなくカラム名で結果を取得
Slickの生SQLの結果を取得してオブジェクトにマッピングするには, カラムを左から順に取り出してオブジェクトを初期化する操作をimplicit val
で与えるようになっている.
case class Entry(id: Long, url: String) implicit val getEntryResult = GetResult{ r => Entry(r.<<, r.<<) } sql"SELECT * FROM entry WHERE entry_id = $id".as[Entry]
こうなっていると, ALTER TABLE
でカラムを増やすときに, 既存のカラムの順序が崩れないようにするか, DBスキーマに追加カラムを反映するタイミングと, マッピング定義を追加カラムに対応させる変更を反映するタイミングを完全に揃える必要があり, 運用が難しい. あるいは, SELECT *
は禁止して必ずカラム名を書くことにすればALTER TABLE
に関しては問題なくなるものの, 今度はSELECT
を書く度にマッピング定義で取り出している順番を確認して間違えないようにしないといけない(そして人は必ず間違える).
これらの問題は, マッピング定義をカラムの順序ではなくカラム名で記述できるようになっていればそもそも発生しない.
implicit val getEntryResult = GetResult{ r => Entry( r.column("entry_id"), r.column("url") ) }
従来通り順序でマッピングする方法とカラム名で取り出す方法の両方が使えるようにするためのしくみはScalikeJDBCのTypeBinderの実装がとても参考になった. 取り出すときにOption[]
で返すのをデフォルトにして, Option[]
でない版(None
だったら例外を投げる版)はTypeBinder[ Option[T] ]
をTypeBinder[T]
にする暗黙変換によって提供する, という点以外はだいたい同じようにした.
ただこれを愚直にやると, TypeBinder[T]
が未定義の場合にTypeBinder[ Option[ Option[T] ] ]
を探そうとしてimplicit
解決が発散し, コンパイルエラーのメッセージがわかりにくくなってしまう. TypeBinder[]
が未定義のために失敗した場合はそれとわかるようなメッセージにしたかったので, Option[]
の入れ子はそもそも探さないような工夫を入れる必要があった.
実装上のいろいろ
マクロ
はじめSlick 2系統をベースに書きはじめて, これだとSlickに実装されている元々のSQL補間子をラップする形での実装が難しく, 補間子の中身も自前でやる必要がでてきて見通しが悪かった. どうにかならないかといろいろ見ていたら, Slick 3.0のRC版では補間子まわりがごっそりマクロ実装に置き換わっていて, マクロになったことを除けばラップするのはだいぶやりやすい設計になっていて助かった.
マクロが導入されたのはおそらくTuple22問題に対応するためで, この利点を維持したままラップするためにはラッパー(slick-jdbc-extensionのsql"..."
の実装部分)もマクロである必要がある.
暗黙変換とエラーメッセージ
マクロになったおかげで, 型情報を見てimplicitly
を挿入しまくるという方法で, 型エラーの原因を@implicitNotFound
でわかりやすく出すことができた. マクロの有無によらず, 実際に使いたいimplicit
とは関係なくエラーメッセージを適切に出すためのimplicit
をとるようにしておく, というテクニックは割と応用範囲が広そうなので, この話は別途記事を書くかもしれない.
Scalaを書いてみた感想
まだ4月7日にhello worldから始めたところでScala歴1ヶ月未満なので, Scala界隈の猛者のみなさまはお手柔らかにお願いします. と思いつつも, 「Scala力を上げるための練習」という意味では十分に書けるようになったので個人的には満足できた.
学生の頃に言語仕様を知るために"Programming in Scala"を読む有志の輪読会に参加したことはあった(コードはREPLでしか書いてない)とか, Javaの仕様にはそれなりに詳しいとか, LLは書き慣れているとか, 関数型言語は出身研究室での公用語であったとか, C++のメタプログラミングはめっちゃ好きとか, アドバンテージはいろいろあった気もする. バックグラウンドにこういう要素を持つ人にとってはScalaはだいぶとっつきやすい言語. 逆に, どれもかすってない人がいきなりやると難しいのかもしれない. 僕が書いてて楽しいと思う時点で変態的な言語としての側面は否めない.
マクロ版のSQL補間子を書くとき, 最初はquasiquoteなしで書いたのでかなり面倒だった. とはいえ, 過去にJavaコンパイラに手を入れて抽象構文木を組み替えるというのをやったことがある(というか大学の課題とかこういうのを除くとJavaのコードは実はこれしか書いたことがない)ので, 何をすればいいのかはわかるし, Javaでやるよりはだいぶましだった. Quasiquoteで書き直したらもう最高べんりとしか言いようがない. まだマクロの仕様は暫定で, Scala 2.12かその次くらいで固まりそうな雰囲気だけれど, まぁなんかいいかんじになりそう, という手応えは得られた. もうあとマクロの機能性として微妙な部分なんて型がついているために起きる面倒なポイントばかりのような気がするし, そこが気に入らなければLispに戻るか, あるいは型付多段階計算の世界へようこそ, ということになるんじゃないか.