0
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?

Rails will_save_change_to_xxx?の正体とActiveModelのdefine_attribute_methodsの仕組み

0
Posted at

背景

  • 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)

参考

0
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
0
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?