Rails 6からActiveRecordのAssociationはscopingの影響を受けなくなっている件と対処法
こんにちは。日本ではすっかり秋の空が近づいてきました。個人的に夏は暑さが苦手であまり好きではないのですが、終わってしまうとなると毎年どこか寂しい気持ちになります。
一方で、秋はご飯が美味しいので食べる事が好きな僕にとっては一番好きな季節でもあり、結構心が踊っています。笑
さて、前置きは置いといて今回は表題の通り、とあるRails 5.2プロジェクトをRails 6にアップグレードした際に、ActiveRecordのscopingの仕様変更を踏んでしまったのでその対処法をご紹介します。
そもそもscopingとは
ActiveRecord::Relation
の機能で、引数にブロックを受け取り、その中では、実行時に適用されていたスコープを全クエリでデフォルトで適用する、と言う物になります。
例として、deleted_at (timestamp, nullable)
と言うattributeを持つモデル Blog
があるとします。このカラムに日時が入っている場合、その記事は論理削除されている物とします。
この時、論理削除されていないデータを返すスコープは次の通りなるかと思います:
class Blog
scope :not_deleted, -> { where(deleted_at: nil) }
end
割とよくある実装ですね。(個人的に論理削除はあまり好まないですが今回遭遇したケースを簡単に説明する為にあえて題材として選んでいます)
エンドユーザーに見せる画面では、基本的に削除済みのデータは出してはいけないと思うので基本的にこのスコープを使う事になると思いますが、後から書くコードでそれを継続的に徹底するのは中々チャレンジングかと思います。
そこで、先の通りActiveRecordにはscopingと言う機能があるので、これを、ActionController::Base
の around_action
機能と組み合わせて、横断的に論理削除データを結果から排除すると言うテクニックがあります。
参考:default_scopeよりaround_actionとscopingを使おう
例えば、エンドユーザーが使う機能は以下の EndUserBaseController
を必ず継承するとします:
class EndUserBaseController < ApplicationController
around_action :set_scope, if: :block_users_present?
private def set_scope
Blog.not_deleted.scoping { yield }
end
end
こうすると、全てのアクションはこの set_scope
内で呼び出している scoping
ブロックの中で実行される事になります。
つまり、全てのアクションで、 Blog
モデルのクエリは deleted_at IS NULL
の条件が付き、削除済みのデータが表示される心配が無くなります。
なお、ブロックの後はスコープは解除されるので、外の機能に影響を与えません。
また、scopingは結構かしこくて、設定したスコープはAssociationに対しても有効です(※後述しますがこの認識は誤りです)。例えば Blog
はhasManyな BlogComment
へのAssociationを持つとします:
class Blog
has_many :comments, class_name: "BlogComment", inverse_of: :blog
end
class BlogComment
belongs_to :blog, inverse_of: :comments
end
先程設定したscopingの中では、以下のようにしても論理削除されたデータは表示されません:
Blog.find(1).save!(deleted_at: Time.now) # id=1のBlogは論理削除済み
Blog.not_deleted.scoping do
# `blog` associationの解決には `SELECT * FROM blogs WHERE id = 1 AND deleted_at IS NULL` の様なクエリが実行される。
BlogComment.where(blog_id: 1).blog # nil
end
BlogComment.where(blog_id: 1).blog # ここではid=1のBlogが返る
また、当たり前ですがブロックはネストさせる事ができるので、論理削除するモデルが複数ある場合でも次のように書く事ができます:
Blog.not_deleted.scoping do
BlogComment.not_deleted.scoping do
yield
end
end
Blog
だけでなく、 BlogComment
の論理削除もこれでバッチリです。やったね★
しかし、Rails 6では…
仕様が変更された為、先程のコードはそのままでは動きません。具体的には、
Blog.not_deleted.scoping do
BlogComment.where(blog_id: 1).blog
end
は nil
を返しません。 BlogComment.blog
のAssociation解決時に実行される Blog
モデルへのSELECTクエリへのスコープがリセットされ、deleted_at IS NULL
の条件が消える為です。
これは一見バグに見えますが、CHANGELOGにも記載してある為、どうやら仕様変更のようです。
Association loading isn’t to be affected by scoping consistently whether preloaded / eager loaded or not, with the exception of unscoped.
Before:
Post.where(“1=0”).scoping do
Comment.find(1).post # => nil
Comment.preload(:post).find(1).post # => #
Comment.eager_load(:post).find(1).post # => #
endAfter:
Post.where(“1=0”).scoping do
※CHANGELOGより: https://github.com/rails/rails/blob/v6.0.0/activerecord/CHANGELOG.md
Comment.find(1).post # => #
Comment.preload(:post).find(1).post # => #
Comment.eager_load(:post).find(1).post # => #
end
Associationの読み込みは unscoped
以外のスコープの影響を受けないと明記されています。(記載はされていませんが default_scope
は有効です)
具体的なコードの変更点は以下のようです:
Rails 5.2
# https://github.com/rails/rails/blob/v5.2.3/activerecord/lib/active_record/associations/association.rb#L135
# Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the
# through association's scope)
def target_scope
AssociationRelation.create(klass, self).merge!(klass.all)
end
Rails 6.0
# https://github.com/rails/rails/blob/v5.2.3/activerecord/lib/active_record/associations/association.rb#L135
# Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the
# through association's scope)
def target_scope
AssociationRelation.create(klass, self).merge!(klass.scope_for_association)
end
AssociationRelation
オブジェクト初期化時のデフォルトスコープとして、これまでは klass.all
(current_scope
を含む) であった物が、 klass.scope_for_association
(current_scope
を含まない) に変更されています。
この変更については、該当のPullReqから経緯を追う事ができます。
まず、このPullReqで unscoped
がAssociation解決時にも適用されるようになったようです:
# Fooのdefault_scopeがwhere(status: 1)の時、以下のクエリは
# Before: SELECT bar.* FROM bar INNER JOIN foo ON foo.id = bar.foo_id AND foo.status = 1
# After: SELECT bar.* FROM bar INNER JOIN foo ON foo.id = bar.foo_id
Foo.unscoped { Bar.joins(:foo).to_sql }
Beforeでは unscoped
が意図した通り動いていなかったので、妥当な修正と言えます。
しかし、この変更により、Foo.current_scope
が Bar.joins(:foo)
に適用される結果となってしまい、これが意図してなかった様で、このcommitにてそれが修正されています:
# Before: SELECT bar.* FROM bar INNER JOIN foo ON foo.id = bar.foo_id AND foo.status = 1
# After: SELECT bar.* FROM bar INNER JOIN foo ON foo.id = bar.foo_id
Foo.where(status: 1).scoping { Bar.joins(:foo).to_sql }
ここまではRails 5.2にも適用されているので、先に紹介した scoping
を使った横断的な条件の適用はRailsの意図する使い方ではなかったと言う事が分かります。
現に、先に紹介したdefault_scopeよりaround_actionとscopingを使おうの書き込み日が2016年12月となっており、unscoped
の修正は同年2月の為、偶然にも意図していない手法を使ってしまった物と思われます。(current_scope
をAssociationに波及させない修正は翌年)
とは言え、ActiveRecordの開発動向を追っていない限りこの手の誤りは致し方ない事だと言えます。
さて、以上を踏まえた上で、最初のPullReqに戻ると話が繋がります。このパッチはRails 6.0以降にしか当たっていないのですが、要は joins
, preload
, eager_load
等のAssociation解決時に current_scope
を引き繋がないのと同様に、遅延読み込み時にも同様に current_scope
を支持しない様修正が加えられた様です。
CHANGELOGを再掲しますが、一目瞭然です:
Post.where("1=0").scoping do
Comment.find(1).post # => #<Post id: 1, ...>
Comment.preload(:post).find(1).post # => #<Post id: 1, ...>
Comment.eager_load(:post).find(1).post # => #<Post id: 1, ...>
end
対処療法として
と言う訳で、タイトルのテクニックはRails的には誤りだった訳ですが、既に運用中のソフトウェアでこの問題を横断的に対処するのは非常に骨が折れます。
なので、「今回僕が関わったプロジェクトではこうした」と言う対処療法的なバッドノウハウをご紹介します。あくまでバッドノウハウなので使用には注意が必要です。
基本方針
先のPullReqの経緯を追うと、default_scope
およびそれを解除する unscoped
についてはAssociationに波及させるのが意図した動作の様です。
なので、今回は古き良き default_scope
を使う事にしました。実際に論理削除を実現するGemであるacts_as_paranoidでは、default_scopeを使っているようで、このコードでRails 6でも動作するみたいです。
とは言え、元はと言えば scoping
によるテクニックはdefault_scope is evil、つまりむやみやたと default_scope
を使う事を止めようキャンペーンが元になっているので、できればこれは使わないでおきたい(なぜなら論理削除を表示したい場合もあり、その様なケースで unscoped
を徹底するのはナンセンスなので)です。
ではどうしたかと言うと、 default_scope
が複数指定できる性質を活かし、「scoping
を論理削除の為に使う」と言う誤った使い方ではあるがその使い勝手を尊重し、次のようにしました:
class EndUserBaseController < ApplicationController
around_action :set_scope
private
def set_scope
persistent_scoping(Blog.not_deleted) { yield }
end
# 受け取ったscopeを維持しながらblockを実行する.
# `ActiveRecord::Relation.scoping` とは異なり、Associationに対してもscopeは維持される.
#
# ex)
# User.where('0=0').scoping { Post.first.user } # not nil
# persistent_scoping(User.where('0=0')) { Post.first.user } # nil
def persistent_scoping(scope)
klass = scope.model
previous = klass.default_scopes
klass.default_scopes += [-> { scope }]
logger.debug "The default_scopes of #{klass} is expanded within the current action (#{previous.count} scope(s) had been set originally)"
yield
ensure
klass.default_scopes = previous
logger.debug "The expanded default_scopes of #{klass} within the current action has been recovered."
end
end
ブロック実行前に一時的に default_scope
に現在のスコープを適用し、ブロック実行後にもとに戻すと言う荒療治です。
しかし、これで元の scoping
通り、ネストも可能ですし使い勝手は全く同じになりました。
コメントを残す