Active Record コールバック

このガイドでは、Active Recordオブジェクトのライフサイクルにフックをかける方法について説明します。

このガイドの内容:

  • Active Recordオブジェクトのどのライフサイクルでどのイベントが発生するか
  • それらのイベントに応答するコールバックを登録・実行・スキップする方法
  • リレーション/関連付け/条件付き/トランザクションのコールバックを作成する方法
  • コールバックを再利用するためのオブジェクトを作成する方法

1 オブジェクトのライフサイクル

Railsアプリケーションの通常の操作中に、オブジェクトが作成・更新・破棄されることがあります。Active Recordは、このオブジェクトのライフサイクルへのフックを提供することでアプリケーションとそのデータを制御できます。

コールバックを使うと、オブジェクトの状態の変更「前」または変更「後」にロジックをトリガーできます。コールバックとは、オブジェクトのライフサイクルの特定の瞬間に呼び出されるメソッドのことです。コールバックを使えば、Active Recordオブジェクトがデータベースで初期化・作成・保存・更新・削除・バリデーション・読み込みのたびに実行されるコードを記述できます。

class BirthdayCake < ApplicationRecord
  after_create -> { Rails.logger.info("Congratulations, the callback has run!") }
end
irb> BirthdayCake.create
Congratulations, the callback has run!

このように、ライフサイクルにはさまざまなイベントがあり、イベントの「前」「後」「前後」のフックするさまざまなオプションがあります。

2 コールバックを登録する

利用可能なコールバックを使うには、コールバックを実装して登録する必要があります。コールバックの実装は、通常のメソッド、ブロック、procを利用したり、クラスまたはモジュールでカスタムコールバックオブジェクトを定義するなど、さまざまな方法で行えます。これらの実装手法をそれぞれ見ていきましょう。

コールバックを登録するために、通常のメソッドを呼び出すマクロ形式のクラスメソッドを実装用に利用できます。

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation :ensure_username_has_value

  private
    def ensure_username_has_value
      if username.blank?
        self.username = email
      end
    end
end

このマクロスタイルのクラスメソッドはブロックも受け取れます。以下のようにコールバックしたいコードがきわめて短く、1行に収まるような場合にこのスタイルを検討しましょう。

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation do
    self.username = email if username.blank?
  end
end

以下のように、コールバックにprocを渡してトリガーさせることも可能です。

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation ->(user) { user.username = user.email if user.username.blank? }
end

最後に、独自のカスタムコールバックオブジェクトも定義できます。これについては後述します。

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation AddUsername
end

class AddUsername
  def self.before_validation(record)
    if record.username.blank?
      record.username = record.email
    end
  end
end

2.1 ライフサイクルイベントで実行されるコールバックを登録する

コールバックは、特定のライフサイクルイベントでのみ実行されるように登録することも可能です。:onオプションを指定することで、コールバックがいつ、どのようなコンテキストでトリガーされるかを完全に制御できます。

コンテキスト(context)とは、特定のバリデーションを適用するカテゴリまたはシナリオのようなものです。Active Recordモデルをバリデーションするときに、コンテキストを指定することでバリデーションをグループ化できます。これにより、さまざまな状況に適用される多種多様なバリデーションセットを作成できます。Railsには、:create:update:saveなどのバリデーション用コンテキストがデフォルトで用意されています。

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation :ensure_username_has_value, on: :create

  # :onは配列も受け取れる
  after_validation :set_location, on: [ :create, :update ]

  private
    def ensure_username_has_value
      if username.blank?
        self.username = email
      end
    end

    def set_location
      self.location = LocationService.query(self)
    end
end

コールバックはprivateメソッドとして宣言するのが好ましい方法です。コールバックメソッドがpublicな状態のままだと、このメソッドがモデルの外から呼び出され、オブジェクトのカプセル化の原則に違反する可能性があります。

コールバックメソッド内では、updatesaveなどのメソッドや、オブジェクトに副作用を引き起こすその他のメソッド呼び出しは避けてください。

たとえば、コールバック内でupdate(attribute: "value")を呼び出してはいけません。この方法はモデルの状態を変更してしまい、コミット中に思わぬ副作用を引き起こす可能性があります。

代わりに、より安全なアプローチとして、before_createbefore_update、またはそれ以前のタイミングでトリガーされるコールバックを使えば安全に値を直接代入できます(例: self.attribute = "value")。

3 利用可能なコールバック

