📘

Railsの6.1で変更されたinverse_ofの挙動とハマったポイント

2022/12/02に公開約11,000字

このエントリーはAkatsuki Advent Calendar 2022の3日目の記事です。昨日はtakanakahikoさんのGASでカレンダー自動招待でした。
毎回カレンダーを開いての招待...なかなか面倒臭いので、自動化の手法を紹介してくれて助かりますね!

前提

この記事では、Rails7がリリースされている中、今更ながらなのですがRails6.1で挙動に変更があったinverse_ofについて、挙動変更によってつまづいたポイントをactive_recordのソースコードを覗きながら解説していきます。
inverse_ofの機能自体は把握済みである前提で記事を書き進めるので、inverse_ofの機能自体を知らない方はRails Guideの内容を把握してから覗かれると理解が進むかと思います。

さて、早速本題なのですが、Railsの6.1では、inverse_ofの挙動に変更が入りました。
内容としては、Rails6.0以前までは動いていなかったbelongs_toからのinverse_ofも機能するようにする変更が入っています。
どういうことかというと、例えば以下のようなhas_many,belongs_toで紐づいているモデルがあるとします。

# attributes
# name: string
class Author < ApplicationRecord
  has_many :books
  # has_many, inverse_ofはthroughtやforeign_keyオプションを設定していない場合は、自動認識される
  # そのため、以下と同等の挙動をする
  # has_many :books, inverse_of :author
end

# attributes
# title: string
# author_id: integer
class Book < ApplicationRecord
  belongs_to :author
end

この時、Rails6.0以前ではhas_manyで関連づけたモデルに参照を行い、その後関連づけたモデルから逆参照を行えるようになっています。

author = Author.new(name: 'Author A')
book = author.books.build(title: 'Book A')
book.author
=> #<Author:0x0000000105dee328 id: nil, name: "Author A", created_at: nil, updated_at: nil>

しかし、以下のようにbelongs_toにinverse_ofを設定した場合はどうなるでしょうか?

class Author < ApplicationRecord
  has_many :books
  # has_many, inverse_ofはthroughtやforeign_keyオプションを設定していない場合は、自動認識される
  # そのため、以下と同等の挙動をする
  # has_many :books, inverse_of :author
end

class Book < ApplicationRecord
  belongs_to :author, inverse_of :books
end
book = Book.new(title: 'Book A')
author = book.build_author(name: 'Author A')
author.books
=> []

belongs_toからのinverse_ofでは参照出来ませんでした。
これは、Rails6.1以前ではbelongs_toからのinverse_ofは参照先とhas_oneで紐づいている場合しかサポートされていないためです。

# activerecord/lib/active_record/associations/belongs_to_association.rb

# NOTE - for now, we're only supporting inverse setting from belongs_to back onto
# has_one associations.
def invertible_for?(record)
  inverse = inverse_reflection_for(record)
  inverse && inverse.has_one?
end

では、Rails6.1の挙動に変更するとどうなるでしょうか?

