この記事は CrowdWorks Advent Calendar 2017 の12日目の記事です。
はじめに
クエリを発行して調べたレコード件数と、Railsのプロダクト上で出てきた件数が違ったことはありませんか?私はあります。何を隠そう、それが私とdefault_scope
との出会いでした。
今日はそんなdefault_scope
についてお話したいと思います。
本当にあったdefault_scope
な話
それはとある昼下がり、いつもしているようにプロダクトの仕様調査で、データの件数を調べていたんです。「タスク作業」という、そこそこ件数が発生するクラスの調査だったんですが、条件に当てはまるレコードがどれだけあるのか、クラス間の関連に注意をしながら黙々と作業を進めていました。
そんな慎重な作業の末、ついにはMacの黒い画面に「7869件のレコードが該当」という結果が映し出されたのです。が、何か違和感を感じたんです。条件を絞っているとはいえ、日次を範囲としたタスク作業ならもう少し件数があってもいいはずだ、と。
私は何度もコマンドを叩き、関連を見直しました。ところが、何をどう確認しても間違っていなそうなのです。件数の規模を見誤ったのかな?と思い分析基盤から直接クエリを発行してみたら、そこには「9308件」との表示が。規模感はだいたいあっていた・・・だけど、なぜか件数が少ない。
1500件程度のデータがどこかに消えてしまったのです。私は怖くなりました。同じDBのテーブルを覗いているはずなのに、データを取得すると件数が異なるからです。どうして?なんで?神隠しか?そんな言葉が頭の中でぐるぐる回りだし、ついには徳を積むにはどうしたらいいのか、日頃の行いについて悔い改める点は無いのかを思案しはじめたのです。
一人で考えるのがあまりにも怖くなってしまったので、通りすがりの人に駆け寄り、事のすべて話してどうするべきか意見を聞いてみることにしました。
タスク作業か・・・あそこは出るんだ。
夜な夜なデータがキャンセルされるたびにアイツのせいで検索結果からはずれちまうのさ・・・。
そうか、アイツだったのか・・・。
スクロールバーが小さく、様々な関連を持っているタスク作業のクラスを開き、
検索欄へアイツを入力してReturnを押す。
あった・・・あったぞ・・・こいつのせいだったのか・・・
default_scope -> { where(deleted_at: nil) }
シンプルな例
User
が複数のContract
と関連をもっている例の場合、発行されるクエリは以下の通りになります。
Contract.where(user_id: 1)
=> SELECT `contracts`.* FROM `contracts` WHERE `contracts`.`user_id` = 1
ここでもし、Contract
にdefault_scope
が書かれていたらどうでしょう?以下の出力を見ると、クエリにcancelled_at
の条件が勝手に加わっています。
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
を使いたくなってきますね。書き方も簡単で、以下の通りたった一行書くだけです。
default_scope { where(contracts: { cancelled_at: nil }) }
こんなに簡単に書けちゃうと思わず書きたくなってしまいますが、ここはその気持ちをグッと堪えましょう。
書かないほうがいい3つの理由
限られた条件下ではdefault_scope
の有用性もあるのでしょう。ただ、そんな検討するくらいならscope
にしてに使ったほうがみんな幸せになるでしょう。共同でメンテナンスするプロダクトコードでは、default_scope
は遅効性の厄介者です。
# 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
は過剰な機能です。
# 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