こんにちは。 Railsエンジニアの横山です。
私がRailsで開発を行うようになって2年弱になりますが、日々、知らない事・分からない事・理解の不十分な事があり勉強の毎日です。
今回は、そのなかでも、理解が不十分であった故にActiveRecordのコールバックでハマってしまったお話をしようかと思います。
ActiveRecordのコールバックについて
細かい説明は省略させていただきますが、RailsはActiveRecordの操作(検索・新規登録・更新・削除)を行う際に、自動で呼び出されるメソッドがあります。
それがコールバックと呼ばれる処理で、以下のような種類があります。
* after_find
* after_initialize
* before_validation
* after_validation
* before_save
* after_save
* before_create
* after_create
* before_update
* after_update
* after_commit
* after_rollback
* after_touch
今回はこの中でもafter_save
のお話になります。
新規追加時と更新時で挙動が違う?
やりたかった処理はオブジェクトの更新後、自身と子モデルの状態をチェックして、条件に一致したら自身の情報を一部変更する、というものです。
子モデルの更新をafter_save
で行っていたためbefore_validation
、after_validation
、before_save
などを使えなかった、という事情もありました。
(今回の説明では簡略化のため、子モデルの処理は省略)
以下のコマンドでarticle
モデルを作成します。
rails g model article name:string text:text
articleのtextに"hogehoge"という文字が含まれていたら"xxxxx"に置き換えて保存するという処理を行う事にします。
当初は単純に「after_save
の中に混ぜ込めばいいだろ」と思っていたので、以下のように記述しました。
class Article < ActiveRecord::Base NG_WORD = "hogehoge" after_save :replace_text def replace_text return unless self.text.index(NG_WORD) replace_word = self.text.gsub(NG_WORD, "xxxxx") Article.transaction do self.update_attributes!(text: replace_word) end end end
賢明な皆様なら一発かと思いますが、上記の記載方法だと、新規作成の処理ではうまくいきますが、更新処理ではうまく機能しない時があります。
手順
1.新規にtext:ああhogehogeを登録
=>これは普通に成功し、「ああxxxxx」になるはず
2.追加したarticleのtext:ああxxxxxに対して「ああhogehoge」と再登録
=>これは失敗。「ああhogehoge」で登録されてしまいます。
これ、なんで駄目なのか良く分からないのでRailsの中まで追ってみました。
コールバックのタイミングの問題かと思いきや、どうもupdate_attributes!
とafter_save
の相性?の問題のようでした。
問題はここ
v3.2.17/activerecord/lib/active_record/attribute_methods/dirty.rb#L56
def write_attribute(attr, value) attr = attr.to_s # The attribute already has an unsaved change. if attribute_changed?(attr) old = @changed_attributes[attr] @changed_attributes.delete(attr) unless _field_changed?(attr, old, value) else old = clone_attribute_value(:read_attribute, attr) # Save Time objects as TimeWithZone if time_zone_aware_attributes == true old = old.in_time_zone if clone_with_time_zone_conversion_attribute?(attr, old) @changed_attributes[attr] = old if _field_changed?(attr, old, value) end # Carry on. super(attr, value) end
と、attribute_changed?
の中身であるここ
v3.2.17/activemodel/lib/active_model/dirty.rb#L137
# Map of change <tt>attr => original value</tt>. def changed_attributes @changed_attributes ||= {} end #中略 # Handle <tt>*_changed?</tt> for +method_missing+. def attribute_changed?(attr) changed_attributes.include?(attr) end
write_attribute
の中で実際に差し替える処理を行い、attribute_changed?
の中で、その差し替え必要性の判定を行っています。
上記のケースだと、最初のsave処理で@changed_attributes
内に修正するデータが格納されます。
その後、after_save
でupdate_attributes!
が呼ばれ、再度、ここの判定処理を行いますが
一回目のsaveで登録した@changed_attributes
の情報がそのままであるため、2回目のsaveでは「さっき更新しただろ?」と判定されてしまい、after_save
で行ったupdate_attributes!
が無効化されてしまう、というオチでした。
まとめ
今回のケースに限っていえば、after_save
ではなく、after_commit
を使えば @changed_attributes
の情報が初期化されるので、こっちを使うようにしています。
とはいえ、save終わった後に@changed_attributes
の情報を保持しとく理由ってなんかあるのかな? 初期化すべきでは?
または
def attribute_changed?(attr) changed_attributes.include?(attr) end
で、Hashのkeyの有無だけで更新の必要性を判定するのではなく、中身もちゃんと見るようにしても良いかもしれませんね。 など、もうちょっと調べてpull requestも検討してみます。
最後に
マネーフォワードでは、新しい事にチャレンジできるエンジニアを募集しています! 私たちと一緒に最高のサービスを作っていきませんか?
マネーフォワード採用サイト https://recruit.moneyforward.com/
日本No.1お金のサービスを創り上げるRailsエンジニアWanted! https://www.wantedly.com/projects/9979