active-record-validations について
Rails 8.1 の開発者向けに、Active Record の検証とデータベース制約を組み合わせる方法を案内するスキルです。組み込み・独自のバリデーター、正規化、エラー処理を扱い、データ整合性を重視します。
主な機能
- 01モデル検証とDB制約の併用
- 02組み込みバリデーターの適切な使用
- 03独自の検証ロジック作成
- 04検証前の入力正規化
- 05検証エラーとコンテキストの処理
活用シーン
- 01モデル属性の確実な検証設計
- 02一意性検証に対応するインデックス追加
- 03メールなどの入力を正規化して検証
- 04エラーや検証を迂回する書き込みの調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill active-record-validationsインストール後、Claude Code で/active-record-validationsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/built-in-validators.mdconditional-validations.mdcustom-validators.mddb-constraints.mderrors-api.mdtesting.md詳細
Active Record Validations Expert
Rails 8.1 アプリケーション向けに、適切で多層的なバリデーションを作成します。モデルの各バリデーションに、適切なデータベース制約を組み合わせます。データの整合性をモデルのバリデーションだけに頼ってはいけません。
基本方針
- バリデーションは UX、制約は安全策です — モデルのバリデーションは分かりやすいエラーメッセージを生成します。DB 制約はデータの破損を防ぎます。両方が必要です。
- モデル層でバリデーションし、DB 層で制約を設けます —
validates :email, presence: trueとマイグレーションのnull: falseを組み合わせます。必ず両方を設定してください。 - 一意性チェックには競合状態が伴います — 一意の DB インデックスを設定せずに
validates :email, uniqueness: trueを使うのはバグです。以上です。 - バリデーション前に正規化します —
normalizes(Rails 7.1 以降)を使い、バリデーションの実行前に空白を削除し、小文字に変換します。汚れた入力をバリデーションしてはいけません。 - カスタムバリデーターは再利用向け、validate メソッドは単発の処理向けです — 1 つのモデルでしか使わないロジックのために
EachValidatorクラスを作らないでください。
重要なルール — まずはこちらをお読みください
バリデーションと DB 制約を組み合わせる
# Model
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
end
# Migration — without this, anything that bypasses Active Record (bulk imports, raw SQL, other apps) can insert invalid data
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email, null: false # backs presence
t.timestamps
end
add_index :users, :email, unique: true # backs uniqueness
end
end
組み合わせの早見表:
| バリデーション | DB 制約 |
|---|---|
presence: true | null: false |
uniqueness: true | add_index unique: true |
uniqueness: { scope: :tenant_id } | add_index [:tenant_id, :email], unique: true |
numericality: { greater_than: 0 } | CHECK 制約(任意ですが、理想的です) |
inclusion: { in: %w[a b c] } | CHECK 制約または enum 型 |
length: { maximum: 255 } | カラムの limit: 255 |
一意性バリデーションだけでは競合状態が発生します
# Two concurrent requests can both pass validation and insert duplicates:
validates :slug, uniqueness: true
# Without: add_index :posts, :slug, unique: true
# Handle the DB constraint error in your controller:
def create
@user = User.new(user_params)
@user.save!
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, :taken)
render :new, status: :unprocessable_entity
end
バリデーション前に正規化する
class User < ApplicationRecord
# Rails 7.1+ normalizations — runs before validation
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :name, with: ->(name) { name.strip }
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
end
正規化しないと、" [email protected] " は "[email protected]" に対する一意性チェックに失敗します。さらに悪い場合は、重複データが作成されます。
真偽値の presence は特別です
# WRONG — false.blank? is true, so this rejects false!
validates :active, presence: true
# RIGHT — validates the value is actually true or false
validates :active, inclusion: { in: [true, false] }
# Also acceptable
validates :active, exclusion: { in: [nil] }
手順
ステップ 1:適切なバリデーションを選ぶ
バリデーションを書く前に、「このルールはどこに置くべきか?」と考えます。
| ルールの種類 | 定義する場所 | 例 |
|---|---|---|
| データが存在する | モデル + DB の null: false | validates :name, presence: true |
| データが一意である | モデル + DB の一意インデックス | validates :email, uniqueness: true |
| データの形式 | モデルのみ(DB では正規表現を効率的に使えません) | validates :email, format: { with: /.../ } |
| ビジネスロジック | モデルの validate メソッド | validate :end_after_start |
| 参照整合性 | DB 外部キー | マイグレーションの add_foreign_key |
| レコード間の整合性 | DB 制約またはサービスオブジェクト | CHECK 制約またはトランザクション |
ステップ 2:組み込みバリデーターを正しく使う
presence
validates :title, presence: true
# Checks: !value.blank? (rejects nil, "", " ")
# Pair with: null: false in migration
関連付けの場合は、外部キーではなく関連付けをバリデーションします。
# WRONG — only checks the integer column isn't nil
validates :author_id, presence: true
# RIGHT — also verifies the Author record exists
belongs_to :author # Rails 5+ validates presence by default
Rails 5 以降、belongs_to はデフォルトで存在をバリデーションします。任意にするには、belongs_to :author, optional: true と指定します。
uniqueness
validates :email, uniqueness: true
validates :name, uniqueness: { scope: :account_id } # unique per account
validates :slug, uniqueness: { case_sensitive: false }
validates :email, uniqueness: { conditions: -> { where(deleted_at: nil) } }
必ず、対応する一意インデックスをマイグレーションに追加してください。
format
# Use \A and \z for string boundaries, not ^ and $
validates :username, format: { with: /\A[a-z0-9_]+\z/ }
# ^ and $ match LINE boundaries in Ruby, not string boundaries.
# "valid\nevil" passes /^[a-z]+$/ — this is a security hole that enables injection attacks.
numericality
validates :price, numericality: { greater_than: 0 }
validates :quantity, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :discount, numericality: { in: 0..100 }
注意点: numericality はデフォルトで nil を拒否します。フィールドが任意の場合は、allow_nil: true を追加してください。
注意点: 浮動小数点数の精度 — numericality は値を Float に変換してから BigDecimal に変換します。金額には、精度とスケールを明示した decimal カラムを使い、整数のセント単位でバリデーションするか、金額を扱う gem を使ってください。
length
validates :name, length: { minimum: 2, maximum: 100 }
validates :bio, length: { maximum: 500 }
validates :pin, length: { is: 4 }
validates :password, length: { in: 8..128 }
inclusion / exclusion
validates :role, inclusion: { in: %w[admin editor viewer] }
validates :subdomain, exclusion: { in: %w[www admin api] }
# Dynamic:
validates :size, inclusion: { in: ->(record) { record.available_sizes } }
comparison
validates :end_date, comparison: { greater_than: :start_date }
validates :retry_count, comparison: { less_than_or_equal_to: 10 }
confirmation
validates :email, confirmation: true
validates :email_confirmation, presence: true # confirmation field itself must be present
# Only validate confirmation when email changes:
validates :email, confirmation: true
validates :email_confirmation, presence: true, if: :email_changed?
acceptance
validates :terms, acceptance: true # virtual attribute, no DB column needed
validates :eula, acceptance: { accept: ["TRUE", "accepted"] }
absence
validates :supplementary_address, absence: true, unless: :has_primary_address?
validates_associated
has_many :line_items
validates_associated :line_items # calls valid? on each line_item
# Don't put validates_associated on BOTH sides of an association — it creates
# an infinite loop (parent validates child, child validates parent, etc.).
# Parent validates children; children rely on belongs_to for the reverse.
ステップ 3:条件付きバリデーション
:if / :unless を使って、バリデーションを実行する条件を指定します。
# Symbol — cleanest for named methods
validates :card_number, presence: true, if: :paid_with_card?
# Lambda — fine for one-liners
validates :company_name, presence: true, if: -> { account_type == "business" }
# Group related conditionals with with_options
with_options if: :is_admin? do
validates :password, length: { minimum: 10 }
validates :email, presence: true
end
# Combine conditions (ALL :if must be true, NO :unless can be true)
validates :parking_spot, presence: true,
if: [:has_car?, :works_onsite?],
unless: -> { remote_employee? }
複雑なラムダより名前付きメソッドを優先します — メソッド名(paid_with_card? など)はインラインのロジックより意図が伝わりやすく、単独でテストしやすくなります。
on: context
validates :email, uniqueness: true, on: :create # skip on update
validates :age, numericality: true, on: :update
# Custom contexts for multi-step forms:
validates :address, presence: true, on: :checkout
# Trigger: user.valid?(:checkout) or user.save(context: :checkout)
ステップ 4:カスタムバリデーション
validate メソッド(単発の処理、単一モデル向け)
class Event < ApplicationRecord
validate :end_after_start
validate :not_in_past, on: :create
private
def end_after_start
return if end_date.blank? || start_date.blank?
if end_date <= start_date
errors.add(:end_date, "must be after start date")
end
end
def not_in_past
return if start_date.blank?
if start_date < Date.current
errors.add(:start_date, "can't be in the past")
end
end
end
nil に備えてガードします — return if field.blank? を使います。カスタムバリデーションでは、フィールドが空の場合に nil が渡され、NoMethodError が発生するためです。「値が存在するか」の確認は presence に任せ、別々に処理してください。
EachValidator(モデル間で再利用可能)
# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless URI::MailTo::EMAIL_REGEXP.match?(value)
record.errors.add(attribute, options[:message] || "is not a valid email")
end
end
end
# Usage — name becomes the validation key (email:)
class User < ApplicationRecord
validates :email, email: true
validates :backup_email, email: true, allow_blank: true
end
命名規則: XxxValidator → validates :attr, xxx: true。クラス名から Validator を除き、小文字化してアンダースコア区切りにします。
バリデータークラス(validates_with、複雑な複数フィールド間の検証)
# app/validators/address_validator.rb
class AddressValidator < ActiveModel::Validator
def validate(record)
%i[street city zip].each do |field|
if record.send(field).blank?
record.errors.add(field, "is required for a complete address")
end
end
end
end
class Order < ApplicationRecord
validates_with AddressValidator, if: :shipping_required?
end
注意: validates_with のバリデーターは、アプリケーションのライフサイクル全体を通して一度だけ初期化されます。インスタンス状態を保持すると、異なるレコードの検証間でデータが漏れる原因になります。
ステップ 5:エラー処理
エラーの読み取り
record.valid? # triggers validations, returns bool
record.errors.full_messages # ["Name can't be blank", ...]
record.errors[:name] # ["can't be blank", "is too short..."]
record.errors.where(:name, :too_short) # [ActiveModel::Error objects]
record.errors.added?(:name, :blank) # true/false — check specific error type
record.errors.of_kind?(:name, :blank) # same as added? but doesn't check options
エラーの手動追加
errors.add(:email, :invalid) # uses i18n default
errors.add(:email, :taken, value: email) # interpolates %{value}
errors.add(:base, "Something is wrong with this record") # record-level error
プログラムから処理するためのカスタムエラータイプ
errors.add(:discount, :exceeds_maximum, count: 50)
# In en.yml:
# activerecord.errors.models.order.attributes.discount.exceeds_maximum: "cannot exceed %{count}%"
ビューでの表示
<% if @record.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@record.errors.count, "error") %> prohibited saving:</h2>
<ul>
<% @record.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
ステップ 6:厳格なバリデーション
ユーザー入力のエラーではなく、プログラマーのエラーに使用します。
validates :token, presence: { strict: true }
# Raises ActiveModel::StrictValidationFailed instead of adding to errors
validates :token, presence: { strict: TokenMissingError }
# Raises your custom exception
厳格なバリデーションを使用する場合: 内部不変条件、システムが生成するフィールド、コードが正しければ決して失敗しないはずの項目。使用しない場合: ユーザー向けのフォームフィールド。
ステップ 7:正規化(Rails 7.1+)
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :phone, with: ->(phone) { phone.gsub(/\D/, "") }
normalizes :name, with: ->(name) { name.squish } # collapse whitespace
# apply: false — skip normalization on nil (default normalizes nil too)
normalizes :nickname, with: ->(n) { n.strip }, apply_to_nil: false
end
# Normalizations run:
# - On assignment: user.email = " [email protected] " → "[email protected]"
# - Before validation
# - On finder methods: User.find_by(email: " [email protected] ") normalizes the query value
バリデーションの前に正規化してください。 これを行わないと、" [email protected] " と "[email protected]" は異なる値として扱われ、重複や検索の失敗、分かりにくいエラーメッセージにつながります。
バリデーションをスキップするメソッド
これらのメソッドは、バリデーションを実行せずに DB に書き込みます。把握しておきましょう。
update_all、update_column、update_columns、insert、insert!、insert_all、insert_all!、upsert、upsert_all、touch、touch_all、toggle!、increment!、decrement!、update_attribute、save(validate: false)
これらを使う場合、DB 制約だけが最後の安全策になります。 そのため、DB 制約が重要です。
エージェントがよく犯す間違い
-
マイグレーションで
null: falseを指定せずにvalidates :foo, presence: trueを追加する — Active Record を迂回する処理があると、データに不整合が生じます。 -
ユニークインデックスを設定せずに
validates :email, uniqueness: trueを使用する — 競合状態が発生します。同時に届いた 2 つのリクエストがどちらもバリデーションを通過し、重複データを挿入する可能性があります。 -
ブール値の存在を検証する —
validates :active, presence: trueはfalseを拒否します。inclusion: { in: [true, false] }を使用してください。 -
フォーマットの正規表現で
^と$を使用する —\Aと\zを使用してください。行アンカーを使うと、改行によるインジェクションが可能になります。 -
カスタム validate メソッドで nil をチェックしない —
presenceが任意の場合、カスタムメソッドにnilが渡され、NoMethodErrorが発生します。 -
両側に validates_associated を設定する — 無限ループが発生します。親だけで子を検証してください。
-
numericality が nil を拒否することを忘れる — フィールドが任意の場合は
allow_nil: trueを追加してください。 -
一意性チェックの前に正規化しない — 正規化または
case_sensitive: falseを設定しないと、"[email protected]"と"[email protected]"は異なる値として扱われます。 -
関連付けではなく外部キーのカラムを検証する —
validates :author_id, presence: trueでは、author が存在することを確認できません。belongs_to :authorなら確認できます。 -
単一モデルのロジックに EachValidator を作成する — やりすぎです。2 つ以上のモデルで必要な場合を除き、
validateメソッドを使用してください。
クイックリファレンス
バリデーションを実行するトリガー
create、create!、save、save!、update、update!、valid?、invalid?
すべてのバリデーターで使用できるオプション
| オプション | 用途 |
|---|---|
:message | カスタムエラーメッセージ(String または Proc) |
:on | コンテキスト — :create、:update、またはカスタムシンボル |
:if / :unless | 条件 — シンボル、Proc、または配列 |
:allow_nil | 値が nil の場合はスキップ |
:allow_blank | 値が blank? の場合はスキップ |
:strict | エラーを追加する代わりに例外を発生させる |
メッセージの補間
validates :name, length: { minimum: 3, message: "must be at least %{count} characters" }
# Available: %{value}, %{attribute}, %{model}, %{count}
validates :email, uniqueness: {
message: ->(object, data) { "#{data[:value]} is already taken" }
}
完全な例とエッジケースについては、references/ ディレクトリを参照してください。
references/built-in-validators.md— すべての組み込みバリデーターとオプション、エッジケース、正規化references/custom-validators.md— EachValidator、Validator クラス、PORO バリデーターreferences/errors-api.md— Errors API(読み取り、追加、フィルタリング)と I18n の設定references/conditional-validations.md— if/unless、with_options、:on コンテキスト、複数ステップのフォームreferences/db-constraints.md— DB 制約との組み合わせ、一意性に関する競合状態、数値検証のエッジケースreferences/testing.md— テストのパターン、パフォーマンスに関する考慮事項、その他のレシピ