Railsのdefault_scopeは撒いてはいけない種

この記事は [CrowdWorks Advent Calendar 2017](https://qiita.com/advent-calendar/2017/crowdworks) の12日目の記事です。

# はじめに

クエリを発行して調べたレコード件数と、Railsのプロダクト上で出てきた件数が違ったことはありませんか？私はあります。何を隠そう、それが私と`default_scope`との出会いでした。

今日はそんな`default_scope`についてお話したいと思います。

## 本当にあった`default_scope`な話

それはとある昼下がり、いつもしているようにプロダクトの仕様調査で、データの件数を調べていたんです。「タスク作業」という、そこそこ件数が発生するクラスの調査だったんですが、条件に当てはまるレコードがどれだけあるのか、クラス間の関連に注意をしながら黙々と作業を進めていました。

そんな慎重な作業の末、ついにはMacの黒い画面に「7869件のレコードが該当」という結果が映し出されたのです。が、何か違和感を感じたんです。条件を絞っているとはいえ、日次を範囲としたタスク作業ならもう少し件数があってもいいはずだ、と。

私は何度もコマンドを叩き、関連を見直しました。ところが、何をどう確認しても間違っていなそうなのです。件数の規模を見誤ったのかな？と思い分析基盤から直接クエリを発行してみたら、そこには「9308件」との表示が。規模感はだいたいあっていた・・・だけど、なぜか件数が少ない。

1500件程度のデータがどこかに消えてしまったのです。私は怖くなりました。同じDBのテーブルを覗いているはずなのに、データを取得すると件数が異なるからです。どうして？なんで？神隠しか？そんな言葉が頭の中でぐるぐる回りだし、ついには徳を積むにはどうしたらいいのか、日頃の行いについて悔い改める点は無いのかを思案しはじめたのです。

一人で考えるのがあまりにも怖くなってしまったので、通りすがりの人に駆け寄り、事のすべて話してどうするべきか意見を聞いてみることにしました。

> タスク作業か・・・あそこは出るんだ。
> 夜な夜なデータがキャンセルされるたびに**アイツ**のせいで検索結果からはずれちまうのさ・・・。


そうか、**アイツ**だったのか・・・。

スクロールバーが小さく、様々な関連を持っているタスク作業のクラスを開き、

検索欄へ**アイツ**を入力してReturnを押す。

あった・・・あったぞ・・・こいつのせいだったのか・・・

```ruby
default_scope -> { where(deleted_at: nil) }
```


## シンプルな例


`User`が複数の`Contract`と関連をもっている例の場合、発行されるクエリは以下の通りになります。

```ruby
Contract.where(user_id: 1)
=> SELECT  `contracts`.* FROM `contracts` WHERE `contracts`.`user_id` = 1
```

ここでもし、`Contract`に`default_scope`が書かれていたらどうでしょう？以下の出力を見ると、クエリに`cancelled_at`の条件が勝手に加わっています。

```ruby
Contract.where(user_id: 1)
=> SELECT  `contracts`.* FROM `contracts` WHERE `contracts`.`cancelled_at` IS NOT NULL AND `contracts`.`user_id` = 1
```


## `default_scope`を書きたくなってしまう場面

`default_scope`は、発行されるクエリのデフォルト条件を定義できるコードです。「有効な契約はキャンセル日が埋まっていない」のような仕様があると、`default_scope`を使いたくなってきますね。書き方も簡単で、以下の通りたった一行書くだけです。

```ruby
default_scope { where(contracts: { cancelled_at: nil }) }
```

こんなに簡単に書けちゃうと思わず書きたくなってしまいますが、ここはその気持ちをグッと堪えましょう。


## 書かないほうがいい３つの理由

限られた条件下では`default_scope`の有用性もあるのでしょう。ただ、そんな検討するくらいなら`scope`にしてに使ったほうがみんな幸せになるでしょう。共同でメンテナンスするプロダクトコードでは、`default_scope`は遅効性の厄介者です。


```ruby
# scopeの場合
scope :not_cancelled, -> { where( cancelled_at: nil) }

# scopeは操作が明示されているため人間が理解できる
Contract.not_cancelled.all
=> SELECT `contracts`.* FROM `contracts` WHERE `contracts`.`cancelled_at` IS NULL
```


### リスク 

前述の例では、Railsが出力するクエリに注目して記載していましたが、多くの場合はレコードや件数に注目するはずです。結果を知らないと存在に気がつきにくいというリスクがあります。

明らかに違うと分かる結果なら、`default_scope`があるという結論にたどり着くことができるでしょう。しかし実際の開発や保守では、もっと複雑なクエリで、多くの場合は気がつくことに難しいはずです。例えば正常終了した1000万件のうち、`default_scope`の影響で少数件だけ予期せぬ値が入っていたとしたら誰も気がつけないはずです。極端な話ではありますが、それだけのリスク事項という認識は必要でしょう。

### 過剰な機能

`default_scope`は影響範囲が広いため、自分が命令したコードに加えて、他人の命令も暗黙のうちに実行されてしまいます。(なんでこんな面倒な機能を公式にサポートしてんの？)

Railsのコードの多くは有用で使い勝手がよく、最小限のコードで意図した動作を実現することができるエキサイティングなフレームワークです。ただ`default_scope`はクラスに関連するすべてのコードに作用し、人間のオペレーションやロジックの組立てに割って入ってきます。そういった点では`default_scope`は過剰な機能です。

```ruby
# default_scopeの場合
# 他人が書いた条件
default_scope -> { where( cancelled_at: nil) }

# default_scopeだと操作が明示されておらず、条件が無いように見える
# クエリに自分が意図していない条件が織り込まれている
Contract.all
=> SELECT `contracts`.* FROM `contracts` WHERE `contracts`.`cancelled_at` IS NULL
```


### リファクタコストが高い

`default_scope`の多くは、モデルが小さく扱いやすい時に書かれますが、モデルが大きく複雑に成長した時、扱いに困り消したくなってきます。

たとえば1000行にまで成長したモデルがあったとしたとき、成長度合いから察すると、多くの関連を持っており、付随して同じ規模のテストやファクトリーもあるはずです。参照しているコントローラーとビューも膨大でしょう。これら全てに`default_scope`が影響していると考えると、`default_scope`をやめるリファクタはコスト観点で選択できなくなってきます。

最初は「薬」だと思っていたものが、モデルの成長とともにじわじわと「毒」に変わっていきます。どうか書きたい気持ちをグッと堪えてください。


# 最後に

一般的な`default_scope`の受け止められ方は「rails default_scope」なんかで検索するともう一目瞭然で、多くの場合はアンチパターンとして認識されています。円滑にコードを書き続けるためには、`default_scope`は使わないという選択が正しいでしょう。

「豊かな収穫を得たいならば良い種をまきなさい」という言葉があるように、どうせコードを書くなら実り多いコードにしたいですね。


# 参考

default_scope - リファレンス - - Railsドキュメント
http://railsdoc.com/references/default_scope

Railsのdefault_scopeは悪だ！(default_scope is evil) ということらしい -
 https://qiita.com/yusabana/items/f0b3a80111d6bd4ec8b0

Rails Best Practices - default_scope is evil
https://rails-bestpractices.com/posts/2013/06/15/default_scope-is-evil/

Railsのdefault_scopeをどうしても使いたい時 - Qiita
https://qiita.com/qsona/items/2ca522675b27ed2ec5ba

Railsのdefault_scopeとunscopedに関する考察 \[俺の備忘録\]
http://o.inchiki.jp/obbr/236