Rails で ActiveRecord モデルを書いていると、attribute の変更前後の値を簡単に参照できる。
たとえば、記事タイトルが変更されたときだけ通知処理を呼び出したい場合、次のように書ける。
class Article < ApplicationRecord
after_update :notify_title_changed, if: :saved_change_to_title?
validates :title, presence: true
validates :body, presence: true
private
def notify_title_changed
ArticleTitleChangeNotifier.notify(
article: self,
from: title_before_last_save,
to: title
)
end
end
ここで使っている saved_change_to_title? や title_before_last_save は、アプリケーション側で定義したメソッドではない。title という ActiveRecord の attribute に対応して、Rails が Dirty tracking 用に用意しているメソッドだ。
Dirty tracking というと ActiveModel::Dirty が出てくる。ActiveModel::Dirty は、ActiveRecord と同じようにオブジェクトの変更を追跡するための機能を提供するモジュールであり、ActiveRecord 専用のものではない。素の Ruby オブジェクトに include ActiveModel::Dirty し、define_attribute_methods や *_will_change!、changes_applied などを適切に呼ぶことで、変更追跡の仕組みを利用できる。(Ruby on Rails API)
一方、ActiveRecord モデルでは、通常 *_will_change! を明示的に呼ばなくても変更が追跡される。Rails のドキュメントでも、ActiveModel::Dirty では in-place な変更に *_will_change! が必要だが、ActiveRecord では in-place な変更を自動検出できるため、ActiveRecord モデルでは *_will_change! を呼ぶ必要はないと説明されている。(Ruby on Rails API)
つまり今回見たいのは、ActiveModel::Dirty の使い方そのものではない。
この記事では、ActiveRecord モデルである Article に対して、次の2つがどのように実現されているのかを追う。
-
saved_change_to_title?やtitle_before_last_saveのような attribute 別メソッドがどのように定義されるのか -
titleの保存前後の値が内部でどのように追跡されるのか
Article はどこから Dirty tracking を使えるようになるのか
Article は ApplicationRecord を継承していて、ApplicationRecord は ActiveRecord::Base を継承している。
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
ActiveRecord 側の実装を見ると、ActiveRecord::AttributeMethods の中で Dirty が include されている。
included do
initialize_generated_modules
include Read
include Write
include BeforeTypeCast
include Query
include PrimaryKey
include TimeZoneConversion
include Dirty
include Serialization
end
ここで include されている Dirty は、ActiveRecord 側の ActiveRecord::AttributeMethods::Dirty だ。このモジュールは ActiveModel::Dirty のメソッドを取り込みつつ、ActiveRecord の DB 状態を前提にしたメソッドを追加している。Rails の API でも、ActiveRecord::AttributeMethods::Dirty は ActiveModel::Dirty の全メソッドを追加し、さらに database-specific methods を追加すると説明されている。(Ruby on Rails API)
そのため、ActiveRecord モデルである Article では、自分で include ActiveModel::Dirty を書かなくても Dirty tracking を利用できる。
ActiveModel::Dirty と ActiveRecord::AttributeMethods::Dirty の役割
ここを最初に分けておくと、実装を追いやすい。
ActiveModel::Dirty は、オブジェクトの attribute が変更されたか、元の値は何か、保存後にどのような変更があったかを扱うための基本的な仕組みを提供する。API ドキュメント上でも、*_changed?、*_change、*_was、*_previously_changed?、*_previous_change、*_previously_was などの attribute 別メソッドが示されている。(Ruby on Rails API)
一方、ActiveRecord では DB に保存されている値との差分を扱う必要がある。そのため ActiveRecord::AttributeMethods::Dirty には、will_save_change_to_name?、name_change_to_be_saved、name_in_database、saved_change_to_name?、saved_change_to_name、name_before_last_save のような、保存前後や DB 上の値を意識したメソッドが追加されている。(Ruby on Rails API)
今回の Article の例で使っているのは、この ActiveRecord 側で追加されているメソッドだ。
saved_change_to_title?
title_before_last_save
title_before_last_save は「直前の保存の前に title が持っていた値」を返す。saved_change_to_title? は「直前の保存で title が変わったか」を返す。
saved_change_to_title? や title_before_last_save はどこで登録されるのか
Dirty 関連の attribute method のパターンは、active_record/attribute_methods/dirty.rb で登録されている。
included do
if self < ::ActiveRecord::Timestamp
raise "You cannot include Dirty after Timestamp"
end
class_attribute :partial_updates, instance_writer: false, default: true
class_attribute :partial_inserts, instance_writer: false, default: true
# Attribute methods for "changed in last call to save?"
attribute_method_affix(prefix: "saved_change_to_", suffix: "?", parameters: "**options")
attribute_method_prefix("saved_change_to_", parameters: false)
attribute_method_suffix("_before_last_save", parameters: false)
# Attribute methods for "will change if I call save?"
attribute_method_affix(prefix: "will_save_change_to_", suffix: "?", parameters: "**options")
attribute_method_suffix("_change_to_be_saved", "_in_database", parameters: false)
end
ここで attribute_method_suffix("_before_last_save") が登録されているため、title attribute に対して title_before_last_save が使えるようになる。
同じように、attribute_method_affix(prefix: "saved_change_to_", suffix: "?") が登録されているため、title attribute に対して saved_change_to_title? が使えるようになる。
attribute method の生成
attribute_method_suffix や attribute_method_affix を呼んだ時点で、ただちに title_before_last_save というメソッドが定義されるわけではない。まずは、メソッド名のパターンが attribute_method_patterns に登録される。
ActiveModel::AttributeMethods は、prefix / suffix / affix を使って attribute に対応するメソッドを動的に定義する仕組みを提供している。Rails Guide でも、attribute_method_suffix、attribute_method_prefix、attribute_method_affix を呼び、その後 define_attribute_methods で対象 attribute を宣言する流れが説明されている。(Ruby on Rails Guides)
たとえば _before_last_save という suffix から作られる pattern は、概念的には次の情報を持つ。
suffix = "_before_last_save"
proxy_target = "attribute_before_last_save"
method_name = "%s_before_last_save"
そのため、title_before_last_save というメソッド名は、title attribute に対する attribute_before_last_save("title") への呼び出しとして扱える。
実際に生成されるメソッドは、概念的には次のようなものになる。
def title_before_last_save
self.attribute_before_last_save("title")
end
Article.instance_method(:title_before_last_save).owner を確認すると、Article::GeneratedAttributeMethods だった。つまり通常の呼び出しでは、method_missing ではなく、生成済みのメソッドに直接入る。
実際の処理は共通メソッドに委譲される
生成された title_before_last_save は、最終的に attribute_before_last_save("title") に委譲される。
def attribute_before_last_save(attr_name)
mutations_before_last_save.original_value(attr_name.to_s)
end
Rails の API ドキュメントでも、attribute_before_last_save は「直前の保存前の attribute の元の値」を返すメソッドであり、name_before_last_save は attribute_before_last_save("name") の形でも呼び出せると説明されている。(Ruby on Rails API)
saved_change_to_title? も同じ構造になっている。attribute 別メソッドとして呼び出せるが、実処理は saved_change_to_attribute?("title") に委譲される。
def saved_change_to_attribute?(attr_name, **options)
mutations_before_last_save.changed?(attr_name.to_s, **options)
end
Rails の API 例でも、saved_change_to_name? は saved_change_to_attribute?("name") と同じように呼び出せるものとして示されている。(Ruby on Rails API)
ここまでで、saved_change_to_title? や title_before_last_save のようなメソッドがどのように使えるようになるかは見えた。
次に見るのは、mutations_before_last_save がどうやって「保存前の title は何だったか」を知っているのか、という点だ。
値の追跡は AttributeSet と Attribute が担っている
ActiveRecord の各レコードは、内部的に @attributes を持っている。これは ActiveModel::AttributeSet で、各 attribute の値を ActiveModel::Attribute オブジェクトとして保持している。
article.title = "New" のように代入すると、ActiveRecord の writer を通って @attributes.write_from_user が呼ばれる。
def _write_attribute(attr_name, value)
@attributes.write_from_user(attr_name, value)
end
AttributeSet#write_from_user は、対象 attribute の Attribute オブジェクトを差し替える。
def write_from_user(name, value)
raise FrozenError, "can't modify frozen attributes" if frozen?
@attributes[name] = self[name].with_value_from_user(value)
value
end
ここで呼ばれる with_value_from_user が重要になる。
def with_value_from_user(value)
type.assert_valid_value(value)
self.class.from_user(name, value, type, original_attribute || self)
end
新しい Attribute::FromUser を作るとき、代入前の Attribute を original_attribute として渡している。
つまり、title が "Old" から "New" に変わると、内部的にはおおよそ次のような構造になる。
title_attribute = ActiveModel::Attribute::FromUser.new(
name: "title",
value_before_type_cast: "New",
original_attribute: old_title_attribute
)
original_value は、代入後の Attribute 自身の値ではなく、original_attribute をたどって返す。
def original_value
if assigned?
original_attribute.original_value
else
type_cast(value_before_type_cast)
end
end
また、変更されたかどうかの判定も Attribute が持っている。
def changed?
changed_from_assignment? || changed_in_place?
end
代入による変更は、元の値と現在の値を type に問い合わせて判定する。
def changed_from_assignment?
assigned? && type.changed?(original_value, value, value_before_type_cast)
end
ここで分かるのは、通常の代入経路では、Dirty tracking のために古い値だけを別の Hash に退避しているわけではないということだ。代入後の Attribute が original_attribute として代入前の Attribute を持ち、そこから元の値を参照できるようになっている。
AttributeMutationTracker が変更情報を集める
Dirty の実装では、mutations_from_database を通して AttributeMutationTracker を作る。
def mutations_from_database
@mutations_from_database ||= if defined?(@attributes)
ActiveModel::AttributeMutationTracker.new(@attributes)
else
ActiveModel::ForcedMutationTracker.new(self)
end
end
AttributeMutationTracker は @attributes を見て、どの attribute が変わったか、元の値は何か、現在値は何かを集める。
class AttributeMutationTracker # :nodoc:
OPTION_NOT_GIVEN = Object.new
def initialize(attributes)
@attributes = attributes
end
def changes
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
if change = change_to_attribute(attr_name)
result.merge!(attr_name => change)
end
end
end
def change_to_attribute(attr_name)
if changed?(attr_name)
[original_value(attr_name), fetch_value(attr_name)]
end
end
end
changed? と original_value は、最終的に Attribute に問い合わせる。
def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
attribute_changed?(attr_name) &&
(OPTION_NOT_GIVEN == from || original_value(attr_name) == type_cast(attr_name, from)) &&
(OPTION_NOT_GIVEN == to || fetch_value(attr_name) == type_cast(attr_name, to))
end
def original_value(attr_name)
attributes[attr_name].original_value
end
def attribute_changed?(attr_name)
forced_changes.include?(attr_name) || !!attributes[attr_name].changed?
end
def fetch_value(attr_name)
attributes.fetch_value(attr_name)
end
保存前に article.changes_to_save のようなメソッドが呼ばれると、この mutations_from_database が使われる。Rails の API でも、changes_to_save や name_change_to_be_saved は、次回保存時に永続化される変更を確認するための ActiveRecord 側の Dirty メソッドとして説明されている。(Ruby on Rails API)
保存後に mutations_before_last_save へ移される
title_before_last_save は「直前の保存前の値」を返すメソッドなので、保存後にも直前の変更情報が残っている必要がある。
それを行うのが changes_applied だ。
def changes_applied
unless defined?(@attributes)
mutations_from_database.finalize_changes
end
@mutations_before_last_save = mutations_from_database
forget_attribute_assignments
@mutations_from_database = nil
end
Rails の API ドキュメントでも、changes_applied は Dirty data をクリアし、mutations_from_database を mutations_before_last_save へ移す処理として説明されている。(Ruby on Rails API)
ActiveRecord は update / create の後で changes_applied を呼ぶ。
def _update_record(attribute_names = attribute_names_for_partial_updates)
affected_rows = super
changes_applied
affected_rows
end
def _create_record(attribute_names = attribute_names_for_partial_inserts)
id = super
changes_applied
id
end
ここで、保存前に使われていた mutations_from_database が @mutations_before_last_save に退避される。
そのため after_update の中では、mutations_before_last_save を通じて、直前の保存で何が変わったかを参照できる。
mutations_before_last_save 自体は次のように定義されている。
def mutations_before_last_save
@mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance
end
変更がなければ NullMutationTracker が返り、変更があれば changes_applied で退避された AttributeMutationTracker が返る。
title_before_last_save が値を返すまで
ここまでを踏まえると、title_before_last_save が "Old" を返す流れは次のようになる。
article.title = "New"
# => @attributes["title"] が Attribute::FromUser に差し替わる
# => その Attribute::FromUser は original_attribute として代入前の Attribute を持つ
article.save!
# => changes_applied が呼ばれる
# => mutations_from_database が mutations_before_last_save に退避される
article.title_before_last_save
# => attribute_before_last_save("title")
# => mutations_before_last_save.original_value("title")
# => attributes["title"].original_value
# => original_attribute.original_value
# => "Old"
実際に Article で確認すると、代入前後で @attributes["title"] の class と値は次のように変わった。
# after load/save
# class: ActiveModel::Attribute::FromDatabase
# value: "Old"
# original: "Old"
# changed: false
# after assignment
# class: ActiveModel::Attribute::FromUser
# value: "New"
# original: "Old"
# changed: true
# after save
# class: ActiveModel::Attribute::FromDatabase
# value: "New"
# original: "New"
# changed: false
# title_before_last_save
# => "Old"
# saved_change_to_title
# => ["Old", "New"]
保存後の現在の @attributes["title"] は、すでに "New" を DB 由来の値として持つ FromDatabase に戻っている。それでも title_before_last_save が "Old" を返せるのは、保存直前の mutation tracker が @mutations_before_last_save に残っているからだ。
まとめ
ActiveModel::Dirty は、ActiveRecord と同じように attribute の変更を追跡するための基本的な仕組みを提供するモジュールだ。素の Ruby オブジェクトでも、define_attribute_methods、*_will_change!、changes_applied などを組み合わせれば Dirty tracking を実装できる。(Ruby on Rails API)
一方、ActiveRecord モデルでは ActiveRecord::AttributeMethods::Dirty が使われる。このモジュールは ActiveModel::Dirty の仕組みを取り込みつつ、DB に保存されている値との差分や、直前の保存で発生した変更を扱うためのメソッドを追加している。(Ruby on Rails API)
今回見た Article の例では、次の2つの仕組みが組み合わさっていた。
attribute_method_suffix("_before_last_save")
# => title_before_last_save
# => attribute_before_last_save("title")
attribute_method_affix(prefix: "saved_change_to_", suffix: "?")
# => saved_change_to_title?
# => saved_change_to_attribute?("title")
article.title = "New"
# => @attributes["title"] が Attribute::FromUser に差し替わる
# => その Attribute は original_attribute として代入前の Attribute を持つ
article.save!
# => changes_applied
# => mutations_from_database が mutations_before_last_save に退避される
article.title_before_last_save
# => mutations_before_last_save.original_value("title")
title_before_last_save や saved_change_to_title? は、どこかに個別実装があるわけではない。ActiveModel::AttributeMethods による attribute method の生成と、ActiveRecord の Attribute / AttributeMutationTracker による変更追跡が組み合わさることで、title という attribute に対応する Dirty tracking メソッドとして使えるようになっている。