こんにちは 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_destroy
はhas_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 に教えてください!