Edited at

複雑な条件式でもunscopeがしたい!

More than 1 year has passed since last update.


はじめに

default_scopeは悪だ!みたいな言説はたくさんあり、実際に痛感したこともあるのですが、やはり可能性というものを感じてしまいます。

例えばRails 2からビュー内で出力用のERBタグを使うと自動的にエスケープされるようになったように、安全側へ倒すのにdefault_scopeは使えるのではと感じています。

そこでこの記事ではdefault_scopeを有用に使えそうな状況を仮定して、ぶちあたった問題(ネストされた条件式でunscopeが使えない)とその解決策について提示します。


default_scopeの有用性


状況: アイテムが作成者、ステータス、投票数を持つ

Rails 4を使っていると想定し、ユーザが複数のアイテムを持つ状況を考えてください。

アイテムのテーブルはこんな感じ。ユーザがhas-manyでアイテムを持つためのuser_idと、ステータス(status)、スコア(score)を持ち、あとはなんやらかんやらアイテムの名前やら詳細を持つとします。


db/scheme.rb

  create_table "items", force: :cascade do |t|

t.integer "user_id", limit: 4, null: false
t.integer "status", limit: 4, default: 1, null: false
t.integer "score", limit: 4, default: 0, null: false
...
end


ステータス(status)

アイテムのstatusには3つあり、ユーザ全員が見られるopenと、作成したユーザ自身しか見られないplan、作成したユーザ自身も見られないdeleteがあるとします。


app/models/item.rb

class Itm < ActiveRecord::Base

enum status: { open: 1, plan: 2, delete: 3 }
end


スコア(score)

各ユーザが +1 か -1 を各アイテムにつき1回ずつ投票できることとします。

例えばあるアイテムに、ユーザ2人が +1 を投票し10人が -1 を投票すると、そのアイテムの score は -8 になります。


活用編: デフォルトで見せたくないものは抜いておきたい

このサイトではアイテムをfindする時、デフォルトでは「openの状態のアイテム」か「自身が作成したplan状態のアイテム」しか見せたくありません。

なぜなら「他人のplan状態のアイテム」が見えたらセキュリティインシデントですし、違反申告があって「delete状態にしたアイテム」が見えたらそれも問題です。

更にこのサイトでは非常にアイテムの投稿数が多いので、scoreが-5以下のアイテムは基本的にサイト全体で非表示にしたいです。が、作成者自身にはそのアイテムがdelete状態になるまでscoreに関係なく見せておきたいです。

こんな条件をクリアするdefault_scopeは以下になります(以下、current_userには現在のログインしているユーザのオブジェクトが入っているとします)。


app/models/item.rb

  default_scope {

open_arel = self.arel_table[:status].eq 1
score_arel = self.arel_table[:score].gt -5
plan_arel = self.arel_table[:status].not_eq 3
user_arel = self.arel_table[:user_id].eq current_user.id

public_arel = self.arel_table.grouping open_arel.and(score_arel)
myself_arel = self.arel_table.grouping plan_arel.and(user_arel)

where public_arel.or(myself_arel)
}


こうすると Item.all した時のSQLは以下になります(current_user.idが117とします)。

SELECT `items`.* FROM `items` WHERE ((`items`.`status` = 1 AND `users`.`score` > -5) OR (`items`.`status` != 3 AND `items`.`user_id` = 117))


問題編: scoreの条件だけ抜きたい

ここでサイトのパブリックなエリアでは上記の条件を使いたいのですが、各ユーザのページでは限定的に score が何であっても表示させたいです。

ここで素直にunscopeを使うとSQLはこうなります。

Item.unscope(where: :score)

するとSQLは、

SELECT `items`.* FROM `items` WHERE ((`items`.`status` = 1 AND `users`.`score` > -5) OR (`items`.`status` != 3 AND `items`.`user_id` = 117))

変わっとらんやんけ!

はい。unscopeはネストされた条件式に対応していません。


解決編: deep_unscopeを実装しました

そこでネストされた条件式にも対応したdeep_unscopeを実装しました(これが今回の記事のメイン)。gistはこちら。config/initializersにぶちこむと使えます。

肝はArelのASTを再帰で探索し、標準のunscopeと同じようなことをしています。

deep_unscopeをこんな風に使うと、

Item.all.deep_unscope(:score)

するとSQLは、

SELECT `items`.* FROM `items` WHERE (`items`.`status` = 1 OR (`items`.`status` != 3 AND `items`.`user_id` = 117))

ヨッシャヨッシャ!!

ATTENTION! 動作が不安定な気がするしRails5対応してない気がするし意図から外れた適用のされ方もするので、保証できない感じのアレです(どこかでgem作りたい…)


おわりに

なんだかんだ言って、権限周りは結構疲弊する部分なので、仕組みを作ったらあとは何も考えたくないですし、インシデントはなるたけ起こしたくないです。

そんな中、default_scopeをあるいは使えるのでは?といった実験と、そこで生じた課題の解決のためにdeep_unscopeを作成しました。

ではみなさん、良いクリスマスを(ってもう終わってるか)…