ActiveRecord attribute methodまとめ（その②: Dirty編）

# はじめに
今回はActiveRecordのattribute methodのうち、Dirtyメソッドをまとめていきます。
（2022年6月時点。Rails7.0対応。）
以前、[BeforeTypeCastの内容](https://qiita.com/maabow/items/33ad5328b192e3adb49e)をまとめましたが、今回のDirtyメソッドはBeforeTypeCastに比べて、より実用的なメソッドが多いと思います。

# 検証環境
以下のschemaとmodelに基づいて検証を行なっています。
enumを利用しているので、実際にDBに入っている値と出力される内容が異なる箇所が存在します。
``` db/schema.rb
create_table "todos", charset: "utf8mb4", force: :cascade do |t|
  t.string "title"
  t.datetime "scheduled_at"
  t.integer "status"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end
```
``` app/model/todo.rb
class Todo < ApplicationRecord
  enum status: { not_yet: 0, complete: 1, in_progress: 2, cancel: 3 }
end
```

# Dirty
Dirtyとは、オブジェクトの変更を検出し、変更前後の値を取得できるメソッド群です。
カラムの変更に応じてログを残したり、保存前後の値を比較してなにか処理をしたりなど、たびたびお世話になるメソッド達です。
何故「Dirty」と呼ばれるているのかは未知なのですが、持論では、オブジェクトに変更があるのに保存されてない状態を「汚れている」とみなし、その内容を検知するためのメソッドだから、「Dirty」と呼んでいるのではないかと考えています。
理由を知っている方は、是非教えてください。

## attribute_before_last_save
指定したattributeの最終保存前の値を返却します。
保存時のコールバックの処理で、元のattributeの値を使用したい時に使えます。
`[attribute名]_before_last_save`でも同様の呼出可能です。
保存前はnilを返却します。
``` Ruby
todo = Todo.find(1) #=> #<Todo id: 1, status: "not_yet",...>
# 保存前はnilを返却
todo.attribute_before_last_save(:status) #=> nil

todo.update!(status: :in_progress)
# 保存前のattributeの値を返却
todo.attribute_before_last_save(:status) #=> "not_yet"

todo.update!(status: :cancel)
# [attribute名]_before_last_saveでも同様
# 最終保存前の値を取得するため、"in_progress"を返却
todo.status_before_last_save #=> "in_progress"
```

## attribute_change_to_be_saved
指定したattributeの保存前の値と保存予定の値を配列形式で返却します。
配列の形式は`[元の値, 保存予定の値]`となります。
レコードが保存されるときに発生するattributeの変更を確認するために使えます。
`[attribute名]_change_to_be_saved`でも同様の呼出可能です。
保存後はnilを返却します。
途中で値が書き換えられても、最初の値と保存予定の値が返却されます。
``` Ruby
todo = Todo.new #=> #<Todo id: nil, status: nil,...>
todo.status = :in_progress
# 新規作成なので、初期値はnil
todo.attribute_change_to_be_saved(:status) #=> [nil, "in_progress"]

todo.status = :cancel
# [attribute名]_change_to_be_savedでも同様
# 最初の値と保存予定の値を返却
todo.status_change_to_be_saved #=> [nil, "cancel"]

todo.save!
# 保存後はnilを返却
todo.attribute_change_to_be_saved(:status) #=> nil
```

## attribute_in_database
変更に関わらず、指定したattributeのデータベース上の値を返却します。
ただし、型はキャストされた状態で返却されるので注意が必要です。
`[attribute名]_in_database`でも同様の呼出可能です。
``` Ruby
todo = Todo.find(3) #=> #<Todo id: 3, status: "in_progress",...>
# 型キャストされる前の値は2
todo.status_for_database #=> 2

todo.status = :complete
# 型キャストされた状態のデータベース上の値を返却
todo.attribute_in_database(:status) #=> "in_progress"

todo.save!
# [attribute名]_in_databaseでも同様
# 保存後なので、データベースに保存された"complete"を返却
todo.status_in_database #=> "complete"
```

## attributes_in_database
変更保存予定のattributeのデータベース上の値をハッシュで返却します。
`attribute_in_database`同様、型はキャストされた状態で返却されるので注意が必要です。
``` Ruby
todo = Todo.find(3) #=> #<Todo id: 3, status: "complete",...>
# 変更がない場合は空ハッシュを返却
todo.attributes_in_database #=> {}

todo.status = :complete
# 同じ値を入れても、変更とは見なされない
todo.attributes_in_database #=> {}

todo.status = :cancel
# 変更保存予定のattributeのデータベース上の値を返却
todo.attributes_in_database #=> {"status"=>"complete"}

todo.save!
# 保存後は変更がない時と同様
todo.attributes_in_database #=> {}
```

## changed_attribute_names_to_save
変更保存予定のattribute名の配列を返却します。
``` Ruby
todo = Todo.find(3) #=> #<Todo id: 3, scheduled_at: 2022-03-10 00:00:00.000000000 +0900, status: "cancel", ...>
# 変更がない時は空配列を返却
todo.changed_attribute_names_to_save #=> []

todo.scheduled_at = Time.current.since(14.days)
todo.status = :complete
# 変更があるattribute名を返却
todo.changed_attribute_names_to_save #=> ["scheduled_at", "status"]

todo.save!
# 保存後は変更がない時と同様
todo.changed_attribute_names_to_save #=> []
```

## changes_to_save
変更保存予定のattributeの変更前後の値を返却します。
``` Ruby
todo = Todo.find(2) #=> #<Todo id: 2, scheduled_at: 2022-03-10 00:00:00.000000000 +0900, status: "in_progress", ...>
todo.attributes
# 変更がない時は空ハッシュを返却
todo.changes_to_save #=> {}

todo.scheduled_at = Time.current.since(14.days)
todo.status = :complete
# 変更保存予定の前後の値を返却
todo.changes_to_save
#=> {"scheduled_at"=>[Thu, 10 Mar 2022 00:00:00.000000000 JST +09:00, Sat, 19 Mar 2022 17:47:46.799374000 JST +09:00], "status"=>["in_progress", "complete"]}

todo.save!
# 保存後は変更がない時と同様に空ハッシュを返却
todo.changes_to_save #=> {}
```

## has_changes_to_save?
`changes_to_save`の値の有無で`true`か`false`を返却します。
``` Ruby
todo = Todo.find(2) #=> #<Todo id: 2, scheduled_at: 2022-03-10 00:00:00.000000000 +0900, status: "in_progress", ...>
# 変更保存予定のattributeがないためfalseを返却
todo.has_changes_to_save? #=> false

todo.status = :complete
todo.scheduled_at = Time.current.since(14.days)
# 変更保存予定のattributeがあるためtrueを返却
todo.has_changes_to_save? #=> true

todo.save!
# 保存後は変更がない時と同様
todo.has_changes_to_save? #=> false
```

## reload
attributeの変更を初期化し、設定されているプライマリーIDのデータを読み直します。
データの読み直しなので、select句のSQLが再実行されます。
オブジェクトが持っているIDを用いて、findが再実行されると考えると分かりやすいと思います。
そのため、新規のActiveRecordオブジェクトのように、プライマリーIDをもっていない状態のオブジェクトにreloadをするとRecordNotFoundの例外を吐きます。IDを指定せずにfindした時と同じ挙動です。
``` Ruby
todo = Todo.find(2) #=> SELECT `todos`.* FROM `todos` WHERE `todos`.`id` = 2 LIMIT 1
todo.attributes #=> {..., "title"=>"初期タイトル",..., "status"=>"complete", ...}
todo.status = :cancel
todo.title = "reload前変更"
todo.attributes #=> {..., "title"=>"reload前変更",..., "status"=>"cancel", ...}

# reload時にselect再実行
todo.reload #=> SELECT `todos`.* FROM `todos` WHERE `todos`.`id` = 2 LIMIT 1
# 元の状態に戻る
todo.attributes #=> {..., "title"=>"初期タイトル",..., "status"=>"complete", ...}

# 新規のActiveRecordオブジェクトに対してreloadするとRecordNotFoundエラー
todo = Todo.new
todo.status = :cancel
# Todo.find()とした時と同じ挙動
todo.reload #=> ActiveRecord::RecordNotFound (Couldn't find Todo without an ID):
```

## saved_change_to_attribute
指定したattributeの最終保存時に変更された値と保存前の値を配列で返却します。
配列は、`[保存前の最初の値, 保存後の値]`の順になっています。
`saved_change_to_[attribute名]`でも同様の呼出可能です。
``` Ruby
@todo = Todo.find(3) #=> #<Todo id: 3, status: "complete", ...>
todo.status = :in_progress
# 未保存の状態はnilを返却
todo.saved_change_to_attribute(:status) #=> nil

todo.save!
todo.saved_change_to_attribute(:status) #=> ["complete", "in_progress"]

todo.status = :cancel
todo.update!(status: :complete)
# saved_change_to_[attribute名]でも同様
# 途中cancelを入れているが、保存前の最初の値はin_progressで保存後にcompleteに変わっているので、["in_progress", "complete"]を返却
todo.saved_change_to_status #=> ["in_progress", "complete"]
```

## saved_change_to_attribute?
指定したattributeの前回保存時の変更有無で`true`か`false`を返却します。
`saved_change_to_[attribute名]?`でも同様の呼出可能です。
`from`や`to`を指定して、変更前の値、変更後の値の条件を指定することも可能です。
``` Ruby
todo = Todo.find(3) #=> #<Todo id: 3, status: "complete", ...>
todo.status = :in_progress
# 保存前なので、falseが返却される
todo.saved_change_to_attribute?(:status) #=> false

todo.save!
# 保存時に変更があるので、trueを返却
todo.saved_change_to_attribute?(:status) #=> true
# from, toと変更内容が一致してないとfalseを返却
todo.saved_change_to_attribute?(:status, from: "cancel") #=> false
todo.saved_change_to_attribute?(:status, to: "cancel") #=> false
# from, toと変更内容が一致しているので、trueを返却
todo.saved_change_to_attribute?(:status, from: "complete", to: "in_progress") #=> true

todo.status = :cancel
todo.update!(status: :in_progress)
# saved_change_to_[attribute名]?でも同様
# 保存時にstatusの値は変更がなかったので、falseを返却
todo.saved_change_to_status? #=> false

# saved_change_to_[attribute名]?でfrom,toを指定する場合は以下のように指定
# from, toを同じ値にしても、変更時に変化がない場合はfalseを返却
todo.saved_change_to_status?(from: "in_progress", to: "in_progress") #=> false
```

## saved_changes
直前の保存による変更内容をハッシュで返却します。
ハッシュは、attributeをkeyにして、valueに`saved_change_to_attribute`と同様の値が入ります。
``` Ruby
todo = Todo.find(2) #=> #<Todo id: 2, title: "変更前タイトル", status: "in_progress",...>
todo.title = "変更後タイトル"
todo.status = :complete
# 保存前は空ハッシュを返却
todo.saved_changes #=> {}

todo.save!
todo.saved_changes
#=> { "title"=>["変更前タイトル", "変更後タイトル"], "status"=>["in_progress", "complete"], "updated_at"=>[Wed, 08 Jun 2022 12:18:38.734546000 JST +09:00, Wed, 08 Jun 2022 12:20:10.658886000 JST +09:00]}

todo.save!
# 直前の保存では変更がないので、空ハッシュを返却
todo.saved_changes #=> {}
```

## saved_changes?
直前の保存時に何か変更点があるかどうかで、`true`か`false`を返却します。
``` Ruby
todo = Todo.find(2) #=> title: "変更前タイトル"
todo.title = "変更後タイトル"
# 保存してないので、falseを返却
todo.saved_changes? #=> false

todo.save!
todo.saved_changes? #=> true

todo.save!
# 直前の保存では、変更がないので、falseを返却
todo.saved_changes? #=> false
```

## will_save_change_to_attribute?
指定したattributeが次の保存時に変更があるかどうかを返却します。
`from`や`to`を指定して、変更前の値、変更後の値の条件を指定することも可能です。
``` Ruby
todo = Todo.find(3) #=> status: "in_progress"
todo.status = :in_progress
# 変更がないので、falseを返却
todo.will_save_change_to_attribute?(:status) #=> false

todo.status = :complete
todo.will_save_change_to_attribute?(:status) #=> true
todo.will_save_change_to_attribute?(:status, to: "complete") #=> true
# toの値が変更後の値と異なるので、falseを返却
todo.will_save_change_to_attribute?(:status, from: :in_progress, to: "cancel") #=> false

todo.save!
# 保存されていない変更がないので、falseを返却
todo.will_save_change_to_attribute?(:status) #=> false
```

# おわりに
今回は、Dirtyメソッドについてまとめていきました。

ActiveRecordで、保存前後の値を使用して何かしたい、カラム/attributeの変更を検知したいといった時に参考になればと思います。
便利なメソッドは用法・用量を守って正しく使いましょう。

その他のメソッドについては、別記事でまとめています。↓↓
[ActiveRecord attribute methodまとめ（その①: BeforeTypeCast編）](https://qiita.com/maabow/items/33ad5328b192e3adb49e)
[Active Record attribute methodまとめ（その③: PrimaryKey編）](https://qiita.com/maabow/items/012c473312961ce7e060)
[Active Record attribute methodまとめ（その④: Query, Read, Write編）](https://qiita.com/maabow/items/3d704bde6c4531bbb0ac)

# 参考
- [rails github](https://github.com/rails/rails/tree/main/activerecord/lib/active_record/attribute_methods)
- [Railsガイド](https://railsguides.jp/)
- [ActiveRecord::AttributeMethods::Dirty](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html)