Help us understand the problem. What is going on with this article?

Rails - ActiveRecord の scope を Query object で実装する

この記事は Ruby on Rails Advent Calendar 2015 16日目の記事です(投稿が無かったので過去に書いた記事をねじ込みました)

※2020/05/09追記 Finder Object についての記事を書きました。
https://furaji.hatenablog.jp/entry/2020/05/09/043924

参考記事

http://d.hatena.ne.jp/asakichy/20120821/1345505916
http://craftingruby.com/posts/2015/06/29/query-objects-through-scopes.html ※本投稿はこの記事を翻訳してまとめたものです。

Query object とは

それ自身が、SQLクエリーとして機能するオブジェクト構造
ActiveRecord にある複雑なSQLクエリを抽出するのに便利

実装

ActiveRecord からの抽出

以下の ActiveRecord のモデルは人気おすすめ動画を返すscopeを持っています。
複雑なSQLクエリではないですが、この例を用いて Query object についてまとめていきます。

class Video < ActiveRecord::Base
  scope :featured_and_popular,
        -> { where(featured: true).where('views_count > ?', 100) }
end

scopeは簡単に、Query object に抽出することができます。

module Videos
  class FeaturedAndPopularQuery
    def initialize(relation = Video.all)
      @relation = relation
    end

    def featured_and_popular
      @relation.where(featured: true).where('views_count > ?', 100)
    end
  end
end

モデルのscopeを呼ぶ代わりに、Query object を呼ぶことができます。

Videos::FeaturedAndPopularQuery.new.featured_and_popular

Videoクラスからロジックを抽出できたのは良いですが、このままでは既存のVideo.featured_and_popular呼び出し箇所に影響が出てしまいます。
Query object にロジックを抽出し、かつ呼び出しのインターフェイスが変わることが無ければもっとスマートです。

これを実現する方法を見つけるために、まずは、scopeメソッドの実装を見てみましょう。

scopeメソッドの確認

def scope(name, body, &block)
  unless body.respond_to?(:call)
    raise ArgumentError, 'The scope body needs to be callable.'
  end

  # ...

  singleton_class.send(:define_method, name) do |*args|
    scope = all.scoping { body.call(*args) }
    # ...

    scope || all
  end
end
  1. bodycallを呼び出せれば任意のオブジェクトで良い
  2. scopingメソッドはレシーバのscope+body.call(*args)scopeを結果として利用する

scopingメソッドは現在のスコープがbody.call(*args)で絞りこまれたものが返されます。
この例はちょっと複雑なので下記のドキュメントを読むとわかりやすいかもしれません。
http://apidock.com/rails/ActiveRecord/Relation/scoping

scope で Query object を利用

単にスコープから Query object を呼び出すことができます。
scopingのおかげで、Query object に現在のスコープを渡す必要はありません。

class Video < ActiveRecord::Base
  scope :featured_and_popular,
        -> { Videos::FeaturedAndPopularQuery.new.featured_and_popular }
end

しかし、これは非常に冗長になります。

scopeの実装で見てきたように、bodycallを呼び出せるオブジェクトであることを想定しています。
今、procを渡していますが、callを呼び出せる Query object に置き換えることができます。
メソッド名を変更して Query object におきかえてみましょう。

module Videos
  class FeaturedAndPopularQuery
    def initialize(relation = Video.all)
      @relation = relation
    end

    def call
      @relation.where(featured: true).where('views_count > ?', 100)
    end
  end
end

procは、スコープ宣言から削除され、Query object 自体がスコープのbodyになることができます。

class Video < ActiveRecord::Base
  scope :featured_and_popular, Videos::FeaturedAndPopularQuery.new
end

実はこれをさらに短くすることができます。
Videos::FeaturedAndPopularQueryは、callというクラスメソッドを持つことができ、
newcallを委譲させることが可能です。

module Videos
  class FeaturedAndPopularQuery
    class << self
      delegate :call, to: :new
    end
    # ↑は↓と同意味です
    # def self.call
    #   new.call
    # end

    def initialize(relation = Video.all)
      @relation = relation
    end

    def call
      @relation.where(featured: true).where('views_count > ?', 100)
    end
  end
end

最終的にはこのように明瞭な記述ができます。

class Video < ActiveRecord::Base
  scope :featured_and_popular, Videos::FeaturedAndPopularQuery
end  

クエリのロジックは、Query object に抽出されましたが、コードの他の部分を変更する必要がないようにscopeはそのまま残されています。
scopeは、基本的に Query object に委任されながら、 ActiveRecord のモデルは、きれいに見えるようになりました。

補足

ところどころ翻訳が雑なので何かあれば編集リクエストを投げてもらえるとありがたいです。

元記事に書かれていないこと

ディレクトリ構造

▾ app/
  ▸ assets/
  ▸ controllers/
  ▸ forms/
  ▸ helpers/
  ▸ mailers/
  ▾ models/                          
      video.rb
  ▾ queries/
    ▾ videos/
        featured_and_popular_query.rb
      query.rb
  ▸ services/
  ▸ views/

私はこのように、queriesディレクトリ以下に Query object を設置しました。

scopeに定義する前提で、以下のようにQueryクラスを継承する設計にしています。

  • query.rb
class Query
  class << self             
    delegate :call, to: :new
  end
end
  • videos/featured_and_popular_query.rb
module Videos
  class FeaturedAndPopularQuery < Query
    def initialize(relation = Video.all)                           
      @relation = relation
    end

    def call
      @relation.where(featured: true).where('views_count > ?', 100)
    end
  end
end

おまけ

引数を用いた例

  • videos/featured_and_popular_query.rb
module Videos
  class FeaturedAndPopularQuery < Query
    def initialize(relation = Video.all)
      @relation = relation
    end

    def call(views_count)
      @relation.where(featured: true).where('views_count > ?', views_count)
    end
  end
end

と書き換え、
Video.featured_and_popular(1000)
のように呼び出すことができます。

arelを用いた例

where('views_count > ?', views_count)
ってなんかダサいですよね?
そんな時はarelが使えます。

  • videos/featured_and_popular_query.rb
module Videos
  class FeaturedAndPopularQuery < Query
    def initialize(relation = Video.all)
      @relation = relation
    end

    def arel_views_count
      Video.arel_table[:views_count]
    end

    def call(views_count)
      @relation.where(featured: true).where(arel_views_count.gt(views_count))
    end
  end
end

まとめ

Query object を用いた事により ActiveRecord のモデルがすっきりしました。
今回は簡単な例を取り上げましたが、複雑なクエリになるほど力を発揮してくると思います。

furaji
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away