Rails 6からActiveRecordのAssociationはscopingの影響を受けなくなっている件と対処法

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 # => #
end

After:

Post.where(“1=0”).scoping do
  Comment.find(1).post # => #
  Comment.preload(:post).find(1).post # => #
  Comment.eager_load(:post).find(1).post # => #
end

※CHANGELOGより: https://github.com/rails/rails/blob/v6.0.0/activerecord/CHANGELOG.md

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以降にしか当たっていないのですが、要は joinspreloadeager_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 通り、ネストも可能ですし使い勝手は全く同じになりました。

we are hiring

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。