Railsでbefore_validationコールバックを使う時の落とし穴
TL;DR: before_validation
に登録されているコードがfalse
を返すと、obj.save
はfalse
を返しobj.save!
は例外を投げます。DBには何も登録されません。
Railsのコールバック
便利ですよね!使ってますか?
Rails Guidesの一節を引用してみます。
Callbacks are methods that get called at certain moments of an object's life cycle. With callbacks it is possible to write code that will run whenever an Active Record object is created, saved, updated, deleted, validated, or loaded from the database.
すなわち
コールバックは(ActiveRecordの)オブジェクトのライフサイクルの中の特定の時点で呼び出されるメソッドです。コールバックを利用することで、ARオブジェクトが作られるとき・保存されるとき・更新されるとき・削除されるとき・validationが実行されるとき・DBから読み込まれるとき などに必ず実行されるコードを登録することができます。
controllerになんでもかんでも任せてしまうのを防ぎ、modelの責務をmodelに記述することができるようなるのでとても有用です。
※上記guidesやreferencesと合わせて、昨年末のRoR Advent Calendarに投稿されたこの記事を読むと勉強になります ↓
Ruby - てめえらのRailsはオブジェクト指向じゃねえ!まずはCallbackクラス、Validatorクラスを活用しろ! - Qiita [キータ]
before_validation
ここから本題です。
例題として、RailsでSNSを構築することを考えます。ユーザーはメールアドレスに紐付けてアカウントを取得し、登録するとメールアドレス確認メールが送られてきます。メールアドレスの確認が完了しないと、SNS上で活動し始めることができません。利用開始後にユーザーがメールアドレスを変更した場合も、確認のメールが送られてきます。
# db/migrate/20140104120000_create_users.rb class CreateUsers < ActiveRecord::Migration def change create_table :users do |t| t.string :name t.string :email # some more columns... t.string :email_verification_code # メールアドレス確認用乱数 t.boolean :email_verificated # メールアドレス確認済み権限フラグ end end end
このようにテーブルを定義し、以下のようなコードを書きます。
# app/models/users.rb class User < ActiveRecord::Base # validation前に、emailが変更されていればコールバック関数を呼び出す before_validation :set_email_verification_code, if: 'self.email_changed?' # 保存後、メールアドレス確認メールを送信する after_save :send_verification_mail, if: 'self.email_changed?' private def set_email_verification_code # 新しい確認用コードを生成 self.email_verification_code = SecureRandom.hex(10) # 状態を「未確認」にする self.email_verificated = false end def send_verification_mail # 略 end end
一見、とても自然ですよね?しかしこれでは、いくらすばらしいSNSを作ってもユーザーは集まりません。なんたって、登録できないのですから!
Reference中の一文を引用します。
If the returning value of a
before_validation
callback can be evaluated tofalse
, the process will be aborted andBase#save
will returnfalse
. IfActiveRecord::Validations#save!
is called it will raise aActiveRecord::RecordInvalid
exception. Nothing will be appended to the errors object.
意訳しますと
before_validation
コールバックのコードがfalse
(またはfalse
と評価されるもの)を返すと、そこで処理が中止されます(validationは実行されません)。user.save
の途中でこれが起こると、user.save
は単にfalseを返します(DBには保存されません)。user.save!
の途中でこれが起こると、ActiveRecord::RecordInvalid
が起こります。いずれにしても、user.errors
にはいかなるvalidation errorも登録されません。
なんということでしょう!見かけ上はvalidationに失敗しているのとまったく変わらないのに、実はvalidationそのものがスキップされているのです。
上で作ったUserモデルの例では、set_email_verification_code
の最後の式でfalse
を代入しているために、メソッドそのものの戻り値がfalse
になります。(Rubyでは代入式の戻り値は代入された値ですね。)
これを避けるには単にこのメソッド中の式の順番を入れ替えれば良いだけです。
def set_email_verification_code # 状態を「未確認」にする self.email_verificated = false # 新しい確認用コードを生成 self.email_verification_code = SecureRandom.hex(10) # => 戻り値が乱数文字列になる end
Railsチームがどのような意図でこの挙動を設計したのかわかりませんが、陥りやすい落とし穴でなおかつ一度ハマると気づいて抜け出すのが困難です(数十分悩みました)。お気をつけください。
半年ほど放置しておりましたがあけましておめでとうございます(笑
今年もマイペースに更新してまいりますので何卒よろしくおねがいします