LoginSignup
2
1

More than 5 years have passed since last update.

Ecto で MySQL の SELECT SQL_CALC_FOUND_ROWS と SELECT FOUND_ROWS() を呼び出す

Posted at

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 になっていないことの確認

などの処理をするはめになりました。

最終的に、一連の処理はモジュールとしてまとめましたので、もし同様のニーズがある方はぜひ使ってみてください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1