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

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
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.