RailsのActiveRecord::AttributeMethods::Dirtyを使ってみた

f:id:tiphp452:20210422095032p:plain

はじめに

はじめまして、スタメンでエンジニアをしているショウゴです。普段は、バックエンドグループで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モジュールの活用方法について紹介させていただきました。 今回の紹介した内容が少しでも参考になれば幸いです。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。

Webアプリケーションエンジニア募集ページ

参考

ActiveRecord::AttributeMethods::Dirty