Active Recordで利用可能なコールバックの一覧を以下に示します。これらのコールバックは、実際の操作中に呼び出される順序に並んでいます

3.1 オブジェクトの作成

この2つのコールバックについて詳しくは、after_commitafter_rollbackセクションを参照してください。

これらのコールバックの利用方法を示す例を以下に示します。コールバックは関連する操作ごとにグループ化されており、最後に組み合わせて使う方法を示します。

3.1.1 バリデーション時のコールバック

バリデーション時のコールバックは、レコードがvalid?(またはエイリアスのvalidate)、またはinvalid?メソッドで直接バリデーションされるか、もしくはcreateupdatesaveで間接的にバリデーションされるたびにトリガーされます。これらはバリデーションフェーズの直前(before_validation)または直後(after_validation)に呼び出されます。

class User < ApplicationRecord
  validates :name, presence: true
  before_validation :titleize_name
  after_validation :log_errors

  private
    def titleize_name
      self.name = name.downcase.titleize if name.present?
      Rails.logger.info("Name titleized to #{name}")
    end

    def log_errors
      if errors.any?
        Rails.logger.error("Validation failed: #{errors.full_messages.join(', ')}")
      end
    end
end
irb> user = User.new(name: "", email: "john.doe@example.com", password: "abc123456")
=> #<User id: nil, email: "john.doe@example.com", created_at: nil, updated_at: nil, name: "">
irb> user.valid?
Name titleized to
Validation failed: Name can't be blank
=> false
3.1.2 保存時のコールバック

保存時のコールバックは、レコードがcreateupdate、またはsaveメソッドで背後のデータベースに永続化(保存)されるたびにトリガーされます。これらは、オブジェクトが保存される直前(before_save)、保存された直後(after_save)、および保存の直前直後(around_save)に呼び出せます。

class User < ApplicationRecord
  before_save :hash_password
  around_save :log_saving
  after_save :update_cache

  private
    def hash_password
      self.password_digest = BCrypt::Password.create(password)
      Rails.logger.info("Password hashed for user with email: #{email}")
    end

    def log_saving
      Rails.logger.info("Saving user with email: #{email}")
      yield
      Rails.logger.info("User saved with email: #{email}")
    end

    def update_cache
      Rails.cache.write(["user_data", self], attributes)
      Rails.logger.info("Update Cache")
    end
end
irb> user = User.create(name: "Jane Doe", password: "password", email: "jane.doe@example.com")
Password encrypted for user with email: jane.doe@example.com
Saving user with email: jane.doe@example.com
User saved with email: jane.doe@example.com
Update Cache
=> #<User id: 1, email: "jane.doe@example.com", created_at: "2024-03-20 16:02:43.685500000 +0000", updated_at: "2024-03-20 16:02:43.685500000 +0000", name: "Jane Doe">
3.1.3 作成時のコールバック

作成時のコールバックは、レコードが背後のデータベースに初めて保存されるたびに、つまり、createまたはsaveメソッドで新規レコードを保存するときにトリガーされます。これらは、オブジェクトが作成される直前(before_create)、作成された直後(after_create)、および作成の直前直後(around_create)に呼び出されます。

class User < ApplicationRecord
  before_create :set_default_role
  around_create :log_creation
  after_create :send_welcome_email

  private
    def set_default_role
      self.role = "user"
      Rails.logger.info("User role set to default: user")
    end

    def log_creation
      Rails.logger.info("Creating user with email: #{email}")
      yield
      Rails.logger.info("User created with email: #{email}")
    end

    def send_welcome_email
      UserMailer.welcome_email(self).deliver_later
      Rails.logger.info("User welcome email sent to: #{email}")
    end
end
irb> user = User.create(name: "John Doe", email: "john.doe@example.com")
User role set to default: user
Creating user with email: john.doe@example.com
User created with email: john.doe@example.com
User welcome email sent to: john.doe@example.com
=> #<User id: 10, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe">

3.2 オブジェクトの更新

更新時のコールバックは、既存のレコードが背後のデータベースで永続化(保存)されるたびにトリガーされます。これらは、オブジェクトが更新される直前、更新された直後、および更新の直前直後に呼び出されます。

after_saveコールバックはcreateupdateの両方で実行されますが、マクロ呼び出しの実行順序にかかわらず、常にafter_createafter_updateという特定のコールバックよりもに呼び出されます。同様に、保存前と保存前後のコールバックも同じルールに従います。before_saveは作成・更新よりもに実行され、around_saveは作成・更新操作の直前直後で実行されます。保存コールバックは常に、より具体的な作成・更新コールバックの直前/直前直後/直後に実行されることに注意しておくことが重要です。

