Scalaで生SQL - SlickのSQL補間子にリストを渡す 他

ScalaSQLを書くのに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から取得可能.

機能
  • SQL補間子に埋め込めるものを増やす
    • リテラル
    • (非空)リスト
    • 組やケースクラス (Product)
  • クエリを組み立てた後に(文字列ベースで)変換
    • クエリの生成位置を埋め込む
    • 余計なマージンを削る
  • カラム名による結果の(モデルクラス等への)マッピング

細かい使い方はREADMEを見てもらうとして, ここではどちらかというとこれらの機能の意図を書いておく.

補間子に埋め込めるものを増やす

Slickのsql"..."では, 基本的にすべての埋め込み値($value)はプリペアドステートメントプレースホルダ(?)になってしまう. しかもリストを渡そうが組を渡そうが, 単一の?になってしまう.

リテラル

埋め込むときにsql"... #$value ..."の形にすれば, プレースホルダにせずにvalue.toStringをそのまま埋め込める. ただいちいち#が要るかどうか考えるのは面倒で, 間違いも起きやすい.

プレースホルダにするかどうかは, 埋め込む対象がどういうものなのかによって決まるはずなので, Literalトレイトをミックスインしていたら#をつけなくてもプレースホルダにならないようにした. たとえば, テーブル名はプレースホルダにしないので, (Literalをミックスインした)TableNameというクラスを用意してそのまま展開されるようにした.

リストと組とケースクラス

$listが単一の?に展開されると非常に困る. 具体的にはWHERE column IN ($list)ができない. なのでリストの場合は複数の?に展開されるようにした. ついでにINSERTUPDATEでの展開が楽になるように, 組やケースクラスも複数の?に展開されるようにした. ただこれらを単純にやるといくつか問題があるので多少の工夫を入れた.

  • 空リストをコンパイル時に拒否したい
    • WHERE column IN ()になってしまうとぶっ壊れる
      • テストで見逃しがちなので静的になんとかしたい
    • ふつうにやろうとするとTraversable[]互換の非空リストがほしくなる
    • 本質的には非空かどうかのチェックをユーザに(静的に)強制できればよい
      • リストをそのまま昇格するNonEmpty[]というラッパーを定義した
        • 補間子にはこの型のみを許す
      • NonEmpty[]はコンストラクタが制限されていて実際はOption[]にしか昇格できない
        • 空リストだとNoneになる
      • Option[]なので非空リストとして使いたかったらSome[]かどうか確かめるしかない
        (直接getとかする奴はコードレビューでフルボッコ)
      • 多少妥協していて, これに入れるとTraversable[]でしか取り出せないのでArrayBufferだったのかどうかとかわからなくなる (追記: nonempty:0.2.0以降では元のコレクション型を保存した使い方も可能になっている)
        • 補間子に値を渡しているメソッドのインタフェースとして使うべき
      • これもライブラリ化: 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に戻るか, あるいは型付多段階計算の世界へようこそ, ということになるんじゃないか.

まだコンパイル遅すぎて死ぬみたいな体験にまでは至っていないので, いまのところScala最高というかんじです.