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、passitintothe blockdirectly 、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
itintothe blockdirectly =>先ほど示した通り。こっちは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 fromtoplevelin 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, useTopic.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に含まれることではないぞ、ということを意識したためです。