Help us understand the problem. What is going on with this article?

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
mohikanz
エンジニアのための雑談コミュニティ
https://mohikanz.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away