背景
- Railsのコードで
will_save_change_to_xxx?やattribute_changed?みたいなメソッドをコールバック条件などでよく見かける - たとえばこういうコード
before_update :clear_preview_token, if: -> { will_save_change_to_status?(to: Post.statuses[:published]) }
- カラム名(
status)を含んだメソッドが、何も書いてないのに勝手に生えてくる - いったいこれは何者なのか、どこで定義されてるのかを調べてみた
結論
-
attribute_changed?やwill_save_change_to_attribute?みたいなメソッドはActiveModel::Dirtyというモジュールが提供している - モデルの属性が変更されたかを追跡する仕組み
-
name_changed?email_wasのように属性ごとのバリエーションは、Rails がdefine_attribute_methodsの仕組みで内部的に勝手に生やしてくれてる - なので
ApplicationRecordを継承したモデルでは、何も意識しなくても勝手に使える
ActiveModel::Dirty のメソッド一覧
| メソッド | 説明 |
|---|---|
attr_changed? |
属性が変更されたか |
attr_was |
変更前の値 |
attr_change |
[変更前, 変更後] の配列 |
changed? |
いずれかの属性が変更されたか |
changed |
変更された属性名の配列 |
changes |
変更内容のハッシュ |
will_save_change_to_attr? |
これからのsaveで変わる予定か(保存前) |
saved_change_to_attr? |
直前のsaveで変わったか(保存後) |
attr_previously_changed? |
直前のsaveで変更されたか(保存後) |
attr_previous_change |
直前のsaveでの変更内容(保存後) |
Dirtyって何?
Railsに限らず、プログラミングの世界で「Dirty(ダーティ)」は「何かが変更された状態」を表す、らしい。逆に保存済みの状態から変更されていないことを「Clean(クリーン)」と呼ぶらしい
| 状態 | 意味 |
|---|---|
| Dirty | 変更されたが保存されていない |
| Clean | 変更されていない、または保存済み |
要は「データの中身が触られたかどうか」を見張る概念。メモアプリの「未保存」マークと同じ発想と捉えてよさそう
ActiveModel::Dirty が内部でメソッドを作ってくれてる仕組み
name_changed? status_was will_save_change_to_email? … 属性が増えるたびに似たメソッドが勝手に生える。これは Rails の define_attribute_methods という動的メソッド生成の仕組みで実現されている
そもそも何を解決したかったか
属性ごとに似たメソッドを手書きしてたら、こうなる
def name_changed?; ... end
def email_changed?; ... end
def status_changed?; ... end
# 属性が増えるたびに同じパターンを書き続ける
DRYじゃない。共通処理を1回書くだけで、属性ごとに自動でメソッドを生やしたいというのがモチベーション
3つの登場人物
| 名前 | 役割 |
|---|---|
attribute_method_prefix / _suffix / _affix
|
「こういう名前で生やしたい」というパターンを登録する(Rails) |
define_attribute_methods |
登録されたパターンに従って、属性ごとに実際のメソッドを生成する(Rails) |
send |
生成されたメソッドの中で属性名を文字列として受け取り、動的に値を取りに行く(Ruby) |
パターン登録は3種類ある
| 登録メソッド | 生成パターン | 例(属性: name) |
|---|---|---|
attribute_method_prefix 'reset_' |
reset_<属性> |
reset_name |
attribute_method_suffix '_contains?' |
<属性>_contains? |
name_contains? |
attribute_method_affix prefix: 'xxx_', suffix: '_yyy' |
xxx_<属性>_yyy |
xxx_name_yyy |
自前で動的メソッドを生やしてみる
class ContactForm
include ActiveModel::AttributeMethods
attribute_method_suffix '_contains?' # ① パターンを登録
define_attribute_methods :name, :email # ② メソッドを生成
attr_accessor :name, :email
private
def attribute_contains?(attr, value) # ③ 共通処理
send(attr).to_s.include?(value)
end
end
form = ContactForm.new
form.name = "Yamada Taro"
form.name_contains?("Taro") # => true
- ①
_contains?で終わるメソッドを作りたい、と登録 - ②
:name:emailぶんを一気に生成 - ③ 呼ばれたら共通処理
attribute_contains?に集約。中でsend(attr)を使って属性値を取りに行く
じゃあ will_save_change_to_status? って何で動いてる?
これは affix(前後両方) で定義されているパターン
# ActiveModel::Dirty 内のイメージ
attribute_method_affix prefix: "will_save_change_to_", suffix: "?"
private
def will_save_change_to_attribute?(attr_name, **options)
mutations_from_database.will_change?(attr_name.to_s, **options)
end
呼び出しの流れはこう
post.will_save_change_to_status?(to: 1)
↓ define_attribute_methods が生成したメソッドが受ける
will_save_change_to_attribute?("status", to: 1)
↓ 共通処理本体に集約
mutations_from_database.will_change?("status", to: 1)
つまり属性ごとのメソッドは「入口」だけが量産されてて、中身は1つの共通処理にまとまってる
なんで2段階構造(登録 → 生成)なの?
- パターン登録の時点では、まだ「どの属性に生やすか」がわからない
- メソッド生成の時点では、事前にパターンが登録されている必要がある
- → 一旦内部メモにパターンをためておいて、
define_attribute_methodsが呼ばれた瞬間にまとめて生成、という設計
ActiveRecord はテーブルのカラム情報をもとに define_attribute_methods を内部で自動的に呼んでくれているので、普段は意識しなくてOK
いつ・どれを使う?(タイミング別の使い分け)
will_save_change_to_xxx? saved_change_to_xxx? xxx_changed? ... 似てるメソッドが多くて紛らわしいが、呼ぶタイミングで使うメソッドが変わる
| メソッド | 呼ぶタイミング | 何を見るか |
|---|---|---|
will_save_change_to_xxx? |
save 前 (before_save / before_update) |
これから変わる値 |
saved_change_to_xxx? |
save 後 (after_save / after_update) |
直前のsaveで変わった値 |
xxx_changed? |
任意(in-memory) | メモリ上の変更(コールバック内で使うとタイミングが曖昧になる) |
Rails 5.1 以降は before系では will_save_change_to_*、after系では saved_change_to_* が推奨されている
enum値の比較に注意
will_save_change_to_status?(to: Post.statuses[:published])
Post.statuses[:published] は DB に保存される整数値(例: 1)を返すwill_save_change_to_*? は内部値(DB値)と比較するので、シンボル :published を直接渡してもマッチしない
# NG(シンボル)
will_save_change_to_status?(to: :published)
# OK(整数)
will_save_change_to_status?(to: Post.statuses[:published])
まとめ
-
will_save_change_to_xxx?やattribute_changed?はActiveModel::Dirtyが提供する変更追跡メソッド - 属性ごとのバリエーションは
define_attribute_methodsの動的メソッド生成で勝手に生えてる -
attribute_method_affixでパターンを登録 →define_attribute_methodsで属性ごとに量産 → 中身は共通処理に集約、という構造 - before / after / in-memory でメソッドを使い分けるのが Rails 5.1 以降の作法
感想
- 「カラム名を含むメソッドが勝手に生える」のがずっとメタプロっぽくて曖昧だったけど、
attribute_method_*で設計図を描いてdefine_attribute_methodsが工場で量産する、という比喩で腹落ちした(サンキューAI)