RailsのデザインパターンのひとつにQueryオブジェクトがあります。 これはコントローラからActiveRecordモデルに対する絞り込みなどの操作を、ひとつの責務としてクラスに切り出すパターンです。 コントローラの肥大化を防ぎ、またテストが書きやすくなります。 このQueryオブジェクトについて示します。
以下、ActiveRecord::Relationを単にRelationと表記します。 Queryオブジェクトは「Relationに対し結合や絞り込み、ソートなどの操作を定義し、Relationを返すクラス」です。 ActiveRecordモデルのscopeとして設定することで、チェーンの一部として使用できるようになります。
よく見られるQueryオブジェクトの例として、Relationではなく配列などほかのクラスを返すものがあります。 ただ、クラスやデザインパターンからはインタフェースや返り値が予想できるべきです。 「QueryオブジェクトはRelationを返す」というルールのもとに設計することで、コードの品質が保たれることにつながります。
Queryオブジェクトは「コントローラからクエリ操作の責務を分離し、ActiveRecordモデルと疎結合に保つため」に必要なデザインパターンです。
コントローラからクエリを操作する場合、複雑なクエリは長くなり、肥大化してしまいます。 また正しいRelationが取得できているかのテストが書きづらくなります。 再利用性もありません。
たとえば「1日以内に記事を投稿したユーザの一覧」を取得するコードを見てみます。 コントローラに書くと、次のようになります。
class UsersController < ApplicationController
def index
@users = User.joins(:posts)
.where(
posts: {
published_at: 1.day.ago..
}
)
.order(created_at: :desc)
end
end
この例ならまだいいですが、これに「PVが100以上の記事を書いたユーザ」「フォロワーが3人以上ついたユーザ」のように条件がふえていくと、コントローラがどんどん肥大化していきます。
コントローラの責務はモデル層に命令を出し、ビュー層にデータを渡すことにあります。 モデル層の操作を組み立てることではありません。 コントローラがモデル層の知識を知りすぎると、モデル層の変更の影響を受けてしまいます。
Queryオブジェクトは、コントローラからクエリ操作の責務を分離し、クラス間を疎結合に保つためのデザインパターンです。
ここでは、上述したユーザ一覧の例をQueryオブジェクトで書いてみます。
この記事にあるコードは次の各バージョンで動作を確認しています。
名前 | バージョン |
---|---|
Ruby | 2.7.1 |
Ruby on Rails | 6.0.3.2 |
まず、Queryオブジェクトの使われ方を把握するために、コントローラを見てみます。 コントローラからは次のようにActiveRecordモデルのscopeとして呼び出します。 引数として日付を指定しています。
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.recently_posted(3.days)
end
end
次にActiveRecordモデルです。 scopeとしてQueryオブジェクトを渡しています。 AcitveRecordモデルのscopeにすることで、「返ってくるのがそのモデルのRelationである」ことが自明になるというメリットがあります。
# app/models/user.rb
class User < ApplicationRecord
scope :recently_posted, RecentlyPostedUsersQuery
end
最後にQueryオブジェクトです。 QueryオブジェクトのベースとなるQueryクラスと、それを継承したRecentlyPostedUsersQueryクラスを定義しています。
ActiveRecordモデルのscopeとしてオブジェクトを渡すと、そのオブジェクトの#call
メソッドが呼ばれます。
これを#new
に委譲することで、Queryオブジェクトの#call
が実行される仕組みです。
#call
に引数を定義することで、コントローラから渡せるようになります。
# app/queries/query.rb
class Query
class << self
delegate :call, to: :new
end
def call
raise NotImplementedError
end
private
attr_reader :relation
end
# app/queries/recently_posted_users_query.rb
class RecentlyPostedUsersQuery < Query
DEFAULT_FROM = 1.day
def initialize(relation = User.all)
@relation = relation
end
def call(from = DEFAULT_FROM)
relation
.joins(:posts)
.where(
posts: {
updated_at: from.ago..
}
)
end
end
以上となります。 コントローラからRelationに対する操作の責務をひとつのクラスに分離できました。 ActiveRecordモデルに変更があってもコントローラへの影響がなくなり、またテストも書きやすくなりました。
以上の内容をもとに、Queryオブジェクトを設計するときのルールについてまとめます。
app/queries
に配置する#call
を定義し、ActiveRecord::Relationクラスのオブジェクトを返す#call
は副作用のないメソッドにするQueryオブジェクトの例として、ひとつのクラスに複数のpublicメソッドを定義する例を見かけます。 これはひとつのクラスに複数の責務をもつことになります。 ある責務の変更が別の責務のメソッドに影響を及ぼすため、避けるべきだと考えます。
ひとつの責務をひとつのクラスに切り出し、単一責任の原則を守って設計することで、保守しやすいコードにすることができます。
Webエンジニア&プロダクトマネージャ。 プログラミングで『ひとりで働く』を模索中。 三重の山の中で妻とこども、ネコとのんびり暮らしています。
Follow @zenizhWebエンジニア&プロダクトマネージャ。 プログラミングで『ひとりで働く』を模索中。 三重の山の中で妻とこども、ネコとのんびり暮らしています。
Follow @zenizh共著で『現場で使えるRuby on Rails 5(マイナビ出版)』を書きました。
Amazonでみる