esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

CurrentAttributesを使ってリクエストごとのdefault_scopeを設定する

こんにちは、永和システムマネジメントに新卒で入社して4年目になりました swamp09 です。

この記事では、以前関わったRailsプロジェクトの default_scopeユースケースと実装例の話をします。

ユーザーが記事を投稿したり、記事にコメントしたりするWebサービスがあり、Twitterのようにユーザーが他のユーザーをブロックする機能がありました。 ブロックすると、ブロックしたユーザーからはブロックされたユーザーの投稿した記事やコメントが一切非表示になります。このブロックのロジックを default_scope で書けるとコーディングが楽になりそうだなぁとプロジェクトメンバーと話していました。 ただ、リクエストごとに異なるログインユーザーのブロックされたユーザーを非表示にするのは default_scope でうまく書けない、というところで悩んでいました。

そんな時に見つけたのが ActiveSupport::CurrentAttributes でした。

ActiveSupprt::CurrentAttributes ではリクエストごとの属性をシステム全体で簡単に利用できるよう保持できます。これを使って上記のような default_scope を書けるのではと思いました。

具体例をRailsチュートリアル第6版のサンプルアプリを元にコードで示します。sample_appにはUserとMicropostというモデルがあります。

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
end

class Micropost < ApplicationRecord
  belongs_to :user
end

sample_appに追加でユーザーが他のユーザーを非表示にするためのブロック機能を追加していきます。

# Blockモデルを追加する
class Block < ApplicationRecord
  belongs_to :blocker, class_name: "User"
  belongs_to :blocked, class_name: "User"
  validates :blocker_id, presence: true
  validates :blocked_id, presence: true
end

# UserモデルにBlockモデルとの関連を追加する
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy

  has_many :active_blocks, class_name:  "Block",
                                  foreign_key: "blocker_id",
                                  dependent:   :destroy
  has_many :blocking, through: :active_blocks, source: :blocked
  has_many :passive_blocks, class_name:  "Block",
                                   foreign_key: "blocked_id",
                                   dependent:   :destroy
  has_many :blockers, through: :passive_blocks, source: :blocker
end

Blockモデルはsample_appのRelationshipモデルとほぼ同じです。

これに ActiveSupport::CurrentAttributes を用いてリクエストごとの default_scope を設定していきます。

# app/models/current.rb を追加する
class Current < ActiveSupport::CurrentAttributes
  attribute :blocking_ids
end

# app/controllers/concerns/hide_blocking_user.rb を追加する
module HideBlockingUser
  extend ActiveSupport::Concern

  included do
    before_action :set_current
  end

  private

  def set_current
    Current.blocking_ids = current_user&.blocking&.ids
  end
end

# ブロック機能が関わるControllerでconcernをincludeする
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy,
                                        :following, :followers]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy

  # concernをincludeする
  include HideBlockingUser

  def index
    @users = User.paginate(page: params[:page])
  end
end

# MicropostモデルとUserモデルのdefault_scopeを書き換える
class Micropost < ApplicationRecord
  belongs_to :user
  has_one_attached :image
  
  default_scope do
    if Current.blocking_ids.present?
      where.not(user_id: Current.blocking_ids).order(created_at: :desc)
    else
      order(created_at: :desc)
    end
  end
end

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy

  default_scope do
    where.not(id: Currnet.blocking_ids) if Current.blocking_ids.present?
  end
end

以上のようにして、HideBlockedUserinclude したControllerであれば default_scope を使ったブロック機能が使えるようになりました。

まとめ

リクエストごとに default_scope を指定したい、というユースケースはあまり出会うことはないかもしれませんが、ActiveSupprt::CurrentAttributes を使えば実装できることがわかりました。実際に使うかどうかは別として… CurrentAttributesのドキュメントにも注意点が書かれていますが、CurrentAttributes を使う際はグローバルな値をいろんなところからアクセスできるようになるので、慎重に扱う必要があるかと思います。

ブロック機能を追加したコードは GitHub に置きました。なにかの参考になれば幸いです。