mcpField
A
active-record-validations

active-record-validations

github.com/thinkoodle开发7178 次安装8 周前更新
代码仓库

关于 active-record-validations

面向 Rails 8.1 开发者的 Active Record 验证技能,指导编写分层校验并配套数据库约束。内容涵盖内置与自定义验证、输入规范化和错误处理,强调数据完整性不能只依赖模型验证。

主要功能

  1. 01配套模型验证与数据库约束
  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 验证专家

为 Rails 8.1 应用编写正确、分层的验证。为每项模型验证配备适当的数据库约束。绝不要只依赖模型验证来保证数据完整性。

理念

  1. 验证改善用户体验,约束保障安全 — 模型验证会生成友好的错误消息。数据库约束可防止数据损坏。两者缺一不可。
  2. 在模型层验证,在数据库层约束 — 迁移中同时使用 validates :email, presence: true 和 null: false。始终如此。
  3. 唯一性验证存在竞态条件 — 使用 validates :email, uniqueness: true 却没有唯一数据库索引,就是一个 bug。毫无疑问。
  4. 先规范化,再验证 — 使用 normalizes(Rails 7.1+)在验证运行前去除空白并转换为小写。不要验证不规范的输入。
  5. 自定义验证器用于复用,validate 方法用于一次性逻辑 — 不要为只在一个模型中使用的逻辑创建 EachValidator 类。

重要规则 — 请先阅读

为验证配备数据库约束
# 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

配对速查表:

验证数据库约束
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 约束或枚举类型
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:选择合适的验证方式

编写验证前,先问自己:“这条规则应该放在哪里?”

规则类型位置示例
数据存在模型 + 数据库 null: falsevalidates :name, presence: true
数据唯一模型 + 数据库唯一索引validates :email, uniqueness: true
数据格式仅模型(数据库无法高效地使用正则表达式)validates :email, format: { with: /.../ }
业务逻辑模型 validate 方法validate :end_after_start
引用完整性数据库外键在迁移中使用 add_foreign_key
跨记录一致性数据库约束或服务对象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? }

优先使用具名方法,而不是复杂的 lambda — 方法名(如 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]" 会被视为不同值,导致重复数据、查找失败和令人困惑的错误消息。

跳过验证的方法

这些方法会在不运行验证的情况下写入数据库。请牢记:

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)

**如果使用这些方法,数据库约束就是唯一的安全保障。**这就是数据库约束很重要的原因。

Agent 常犯的错误

  1. 添加 validates :foo, presence: true,但迁移中没有 null: false——如果有任何操作绕过 Active Record,数据就会不一致。

  2. 使用 validates :email, uniqueness: true,但没有唯一索引——会产生竞态条件。两个并发请求都可能通过验证并插入重复数据。

  3. 验证布尔值是否存在——validates :active, presence: true 会拒绝 false。请使用 inclusion: { in: [true, false] }。

  4. 在格式正则表达式中使用 ^ 和 $——请使用 \A 和 \z。行锚点会允许通过换行符注入内容。

  5. 自定义 validate 方法中没有处理 nil——如果 presence 是可选的,自定义方法会收到 nil 并引发 NoMethodError。

  6. 在关联的两侧都使用 validates_associated——会导致无限循环。只有父级验证子级。

  7. 忘记数值验证会拒绝 nil——如果字段是可选的,请添加 allow_nil: true。

  8. 唯一性检查前没有进行规范化——如果没有规范化或设置 case_sensitive: false,"[email protected]" 和 "[email protected]" 会被视为不同值。

  9. 验证外键列而不是关联——validates :author_id, presence: true 不会验证作者是否存在。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 — 测试模式、性能注意事项、其他实用方案

评论

支持 Markdown