mcpField
A
active-record-validations

active-record-validations

github.com/thinkoodle開発7178 回インストール8 週間前に更新
リポジトリ

active-record-validations について

Rails 8.1 の開発者向けに、Active Record の検証とデータベース制約を組み合わせる方法を案内するスキルです。組み込み・独自のバリデーター、正規化、エラー処理を扱い、データ整合性を重視します。

主な機能

  1. 01モデル検証とDB制約の併用
  2. 02組み込みバリデーターの適切な使用
  3. 03独自の検証ロジック作成
  4. 04検証前の入力正規化
  5. 05検証エラーとコンテキストの処理

活用シーン

  1. 01モデル属性の確実な検証設計
  2. 02一意性検証に対応するインデックス追加
  3. 03メールなどの入力を正規化して検証
  4. 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.md
conditional-validations.md
custom-validators.md
db-constraints.md
errors-api.md
testing.md

詳細

Active Record Validations Expert

Rails 8.1 アプリケーション向けに、適切で多層的なバリデーションを作成します。モデルの各バリデーションに、適切なデータベース制約を組み合わせます。データの整合性をモデルのバリデーションだけに頼ってはいけません。

基本方針

  1. バリデーションは UX、制約は安全策です — モデルのバリデーションは分かりやすいエラーメッセージを生成します。DB 制約はデータの破損を防ぎます。両方が必要です。
  2. モデル層でバリデーションし、DB 層で制約を設けます — validates :email, presence: true とマイグレーションの null: false を組み合わせます。必ず両方を設定してください。
  3. 一意性チェックには競合状態が伴います — 一意の DB インデックスを設定せずに validates :email, uniqueness: true を使うのはバグです。以上です。
  4. バリデーション前に正規化します — normalizes(Rails 7.1 以降)を使い、バリデーションの実行前に空白を削除し、小文字に変換します。汚れた入力をバリデーションしてはいけません。
  5. カスタムバリデーターは再利用向け、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: truenull: false
uniqueness: trueadd_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: falsevalidates :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 制約が重要です。

エージェントがよく犯す間違い

  1. マイグレーションで null: false を指定せずに validates :foo, presence: true を追加する — Active Record を迂回する処理があると、データに不整合が生じます。

  2. ユニークインデックスを設定せずに validates :email, uniqueness: true を使用する — 競合状態が発生します。同時に届いた 2 つのリクエストがどちらもバリデーションを通過し、重複データを挿入する可能性があります。

  3. ブール値の存在を検証する — validates :active, presence: true は false を拒否します。inclusion: { in: [true, false] } を使用してください。

  4. フォーマットの正規表現で ^ と $ を使用する — \A と \z を使用してください。行アンカーを使うと、改行によるインジェクションが可能になります。

  5. カスタム validate メソッドで nil をチェックしない — presence が任意の場合、カスタムメソッドに nil が渡され、NoMethodError が発生します。

  6. 両側に validates_associated を設定する — 無限ループが発生します。親だけで子を検証してください。

  7. numericality が nil を拒否することを忘れる — フィールドが任意の場合は allow_nil: true を追加してください。

  8. 一意性チェックの前に正規化しない — 正規化または case_sensitive: false を設定しないと、"[email protected]" と "[email protected]" は異なる値として扱われます。

  9. 関連付けではなく外部キーのカラムを検証する — validates :author_id, presence: true では、author が存在することを確認できません。belongs_to :author なら確認できます。

  10. 単一モデルのロジックに 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 — テストのパターン、パフォーマンスに関する考慮事項、その他のレシピ

コメント