关于 active-record-validations
面向 Rails 8.1 开发者的 Active Record 验证技能,指导编写分层校验并配套数据库约束。内容涵盖内置与自定义验证、输入规范化和错误处理,强调数据完整性不能只依赖模型验证。
主要功能
- 01配套模型验证与数据库约束
- 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 验证专家
为 Rails 8.1 应用编写正确、分层的验证。为每项模型验证配备适当的数据库约束。绝不要只依赖模型验证来保证数据完整性。
理念
- 验证改善用户体验,约束保障安全 — 模型验证会生成友好的错误消息。数据库约束可防止数据损坏。两者缺一不可。
- 在模型层验证,在数据库层约束 — 迁移中同时使用
validates :email, presence: true和null: false。始终如此。 - 唯一性验证存在竞态条件 — 使用
validates :email, uniqueness: true却没有唯一数据库索引,就是一个 bug。毫无疑问。 - 先规范化,再验证 — 使用
normalizes(Rails 7.1+)在验证运行前去除空白并转换为小写。不要验证不规范的输入。 - 自定义验证器用于复用,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: 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 约束或枚举类型 |
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: false | validates :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 常犯的错误
-
添加
validates :foo, presence: true,但迁移中没有null: false——如果有任何操作绕过 Active Record,数据就会不一致。 -
使用
validates :email, uniqueness: true,但没有唯一索引——会产生竞态条件。两个并发请求都可能通过验证并插入重复数据。 -
验证布尔值是否存在——
validates :active, presence: true会拒绝false。请使用inclusion: { in: [true, false] }。 -
在格式正则表达式中使用
^和$——请使用\A和\z。行锚点会允许通过换行符注入内容。 -
自定义
validate方法中没有处理 nil——如果presence是可选的,自定义方法会收到nil并引发NoMethodError。 -
在关联的两侧都使用 validates_associated——会导致无限循环。只有父级验证子级。
-
忘记数值验证会拒绝 nil——如果字段是可选的,请添加
allow_nil: true。 -
唯一性检查前没有进行规范化——如果没有规范化或设置
case_sensitive: false,"[email protected]"和"[email protected]"会被视为不同值。 -
验证外键列而不是关联——
validates :author_id, presence: true不会验证作者是否存在。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— 测试模式、性能注意事项、其他实用方案