バリデーション時のコールバック保存時のコールバックについては既に説明しました。これら2つのコールバックの利用例については、after_commitafter_rollbackセクションを参照してください。

3.2.1 更新時のコールバック
class User < ApplicationRecord
  before_update :check_role_change
  around_update :log_updating
  after_update :send_update_email

  private
    def check_role_change
      if role_changed?
        Rails.logger.info("User role changed to #{role}")
      end
    end

    def log_updating
      Rails.logger.info("Updating user with email: #{email}")
      yield
      Rails.logger.info("User updated with email: #{email}")
    end

    def send_update_email
      UserMailer.update_email(self).deliver_later
      Rails.logger.info("Update email sent to: #{email}")
    end
end
irb> user = User.find(1)
=> #<User id: 1, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe", role: "user" >
irb> user.update(role: "admin")
User role changed to admin
Updating user with email: john.doe@example.com
User updated with email: john.doe@example.com
Update email sent to: john.doe@example.com
3.2.2 コールバックを組み合わせる

欲しい振る舞いを実現するには、コールバックを組み合わせて使う必要が生じることがよくあります。たとえば、ユーザーが作成された後に確認メールを送信したいが、そのユーザーが新規で更新されていない場合のみ確認メールを送信したい場合や、ユーザー更新時に重要な情報が変更された場合は管理者に通知したい場合があります。

この場合、after_createコールバックとafter_updateコールバックを組み合わせて使えます。

class User < ApplicationRecord
  after_create :send_confirmation_email
  after_update :notify_admin_if_critical_info_updated

  private
    def send_confirmation_email
      UserMailer.confirmation_email(self).deliver_later
      Rails.logger.info("Confirmation email sent to: #{email}")
    end

    def notify_admin_if_critical_info_updated
      if saved_change_to_email? || saved_change_to_phone_number?
        AdminMailer.user_critical_info_updated(self).deliver_later
        Rails.logger.info("Notification sent to admin about critical info update for: #{email}")
      end
    end
end
irb> user = User.create(name: "John Doe", email: "john.doe@example.com")
Confirmation email sent to: john.doe@example.com
=> #<User id: 1, email: "john.doe@example.com", ...>
irb> user.update(email: "john.doe.new@example.com")
Notification sent to admin about critical info update for: john.doe.new@example.com
=> true

3.3 オブジェクトの破棄

破棄(destroy)時のコールバックは、レコードが破棄されるたびにトリガーされますが、レコードが削除(delete)されるときは無視されます。破棄時のコールバックは、オブジェクトが破棄される直前(before_destroy)、破棄された直後(after_destroy)、および破棄される直前直後(around_destroy)に呼び出されます。

利用例については、after_commitafter_rollbackを参照してください。

3.3.1 破棄時のコールバック
class User < ApplicationRecord
  before_destroy :check_admin_count
  around_destroy :log_destroy_operation
  after_destroy :notify_users

  private
    def check_admin_count
      if admin? && User.where(role: "admin").count == 1
        throw :abort
      end
      Rails.logger.info("Checked the admin count")
    end

    def log_destroy_operation
      Rails.logger.info("About to destroy user with ID #{id}")
      yield
      Rails.logger.info("User with ID #{id} destroyed successfully")
    end

    def notify_users
      UserMailer.deletion_email(self).deliver_later
      Rails.logger.info("Notification sent to other users about user deletion")
    end
end
irb> user = User.find(1)
=> #<User id: 1, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe", role: "admin">

irb> user.destroy
Checked the admin count
About to destroy user with ID 1
User with ID 1 destroyed successfully
Notification sent to other users about user deletion

3.4 after_initializeafter_find

after_initializeコールバックは、Active Recordオブジェクトがnewで直接インスタンス化されるたびに、またはレコードがデータベースから読み込まれるたびに呼び出されます。これを利用すれば、Active Recordのinitializeメソッドを直接オーバーライドせずに済みます。

after_findコールバックは、Active Recordがデータベースからレコードを1件読み込むたびに呼び出されます。after_findafter_initializeが両方定義されている場合は、after_findが先に呼び出されます。

after_initializeafter_findコールバックには、対応するbefore_*メソッドはありません。

これらも、他のActive Recordコールバックと同様に登録できます

