MySQL で(カーソルじゃなく総ページ数を必要とするような)ページングなどを行う場合、SQL_CALC_FOUND_ROWS と FOUND_ROWS() を使うととても便利ですが、Ecto で簡潔に実現する方法がなかなかわからず、とても苦労しました。
もちろん生の SQL を書いて Ecto.Adapters.SQL.query!/2 を呼べばできないことは無いのですが、それもアレなので、queryable を使ってできる方法を実現しました。
最終的にモジュールとしてまとめましたので、もし同様のニーズがある方は使ってみてください。
TL;DR
結局 Ectoの標準(?)機能では実現できないので、
- Repo.to_sql/2 で queryable を SQL に変換し、文字列置換でその SQL に
SQL_CALC_FOUND_ROWS
を追加して、Ecto.Adapters.SQL.query!/2
に渡す
という、とてもベタな方法で実現しています。
Select のカラムを取得するために queryable の select.expr など内部表現を使っていますが、プロダクションで使う予定なので当該モジュールの中では、念の為 AST が変わってないかのチェックや、changeset にも一応通すなど、変にバグるようなことは無いような工夫をしています。
もうちょっと詳しい内容
例えばid:1〜100の100レコードの questions テーブルがあったとします。MySQL には
SELECT SQL_CALC_FOUND_ROWS id, title, body FROM questions WHERE id > 50 LIMIT 5;
SELECT FOUND_ROWS()
みたいに書くと、5ではなく50つまり WHERE 条件にマッチしたレコード数を LIMIT(やOFFSET)を無視して返してくれる機能があり、ページングを実現する時にとても便利です(他の RDMS に同様の機能があるかどうかは知りません)。
最初は Ecto の select に対して select: {fragment("SQL_CALC_FOUND_ROWS"), q},
のようにできないか見てみたのですが、この場合 SELECT SQL_CALC_FOUND_ROWS, * FROM questions
のように、余計な ,
が付与されてしまいます。
しょうがないので、build_inject_sql/2の実装のように「queryable を Repo.to_sql で SQL に変換し、文字列置換で SQL_CALC_FOUND_ROWS
を付与する」という方法で回避しました。ただし Ecto.Adapters.SQL.query!/2
の戻り値は %Question{}
のような __struct__
ではありません。
これをそのまま使えるのであれば問題ないのですが、実はこの機能は、とあるプロダクションのバックエンドで採用予定の Absinthe の resolver の中で必要になりました。absinthe ではデータを __struct__
の形で引き回せるようにしておくと色々と楽なので、これを Repo.all/1 などと同様に __struct__
に詰め戻すため、
- queryable.select からキーのリストを取得
- これを利用して、キーと
Ecto.Adapters.SQL.query!/2
の結果をマージ
- これを利用して、キーと
-
__struct__
に戻す - (念の為)changeset で valid? = false になっていないことの確認
などの処理をするはめになりました。
最終的に、一連の処理はモジュールとしてまとめましたので、もし同様のニーズがある方はぜひ使ってみてください。