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