class User < ApplicationRecord
  after_initialize do |user|
    Rails.logger.info("オブジェクトは初期化されました")
  end

  after_find do |user|
    Rails.logger.info("オブジェクトが見つかりました")
  end
end
irb> User.new
オブジェクトは初期化されました
=> #<User id: nil>

irb> User.first
オブジェクトが見つかりました
オブジェクトは初期化されました
=> #<User id: 1>

3.5 after_touch

after_touchコールバックは、Active Recordオブジェクトがtouchされるたびに呼び出されます。詳しくはAPIドキュメントのtouchを参照してください。

class User < ApplicationRecord
  after_touch do |user|
    Rails.logger.info("オブジェクトにtouchしました")
  end
end
user = User.create(name: "Kuldeep")
=> #<User id: 1, name: "Kuldeep", created_at: "2013-11-25 12:17:49", updated_at: "2013-11-25 12:17:49">

irb> user.touch
オブジェクトにtouchしました
=> true

このコールバックはbelongs_toと併用できます。

class Book < ApplicationRecord
  belongs_to :library, touch: true
  after_touch do
    Rails.logger.info("Bookがtouchされました")
  end
end

class Library < ApplicationRecord
  has_many :books
  after_touch :log_when_books_or_library_touched

  private
    def log_when_books_or_library_touched
      Rails.logger.info("Book/Libraryがtouchされました")
    end
end
irb> book = Book.last
=> #<Book id: 1, library_id: 1, created_at: "2013-11-25 17:04:22", updated_at: "2013-11-25 17:05:05">

irb> book.touch # book.library.touchがトリガーされる
Bookがtouchされました
Book/Libraryがtouchされました
=> true

4 コールバックの実行

以下のメソッドはコールバックをトリガーします。

  • create
  • create!
  • destroy
  • destroy!
  • destroy_all
  • destroy_by
  • save
  • save!
  • save(validate: false)
  • save!(validate: false)
  • toggle!
  • touch
  • update_attribute
  • update_attribute!
  • update
  • update!
  • valid?
  • validate

また、after_findコールバックは以下のfinderメソッドを実行すると呼び出されます。

  • all
  • first
  • find
  • find_by
  • find_by!
  • find_by_*
  • find_by_*!
  • find_by_sql
  • last
  • sole
  • take

after_initializeコールバックは、そのクラスの新しいオブジェクトが初期化されるたびに呼び出されます。

find_by_*メソッドとfind_by_*!メソッドは、属性ごとに自動的に生成される動的なfinderメソッドです。詳しくは動的finderのセクションを参照してください。

5 条件付きコールバック

バリデーションのときと同様に、指定された述語の条件を満たす場合に実行されるコールバックメソッドの呼び出しも作成可能です。これを行なうには、コールバックで:ifオプションまたは:unlessオプションを使います。このオプションはシンボル、Proc、またはArrayを引数に取ります。

特定の状況でのみコールバックを呼び出す必要がある場合は、:ifオプションを使います。 特定の状況でコールバックを呼び出してはならない場合は、:unlessオプションを使います。

5.1 :ifおよび:unlessオプションでシンボルを使う

:ifオプションまたは:unlessオプションは、コールバックの直前に呼び出される述語メソッド名に対応するシンボルと関連付けることが可能です。

:ifオプションを使う場合、述語メソッドがfalseを返せばコールバックは実行されません:unlessオプションを使う場合、述語メソッドがtrueを返せばコールバックは実行されません。これはコールバックで最もよく使われるオプションです。

class Order < ApplicationRecord
  before_save :normalize_card_number, if: :paid_with_card?
end

この方法で登録すれば、さまざまな述語メソッドを登録して、コールバックを呼び出すべきかどうかをチェックできるようになります。詳しくはコールバックで複数の条件を指定するで後述します。

5.2 :ifおよび:unlessオプションでProcを使う

:ifおよび:unlessオプションではProcオブジェクトも利用できます。このオプションは、1行以内に収まるワンライナーでバリデーションを行う場合に最適です。

class Order < ApplicationRecord
  before_save :normalize_card_number,
    if: ->(order) { order.paid_with_card? }
end

procはそのオブジェクトのコンテキストで評価されるので、以下のように書くこともできます。

class Order < ApplicationRecord
  before_save :normalize_card_number, if: -> { paid_with_card? }
end

5.3 コールバックで複数の条件を指定する

:if:unlessオプションは、procやメソッド名のシンボルの配列を受け取ることも可能です。

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, :untrusted_author?]
end

条件リストではprocを手軽に利用できます。

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, -> { untrusted_author? }]
end

