こんにちは Rails と Ruby が好きな @zaru です。今回は Rails の代表的な機能の一つ ActiveRecord コールバックの罠について記事を書きます。誰もが…というより僕自身がはまった罠を中心に紹介します。この記事を読むことで ActiveRecord コールバックをゴリゴリに使って罠にはまる人を一人でも少なくなれば嬉しいです。

最初に 3 つの問題を出します。この問題がスッと答えられる人は、ほぼ完璧に ActiveRecord コールバックについて理解をしているので、この記事を読む必要はありませんし、ActiveRecord コールバックを存分に活用したコードを書いても問題ないです。

ちなみに僕はぜんぜん自信がない&正解できなかったので、ActiveRecord コールバックをなるべく使わないスタイルにしていこうと強く思いました。

ActiveRecord 理解度チェック問題

問題 A

Article モデルの update_same_record_but_different_object クラスメソッドを実行した時に各コールバックから出力する文字列の結果を答えてください。なお、ID=1 のレコードは既に存在しているものとします。

Article.update_same_record_but_different_object
#=> ???

問題のコード

class Article < ApplicationRecord
  before_validation { puts 'before_validation' }
  before_update { puts 'before_update' }
  after_save { puts 'after_save' }
  after_update { puts 'after_update' }
  after_commit { puts 'after_commit'}
  after_commit :commit_message
  after_update_commit :commit_message
  after_commit { puts 'after_commit in block' }
  after_update_commit { puts 'after_update_commit in block' }

  def self.update_same_record_but_different_object
    ActiveRecord::Base.transaction do
      article_a = find(1)
      article_b = find(1)

      article_a.update(title: SecureRandom.hex(16))
      article_b.update(title: SecureRandom.hex(16))
    end
  end

  private
  
  def commit_message
    puts 'commit_message'
  end
end

問題 B

Author has_many Articles な関係の2つのモデルがあります。下記コードは、Author レコードを削除する際に、子レコードの articles の中に公開済みのデータがあれば削除を注視することを期待しています。しかし、実際には期待通りには動きません。その理由と回避策を説明してください。

# 事前準備
author = Author.find(1)
author.articles.create(published: true) # 公開済みデータを作成

# 削除しようとするが、子に公開済みデータがあるため削除中止されるのを期待
author.destroy

問題のコード

class Author < ApplicationRecord
  has_many :articles, dependent: :destroy
  
  before_destroy do
    if articles.where(published: true).exists?
      errors.add(:base, '公開している記事があるため削除できません')
      throw :abort
    end
  end
end

class Article < ApplicationRecord
  belongs_to :author
end

問題 C

以下のコードは Article モデルを作成した後に ActiveJob で非同期処理をしているコードです。しかし、このコードはある問題が発生する可能性を抱えています。その問題とは何でしょうか? (ActiveJob の代わりに Sidekiq でも同様です)

class Article < ApplicationRecord
  after_save do
    IndexArticleJob.perform_later(id)
  end
end

class IndexArticleJob < ApplicationJob
  queue_as :default

  def perform(id)
    article = Article.find(id)
    # 略) 以降、処理
  end
end

回答

問題 A の回答

before_validation
before_update
after_update
after_save
before_validation
before_update
after_update
after_save
after_update_commit in block
after_commit in block
commit_message
after_commit

簡単な解説

この問題は ActiveRecord コールバックの実行順序と、定義に関する知識が必要になります。はっきり言って完璧に把握している人いないんじゃないかと思うくらい難しいです。いくつかの仕様を混ぜた問題になっています。以下の4つがポイントです。

  • after_save は定義順にかかわらず必ず after_create / after_update の後に実行される
  • コールバックは通常は定義順に実行されるが after_commit 系は定義と逆順に実行される
  • after_commit 系を同名メソッド定義すると最後に定義されたコールバックのみが有効になる
    • ただしブロックで定義したコールバックは全て有効になる
  • トランザクション内で、同じレコードを別オブジェクトで参照し更新をすると after_commit 系コールバックは最初のインスタンスに対してのみ発火する
    • 別レコードオブジェクトであれば after_commit 系コールバックはレコード分ちゃんと実行されます

特に after_commit 系に関する罠が多いですね。

問題 B の回答

destroy よりも先に dependent: :destroy が実行され削除されてしまうため。回避するには has_many 定義よりも前に before_destroy を定義すること。

簡単な解説

コードスタイルによっては、ファイルの先頭にリレーションの定義、その下にコールバック定義をすることがあると思います。しかし before_destroy に関してはリレーション定義よりも前に定義しないと意図せぬ挙動になることがあります。

  • before_destroyhas_many, dependent: :destroy 定義よりも前に定義しないと関連レコードが削除済みの状態でコールバック実行される

つまり、has_many, dependent: :destroy よりも下に before_destroy を定義すると、コールバックが実行されたタイミングでは、子レコードは既に削除済みの状態になっていると言うことです。なので子レコード状態を確認して削除を中止するという処理が意味なくなってしまいます。

正しくはこう書きます。

class Author < ApplicationRecord
  before_destroy do
    if articles.where(published: true).exists?
      errors.add(:base, '公開している記事があるため削除できません')
      throw :abort
    end
  end

  has_many :articles, dependent: :destroy
end

問題 C の回答

ジョブに渡された ID の Article レコードが見つからない可能性がある。

簡単な解説

非同期処理をする際に after_save コールバック内でエンキューをすると、トランザクション処理中に非同期処理が実行される可能性があります。非同期処理は一般的に別プロセスで実行されるため INSERT されたレコードはトランザクション完了まで見つけることができません。

シンプルなコードの場合は問題にならないケースがありますが、トランザクション完了まで時間がかかってしまうようなコードだと、非同期処理が失敗してしまいます。

ActiveJob や Sidekiq にエンキューするには after_save ではなく after_commit を使う方が良いでしょう。

まとめ

今回は ActiveRecord コールバックの罠について紹介しました。この記事を書くにあたって改めて挙動を復習したのですが、どう頑張っても全部を把握して完全にコントロールをしたコードを書く自身が僕にはありません。

ActiveRecord コールバックは便利ですが、頼らない実装をした方が最終的には幸せになるのではないかと思います。どうしても使わないといけないときには、意図通りの挙動かどうかをきちんと確認をしましょう。

もしこれら以外にもはまる罠がある場合は @zaru に教えてください!