Ruby
Rails
ActiveRecord

ActiveRecord 5 の dirty attribute methods (*_changed? とか)の挙動一覧

この記事は何?

ActiveRecord 5系の dirty attribute methods (attribute_changed? とか attribute_was とか)の挙動を一覧にしたものです。以下のコードを1行ずつ実行し、各時点で dirty method の返り値を表にまとめました。

o = Obj.create(foo: "V1")
o.foo = "V2"
o.save!
o.foo = "V3"

5.1.xでは attribute_changed? など一部のメソッドが非推奨となり、5.2.0から異なる挙動のメソッドとして復活しました。そのため両バージョンの挙動を併記しています。

一覧

active_record 5.2.0

Create record

o = Obj.create(foo: "V1")
method before_save after_save created
foo "V1" "V1" "V1"
foo_was nil "V1" "V1"
foo_before_last_save nil nil nil
foo_in_database nil "V1" "V1"
saved_change_to_foo nil [nil, "V1"] [nil, "V1"]
foo_changed? true false false
will_save_change_to_foo? true false false
saved_change_to_foo? false true true

Change record

o.foo = "V2"
method changed
foo "V2"
foo_was "V1"
foo_before_last_save nil
foo_in_database "V1"
saved_change_to_foo [nil, "V1"]
foo_changed? true
will_save_change_to_foo? true
saved_change_to_foo? true

Save record

o.save!
method before_save after_save save finished
foo "V2" "V2" "V2"
foo_was "V1" "V2" "V2"
foo_before_last_save nil "V1" "V1"
foo_in_database "V1" "V2" "V2"
saved_change_to_foo [nil, "V1"] ["V1", "V2"] ["V1", "V2"]
foo_changed? true false false
will_save_change_to_foo? true false false
saved_change_to_foo? true true true

Change record

o.foo = "V3"
method changed
foo "V3"
foo_was "V2"
foo_before_last_save "V1"
foo_in_database "V2"
saved_change_to_foo ["V1", "V2"]
foo_changed? true
will_save_change_to_foo? true
saved_change_to_foo? true

active_record 5.1.6

Create record

o = Obj.create(foo: "V1")
method before_save after_save created
foo "V1" "V1" "V1"
foo_was nil nil "V1"
foo_before_last_save nil nil nil
foo_in_database nil "V1" "V1"
saved_change_to_foo nil [nil, "V1"] [nil, "V1"]
foo_changed? true true false
will_save_change_to_foo? true false false
saved_change_to_foo? false true true

Change record

o.foo = "V2"
method changed
foo "V2"
foo_was "V1"
foo_before_last_save nil
foo_in_database "V1"
saved_change_to_foo [nil, "V1"]
foo_changed? true
will_save_change_to_foo? true
saved_change_to_foo? true

Save record

o.save!
method before_save after_save save finished
foo "V2" "V2" "V2"
foo_was "V1" "V1" "V2"
foo_before_last_save nil "V1" "V1"
foo_in_database "V1" "V2" "V2"
saved_change_to_foo [nil, "V1"] ["V1", "V2"] ["V1", "V2"]
foo_changed? true true false
will_save_change_to_foo? true false false
saved_change_to_foo? true true true

Change record

o.foo = "V3"
method changed
foo "V3"
foo_was "V2"
foo_before_last_save "V1"
foo_in_database "V2"
saved_change_to_foo ["V1", "V2"]
foo_changed? true
will_save_change_to_foo? true
saved_change_to_foo? true

Deprecation warnings in 5.1.6

DEPRECATION WARNING: The behavior of `attribute_was` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `attribute_before_last_save` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_change_to_attribute?` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `changed_attributes` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_changes.transform_values(&:first)` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_change_to_attribute?` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `attribute_was` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `attribute_before_last_save` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_change_to_attribute?` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `changed_attributes` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_changes.transform_values(&:first)` instead. (called from public_send at dirty_method.rb:51)
DEPRECATION WARNING: The behavior of `attribute_changed?` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_change_to_attribute?` instead. (called from public_send at dirty_method.rb:51)

使用したスクリプト

dirty_method.rb
require "bundler/inline"

$stdout = open(File::NULL, "w") # Suppress migration log

gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", ENV['ACTIVERECORD_VERSION'], require: "active_record"
  gem "sqlite3"
end

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: ":memory:",
)

ActiveRecord::Schema.define do
  create_table :objs do |t|
    t.string :foo, null: false
  end
end

$stdout = STDOUT

class Obj < ActiveRecord::Base
  METHODS = %i[
    foo
    foo_was
    foo_before_last_save
    foo_in_database
    saved_change_to_foo
    will_save_change_to_foo?
    saved_change_to_foo?
  ]

  Captured = Struct.new(:context, :results)

  after_initialize :clear

  before_save do
    capture("before_save")
  end

  after_save do
    capture("after_save")
  end

  def capture(context)
    results = METHODS.map do |method|
      [method, public_send(method).inspect]
    end.to_h

    @captured << Captured.new(context, results)
  end

  def clear
    @captured = []
  end

  def inspect
    table = []

    table << "| method |" + @captured.map { |cap| " #{cap.context} " }.join("|") + "|"
    table << "|---" + ("|---" *  @captured.length) + "|"

    METHODS.each do |method|
      table << "| `#{method}` |" + @captured.map { |cap| " `#{cap.results[method]}` " }.join("|") + "|"
    end

    table.join("\n") + "\n"
  end
end

BINDING = binding

def action(desc, code)
  puts <<~END
  ### #{desc}

      #{code}

  END

  BINDING.eval(code)
end

puts "## active_record #{ENV["ACTIVERECORD_VERSION"]}\n\n"

o = action("Create record", %q{o = Obj.create(foo: "V1")})
o.capture("created")
p o
o.clear

action("Change record", %q{o.foo = "V2"})
o.capture("changed")
p o
o.clear

action("Save record", %q{o.save!})
o.capture("save finished")
p o
o.clear

action("Change record", %q{o.foo = "V3"})
o.capture("changed")
p o
o.clear

所感

5.1.6までの attribute_was の挙動が分かりづらい。属性を変更して保存したとき、 after_save コールバック内では変更前の値を返すが、コールバックを抜けると 現在の値 を返すようになる。 attribute_changed? も同様。

謝辞

1枚のスクリプトに Gemfile と ActiveRecord スキーマを詰め込む方法はActiveRecordを試すときに便利なやつ – r7kamura – Mediumを参考にしました。