tl;dr
- ActiveRecordで動的にRelation chainが変わるような場合を考える
- ユーザ入力によってfilterやorderが変わったりするよくあるケース
-
yield_selfを使ってchainをスマートに書く
Using yield_self for composable ActiveRecord relations
元ネタはこちらの記事です。ほぼこれの紹介といってよい。
https://robots.thoughtbot.com/using-yieldself-for-composable-activerecord-relations
yield_selfについて
yield_selfはRuby2.5で追加されたmethodでtapと同じようなユースケースで使用されるけど、tapと違ってyieldの戻り値がそのままyield_selfの戻り値となるところが違います。
これをRelation chainに使うことでいい感じに書けるという話です。
これだけだとよくわからんので、サンプルコードで考えてみます。
Model
ブログサイトで考えてみます。
User(ユーザ)、Article(記事)、Tag(記事につけるタグ)の3つのModelにします。
migrationファイルは以下みたいな。
class CreateUsers < ActiveRecord::Migration[5.2]
def change
create_table :users do |t|
t.string :name, null: false
t.timestamps
end
end
end
class CreateArticles < ActiveRecord::Migration[5.2]
def change
create_table :articles do |t|
t.string :title, null: false
t.text :body, null: false
t.references :author, foreign_key: {to_table: :users}, null: false
t.timestamps
end
end
end
class CreateTags < ActiveRecord::Migration[5.2]
def change
create_table :tags do |t|
t.string :name, null: false
end
end
end
以下がN:Mのマッピングテーブル。よくある感じです。
class CreateArticleTags < ActiveRecord::Migration[5.2]
def change
create_table :article_tags do |t|
t.references :article, null: false
t.references :tag, null: false
t.datetime :created_at, null: false
t.index :article, :tag, unique: true
end
end
end
FormObject
ブログ記事の一覧ページ用のFormObjectを以下のように考えます。
class ArticleForm
include ActiveModel::Model
attr_reader :title, :order
attr_accessor :tag_ids
def initialize(_attributes = {})
super
@tag_ids ||= []
end
end
記事のタイトル(title)、タグ(tag_ids)による検索と表示順(order)を指定できるとします。
安直なRelation chain
なにも考えないやつです。
ここからリファクタしていく想定。
class QueryBuilder
delegate :title, :tag_ids, :order, to: :@article_form
def initialize(article_form)
@article_form = article_form
end
def query
relation = base_relation
relation = relation.where(title: title) if title.present?
relation = relation.where(tags: { id: tag_ids }) if tag_ids.present?
relation = relation.order(created_at: order) if order.present?
relation
end
private
def base_relation
Article.includes(:author, :tags)
end
end
これぐらいならまだよいかもしれませんが、もっと複雑に検索条件があるような場合はqueryに分岐をひたすら足していくことになってつらくなってきます。
query内でrelationに都度代入しなおしているのも気になりますね。
tapを使ってみる
手始めにqueryをtapを使ってリファクタしてみます。
def query
base_relation.tap do |relation|
relation.merge! Article.where(title: title) if title.present?
relation.merge! Article.where(tags: { id: tag_ids }) if tag_ids.present?
relation.merge! Article.order(created_at: order) if order.present?
end
end
代入の代わりにmerge!が登場してきました。
まあ少しはよくなったかも(?)ってぐらいでしょうか。
yield_selfにいく前に上記からwhere、orderの羅列をmethodに分解してみます。
class QueryBuilder
delegate :title, :tag_ids, :order, to: :@article_form
def initialize(article_form)
@article_form = article_form
end
def query
base_relation.tap do |relation|
relation.merge! matching_title if title.present?
relation.merge! matching_tags if tag_ids.present?
relation.merge! order_created_at if order.present?
end
end
private
def base_relation
Article.includes(:author, :tags)
end
def matching_title
Article.where(title: title)
end
def matching_tags
Article.where(tags: { id: tag_ids } )
end
def order_created_at
Article.order(create_at: order)
end
end
merge!で破壊的なのと、せっかくmatching_xxxとか分割したのにif文はqueryにあるのが気になりますね。
tapが自分自身を返す影響がでているので、ここまでくるとyield_selfの使い所がみえてきます。
yield_self版
class QueryBuilder
delegate :title, :tag_ids, :order, to: :@article_form
def initialize(article_form)
@article_form = article_form
end
def query
base_relation
.yield_self(&method(:matching_title))
.yield_self(&method(:matching_tags))
.yield_self(&method(:order_created_at))
end
private
def base_relation
Article.includes(:author, :tags)
end
def matching_title(relation)
return relation unless title.present?
relation.where(title: title)
end
def matching_tags(relation)
return relation unless tag_ids.present?
relation.where(tags: { id: tag_ids } )
end
def order_create_at(relation)
return relation unless created_at.present?
relation.order(create_at: order)
end
end
一気にやっちゃいましたが、queryがmethod chainになってだいぶみやすくなりました。
yield_selfのmethod chainのところをみていきましょう。
methodは動的method呼び出しのためにmethodのSymbol表記を渡すとMethodオブジェクトを返します。
&をつけることでProcに変換してyield_selfに渡します。
yield_selfにすることで前のyieldの返り値が各methodの引数となります。
これでmerge!を使う必要がなくなるので、FormObject(ArticleForm)の値による分岐を分割したmethod内にいれることができます。
おまけでinjectを使うとloopにできます。(あんまり読み易いとはいえないですが)
def query
%i[matching_title matching_tags order_create_at].inject(base_relation){|relation, matcher|
relation.yield_self(&method(matcher))
}
end