save 時に context を指定するとデフォルトの create, update のバリデーションが実行されない話
はじめに
こんにちは, hamaguchi です
今回は ActiveRecord の保存時に context を指定すると任意の条件のときだけ実行するバリデーションを定義することができるものの、注意が必要かもという話です
みなさんは ActiveRecord のバリデーションでカスタムコンテキストを使ったことはありますか?
特定の処理の時だけバリデーションを実行したい場合に便利なので、システムが複雑になってくると使いたい場面が出てくると思います
業務ですこし深い階層まで一気にデータを保存する際に、アソシエーションの先の方のバリデーションのためにコンテキストの指定が必要になり、 .save(context: :hoge) というように保存時にコンテキストの指定を追加しました
指定しない場合、作成時には create, 更新時には update のバリデーションが実行されており、そこに hoge が追加されるようなつもりで書いたわけですが、実際には create, update のバリデーションは実行されず、 hoge のバリデーションのみが実行されていました
やってみた
ruby: 3.2.4
rails: 7.2.1
準備
インストール
rails new blog
モデル・マイグレーションの追加
rails g model article
invoke active_record
create db/migrate/xxxxxxxxxxxxxx_create_articles.rb
create app/models/article.rb
invoke test_unit
create test/models/article_test.rb
create test/fixtures/articles.yml
マイグレーションファイルでカラムを追加
class CreateArticles < ActiveRecord::Migration[7.2]
def change
create_table :articles do |t|
t.string :title
t.datetime :published_at
t.text :content
t.timestamps
end
end
end
マイグレーションを実行
rails db:migrate
モデルにバリデーションを追加
| カラム名 | データ型 | |
|---|---|---|
| title | string | タイトルを保存するカラム、作成時に必須ということにしてみる |
| content | text | 本文を保存するカラム、更新時に必須ということにしてみる |
| published_at | datetime | 公開日時を保存するカラム、公開時に必須ということにしてみる |
それぞれ on でバリデーションを実行する条件を指定しています
- title:
on: :create - content:
on: :update - published_at:
on: :publish<=publishが今回独自に追加したコンテキストです
class Article < ApplicationRecord
validates :title, presence: true, on: :create
validates :content, presence: true, on: :update
validates :published_at, presence: true, on: :publish
end
これで準備は完了です
実行
[create] 値を指定せずに作成した場合
title が必須なのでエラーが発生します
blog(dev)> Article.create!
`<main>': Validation failed: Title can't be blank (ActiveRecord::RecordInvalid)
[create] title を指定して作成した場合
title を指定することでバリデーションが通り、作成されます
blog(dev)> Article.create!(title: 'This is a title')
=>
#<Article:0x0000ffff82648d40
id: 1,
title: "This is a title",
published_at: nil,
content: nil,
created_at: "xxxx-xx-xx xx:xx:xx.xxxxxx000 +0000",
updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxx000 +0000">
[update] content が空の状態で更新した場合
タイトルを変更して保存してみたところ、更新時には content が必須というバリデーションがかかっているのでエラーが発生します
blog(dev)> article.update!(title: 'hoge')
`<main>': Validation failed: Content can't be blank (ActiveRecord::RecordInvalid)
[update] content を指定して更新した場合
更新できました
blog(dev)> article.update!(title: 'hoge', content: 'This is a content')
=> true
[publish] published_at が空の状態で保存した場合
では肝心の publish 時の動きを見てみましょう
まずそのままのデータでコンテキストの指定をして保存してみると、on: published では published_at が必須というバリデーションがかかっているのでエラーが発生します
blog(dev)> article.save!(context: :publish)
`<main>': Validation failed: Published at can't be blank (ActiveRecord::RecordInvalid)
[publish] published_at に値が入った状態で保存した場合
published_at を適当にセットし、保存してみると今度は保存できました
blog(dev)> article.published_at = Time.now
=> "xxxx-xx-xx xx:xx:xx.xxxxxx000 +0000"
blog(dev)> article.save!(context: :publish)
=> true
よさそう。と思いきや、ここが冒頭で説明した話が始まります
content を空にした状態で publish で保存し、update のバリデーションがきちんと実行されているのか確認してみます
コンテキストを publish と指定した場合には update のバリデーションに違反しても保存できました
blog(dev)> article.content = ''
=> ""
blog(dev)> article.save!(context: :publish)
=> true
content が空の状態にも関わらず更新できてしまいました
[publish update] カスタムコンテキストのバリデーションと create, update のバリデーションを両方実行してもらうには?
コンテキストに publish update 両方を指定することで、今度はちゃんと引っかかってくれました
blog(dev)> article.save!(context: %i[publish update])
`<main>': Validation failed: Content can't be blank (ActiveRecord::RecordInvalid)
実装を見てみる
ではどのような実装になっているか見てみましょう
対象は active_record/validations にありました(rails/activerecord/lib/active_record/validations.rb)
保存時のバリデーションに関する部分
def save(**options)
perform_validations(options) ? super : false
end
def save!(**options)
perform_validations(options) ? super : raise_validation_error
end
def raise_validation_error
raise(RecordInvalid.new(self))
end
-
save,save!ではoptionsを受け取りperform_validations実行時に渡している -
save: バリデーションを実行してtrueが返ってきた場合はsuperを実行し、失敗した場合はfalseを返す -
save!: バリデーションを実行してtrueが返ってきた場合はsuperを実行し、失敗した場合はraise_validation_errorで例外をraiseする
ここまではそうだろうなという感じだと思います
では、perform_validations の中身を見てみましょう
コンテキストのデフォルトと指定した場合の挙動
perform_validations
def perform_validations(options = {})
options[:validate] == false || valid?(options[:context])
end
-
options[:validate]が指定されており、かつfalseの場合にはバリデーションを実行しない - 上記以外の場合は
options[:context]を引数にしてvalid?を実行する
という処理が行われていました
valid?
def valid?(context = nil)
context ||= default_validation_context
output = super(context)
errors.empty? && output
end
def default_validation_context
new_record? ? :create : :update
end
-
contextが指定されていない場合はdefault_validation_contextを使用する -
default_validation_contextはnew_record?がtrueの場合は:create、それ以外の場合は:updateを返す -
superで親クラスのvalid?を実行し、エラーがない場合はtrueを返す
まとめ
- バリデーションのコンテキストを指定することで、任意の条件のときだけバリデーションを実行することができる
- コンテキストを指定するとデフォルトで用意されている
create,update時のバリデーションが実行されないので注意が必要 - 絶対つけ忘れるという場合はカスタムコンテキストをなるべく使わないようにするのも手かもしれない
- 知らなかった人は気をつけましょう
Discussion