1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ActiveRecord の Dirty tracking はどう実現されているのか

1
Last updated at Posted at 2026-06-02

Rails で ActiveRecord モデルを書いていると、attribute の変更前後の値を簡単に参照できる。

たとえば、記事タイトルが変更されたときだけ通知処理を呼び出したい場合、次のように書ける。

app/models/article.rb
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 を使えるようになるのか

ArticleApplicationRecord を継承していて、ApplicationRecordActiveRecord::Base を継承している。

app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
end

ActiveRecord 側の実装を見ると、ActiveRecord::AttributeMethods の中で Dirty が include されている。

active_record/attribute_methods.rb
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::DirtyActiveModel::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_savedname_in_databasesaved_change_to_name?saved_change_to_namename_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 で登録されている。

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_suffixattribute_method_affix を呼んだ時点で、ただちに title_before_last_save というメソッドが定義されるわけではない。まずは、メソッド名のパターンが attribute_method_patterns に登録される。

ActiveModel::AttributeMethods は、prefix / suffix / affix を使って attribute に対応するメソッドを動的に定義する仕組みを提供している。Rails Guide でも、attribute_method_suffixattribute_method_prefixattribute_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") への呼び出しとして扱える。

実際に生成されるメソッドは、概念的には次のようなものになる。

generated_attribute_methods_example.rb
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") に委譲される。

active_record/attribute_methods/dirty.rb
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_saveattribute_before_last_save("name") の形でも呼び出せると説明されている。(Ruby on Rails API)

saved_change_to_title? も同じ構造になっている。attribute 別メソッドとして呼び出せるが、実処理は saved_change_to_attribute?("title") に委譲される。

active_record/attribute_methods/dirty.rb
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 が呼ばれる。

active_record/attribute_methods/write.rb
def _write_attribute(attr_name, value)
  @attributes.write_from_user(attr_name, value)
end

AttributeSet#write_from_user は、対象 attribute の Attribute オブジェクトを差し替える。

active_model/attribute_set.rb
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 が重要になる。

active_model/attribute.rb
def with_value_from_user(value)
  type.assert_valid_value(value)
  self.class.from_user(name, value, type, original_attribute || self)
end

新しい Attribute::FromUser を作るとき、代入前の Attributeoriginal_attribute として渡している。

つまり、title"Old" から "New" に変わると、内部的にはおおよそ次のような構造になる。

attribute_structure_example.rb
title_attribute = ActiveModel::Attribute::FromUser.new(
  name: "title",
  value_before_type_cast: "New",
  original_attribute: old_title_attribute
)

original_value は、代入後の Attribute 自身の値ではなく、original_attribute をたどって返す。

active_model/attribute.rb
def original_value
  if assigned?
    original_attribute.original_value
  else
    type_cast(value_before_type_cast)
  end
end

また、変更されたかどうかの判定も Attribute が持っている。

active_model/attribute.rb
def changed?
  changed_from_assignment? || changed_in_place?
end

代入による変更は、元の値と現在の値を type に問い合わせて判定する。

active_model/attribute.rb
def changed_from_assignment?
  assigned? && type.changed?(original_value, value, value_before_type_cast)
end

ここで分かるのは、通常の代入経路では、Dirty tracking のために古い値だけを別の Hash に退避しているわけではないということだ。代入後の Attributeoriginal_attribute として代入前の Attribute を持ち、そこから元の値を参照できるようになっている。

AttributeMutationTracker が変更情報を集める

Dirty の実装では、mutations_from_database を通して AttributeMutationTracker を作る。

active_model/dirty.rb
def mutations_from_database
  @mutations_from_database ||= if defined?(@attributes)
    ActiveModel::AttributeMutationTracker.new(@attributes)
  else
    ActiveModel::ForcedMutationTracker.new(self)
  end
end

AttributeMutationTracker@attributes を見て、どの attribute が変わったか、元の値は何か、現在値は何かを集める。

active_model/attribute_mutation_tracker.rb
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 に問い合わせる。

active_model/attribute_mutation_tracker.rb
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
active_model/attribute_mutation_tracker.rb
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_savename_change_to_be_saved は、次回保存時に永続化される変更を確認するための ActiveRecord 側の Dirty メソッドとして説明されている。(Ruby on Rails API)

保存後に mutations_before_last_save へ移される

title_before_last_save は「直前の保存前の値」を返すメソッドなので、保存後にも直前の変更情報が残っている必要がある。

それを行うのが changes_applied だ。

active_model/dirty.rb
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_databasemutations_before_last_save へ移す処理として説明されている。(Ruby on Rails API)

ActiveRecord は update / create の後で changes_applied を呼ぶ。

active_record/attribute_methods/dirty.rb
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 自体は次のように定義されている。

active_model/dirty.rb
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" を返す流れは次のようになる。

title_before_last_save_value_flow.rb
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 と値は次のように変わった。

rails_runner_observation.rb
# 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つの仕組みが組み合わさっていた。

dirty_method_generation_summary.rb
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")
dirty_value_tracking_summary.rb
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_savesaved_change_to_title? は、どこかに個別実装があるわけではない。ActiveModel::AttributeMethods による attribute method の生成と、ActiveRecord の Attribute / AttributeMutationTracker による変更追跡が組み合わさることで、title という attribute に対応する Dirty tracking メソッドとして使えるようになっている。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?