2015年12月6日

Go 言語用 SQL クエリービルダーを作ってみた話

これは Go Advent Calendar 2015 とは何の関係もない記事です。

Go 言語用の SQL クエリービルダーとしては https://github.com/elgris/golang-sql-builder-benchmark でベンチマークされているものが有名どころのようなのだが、SQLite に対応した純粋なクエリービルダーはなさそうだったのと、「自分で作ってみるのも勉強になるかな」と思い、自前の SQL クエリービルダーを作ってみることにした。
(あとこのベンチマークは dbr が 2.0 になる前のものっぽいので内容も少し古い)

https://github.com/oov/q
実際に作ったのはこちら。

パッケージ名に1文字はちょっと短すぎると自分でも思うけど、必要なら好きな名前を当てればいいし大きな問題ではないだろうと思ってそのまま進めることにした。
実際のコードでは q, args := builder.ToSQL() みたいなノリで q を使いたくなるだろうけど、SelectBuilder.ToSQL で出力してしまえばもうパッケージとしての q にアクセスする必要性は薄いし、また続きのコードでアクセスしたい場合は q という変数名はちょっと可読性が微妙だろうという気もするので問題ない。という言い訳も自分にした。

パッケージの話の前に作ろうと思うに至った話を少し。

元々作ろうと思った発端としては YAML に Go の構造体と SQL とのマッピング情報を書いて、Go のソースコードを自動生成するようなツールを試しに作ってみていた時の話になる。
この手のツールも似たようなものが結構あるのだが、Go の構造体にタグをゴリゴリ書いていくとか、使う前にコードでマッピング情報を追加するとか、使い方を覚える自信があまりなくて個人的には結構精神的な障壁が大きかった。
そういう感じで既存のは実際に使うところまでは気が乗ってこなくて、あと作ると勉強になりそうというのもあって自作してみることにしたのだった。
最初は github.com/jmoiron/sqlx を使っていたけど途中で必要ないかもと思って使うのやめたりとかソース生成用テンプレートがカオスになったりしつつなんとか基本的な動作はできたが、生成されたコード内で SQL は文字列で直書きされるようになっていて、ちょっとだけ条件を加えたクエリーを投げるような柔軟性が持たせられなかった。
「なるほど、これは後からの改変に耐えられるようにクエリービルダー的なものを経由しないとダメかな?」と思ったところで今回のパッケージを作るに至った。
クエリービルダーもこのジェネレータと同じパッケージに含めようか迷ったのだが、一緒にした時のメリットが使う時に import 文がまとまる以外特に思いつかず、本質的には別のパッケージなのでやめた。

あと、前回の記事Docker コンテナを使うテストを書きやすくするためのパッケージを作ったんだけども、これもこの辺の話に関連していて MySQL、PostgreSQL、SQLite での動作を確認するためになるべく手間が減らせるように作ったのだった。
(しかし肝心のテストはかなり薄い)

今回のパッケージの話に戻す。

q を作る上でのテーマは、短く書くためだけにショートハンドメソッドなどをガツガツ増やしたりせず、なるべく素直な形に実装にしようというところ。とはいえやり過ぎると SQL を書くのにクッソ面倒くさくて結局使わなくなるだろうし、色々考えて良い落とし所を探ったつもりではある。
本当はあんまり reflect とか type switch とかも使いたくなかったけど、いくつかやむを得ず使った。でもあまり欲張らなかったので、import するのは fmtreflect だけで済んだ。

使用例としては大体こんな感じ。
user := q.T("user")
sel := q.Select().From(
    user,
).Column(
    user.C("id"),
    user.C("name"),
).Where(
    q.Eq(user.C("age"), 18),
)
fmt.Println(sel) // SELECT "user"."id", "user"."name" FROM "user" WHERE "user"."age" = ? [18]
簡単に説明すると、
  • SELECT 文をつくるには q.Select
    q.Select("SELECT SQL_NO_CACHE") みたいなことも一応できる
  • テーブルを作るには q.T
    クエリービルダーにも同じ名前のメソッドがあり、サブクエリーを FROM に書きたい場合などに使える
  • カラムを作るには q.C か、Table.C か、Expression.C
    q.C の場合は "column" table.C の場合は "table"."column" のような形になる
  • JOIN にも対応
    テーブルにあるメソッドを呼ぶことで使用可能
といった感じで、他のクエリービルダーに比べて文字列で直接指定できる部分は結構少ないはず。
そのためテーブル名やカラム名に関してはキッチリエスケープされる。

ただしこれだけだとパッケージに準備されていない色々なことができないので、SQL の破片を直書きできる q.Unsafe もあり、概ね fmt.Print と同じ使い勝手で以下のように使うことができる。
user := q.T("user", "u")
age := user.C("age")
sel := q.Select().From(user).Column(
    q.C(q.Unsafe(`SUM(CASE WHEN (`, age, ` >= 13)AND(`, age, ` <= 19) THEN 1 ELSE 0 END)`), "teen"),
    q.C(q.Unsafe(`SUM(CASE WHEN `, age, ` >= 20 THEN 1 ELSE 0 END)`), "adult"),
)
fmt.Println(sel) // SELECT SUM(CASE WHEN ("u"."age" >= 13)AND("u"."age" <= 19) THEN 1 ELSE 0 END) AS "teen", SUM(CASE WHEN "u"."age" >= 20 THEN 1 ELSE 0 END) AS "adult" FROM "user" AS "u" []
※ただしこの例で出てくる CASE 文は本当は q.Unsafe を使わなくても組むこともできる。

このように文字列が直接 SQL として書き出されるため外部からの入力を直接埋め込むのは大変危険なのだが、その場合には q.V で外部からの入力を囲うことで、プレースホルダーに置換させることもできる。

このような構造にしておいたら外部ツールで解析して危なそうな箇所を見つけられるかな? と妄想している。文字列リテラルでも Expression でもないものを渡してる箇所を検出するみたいな感じで。
簡単にやるなら grepq.Unsafe を探すのでもいいけど。

あと godoc で目次がある程度読みやすくなるように考えて作ってみた。
Function タイプのように実質的に中身が一緒でも、敢えて別名にすることで一覧を整理したりとか。
英語は全然できないんだけど English > Broken English > Non-English Language という話を見かけて「確かにな」と思ったのでそういう方針で、あと Example を沢山用意することで言語を超えて意図を伝えようというのも試みている。
Go 言語の場合 Example を書くことで出力内容の検証もやってくれるようになるので一石二鳥だった。

小さいパッケージなので話としては大体こんな感じ。
今後も UPDATE, DELETE, INSERT 文辺りへの対応とか、あと日時周りの計算とかもプラットフォーム依存しないように使えるメソッド増やせるといいかもなあ。詳しくないけど。

まだ作ってみたという段階で、これから発端のプロジェクトで使うことでドッグフーディングしていく。そこで使った感触を元にまたドラスティックな変更をするかも知れないけど、ひとまず現状こんな感じ。

---- 2015-12-08 追記

全然まだテストしてないけど UPDATE と DELETE も一応組めるようになった。
あと type switch を減らせそうなところを減らした。
プライベートな型とかインターフェイスとかメソッドも結構 public にしてみたので、足りない関数とか諸機能を外部のパッケージで作って組み合わせて使うようなやり方もいけるかも知れない。
Clip to Evernote