scopeの条件はjoinsで絞ろう


Railsのscopeは強力な機能で、ActiveRecordにできることをかなりこなせますが、それが故にトラブルとなることもあります。

TL; DR

  • 絞込用のscope内でのテーブル結合は.joinsで行う

設例

今回の例を説明するサンプルとして、こんなアプリケーションを考えてみます(何がモデルかは気にしないでください)。

  • ユーザーが投稿(Post)をしていく。
  • 投稿には、複数のコメント(Comment)を付けられる。
  • コメントには、各ユーザーが感謝(Thank)を表明できる。

素直にRailsのモデルに起こすと、以下のようになります。

3モデルまとめて書いています

class Post < ActiveRecord::Base
  belongs_to :user
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
  belongs_to :user
  has_many :thanks
end

class Thank <  ActiveRecord::Base
  belongs_to :comment
  belongs_to :user
end

scopeとは

詳しい記事があるので詳細は省略しますが、要は「モデルに続けられるメソッドチェーンを束ねたもの」です。これ自体もActiveRecord::Relationを返すようにしておけば、メソッドチェーンの一部として使えるようになります。

ここでは、「ある人が感謝をつけたコメント」というスコープを立ててみます。

app/models/comment.rb
  scope :thanked_by, ->(id) { eager_load(:thanks).where(thanks: { user_id: id }) }

このようにしておけば、Comment.thanked_by(2)のように使えます。

scopemerge…失敗

Railsのscopeにはさらに強力な機能がありまして、mergeというものがあります。これは、「別なテーブルとつないだときにも、検索に同じscopeを使える」というものです(関連記事)。ということで、さっき作ったthanked_byを使って、「感謝したコメントのある投稿を検索する」というスコープを作ってみることにします。

app/models/post.rb
  scope :with_thanked_comments, ->(id) { eager_load(:comments).merge(Comment.thanked_by(id)) }

一見動きそうに見えますが、このままPost.with_thanked_commentsを使おうとすると、ActiveRecord::ConfigurationErrorになってしまいました。

失敗の原因と対策

ここの記事に詳しいのですが、Comment.thanked_byの中で使っていた.eager_loadには、「SQLでテーブルをJOINする」ことと「eager_load先をActiveRecordオブジェクトに起こす」という2つの役割があります。そして、mergeした後にも後者の機能が残ってしまって、Post上でthanksのリレーションを使おうとして、エラーとなっていたのですた。

ということで、.eager_load.joinsに置き換えてJOINだけさせるようにすれば、問題なく動くようになりました。なお、.joinsでJOINした先に対して.eager_load.includesを使った場合も、テーブルをもう一度読んでしまうことはなく、最初にJOINした分を有効活用してくれます。

根本的に言えば、絞込用のscopeでActiveRecordを生成する必要は本来ないわけで、そのあたりの役割分担、というのも考えておいたほうがいいかもしれません。