【Active Record】has_manyな関連先の差分更新について

こんにちは、Plex Job開発チームの種井です。

Plex Job開発チームではバックエンド開発にRuby on Railsを使用しています。

開発を進める中で、Active Record で1対多の関連先レコードを差分更新したい場面があります。私個人の話ですが、そのような要件があった際に自前で差分のみを抽出し、関連先の属性に指定するような実装を行っていました。
ある時、関連先の属性に直接指定することで、Active Record が差分更新してくれることを同僚に教えてもらいました。とても便利に思った反面、Active Record の内部でどのように差分更新されているかについて気になったので、調べてみました。

今回は差分更新時の挙動Active Record内部での実装のされ方について調べてみた内容を紹介したいと思います。

例として挙げているソースコードの動作環境は以下となります。

関連先は差分更新される

まずは、差分更新の挙動についてみていきたいと思います。 Railsガイドの例を借りて、以下のようなテーブルを想定します。

モデルの例

対応するモデルは以下のようになります。

class Author < ApplicationRecord  
  has_many :books  
end

class Book < ApplicationRecord  
end

authorsテーブルとbooksテーブルにそれぞれレコードを作成します。

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]

この著者(Author)の書籍(Book)の関連付けを変更してみます。

uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

author.books = [refactoring_book, uml_book, poeaa_book]
author.save!

author.books.pluck(:id)
# => [1, 3, 4]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

Book(id: 2)は削除され、Book(id: 3, 4)は追加、Book(id: 1)はタイトルが変更されています。

このように、関連先として指定されたオブジェクトに応じて、Active Recordがよしなに差分更新(SQLのinsert, update, delete文の発行)を行ってくれることがわかります。

便利な点

  • 関連先の更新後の状態を指定するだけ
  • 実装者間で差分更新のロジックがムラが出ない

が便利なところだと思っています。

関連先の更新後の状態を指定するだけ

=の挙動として直感的であり、何より差分更新ロジックをフレームワーク側が担ってくれるため記述量も少なくなります。 例えば、以下はこの機能を知る前の私が書いた差分更新の実装です。とても冗長ですね、、

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]
uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

ids = author.books.pluck(:id)
delete_ids = ids - [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i)
new_ids = [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i) - ids

if new_ids.present?  
  author.books << Book.where(id: new_ids)  
  author.save!
end

author.books.where(id: delete_ids).find_each(&:destroy!)
author.books.reload

author.books.pluck(:id)
# => [1, 3, 4]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

実装者間で差分更新のロジックがムラが出ない

以下は実際にプロジェクトにあった別の差分更新ロジックです。一度関連レコードを全て削除した上で新しいレコードとして作成するシンプルな実装方法です。

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]
uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

author.books.map(&:destroy)
author.books = [rename_book, book1, book2]

author.books.pluck(:id)
# => [3, 4, 5]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

上記の例のように、開発者が個々人で差分更新のロジックを実装することで、レコードの作成のされ方に差が出てしまったり、コードの統一感がなくなってしまいます。 その点、Active Recordの機能を素直に使うことで仕様やコード品質を均一化することができます。

仕組み

ここまで、has_many 関連を持つ属性にオブジェクトを含む配列を渡すことで、Active Record が対応するテーブルに対して差分更新を行ってくれることを紹介してきました。 続いて、Active Record が自動的に行う差分更新の仕組みについて、内部のコードを追いながら見ていきたいと思います。

イメージしやすいように、登場するクラスやメソッドを簡単にまとめてみました。

has_manyクラスマクロの呼び出し

前述した例を改めて。

class Author < ApplicationRecord  
  has_many :books  
end

has_manyクラスマクロの定義から見てみます。

def has_many(name, scope = nil, **options, &extension)
  reflection = Builder::HasMany.build(self, name, scope, options, &extension)
  Reflection.add_reflection self, name, reflection
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations.rb#L1302

Builder::HasMany.build(self, name, scope, options, &extension)というBuilderを使用して、HasManyAssociationオブジェクト作成しています。

Builderは.build メソッド呼び出し時にaccessorとしてwriter,readerメソッドを動的に定義します。