book = Book.new(title: 'Book A')
author = book.build_author(name: 'Author A')
author.books
=> [#<Book:0x00000001082a7f70 id: nil, title: "Book A", author_id: nil, created_at: nil, updated_at: nil>]

無事に双方向での参照が出来るようになりました。

ハマったポイント

前提で上げたような単純なケースにおいては、変更された挙動を把握しやすいのですが、特殊なケースにおいてpreload時に以前とは違うレコードが取得されることがあります。
私が遭遇したのは以下のようなケースです。
ここからはfactory_bot(6.2.1)とrspec-rails(6.0.0),rails(7.0.4)を使って例示していきます。

# Model
## columns
## name: string
## group_id: integer
class Author < ApplicationRecord
  has_many :books, class_name: 'Book'
                   foreign_key: 'group_id',
		   primary_key: 'group_id',
		   inverse_of: :author
end

## columns
## title: string
## group_id: integer
class Book < ApplicationRecord
  belongs_to :author, class_name: 'Author',
                      foreign_key: 'group_id',
		      primary_key: 'group_id',
		      inverse_of: :books
end
# rspec with factory_bot
let!(:author1) { create(:author, id: 1, name: 'Author1', group_id: 1) }
let!(:author2) { create(:author, id: 2, name: 'Author2', group_id: 1) }
let!(:book1) { create(:book, id: 1, title: 'Book1', group_id: 1) }
let!(:book2) { create(:book, id: 2, title: 'Book2', group_id: 1) }

この場合、Bookからauthorをpreloadした後に関連先のauthorを覗くとどうなるでしょうか?

Rails6.1未満の場合

Book.preload(:author).first.author
=> #<Author:0x000000010c622a20 id: 1, name: "Author1", group_id: 1, created_at: Thu, 01 Dec 2022 19:01:12.794456000 UTC +00:00, updated_at: Thu, 01 Dec 2022 19:01:12.794456000 UTC +00:00>

Book.preload(:author).second.author
=> #<Author:0x000000010d5f4390 id: 1, name: "Author1", group_id: 1, created_at: Thu, 01 Dec 2022 19:01:12.794456000 UTC +00:00, updated_at: Thu, 01 Dec 2022 19:01:12.794456000 UTC +00:00>

Rails6.1以上の場合

Book.preload(:author).first.author
=> #<Author:0x000000010b052218 id: 2, name: "Author2", group_id: 1, created_at: Thu, 01 Dec 2022 19:04:44.551023000 UTC +00:00, updated_at: Thu, 01 Dec 2022 19:04:44.551023000 UTC +00:00>

Book.preload(:author).second.author
=> #<Author:0x000000010b0abf48 id: 2, name: "Author2", group_id: 1, created_at: Thu, 01 Dec 2022 19:04:44.551023000 UTC +00:00, updated_at: Thu, 01 Dec 2022 19:04:44.551023000 UTC +00:00>

二つを見比べるとRails6.1未満の場合はauthor1が返ってきていますが、Rails6.1以上の場合はauthor2が帰ってきているのがわかります。

そもそも、N対Nのテーブル構造になっているものに対して、has_many, belongs_toによるN対1の関連付けを行なってしまっています。
なので、元々のモデルの実装自体が悪いと考えられるのですが、意図しない箇所で変更が行われていて挙動が面白かったので、なぜこのような挙動をするのかActiveRecordの実装を追ってみました。

では、具体的にどのような変更が加わったのか、次の節から見ていきましょう。

inverse_ofの修正を追う...

修正PRは次のものになります。
https://github.com/rails/rails/pull/34533/files#diff-ceff30ddab4e756e3a70ece45076eb17ff2f587a068dae657d2ad3a265a3f0d6R288
これを理解するには、ActiveRecordでクエリが読み込まれるまでにどのような処理をしているのかを追っていく必要があります。

ActiveRecordのクエリ実行までの大まかな段階

大まかに以下の3つの段階に分けられると思います。

  1. クラス読み込み
  • Railsサーバー起動時にクラスが読み込まれるタイミング
  • この段階でユーザーの定義したhas_manyなどのアソシエーション情報をパースし、モデルクラスに保持している
  1. includesやwhereなどでクエリの準備
  • whereなど遅延実行されるメソッドが叩かれた場合、クエリ実行時に必要な情報を保管しておく
  • where => テーブルへのクエリ情報をArel::Nodes::Nodeにパースして保管
  1. to_aなどでトリガーされ、クエリ実行

Preloadのクエリ読み込み時にされる実際の処理

実際に処理をフローチャートで表すと以下のようになります。
このうち、今回の挙動を理解する上で大切になってくるのは赤で塗りつぶしてある部分です。
※ メソッドの全てを書き出すと、見づらい図になってしまうため大分簡素化しています...mm

Rails6.1以上のpreloadでは

まず、Rails6.1以上相当の挙動を見ていきましょう。
Rails6.1以上相当では、次の部分でpreload先のレコードを取得します。

# lib/active_record/associations/preloader/association.rb

class LoaderQuery
  # 実行順: 1
  def load_records_in_batch(loaders)
    raw_records = records_for(loaders) # <= クエリ発効部分

    loaders.each do |loader|
      loader.load_records(raw_records)
      loader.run
    end
  end
  
  # 実行順: 2
  def records_for(loaders)
    LoaderRecords.new(loaders, self).records
  end
  
  # 実行順: 5
  def load_records_for_keys(keys, &block)
    # Author.where(group_id: 1).to_a相当
    scope.where(association_key_name => keys).load(&block)
  end
end

class LoaderRecords
  # 実行順: 3
  def records
    load_records + already_loaded_records
  end
  
  private
    # 実行順: 4
    def load_records
      # load_records_for_keysでクエリ発効
      loader_query.load_records_for_keys(keys_to_load) do |record|
        loaders.each { |l| l.set_inverse(record) } # ここでpreloadで設定されているレコードを取得する
      end
    end
end

実行順1~5の順に実行されていくのですが、4番目に実行されるload_records内に、いかにも6.1で変更が加わったinverse_ofと関わりのありそうなメソッドが生えています。
Rails6.1以上ではここでpreload対象のレコード設定を行なっています。
更に深く潜り込んでみましょう。

# lib/active_record/associations/preloader/association.rb

def set_inverse
  if owners = owners_by_key[convert_key(record[association_key_name])]
    # Processing only the first owner
    # because the record is modified but not an owner
    association = owners.first.association(reflection.name)
    association.set_inverse_instance(record) # <= レコードの設定は更にこの奥
  end
end
# lib/active_record/associations/association.rb

# Set the inverse association, if possible
def set_inverse_instance(record)
  if inverse = inverse_association_for(record) # <= ここでは<ActiveRecord::Associations::BelongsToAssociation>のインスタンスが返ってくる
    inverse.inversed_from(owner) # <= レコード設定箇所
  end
  record
end
# lib/active_record/associations/association.rb

def inversed_from(record)
  self.target = record
end

# Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+.
def target=(target)
  @target = target # <= アソシエーションレコードを設定, books.first.authorの参照先はこのtargetになる
  loaded! # <= アソシエーションレコードをロード済みとしてマークする
end

上記コードを見ると、set_inverseでrecordを設定していることがわかるかと思います。
一つ前のLoaderQuery#load_recordsをもう一度見てみると

def load_records
  # load_records_for_keysでクエリ発効
  loader_query.load_records_for_keys(keys_to_load) do |record| # <= Author.where(group_id: 1)でロードしたクエリを一つずつ回している
    loaders.each { |l| l.set_inverse(record) }
  end
end

Author.where(group_id: 1)でロードしたクエリを一つずつ回してながら、set_inverseをしていることがわかります。
つまり、group_idが重複しているものがある場合は、最後に取得したレコードAuthor.where(group_id: 1).lastがpreloadしたインスタンスとして設定されることになります。

Rails6.1未満のinverse_ofでは

基本的には処理の流れは同じなので、差分がある部分のみ見ていきます。
挙動を理解する上で一番重要な差分としてはset_inverseの部分になります。

# lib/active_record/associations/association.rb

# Set the inverse association, if possible
def set_inverse_instance(record)
  if inverse = inverse_association_for(record) # 何も返ってこない
    inverse.inversed_from(owner) # inverseがnilなのでスキップされる
  end
  record
end

Rails6.1以上ではinversed_fromを通りましたが、6.1未満では通らなくなりました。
ここでRails6.1で加えられた修正が関わってきます。

# lib/active_record/associations/belongs_to_association.rb

def invertible_for?(record)
  inverse = inverse_reflection_for(record)
-   inverse && inverse.has_one?
+   inverse && (inverse.has_one? || inverse.klass.has_many_inversing)

上記修正でRails6.1以上では(has_many_inversing: falseにしない限り)belongs_to, has_many間でのinverse_ofでもinverseが返ってくるようになりました。
しかし、Rails6.1未満ではbelongs_to, has_oneでしか返っていないようになっています。
そのため、load_recordsのタイミングではinverse_of対象のインスタンスにレコードが設定されなくなります。

では、preload時に実際にレコードが取得されるのはどこでしょうか?

# lib/active_record/associations/preloader/association.rb

class LoaderQuery
  # 実行順: 1
  def load_records_in_batch(loaders)
    raw_records = records_for(loaders) # <= クエリ発効部分

    loaders.each do |loader|
      loader.load_records(raw_records)
      loader.run <= Rails6.1未満ではここでpreloadで取得したインスタンスの設定を行う
    end
  end

答えはActiveRecord::Associations::Preloader::Association#runです。

def run
  return self if run?
  @run = true

  records = records_by_owner

  owners.each do |owner|
    associate_records_to_owner(owner, records[owner] || []) # <= ここでpreloadするインスタンス(target)に設定を行なっている
  end if @associate

  self
end

def associate_records_to_owner(owner, records)
  return if loaded?(owner) # => Rails6.1以上ではset_inverseで設定済みになるためreturnされる
  
  association = owner.association(reflection.name)
  if reflection.collection? # => BelongsToReflectionはfalseになる
    association.target = records
  else
    association.target = records.first # => Author.where(group_id: 1).first
  end
end

最終的にrecords.firstがpreloadするインスタンスとして設定されるため、ここで取得するレコードが6.1未満と以上とで差分が生まれます。
つまり、inverse_ofの挙動の変更がpreloadにも影響を及ぼして挙動が変わってしまっていることになります。

まとめ

長々と書いてしまいました...が、挙動が変わった理由は以上の実装を把握することによってようやく理解することが出来ました。
最後に、この記事の内容をまとめると

  1. Rails6.1からbelongs_to => has_manyの関連付けでinverse_ofを設定出来るようになった
  2. ただし、belongs_toでの取得先のテーブルに複数のレコードが紐づいていると、preloadする際に取得されるレコードが6.1とそれ未満で変わってしまう
  3. 変わってしまった原因は、Rails6.1のinverse_ofの修正であり、preloadでも使用しているset_inverseに変更が加わったことによってpreload先に設定するレコードが変わってしまった
  4. 複数の同一値がある状態でbelongs_toを使ってpreloadを行う場合、Rails6.1未満では最初に取得されたレコードがpreload対象として設定されるが、Rails6.1以上では最後に取得されたレコードが設定される。

になります。

突っ込み等は大歓迎です!
閲覧頂きありがとうございました。

Discussion

ログインするとコメントできます