5.4 :if:unlessを同時に使う

コールバックでは、同じ宣言の中で:if:unlessを併用できます。

class Comment < ApplicationRecord
  before_save :filter_content,
    if: -> { forum.parental_control? },
    unless: -> { author.trusted? }
end

このコールバックは、すべての:if条件がtrueと評価され、どの:unless条件もtrueと評価されなかった場合にのみ実行されます。

6 コールバックをスキップする

バリデーションの場合と同様、以下のメソッドを使うことでコールバックをスキップできます。

Userモデルのbefore_saveコールバックがユーザーのメールアドレスの変更を記録する場合を考えてみましょう。

class User < ApplicationRecord
  before_save :log_email_change

  private
    def log_email_change
      if email_changed?
        Rails.logger.info("Email changed from #{email_was} to #{email}")
      end
    end
end

ここで、メールアドレスの変更を記録するbefore_saveコールバックをトリガーせずにユーザーのメールアドレスを更新したいというシナリオがあるとします。これはupdate_columnsメソッドを使えば可能です。

irb> user = User.find(1)
irb> user.update_columns(email: 'new_email@example.com')

上は、before_saveコールバックをトリガーせずにユーザーのメールアドレスを更新しています。

コールバックには、スキップしてはならない重要なビジネスルールやアプリケーションロジックが設定されている可能性もあるので、これらのメソッドの利用には十分注意すべきです。この点を理解せずにコールバックをバイパスすると、データの不整合が発生する可能性があります。

7 コールバックを抑制する

特定のシナリオでは、Railsアプリケーション内で特定のコールバックの実行を一時的に抑制しなければならなくなる場合があります。これは、コールバックを恒久的に無効にせずに、特定の操作で特定のアクションをスキップしたい場合に便利です。

Railsは、ActiveRecord::Suppressorモジュールでコールバックを抑制する(suppress)メカニズムを提供しています。コールバックを抑制したいコードブロックをこのモジュールでラップすれば、その特定の操作中はコールバックが実行されないようにできます。

Userモデルがあり、新規ユーザーがサインアップした後に「ようこそメール」を送信するコールバックがモデルに含まれているシナリオを考えてみましょう。ただし、ようこそメールを送信せずにユーザーを作成しなければならない場合もあります(データベースにテストデータをシードするときなど)。

class User < ApplicationRecord
  after_create :send_welcome_email

  def send_welcome_email
    puts "Welcome email sent to #{self.email}"
  end
end

この例のafter_createコールバックは、新しいユーザーが作成されるたびにsend_welcome_emailメソッドをトリガーします。

ようこそメールを送信せずにユーザーを作成するには、次のようにActiveRecord::Suppressorモジュールを利用します。

User.suppress do
  User.create(name: "Jane", email: "jane@example.com")
end

上記のコードでは、User.suppressブロックによって、"Jane"ユーザーの作成中はsend_welcome_emailコールバックが実行されないようにし、ようこそメールを送信せずにユーザーを作成できるようにしています。

ActiveRecord::Suppressorを利用すると、コールバックの実行を選択的に制御できるメリットがある反面、コードが複雑になって思わぬ振る舞いが発生する可能性もあります。コールバックを抑制すると、アプリケーションで意図したフローがわかりにくくなり、今後のコードベースの理解やメンテナンスが困難になる可能性があります。コールバックを抑制した場合の影響の大きさを慎重に検討し、ドキュメント作成やテストを入念に実施して、意図しない副作用やパフォーマンスの問題、テストの失敗のリスクを軽減する必要があります。

8 コールバックの停止

モデルに新しくコールバックを登録すると、コールバックは実行キューに入ります。このキューには、あらゆるモデルに対するバリデーション、登録済みコールバック、実行待ちのデータベース操作が置かれます。

コールバックチェーン全体は、1つのトランザクションにラップされます。コールバックの1つで例外が発生すると、実行チェーン全体が停止(halt)してロールバックが発行され、エラーが再度raiseします。

class Product < ActiveRecord::Base
  before_validation do
    raise "Price can't be negative" if total_price < 0
  end
end

Product.create # "Price can't be negative"がraiseする

これによって、createsaveなどのメソッドで例外が発生することが想定されていないコードが、予期せず壊れます。

コールバックチェインの途中で例外が発生した場合は、ActiveRecord::RollbackまたはActiveRecord::RecordInvalid例外でない限り、Railsは例外を再度raiseします。代わりにthrow :abortを用いてコールバックチェインを意図的に停止する必要があります。いずれかのコールバックが:abortをスローすると、プロセスは中止し、createはfalseを返します。

