Rails6.0に上げると、特定の状況がご自身のアプリケーションにある場合以下のようなwarningが出ます。
.DEPRECATION WARNING: Class level methods will no longer inherit scoping from `toplevel` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Topic.unscoped`. (called from block in <class:Topic> at パスと該当行番号
- SQLの挙動を担保したままどのようにこのwarningをどうやって出さなくするのか
について詳しく解説します。
ただし私個人的にはRailsの対象PRは読みきれていなく、ただのSQL調査による考察なので、100%正しいかどうかはわかりません。でもこの記事のように考えると色々辻褄が合うような気がしています。
何か間違いやさらなる考察などありましたら教えていただけますと幸いです。
少し前提が長くなりますが、ぜひ最後までよろしくお願いします。
一応 TL;DR
今回の問題
問題を把握すること自体がかなりややこしいです。
状況自体がややこしいのと、言葉遣いがふわっとしてることが原因です。
問題を読み解くための情報源
以下の情報源を使って話を進めていきます。特に2つ目の解説ブログは必ずご一読ください。意味はわからなくても後に私が解説します。
- kamioさんという方がRailsに対してPRを出して、ご本人が軽くそのPRを出した意図のようなものを解説してくだ去っているブログの記事が以下です。
- Railsへの対象PR
- kamipoさんの解説ブログの3つ目の話題
これからの説明に使うため、記事の例にあげてあるコードをそのまま引用させていただきます。
#こちらはブログのsample code そのままです。
class Topic < ActiveRecord::Base
scope :toplevel, -> { where(parent_id: nil) }
scope :children, -> { where.not(parent_id: nil) }
scope :has_children, -> { where(id: Topic.children.select(:parent_id)) }
end
# Works as expected.
Topic.toplevel.where(id: Topic.children.select(:parent_id))
# Doesn't work due to leaking `toplevel` to `Topic.children`.
Topic.toplevel.has_children
とにかくscopeをチェーンすると、クラスグローバルの状態が汚染されると言っています。なので、汚染が今回の問題であり、汚染が何を意味しているのかが正確に理解することから始まります。
問題点を読み解くためのポイント
-
正確にどんな状況でwarning message が出るのか
-
当たり前ですがこのkamipoさんの例がminimum applicationであるということ。
- 「scopeのチェーンの話だから例のscopeは2つで十分では?」と考えたくなりますがそうではありません。問題の要件を満たすためには3つ必要です。
-
汚染されているものは正確にどのオブジェクトなのかを見極めること。
-
暗黙的に
where
の前についているTopic.where
のTopic
なのか、Topic.children
なのか、どっちのTopic
の話をしているのかを常に区別して考えること。ここだけ結論から先に言うとwhere
の前に暗黙的に付いているTopic
は今回の問題と全く関係ありません。ここから「Topic」
と言ったらTopic.children
のTopic
のことだと解釈お願いします。 -
kamipoさんの少し紛らわしい言葉遣いに注意すること。私みたいに素人からするとなかなか理解が大変だと思いますが、このあたりも後に解説します。例えば、
- warnign messageからだと
.DEPRECATION WARNING: Class level methods will no longer inherit scoping from `toplevel` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Topic.unscoped`. (called from block in <class:Topic> at パスと該当行番号
-
Class level methods
、inherit scoping
、scope relation
、passit
intothe block
directly 、instead
、full set of model
など。 ほぼ全部正確に何を意味しているのか微妙にわかりづらいですね。-
ブログからだと
leak to
、汚染
、クラスグローバル
、as expected
が正確に何を意味しているのかなどです。 -
どれがscopeでどれがscopeじゃないのか。当たり前ですがたまに混同します(笑)。要するに対応関係をはっきりさせるということと同じです。
-
「汚染」の意味
今までは前提で、ここからが解説になります。
「汚染」の意味は、
(Topic).where
のTopic
ではなく、Topic.children
のTopic
が、実はtoplevel
にすり替わっていたということ。
つまりkamipoさんの言葉を使うなら、toplevelのscopeが leak into Topic していたということです。
この場合has_children
の中ではクラスグローバルなTopic.children
ではなく、汚染されたtoplevel.children
の挙動になってしまっている、ということです。
もう一度サンプルコードを見てみます
#こちらはブログのsample code そのままの再掲です。
class Topic < ActiveRecord::Base
scope :toplevel, -> { where(parent_id: nil) }
scope :children, -> { where.not(parent_id: nil) }
scope :has_children, -> { where(id: Topic.children.select(:parent_id)) }
end
# Works as expected. このコードがwarning message の 'pass it into the block directly' でやれと言われていること。
Topic.toplevel.where(id: Topic.children.select(:parent_id)) #ここのTopic.childrenのTopic はクラスグローバルな状態が保たれている。
# Doesn't work due to leaking `toplevel` to `Topic.children`. (<- leak to じゃなくて 'into' にした方がよりわかりやすいですね。 toplevel のscopeが落ちちゃうのかと思いました(笑)
Topic.toplevel.has_children #上のコードと同じように見えるが、'has_children'のなかのTopic.childrenのTopic は実は toplevel にすり替わっていた、というのが「汚染」の意味です。
結局原因は...
scopeの「チェーン」
warning message
.DEPRECATION WARNING: Class level methods will no longer inherit scoping from `toplevel` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Topic.unscoped`. (called from block in <class:Topic> at パスと該当行番号
一部である To continue using the scoped relation, pass it into the block directly.
に注目します。
私が先に解説した通り、
# Doesn't work due to leaking `toplevel` to `Topic.children`.
Topic.toplevel.has_children
とするとscopeのチェーンになっていて、has_children
の中に含まれているchildren
のレシーバであるTopic
はtoplevel
にすり替わった挙動をします(toplevel が Topicに leak into している)が、
kamipoさんの言う通り、例にあるように
# Works as expected.
Topic.toplevel.where(id: Topic.children.select(:parent_id))
とすれば、 Topic
はTopic
のままで、クラスグローバルな状態は保たれています。
つまりwarning messageのTo continue using the scoped relation, pass it into the block directly.
の意味としては
「scopeチェーンをして、かつクラスグローバルな状態を保ちたいなら、もともとscopeを定義するときに書いた
has_children
のblock 、つまり{ where(id: Topic.children.select(:parent_id)) }
に it
( レシーバのtoplevel
、もしくは解釈のしようによってはhas_children
そのもの)を直接渡しなさい」と言う意味です。そうすれば、kamipoさんの一つ目の例が示す通り、クラスグローバルな状態が保たれます。
Railsへの対象PRでは何をしているのか
きちんとPRのコードを読めていないので、kamipoさんの言説を信じるとするならばですが、toplevel.has_cildren
と言う風にscopeをチェーンさせた時のhas_children
の中身のTopic.children
の挙動が、toplevel.children
の挙動から本来のコード通りのTopic.children
の挙動に変わります。
これがブログ中の「これからはunscoped
をつけるのを 忘れないようにすること を頑張らなくてもいいようになります」の意味だと私は捉えています。
用語の解説
ここで先の問題点を読み解くためのポイントで出てきた紛らわしい単語の解説をします。
.DEPRECATION WARNING: Class level methods will no longer inherit scoping from `toplevel` in Rails 6.1. To continue using the scoped relation, pass it into the block directly. To instead access the full set of models, as Rails 6.1 will, use `Topic.unscoped`. (called from block in <class:Topic> at パスと該当行番号
- 上記のwarnign messageにおいて
-
Class level methods
=> 言うならばchildren
というscopeのことだと思います。Topic.children
と書いたはず( Topic というclass level にアクセスしたと思った)が、toplevel
というscope を inherit してしまっているつまり汚染されている状態、が今後はinherit(汚染)しませんよ。挙動変わりますよと言うこと。 -
inherit scoping
=> scopeのチェーンそのもののことを言っているのではなく、いわば今回の汚染された状態のことです。Topic.children
がtoplevel.children
の挙動に現行でなってしまっていることを、「toplevel
というscopeをinherit
している」と表現しています。 -
scope relation
=>こっちがscopeのチェーンのことです。toplevel.has_children
のこと。 - pass
it
intothe block
directly =>先ほど示した通り。こっちはinto
じゃなくてto
にすべきな気がしますが、、、。 -
instead
=> 「汚染されたTopic(toplevel)
にアクセスする代わり」に、なのか、一つ上のように「正しい挙動をさせるための一つの対処法である、ブロックに直接渡す方法の代わりに」なのか、正直英語が微妙でよくわかりませんし、どっちでもいいと思います。 -
full set of model
=>has_children
の中のTopic.children
のTopic
のことです。
-
- ブログからだと
-
leak to
=>leak into と解釈しましょう。ブログの言葉を使うと、toplevel が leaks into Topic している
-
汚染
=> 先に示した通り。leak into や「別のscopeが注入」と同義です。 -
クラスグローバル
=>Topic.children
が文字通りTopic
のfull set of modfel
にアクセスできる挙動の状態。 -
as expected
=> 「クラスグローバル」と同じです。文字通りTopic
のfull set of modfel
にアクセスしている挙動を期待している(as expected ) していることでしょう。 - 「意図せず別のscopeが注入されている」=> 別のscopeとは
toplevel
のこと。「注入」はleak into と同じだと捉えています。
-
waning messageの意味
3文に分けて解説します。「汚染」の意味と先の用語の解説が分かれば読み解くのは比較的簡単です。
-
Class level methods will no longer inherit scoping from
toplevelin Rails 6.1
☝️ Rails 6.1では、toplevel
のscopeがleak into Topic しなくなるよってこと。 -
To continue using the scoped relation, pass it into the block directly.
☝️正しい挙動のまま、Topic.children
のTopic
が文字通りクラスグローバルで汚染されていないまま、scopeのチェーンをしたいなら(To continue using the scoped relation
)、Rails 6.0まではブロックに直接渡しなさい。つまり今Rails 6.0環境ではscope チェーンだと正しい挙動はできないですよ。今のあなたのコードでは意図した挙動になっていませんよ。 -
To instead access the full set of models, as Rails 6.1 will, use
Topic.unscoped. (called from block in <class:Topic>
☝️leak intoTopic
したくなくて、このままの実装で6.1の挙動みたいにTopic
そのものを使いたいなら、今はTopic.unscoped.chldren
を使いなさい。ということ。instead
というのは何の「代わり」かというと、「leak intoTopic
している状態の代わりに」、という意味かもしくは「ブロックを使って正しい挙動をさせる策の代わりに」。
結局どうすればいいか
Rails6.0の段階ではunscoped
をつけてTopic.unscoped
にして、Rails6.1に上げるときに unscoped
を除去する
という方法が最適だと思っています。(以下の①のパターン)
つまり既存の汚染されている実装の挙動を修正しつつwarningを出さなくするということですね。
確かにwarningをなくす「だけ」なら Topic
を外す、とかTopic
の代わりにself
をつけるという選択肢でも実現できます。
以下の②、③ような感じです。またサンプルコードをお借りしますが
#こちらはブログのsample code そのままの再掲です。
class Topic < ActiveRecord::Base
#略
①scope :has_children, -> { where(id: Topic.unscoped.children.select(:parent_id)) } #最適な方法は多分これ。
②scope :has_children, -> { where(id: children.select(:parent_id)) } #Topicを外しただけ!!とか
③scope :has_children, -> { where(id: self.children.select(:parent_id)) } #selfをつけるパターン
end
ですが、SQLの挙動を見ればわかりますが、これだとTopic.children
の時と挙動は全く一緒です。つまり汚染されたままです。でもwarningは出ていません。汚染なんてされててもいいからとにかく挙動は現状と変えたくないんだ、という方であれば、Rails6.0までだったらそれでOKだと思います。ですが...
Rails のPRのコードがきちんと読める方なら問題ないかもしれませんが、とにかくkamipoさんは、Topicを外すchildren
だけの時と、self
をつけるself.children
の時の挙動に関して、Rails6.1に上げるとき、変わるとも変わらないとも言っていません。要するに何も言っていないので、Rails6.1にあげた時の挙動がどうなるかわからない、ということです。deprecation warning が出ていないということは、変わらないと考えた方が自然ですが、汚染されたまま残すというのも不自然です。よくわかりません。
なので「汚染」まで無くして、warningもでなくして、かつRails6.1にあげた時の挙動が担保されているのが、unscoped
をつける方法なのではないか、というのが私の考えです。Rails6.1に上げた時、unscoped
は取り外しても外さなくてもどっちでもいいと思います。挙動は同じになるので。
ご自身のコードを今一度確かめてみて、kamipoさんのブログのコードとの対応関係を正確に照らし合わせてみるとわかりやすいと思います。
おまけ
①scopeチェーンの最初のscopeは汚染されているのか
例えば、以下のようなチェーンの時、some_scope
は汚染されているのかどうか
some_scope.another_scope.theother_scope
結論、されていません。
自分の前にscopeのレシーバがある時のみ汚染の対象となるからです。もっと今回の話題に沿って言うと、some_scope
は汚染されているされていないの問題はなく、どっちでもありません。kamipoさんの言葉を使うなら、leak するscopeがレシーバにありません。
②scopeが他のscopeに「含まれること」に関しては何か挙動がおかしくなる訳ではない。
scope :hogehoge_scope, ->(user) do
where(hogehoge_id: Topic.some_scope(user).pluck(:id))
end
みたいに、some_scope
がhogehoge_scope
に含まれていたとしても、そのhogehoge_scope
のTopic
がなんかにすり替わるという訳でもないですし、some_scope
の中のTopic
がhogehoge_scopeにすり替わる、という訳でもありません。詳しくはSQLで確認してください。
結局原因はでスコープの「チェーン」
のチェーンを強調したのは、他のscopeに含まれることではないぞ、ということを意識したためです。