📚

save 時に context を指定するとデフォルトの create, update のバリデーションが実行されない話

2024/09/30に公開

はじめに

こんにちは, 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

保存時のバリデーションに関する部分

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_contextnew_record?true の場合は :create 、それ以外の場合は :update を返す
  • super で親クラスの valid? を実行し、エラーがない場合は true を返す

まとめ

  • バリデーションのコンテキストを指定することで、任意の条件のときだけバリデーションを実行することができる
  • コンテキストを指定するとデフォルトで用意されている create, update 時のバリデーションが実行されないので注意が必要
  • 絶対つけ忘れるという場合はカスタムコンテキストをなるべく使わないようにするのも手かもしれない
  • 知らなかった人は気をつけましょう
GitHubで編集を提案
SocialPLUS Tech Blog

Discussion