Posted at

yield_selfを使ってActiveRecordのRelation chainをいい感じに書く

More than 1 year has passed since last update.


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ファイルは以下みたいな。


create_users.rb

class CreateUsers < ActiveRecord::Migration[5.2]

def change
create_table :users do |t|
t.string :name, null: false

t.timestamps
end
end
end



create_articles.rb

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



create_tags.rb

class CreateTags < ActiveRecord::Migration[5.2]

def change
create_table :tags do |t|
t.string :name, null: false
end
end
end

以下がN:Mのマッピングテーブル。よくある感じです。


create_article_tags.rb

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を以下のように考えます。


article_form.rb

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を使ってみる

手始めにquerytapを使ってリファクタしてみます。

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にいく前に上記からwhereorderの羅列を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