class Product < ActiveRecord::Base
  before_validation do
    throw :abort if total_price < 0
  end
end

Product.create # => false

ただし、(createではなく)create!を呼び出した場合はActiveRecord::RecordNotSavedが発生します。この例外は、コールバックの中断によりレコードが保存されなかったことを示します。

User.create! # => raises an ActiveRecord::RecordNotSaved

throw :abortがdestroy系のコールバックで呼び出された場合は、destroyはfalseを返します。

class User < ActiveRecord::Base
  before_destroy do
    throw :abort if still_active?
  end
end

User.first.destroy # => false

ただし、(destroyではなく)destroy!を呼び出した場合はActiveRecord::RecordNotDestroyedが発生します。

User.first.destroy! # => raises an ActiveRecord::RecordNotDestroyed

9 関連付けのコールバック

関連付けのコールバックは通常のコールバックと似ていますが、コレクションのライフサイクル内で発生するイベントによってトリガーされる点が異なります。利用可能な関連付けコールバックは以下のとおりです。

  • before_add
  • after_add
  • before_remove
  • after_remove

関連付けのコールバックは、関連付けの宣言でオプションを追加することで定義できます。

Authorモデルにhas_many :booksが定義されている例を考えてみましょう。ただし、authorsコレクションに本を追加する前に、その著者が本の個数制限に達していないことを確認する必要があります。個数制限を確認するためのbefore_addコールバックを追加することで、これを実行できます。

class Author < ApplicationRecord
  has_many :books, before_add: :check_limit

  private
    def check_limit
      if books.count >= 5
        errors.add(:base, "この著者には本を5冊までしか追加できません")
        throw(:abort)
      end
    end
end

before_addコールバックが:abortをスローした場合、オブジェクトはコレクションに追加されません。

関連付けられているオブジェクトに対して複数の操作を実行したい場合があります。この場合はコールバックを配列として渡せば、単一のイベントに複数のコールバックを積み上げられます。さらにRailsは、追加または削除されるオブジェクトをコールバックに渡して利用可能にしてくれます。

class Author < ApplicationRecord
  has_many :books, before_add: [:check_limit, :calculate_shipping_charges]

  def check_limit
    if books.count >= 5
      errors.add(:base, "この著者には本を5冊までしか追加できません")
      throw(:abort)
    end
  end

  def calculate_shipping_charges(book)
    weight_in_pounds = book.weight_in_pounds || 1
    shipping_charges = weight_in_pounds * 2

    shipping_charges
  end
end

同様に、before_removeコールバックが:abortをスローした場合、オブジェクトはコレクションから削除されません。

これらのコールバックは、関連付けられているオブジェクトが関連付けコレクションを通じて追加・削除された場合にのみ呼び出されます。

# `before_add`コールバックがトリガーされる
author.books << book
author.books = [book, book2]

# `before_add`コールバックはトリガーされない
book.update(author_id: 1)

10 関連付けのコールバックをカスケードする

コールバックは、関連付けられたオブジェクトが変更されたタイミングで実行できます。コールバックはモデルの関連付けを通じて機能し、ライフサイクルイベントが関連付けにカスケードする形でコールバックを起動できます。

Userモデルにhas_many :articlesが定義されている例を考えてみましょう。ユーザーが破棄(destroy)された場合、そのユーザーの記事も合わせて破棄する必要があります。Articleモデルへの関連付けを介して、Userモデルにafter_destroyコールバックを追加してみましょう。

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
end

class Article < ApplicationRecord
  after_destroy :log_destroy_action

  def log_destroy_action
    Rails.logger.info("Article destroyed")
  end
end
irb> user = User.first
=> #<User id: 1>
irb> user.articles.create!
=> #<Article id: 1, user_id: 1>
irb> user.destroy
Article destroyed
=> #<User id: 1>

before_destroyコールバックを使う場合は、レコードがdependent: :destroyで削除される前に実行されるように、dependent: :destroy関連付けの前に配置する(またはprepend: trueオプションを指定する)必要があります。

11 トランザクションのコールバック

11.1 after_commitコールバックとafter_rollbackコールバック

データベースのトランザクションが完了したときにトリガーされるコールバックが2つあります。after_commitafter_rollbackです。

