🧚

【小ネタ】Railsで .select にサブクエリを使った際の .count の落とし穴とその回避策

に公開

Railsで .select にサブクエリを使った際の .count の落とし穴とその回避策

〜 映画に紐づく代表監督の情報を持たせるケース 〜

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 が返されます。


回避策

この問題を回避するためには、以下の方法が有効です。

方法1: .size を使う

movies = Movie.with_director_name
movies.length  # ← SQLが未発行なら count、発行済みなら配列長を返す

.size.loaded? を自動で判別し、適切な手段で件数を返してくれます。

https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation.rb#L363

SQL未発行時に実行されるcountは上記コードによるとcount(:all)なので、selectを無視してくれます。


方法2: .unscope(:select) を使う

Movie.with_director_name.unscope(:select).count

サブクエリ付きの select を無効化し、通常の COUNT(*) を発行させる方法です。


方法3: .eager_load を併用する

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