【小ネタ】Railsで .select にサブクエリを使った際の .count の落とし穴とその回避策
.select
にサブクエリを使った際の .count
の落とし穴とその回避策
Railsで 〜 映画に紐づく代表監督の情報を持たせるケース 〜
Railsで、モデルに存在しない値を仮想的に保持したい場合に、select
にサブクエリを記述して補助的なカラムを定義することがあります。
たとえば「映画(movies
)」に紐づくスタッフのうち、代表監督(director_flg: true
の人物)の名前を取得しておきたい場合などがその一例です。
サブクエリで代表監督の名前を取得する
以下のようなリレーションがあったとします:
-
movies
テーブル(映画本体) -
movie_staffs
テーブル(映画とスタッフの中間) -
staffs
テーブル(人物情報)
そして、movie_staffs.director_flg = true
のレコードが代表監督を表すと仮定します。
このとき、映画ごとの代表監督名を仮想的に保持するには、以下のように select
にサブクエリを記述します。
Movie.select(<<~SQL)
movies.*,
(
SELECT staffs.name
FROM movie_staffs
INNER JOIN staffs ON staffs.id = movie_staffs.staff_id
WHERE movie_staffs.movie_id = movies.id
AND movie_staffs.director_flg = TRUE
ORDER BY movie_staffs.id ASC
LIMIT 1
) AS director_name
SQL
このようにしておけば、movie.director_name
で代表監督の名前を取得できるようになります。
.count
の罠:MySQL構文エラー
ここからcountすることはあんまりないと思うんですが、自動テスト等で意図せずcountしてたってことありますよね。
この場合、仮想カラム付きの select
を定義したまま .count
を呼び出すと、以下のような構文エラーが発生します:
Movie.with_director_name.count
SELECT COUNT(
movies.*,
(SELECT ...) AS director_name
) FROM movies
このような COUNT(複数カラム)
構文は MySQL では無効 であり、Mysql2::Error: You have an error in your SQL syntax
が返されます。
回避策
この問題を回避するためには、以下の方法が有効です。
.size
を使う
方法1: movies = Movie.with_director_name
movies.length # ← SQLが未発行なら count、発行済みなら配列長を返す
.size
は .loaded?
を自動で判別し、適切な手段で件数を返してくれます。
SQL未発行時に実行されるcountは上記コードによるとcount(:all)なので、selectを無視してくれます。
.unscope(:select)
を使う
方法2: Movie.with_director_name.unscope(:select).count
サブクエリ付きの select
を無効化し、通常の COUNT(*)
を発行させる方法です。
.eager_load
を併用する
方法3: Movie
.with_director_name
.eager_load(movie_staffs: :staff)
.count
eager_load
を使うと、ActiveRecordは LEFT OUTER JOIN
を組んだうえで COUNT(DISTINCT movies.id)
のような構文に変換してくれます。そのため、サブクエリ付きの select
が含まれていても安全に .count
を呼び出せます。
.eager_load
で .count
が通るのか?
補足:なぜ 通常 .select(...).count
は、その select
内容を丸ごと使って COUNT(...)
を生成しようとするため、構文が壊れてしまいます。
しかし eager_load
を使うと、ActiveRecordは安全な COUNT(DISTINCT 主キー)
に自動的に切り替えるため、MySQLでも正しく動作します。
まとめ
方法 | 内容 | 安定性 |
---|---|---|
.size |
SQL未発行で配列長を返すこともあり高速 | ◎ |
.unscope(:select).count |
サブクエリを除いて単純なカウントにする | ◎ |
.eager_load 併用 |
COUNT(DISTINCT ...) に変換され構文エラーを防げる |
◯ |
.select
にサブクエリを使って仮想カラムを定義するのは便利な一方で、.count
のような集計メソッドと組み合わせた際に、思わぬ落とし穴にはまることがあります。
改めて調べてみると、なんにも考えずcountをしていたことが多かったので、sizeも積極的に使っていこうかなと思いました。
JOINの深さやDBの構文仕様を踏まえながら、安全な構文を意識して使い分けていきましょう。
Discussion