これらのコールバックはafter_saveコールバックときわめて似ていますが、データベースの変更のコミットまたはロールバックが完了するまでトリガーされない点が異なります。これらのメソッドは、Active Recordのモデルから、データベーストランザクションの一部に含まれていない外部のシステムとやりとりしたい場合に特に便利です。

例として、PictureFileモデルで、対応するレコードが削除された後にファイルを1つ削除する必要があるとしましょう。

class PictureFile < ApplicationRecord
  after_destroy :delete_picture_file_from_disk

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

after_destroyコールバックの直後に何らかの例外が発生してトランザクションがロールバックすると、ファイルが削除され、モデルの一貫性が損なわれたままになってしまいます。 ここで、以下のコードにあるpicture_file_2オブジェクトが無効で、save!メソッドがエラーを発生するとします。

PictureFile.transaction do
  picture_file_1.destroy
  picture_file_2.save!
end

after_commitコールバックを使えば、このような場合に対応できます。

class PictureFile < ApplicationRecord
  after_commit :delete_picture_file_from_disk, on: :destroy
  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

:onオプションは、コールバックがトリガーされるタイミングを指定します。:onオプションを指定しないと、すべてのアクションでコールバックがトリガーされます。詳しくは:onの利用方法を参照してください。

トランザクションが完了すると、そのトランザクション内で作成・更新・破棄されたすべてのモデルに対してafter_commitコールバックまたはafter_rollbackコールバックが呼び出されます。ただし、これらのコールバックのいずれかで例外が発生した場合、その例外はバブルアップされ、残りのafter_commitafter_rollbackメソッドは実行されません。

class User < ActiveRecord::Base
  after_commit { raise "Intentional Error" }
  after_commit {
    # 1つ上のafter_commitで例外が発生するため、これは呼び出されない
    Rails.logger.info("This will not be logged")
  }
end

コールバックコードで例外が発生した場合は、他のコールバック実行が中断されないよう、その例外をrescueしてコールバック内で処理する必要があります。

after_commitの保証は、after_saveafter_updateafter_destroyとはまったく異なる保証です。たとえば、以下のafter_saveで例外が発生した場合、トランザクションはロールバックし、データは保持されません。

class User < ActiveRecord::Base
  after_save do
    # これが失敗したらユーザーは保存されない
    EventLog.create!(event: "user_saved")
  end
end

しかし、データはafter_commit中に既にデータベースに保存されているため、例外が発生しても何もロールバックしなくなります。

class User < ActiveRecord::Base
  after_commit do
    # これが失敗したらユーザーは既に保存済み
    EventLog.create!(event: "user_saved")
  end
end

after_commitコールバックやafter_rollbackコールバック内で実行されるコード自体は、トランザクション内に囲まれません。

データベース内の同じレコードを単一のトランザクションのコンテキストで表現する場合、after_commitコールバックやafter_rollbackコールバックで注意すべき重要な動作があります。これらのコールバックは、トランザクション内で変更される特定のレコードの最初のオブジェクトに対してのみトリガーされます。読み込まれている他のオブジェクトは、同じデータベースレコードを表現しているにもかかわらず、after_commitコールバックやafter_rollbackコールバックはどのオブジェクトでもトリガーされません。

class User < ApplicationRecord
  after_commit :log_user_saved_to_db, on: :update

  private
    def log_user_saved_to_db
      Rails.logger.info("ユーザーはデータベースに保存されました")
    end
end
irb> user = User.create
irb> User.transaction { user.save; user.save }
# ユーザーはデータベースに保存されました

この微妙な振る舞いは、同じデータベースレコードに関連付けられている個別のオブジェクトに対して独立したコールバック実行が予想されるシナリオで、特に大きな影響を及ぼします。コールバックシーケンスのフローや予測可能性に影響し、そのトランザクションの後のアプリケーションロジックに不整合が生じる可能性があります。

11.2 after_commitコールバックのエイリアス

after_commitコールバックは作成・更新・削除でのみ用いることが多いので、それぞれのエイリアスも用意されています。場合によっては、createupdateの両方に単一のコールバックを使わなければならなくなることもあります。これらの操作の一般的なエイリアスを次に示します。

いくつか例を見てみましょう。

以下は、onオプションを指定したafter_commitdestroyに使っています。

class PictureFile < ApplicationRecord
  after_commit :delete_picture_file_from_disk, on: :destroy

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

上と同じことをafter_destroy_commitを使ってもできます。

class PictureFile < ApplicationRecord
  after_destroy_commit :delete_picture_file_from_disk

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

