Ruby
Rails
Rails4

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

More than 3 years have passed since last update.

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


参考記事

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 のモデルがすっきりしました。

今回は簡単な例を取り上げましたが、複雑なクエリになるほど力を発揮してくると思います。