def self.define_readers(mixin, name)
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}
      association(:#{name}).reader
    end
  CODE
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/builder/association.rb#L102

def self.define_writers(mixin, name)
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}=(value)
      association(:#{name}).writer(value)
    end
  CODE
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/builder/association.rb#L110

これにより、以下の例のように関連先の参照、書き込み操作を可能にしています。

author = Author.new

# reader
author.books

# writer
author.books = [Book.new(title: "タイトル")]

上記から今回の場合、HasManyAssociationクラスに定義されているであろう、writerメソッドを見ると差分更新のロジックが分かりそうです。

writerメソッドの動作

HasManyAssociationクラスを確認すると、writerメソッドは定義されていませんでしたが、親クラスのCollectionAssociationクラスにwriterメソッドが定義されていました。

def writer(records)
  replace(records)
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L46

writerはさらに対象となる配列を受取り、replaceメソッドを呼び出しています。

replaceメソッドの動作

# Replace this collection with +other_array+. This will perform a diff
# and delete/add only records that have changed.
def replace(other_array)
  other_array.each { |val| raise_on_type_mismatch!(val) }
  original_target = skip_strict_loading { load_target }.dup

  if owner.new_record?
     replace_records(other_array, original_target)
  else
    replace_common_records_in_memory(other_array, original_target)
    if other_array != original_target
      transaction { replace_records(other_array, original_target) }
    else
      other_array
    end
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L242

親レコードが新規レコードかどうかを確認して処理が分岐しています。今回はすでに親レコードが存在する場合の処理ついて見ていきたいと思います。

まずはreplace_common_records_in_memoryメソッドです。

def replace_common_records_in_memory(new_target, original_target)
  common_records = intersection(new_target, original_target)
  common_records.each do |record|
    skip_callbacks = true
    replace_on_target(record, skip_callbacks, replace: true)
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L430

intersectionメソッドで既存の関連レコードの配列と変更後の配列の共通部分のみを取り出し、replace_on_targetメソッドを呼び出しています。

def intersection(a, b)
  a & b
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/has_many_association.rb#L162

def replace_on_target(record, skip_callbacks, replace:, inversing: false)
  if replace && (!record.new_record? || @replaced_or_added_targets.include?(record))
    index = @target.index(record)
  end
  ...
  if index
    target[index] = record
  elsif @_was_loaded || !loaded?
    @association_ids = nil
    target << record
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L457

replace_on_targetメソッドでは、メモリ上で既存の関連レコードを変更後のオブジェクトで上書きしています。

replaceメソッドに戻ります。既存の関連レコードの配列と変更後の配列に差分がある場合はtransactionブロック内でreplace_recordsメソッドを呼び出しています。

def replace_records(new_target, original_target)
  delete(difference(target, new_target))

  unless concat(difference(new_target, target))
    @target = original_target
    raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
                                                    "new records could not be saved."
  end

  target
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L418

def difference(a, b)
  a - b
end

https://github.com/rails/rails/blob/9f16995fb9556228ad88a3799b0e349ef6f6e0c7/activerecord/lib/active_record/associations/has_many_association.rb#L158

targetHasManyAssociation(CollectionAssociation)内で共有されているインスタンス変数です。

replace_recordsメソッドが呼び出される前にreplace_common_records_in_memoryメソッドで新旧配列の共通要素に対して属性が更新されている可能性があります。 既存の関連レコードと変更後の配列の差分をとり

  • 変更後の配列にないものは削除
  • 変更前の配列にないものは追加
  • 変更前後の配列に存在するものは上書き

した上で、対応するテーブルに書き込み、差分更新を実現しています。 以上から、Active Recordがどのように関連先のレコードを差分更新するかが分かりました。

まとめ

今回、has_manyな関連先の更新時に Active Record が差分更新を行う挙動について見てきました。 この機能を知る前に自前で実装していた処理が、Active Record 内にすでに組み込まれており、同様の要件であれば Active Record の機能を素直に使用する方が、コードをシンプルに保てることが分かりました。 事前にドキュメントをしっかり読んだり、直感的に使用していれば、そもそも自前で差分更新のロジックを書く必要はなかったかもしれません。 ただ、Active Record がどのように差分更新を行うのかを深掘りできたことで、今後自信を持って活用できるようになる良い機会になりました。

参考