同じ要領でafter_create_commitafter_update_commitも使えます。

ただし、after_create_commitコールバックとafter_update_commitコールバックに同じメソッド名を指定すると、両方とも内部的にafter_commitにエイリアスされ、同じメソッド名で以前に定義されたコールバックをオーバーライドするため、最後に定義されたコールバックだけが有効になってしまいます。

class User < ApplicationRecord
  after_create_commit :log_user_saved_to_db
  after_update_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      # これは1回しか呼び出されない
      Rails.logger.info("ユーザーはデータベースに保存されました")
    end
end
irb> user = User.create # 何も出力しない

irb> user.save          # userを更新する
ユーザーはデータベースに保存されました

この場合は、代わりにafter_save_commitを使う方が適切です。これは、作成と更新の両方でafter_commitコールバックを利用するためのエイリアスです。

class User < ApplicationRecord
  after_save_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      Rails.logger.info("ユーザーはデータベースに保存されました")
    end
end
irb> user = User.create # Userを作成
ユーザーはデータベースに保存されました

irb> user.save # userを更新
ユーザーはデータベースに保存されました

11.3 トランザクショナルなコールバックの順序

Rails 7.1以降のコールバックは、デフォルトでは定義された順序で実行されます。

class User < ActiveRecord::Base
  after_commit { Rails.logger.info("これは1番目に実行される") }
  after_commit { Rails.logger.info("これは2番目に実行される") }
end

ただし、それより前のバージョンのRailsでは、トランザクショナルなafter_コールバック(after_commitafter_rollbackなど)を複数定義すると、コールバックの実行順序が定義と逆順になりました。

何らかの理由で引き続き逆順に実行したい場合は、以下の設定をfalseに設定することで、コールバックが逆順で実行されます。詳しくは、Active Recordの設定オプションを参照してください。

config.active_record.run_after_transaction_callbacks_in_order_defined = false

これは、after_destroy_commitなどを含むすべてのafter_*_commitコールバックに適用されます。

12 コールバックオブジェクト

作成したコールバックメソッドが便利なので、他のモデルで再利用したくなる場合があります。Active Recordでは、コールバックメソッドをカプセル化するクラスを作成することでコールバックを再利用可能にできます。

以下は、ファイルシステム上で破棄したファイルのクリーンアップを処理するafter_commitコールバッククラスの例です。この振る舞いはPictureFileモデルに固有とは限らず、他でも共有したい場合もあるため、これを別のクラスにカプセル化することをオススメします。こうすることで、この振る舞いのテストや変更がずっと簡単になります。

class FileDestroyerCallback
  def after_commit(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

クラス内で上記のように宣言すると、コールバックメソッドはモデルオブジェクトをパラメーターとして受け取ります。これは次のように、そのクラスを利用するすべてのモデルで機能します。

class PictureFile < ApplicationRecord
  after_commit FileDestroyerCallback.new
end

ここではコールバックをインスタンスメソッドとして宣言しているので、FileDestroyerCallbackオブジェクトをnewでインスタンス化する必要があることにご注意ください。これは、コールバックがインスタンス化されたオブジェクトのステートを利用する場合に特に便利です。ただし多くの場合、以下のようにコールバックをクラスメソッドとして宣言する方が合理的です。

class FileDestroyerCallback
  def self.after_commit(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

コールバックメソッドがこのようにクラスメソッドとして宣言されていれば、モデル内でFileDestroyerCallbackオブジェクトをnewでインスタンス化せずに済みます。

class PictureFile < ApplicationRecord
  after_commit FileDestroyerCallback
end

コールバックオブジェクト内では、コールバックを必要なだけいくつでも宣言できます。

フィードバックについて

Railsガイドは GitHub の yasslab/railsguides.jp で管理・公開されております。本ガイドを読んで気になる文章や間違ったコードを見かけたら、気軽に Pull Request を出して頂けると嬉しいです。Pull Request の送り方については GitHub の README をご参照ください。

原著における間違いを見つけたら『Rails のドキュメントに貢献する』を参考にしながらぜひ Rails コミュニティに貢献してみてください 🛠💨✨

本ガイドの品質向上に向けて、皆さまのご協力が得られれば嬉しいです。

Railsガイド運営チーム (@RailsGuidesJP)

支援・協賛

Railsガイドは下記の協賛企業から継続的な支援を受けています。支援・協賛にご興味あれば協賛プランからお問い合わせいただけると嬉しいです。

  1. Star
  2. このエントリーをはてなブックマークに追加