⛑️

【Rails】modelのバリデーションのerrors.addが、controllerのrescueに入るまでを追ってみた

2025/03/09に公開

errors.addの実態

あるmodelでerrors.add()しているとする。
なぜこれがcontrollerの e.record.errors.full_messages で使えるのか疑問に思ったので、ソースコードを追ってみる

# app/models/product.rb
class Product < ApplicationRecord
  belongs_to :category
  
  validate :price_must_be_reasonable
  
  private
  
  def price_must_be_reasonable
    if price.present? && price <= 0
      errors.add(:price, "は0より大きい値にしてください")
    end
  end
end

errorsの実態

継承しているApplicationRecord => ActiveRecord::Baseのソースの中に、includeがあり、validations.rb のなかに def errors があった。

https://github.com/rails/rails/blob/065ba4faaff079b070bf349820d18a90baf1d53b/activerecord/lib/active_record/base.rb#L308

https://github.com/rails/rails/blob/065ba4faaff079b070bf349820d18a90baf1d53b/activemodel/lib/active_model/validations.rb#L328-L330

model内で、errors.class を出力してみると ActiveModel::Errors であった。
errorsメソッドで返却している Errors.new(self) と一致していることがわかる。

Errors.new(self)の実態

同じ active_model ディレクトリ内に、Errorsというclassが無いか探した。あった。

activemodel/lib/active_model/errors.rb

module ActiveModel
    class Errors
    def initialize(base)
      @base = base
      @errors = []
    end

    def add(attribute, type = :invalid, **options)
      attribute, type, options = normalize_arguments(attribute, type, **options)
      error = Error.new(@base, attribute, type, **options)

      if exception = options[:strict]
        exception = ActiveModel::StrictValidationFailed if exception == true
        raise exception, error.full_message
      end

      @errors.append(error)

      error
    end

Errors.new(self) のタイミングでinitializeが発火。
initializeで@errorsを初期化しておいて、addでは@errorsにappendしている。

覚えておくこと

errorsはモデルのインスタンスメソッド。

controllerでエラーを取り出すまでの実態

例えば以下のコードがあるとする。
e.record.errors で取り出せるのはなぜなのか? ActiveRecord::RecordInvalid とは?

class ProductsController < ApplicationController
  def create
    @product = Product.new(product_params)
    
    begin
      @product.save!  # save!メソッドはレコードが無効な場合にActiveRecord::RecordInvalidを発生させる
      redirect_to @product, notice: '商品が正常に作成されました。'
    rescue ActiveRecord::RecordInvalid => e
      # バリデーションエラーが発生した場合
      error_messages = e.record.errors.full_messages.join(', ')
      flash.now[:alert] = "商品の作成に失敗しました: #{error_messages}"
      render :new, status: :unprocessable_entity
    end
  end

まずsave!やcreate!で何が起きるのかを知る必要がある。

save!の実態

https://github.com/rails/rails/blob/065ba4faaff079b070bf349820d18a90baf1d53b/activerecord/lib/active_record/validations.rb#L53-L55

https://github.com/rails/rails/blob/065ba4faaff079b070bf349820d18a90baf1d53b/activerecord/lib/active_record/validations.rb#L86-L88

save!でバリデーションに引っかかった場合
RecordInvalid.new(self)raise される。
この self には、モデルのインスタンスが入る。

RecordInvalidの実態

RecordInvalid.new(self) のselfは、initialize内で@recordに保存される。
そして、
attr_reader :record が定義されているため、{RecordInvalidのインスタンス}.recordの形でモデルのインスタンスにアクセスすることができる。
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/validations.rb#L15-L29

まとめ

controllerでは、RecordInvalidインスタンスから、モデルのインスタンスにrecordでアクセスでき、さらにモデルのインスタンスメソッドerrors呼び出すことで、バリデーションエラーを保存しておいたものを参照することができる。

Discussion