はじめに
はじめまして、スタメンでエンジニアをしているショウゴです。普段は、バックエンドグループでRuby on Railsを用いてバックエンドの開発を主に担当しています。
今回の記事では、ActiveRecordのattributeの変更状況を確認できるRailsのActiveRecord::AttributeMethods::Dirtyモジュールの使い方の検証結果と活用例を紹介します。
背景
今回、特定のカラムの値を変化させて、ステータスの変更・管理を行っているモデルに対して新たなバリデーションを実装する作業の中で、特定のカラムの変化を察知し、特定のステータス変化が発生する時にだけバリデーションを実行するように実装する必要がありました。そのため、特定のカラムの変更状況の確認と変更前後の値の取得を行うために、ActiveRecord::AttributeMethods::Dirtyモジュールを活用しました。
ActiveRecord::AttributeMethods::Dirtyとは
Dirtyは、オブジェクトに変更があった場合に検出ができ、変更前後の値を取得することができます。 使用できるメソッドは下記の通りです。
メソッド一覧
method一覧 | 用途 |
---|---|
changed_attribute_names_to_save | 保存予定の変更があるカラム名 |
has_changes_to_save? | 保存予定の変更があるか判定 |
changes_to_save | 保存予定の変更があるカラム名と変更前後の値 |
カラム名_change_to_be_saved | 特定のカラムの保存予定の変更前後の値 |
will_save_change_to_attribute?(カラム名, from: "hoge", to: "fuga")(※2) | 保存予定の変更があるか判定、変更前後の値を指定可 |
カラム名_in_database | 特定のカラムのDBの値 |
attributes_in_database | 全てのカラムの名前とそれらのDBの値 |
カラム名_before_last_save | 特定のカラムの直近の保存前の値 |
saved_change_to_カラム名 | 直前に保存された変更内容 |
saved_change_to_カラム名?(from: "hoge", to: "fuga")(※1) | 直前に保存された変更があるか判定、変更前後の値を指定可 |
saved_changes?() | 直前に保存で値の変更があったか判定 |
saved_changes() | 直前に保存した変更の変更前後の値 |
※1 : saved_change_to_attribute?(:カラム名, from: "hoge", to: "fuga")とも書けます。
※2 : will_save_change_to_カラム名?(from: "hoge", to: "fuga")でも書けます。
メソッド名の変遷
Railsの旧バージョンでは、下記のメソッドが用意されていましたが、現在ではそれらは非推奨となり、より分かりやすい表現に変わっています。メソッドの数が増えていますがafter_create/after_updateの前後のどちらかということを意識しながら過去形、現在形、未来形の時制に注目することがポイントです。
# 注)以下は現在非推奨です。
attribute_changed?
attribute_change
attribute_was
changes
changed?
changed
changed_attributes
活用に向けた検証
検証に使用したモデル
Rails ver.6.0.3.5において、検証用に下記のモデルを準備しました。 DBのカラムに対するデフォルト値(以下、初期値)の設定の有無の影響を再現するため、初期値が無いnameカラムと初期値があるstatusカラムを用意しました。
# == Schema Information # # Table name: users # # id :bigint not null, primary key # name :string(255) # status :integer default("active"), not null # created_at :datetime not null # updated_at :datetime not null # class User < ApplicationRecord enum status: { active: 0, inactive: 1, inviting: 2 } end
createとupdateの過程における挙動を確認した結果が下記になります。
> user1 = User.new(name: "Tom") => #<User id: nil, name: "Tom", status: "active", created_at: nil, updated_at: nil> > {changed_attribute_names_to_save: user1.changed_attribute_names_to_save, has_changes_to_save?: user1.has_changes_to_save?} => {:changed_attribute_names_to_save=>["name"], :has_changes_to_save?=>true} > {changes_to_save: user1.changes_to_save, name_change_to_be_saved: user1.name_change_to_be_saved} => {:changes_to_save=>{"name"=>[nil, "Tom"]}, :name_change_to_be_saved=>[nil, "Tom"]} > "will_save_change_to_attribute?(:name, from: nil, to: 'Tom')=>#{user1.will_save_change_to_attribute?(:name, from: nil, to: "Tom")}" => "will_save_change_to_attribute?(:name, from: nil, to: 'Tom')=>true" > user1.save => true > user1.name = "Jelly" => "Jelly" > {attributes_in_database: user1.attributes_in_database, name_in_database: user1.name_in_database} => {:attributes_in_database=>{"name"=>"Tom"}, :name_in_database=>"Tom"} > user1.save => true > {name_before_last_save: user1.name_before_last_save, saved_change_to_name: user1.saved_change_to_name} => {:name_before_last_save=>"Tom", :saved_change_to_name=>["Tom", "Jelly"]} > "saved_change_to_name?(from: 'Tom', to: 'Jelly')=>#{user1.saved_change_to_name?(from: "Tom", to: "Jelly")}" => "saved_change_to_name?(from: 'Tom', to: 'Jelly')=>true" > {saved_changes?: user1.saved_changes?, saved_changes: user1.saved_changes} => {:saved_changes?=>true, :saved_changes=>{"name"=>["Tom", "Jelly"], "updated_at"=>[Tue, 06 Apr 2021 11:45:23 UTC +00:00, Tue, 06 Apr 2021 11:45:47 UTC +00:00]}}
上記の挙動確認の結果より、before_create, before_updateのタイミングで、特定のカラムの変更前後を確認するには、カラム名_change_to_be_saved
が最も良いのではないかと当初は考えました。
しかし、実装の過程で初期値がある場合と初期値が無い場合で、下記の様に少し挙動が異なることが分かりました。
> user1 = User.new(name: "Tom") # 初期値なしの場合 => #<User id: nil, name: "Tom", status: "hoge", created_at: nil, updated_at: nil> > user1.name_change_to_be_saved => [nil, "Tom"] # nil -> "Tom" > user2 = User.new(status: 0) # 初期値あり、初期値に設定する場合 => #<User id: nil, name: nil, status: "active", created_at: nil, updated_at: nil> > user2.status_change_to_be_saved => nil # nil -> "active"ではなく 変更なしと判断されnilが返る > user3 = User.new(status: 1) # 初期値あり、初期値以外に設定する場合 => #<User id: nil, name: nil, status: "inactive", created_at: nil, updated_at: nil> > user3.status_change_to_be_saved => ["active", "inactive"] # nil -> "inactive"ではなく "active" -> "inactive" > user4 = User.new(status: 2) # 初期値あり、初期値以外に設定する場合 => #<User id: nil, name: nil, status: "inviting", created_at: nil, updated_at: nil> > user4.status_change_to_be_saved => ["active", "inviting"] # nil -> "inviting"ではなく "active" -> "inviting"
初期値が無い場合は、nilから設定値に変化するのですが、初期値がある場合は、nilではなく初期値から設定値に変化するという挙動になることが分かりました。また、 初期値がある場合にカラム名_change_to_be_saved
を使うと設定値が初期値と同等の場合はnilが返り、設定値が初期値以外の場合は配列が返るため、nilの場合と配列の場合を判定仕分ける必要が出てきました。
Dirtyの活用例
実現したかったこと/実装例
今回の実装で実現したかったことに対して実装した内容が下記の通りです。
- バリデーションエラーのメッセージを分けるため、onオプションでバリデーションを分けたい
# 抜粋 validate :validate_registable_user_condition, on: :create, if: -> { will_add_registered_user? } validate :validate_updatable_user_condition, on: :update, if: -> { will_add_registered_user? } def validate_registable_user_condition # createのバリデーション if can_not_registable? errors.add(:base, "ユーザー数が多すぎるため、ユーザーを新規登録できません。") # エラーメッセージ 1 end end def validate_updatable_user_condition # updateのバリデーション if can_not_updatable? errors.add(:base, "ユーザー数が多すぎるため、ユーザーを更新できません。") # エラーメッセージ 2 end end
- バリデーションが必要か否かを判定するメソッドはcreateとupdateで共通としたい。
# 抜粋 def will_add_registered_user? # create/update共通のバリデーション要否の判定メソッド if new_record? # createの場合 # ~中略~ else not_registerd_user? && will_save_change_to_registerd_user? # updateの場合 end end
- status: active, inviting で新規作成する場合は、バリデーション対象としたい。
status_change_to_be_saved
メソッドを使わずにcreateのsave直前の値を確認したい。
# 抜粋 def will_add_registered_user? if new_record? # createの場合 self[:status] == "active" || self[:status] == "inviting" # save直前の値をチェック else # ~中略~ end end
- active→inviting, inviting→activeの更新はバリデーション対象から除外したい。
# 抜粋 def will_add_registered_user? if new_record? # ~中略~ else # active→inviting, inviting→activeの更新か否かを判定 not_registerd_user? && will_save_change_to_registerd_user? end end def registered_user_in_database # 更新前のstatusカラムのDBの値をチェック status_in_database == 'active' || status_in_database == 'inviting' end # statusがactive, inviting以外の場合か否か判定 def not_registerd_user? !registered_user_in_database end # statusがactiveもしくはinvitingへの更新か否か判定 def will_save_change_to_registerd_user? will_save_change_to_attribute?(:status, to: "active") || will_save_change_to_attribute?(:status, to: "inviting") end
Dirtyの活用したサンプルコード
検証の結果を踏まえて、下記のサンプルコードのように実装することでバリデーション対象の状態変化か否かを判定できる様になりました。
class User < ApplicationRecord enum status: { active: 0, inactive: 1, inviting: 2 } validate :validate_registable_user_condition, on: :create, if: -> { will_add_registered_user } validate :validate_updatable_user_condition, on: :update, if: -> { will_add_registered_user } def validate_registable_user_condition if can_not_registable? errors.add(:base, "ユーザー数が多すぎるため、ユーザーを新規登録できません。") end end def validate_updatable_user_condition if can_not_updatable? errors.add(:base, "ユーザー数が多すぎるため、ユーザーを更新できません。") end end # status: active, invitingのユーザーはカウント対象となる。 def will_add_registered_user? if new_record? # status: active, inviting で新規作成する場合 self[:status] == "active" || self[:status] == "inviting" else # activeもしくはinvitingから登録対象にカウントされる状態へ更新する場合 not_registerd_user? && will_save_change_to_registerd_user? end end def registered_user_in_database status_in_database == 'active' || status_in_database == 'inviting' end def not_registerd_user? !registered_user_in_database end def will_save_change_to_registerd_user? will_save_change_to_attribute?(:status, to: "active") || will_save_change_to_attribute?(:status, to: "inviting") end # 〜中略〜 end
おわりに
今回は、ActiveRecord::AttributeMethods::Dirtyモジュールの活用方法について紹介させていただきました。 今回の紹介した内容が少しでも参考になれば幸いです。
スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。