この記事は 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
-
body
はcall
を呼び出せれば任意のオブジェクトで良い -
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
の実装で見てきたように、body
はcall
を呼び出せるオブジェクトであることを想定しています。
今、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
というクラスメソッドを持つことができ、
new
にcall
を委譲させることが可能です。
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 のモデルがすっきりしました。
今回は簡単な例を取り上げましたが、複雑なクエリになるほど力を発揮してくると思います。