mcpField
A
active-model

active-model

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

关于 active-model

Active Model 技能用于构建无需数据库持久化、但可接入 Rails 表单、验证和序列化的 Ruby 对象,帮助 Rails 开发者选择模块并实现表单、搜索及配置对象。

主要功能

  1. 01兼容 Rails 的非持久化模型
  2. 02类型化属性与默认值
  3. 03验证和生命周期回调
  4. 04变更跟踪与序列化
  5. 05表单及搜索对象模式

适用场景

  1. 01创建聚合多模型的表单对象
  2. 02构建搜索和筛选表单
  3. 03验证配置对象
  4. 04序列化 API 响应对象

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill active-model

安装后在 Claude Code 中输入/active-model确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
api-and-attributes.md
dirty-tracking.md
patterns.md
serialization.md
validations-and-callbacks.md

详细介绍

Active Model 专家

构建类似模型的 Ruby 类,使其能够与 Rails 表单、验证和序列化配合使用,而无需接触数据库。

核心选择:Active Model 还是 Active Record

在以下情况下使用 Active Model:

  • 对象没有对应的数据库表
  • 聚合多个模型的表单对象
  • 搜索/筛选表单
  • API 请求/响应封装对象
  • 配置或设置对象
  • 联系表单、邀请表单、入门引导向导
  • 需要验证功能的装饰器或展示器
  • 需要验证和错误消息的服务对象

在以下情况下使用 Active Record:

  • 数据必须持久化到数据库
  • 需要关联、作用域或查询接口
  • 需要迁移和架构管理

Agent 最常犯的错误: 明明 Active Model 已能免费提供 Rails 表单和控制器所需的一切,却仍选择 Active Record(或手动编写验证逻辑的原始 PORO)。

模块层级结构

了解应该包含哪个模块至关重要:

模块提供的功能适用场景
ActiveModel::ModelAPI + 后续扩展默认选择 — 使用此模块
ActiveModel::API验证、命名、转换、翻译、属性赋值Model 的轻量替代方案
ActiveModel::Attributes带类型转换和默认值的类型化属性需要类型强制转换(日期、布尔值、整数)
ActiveModel::Validations仅验证功能为任意对象添加验证
ActiveModel::Callbacks生命周期钩子(before/after/around)需要回调链
ActiveModel::Dirty变更跟踪跟踪属性修改
ActiveModel::Serializationserializable_hash需要哈希/JSON 输出
ActiveModel::SecurePasswordbcrypt 密码处理无需 Active Record 即可处理密码

关键见解: ActiveModel::Model 包含 ActiveModel::API,后者集成了验证、命名、转换、翻译和属性赋值功能。先从 Model 开始,再按需添加其他模块。

使用说明

步骤 1:选择合适的基类

对于大多数表单对象和虚拟模型,请使用 ActiveModel::Model:

class ContactForm
  include ActiveModel::Model

  attr_accessor :name, :email, :message

  validates :name, :email, :message, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }

  def submit
    return false unless valid?
    ContactMailer.new_message(name:, email:, message:).deliver_later
    true
  end
end

它可以立即与 form_with、render 以及所有 Action View 帮助方法配合使用。

需要类型化属性时,添加 ActiveModel::Attributes:

class SearchForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :query, :string
  attribute :min_price, :decimal
  attribute :max_price, :decimal
  attribute :available_only, :boolean, default: false
  attribute :created_after, :date

  validates :query, length: { minimum: 2 }, allow_blank: true

  def results
    scope = Product.all
    scope = scope.where("name ILIKE ?", "%#{query}%") if query.present?
    scope = scope.where("price >= ?", min_price) if min_price.present?
    scope = scope.where("price <= ?", max_price) if max_price.present?
    scope = scope.where(available: true) if available_only
    scope = scope.where("created_at >= ?", created_after) if created_after.present?
    scope
  end
end

Attributes 会自动进行类型转换 — 字符串 "true" 会变成布尔值 true,字符串 "2024-01-15" 会变成 Date。

步骤 2:接入控制器

Active Model 对象在控制器中的用法与 Active Record 完全相同:

class ContactFormsController < ApplicationController
  def new
    @contact_form = ContactForm.new
  end

  def create
    @contact_form = ContactForm.new(contact_form_params)
    if @contact_form.submit
      redirect_to root_path, notice: "Message sent!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def contact_form_params
    params.require(:contact_form).permit(:name, :email, :message)
  end
end
步骤 3:与表单配合使用
<%= form_with model: @contact_form, url: contact_forms_path do |f| %>
  <% if @contact_form.errors.any? %>
    <div id="errors">
      <% @contact_form.errors.full_messages.each do |msg| %>
        <p><%= msg %></p>
      <% end %>
    </div>
  <% end %>

  <%= f.text_field :name %>
  <%= f.email_field :email %>
  <%= f.text_area :message %>
  <%= f.submit "Send" %>
<% end %>

关键: 必须在 form_with 中提供 url:,因为 Active Model 对象默认不可路由(没有返回 true 的 persisted?,也没有 id)。

步骤 4:按需添加模块

只包含实际用到的模块。不要盲目照搬并添加所有模块。

回调 — 需要生命周期钩子时:

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend ActiveModel::Callbacks

  define_model_callbacks :save

  attribute :email, :string
  attribute :name, :string
  attribute :company_name, :string

  before_save :normalize_email
  after_save :send_welcome_email

  def save
    return false unless valid?
    run_callbacks(:save) do
      create_records!
    end
    true
  end

  private

  def normalize_email
    self.email = email.downcase.strip
  end

  def send_welcome_email
    WelcomeMailer.registration(@user).deliver_later
  end

  def create_records!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(email:, name:)
  end
end

重要: 对于 Callbacks,要使用 extend,而不是 include。此外,必须自行调用 run_callbacks(:event) { ... } — Active Model 不会自动触发回调。

变更跟踪 — 需要检测变更时:

class Settings
  include ActiveModel::Model
  include ActiveModel::Dirty

  define_attribute_methods :theme, :language

  def theme
    @theme
  end

  def theme=(value)
    theme_will_change! unless value == @theme
    @theme = value
  end

  def language
    @language
  end

  def language=(value)
    language_will_change! unless value == @language
    @language = value
  end

  def save
    changes_applied
  end

  def reload!
    clear_changes_information
  end
end

序列化 — 用于 JSON API:

class ApiResponse
  include ActiveModel::Model
  include ActiveModel::Serializers::JSON

  attr_accessor :status, :data, :timestamp

  def attributes
    { "status" => nil, "data" => nil, "timestamp" => nil }
  end
end

response = ApiResponse.new(status: "ok", data: { count: 42 }, timestamp: Time.current)
response.as_json  # => {"status"=>"ok", "data"=>{"count"=>42}, "timestamp"=>"2024-..."}
response.to_json  # => '{"status":"ok",...}'

SecurePassword — 无需 Active Record 即可处理密码:

class SessionForm
  include ActiveModel::Model
  include ActiveModel::SecurePassword

  has_secure_password

  attr_accessor :password_digest

  def authenticate_user(email, password)
    user = User.find_by(email:)
    user&.authenticate(password)
  end
end

需要 bcrypt gem。会提供 password、password_confirmation 和 authenticate 方法。

步骤 5:命名和翻译

ActiveModel::Model 会自动包含 Naming 和 Translation。

自定义模型名称(适用于带命名空间的类):

module Admin
  class InviteForm
    include ActiveModel::Model

    def self.model_name
      ActiveModel::Name.new(self, nil, "InviteForm")
    end
  end
end

# Without override: form params would be admin_invite_form[email]
# With override:    form params are invite_form[email]

属性名称的 I18n:

# config/locales/en.yml
en:
  activemodel:
    attributes:
      contact_form:
        name: "Full Name"
        email: "Email Address"
    errors:
      models:
        contact_form:
          attributes:
            email:
              invalid: "doesn't look right"
步骤 6:测试 Active Model 对象
require "test_helper"

class ContactFormTest < ActiveSupport::TestCase
  test "valid with all attributes" do
    form = ContactForm.new(name: "Jane", email: "[email protected]", message: "Hello")
    assert form.valid?
  end

  test "invalid without name" do
    form = ContactForm.new(email: "[email protected]", message: "Hello")
    refute form.valid?
    assert_includes form.errors[:name], "can't be blank"
  end

  test "invalid with bad email" do
    form = ContactForm.new(name: "Jane", email: "not-an-email", message: "Hello")
    refute form.valid?
    assert_includes form.errors[:email], "is invalid"
  end

  test "#submit delivers email when valid" do
    form = ContactForm.new(name: "Jane", email: "[email protected]", message: "Hello")
    assert_enqueued_emails 1 do
      assert form.submit
    end
  end

  test "#submit returns false when invalid" do
    form = ContactForm.new
    refute form.submit
  end
end

Lint 测试 — 验证 API 合规性:

class ContactFormLintTest < ActiveSupport::TestCase
  include ActiveModel::Lint::Tests

  setup do
    @model = ContactForm.new
  end
end

常见模式

表单对象(多模型)
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :user_email, :string
  attribute :user_name, :string
  attribute :company_name, :string
  attribute :plan, :string, default: "free"

  validates :user_email, :user_name, :company_name, presence: true
  validates :user_email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :plan, inclusion: { in: %w[free pro enterprise] }

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      company = Company.create!(name: company_name)
      company.users.create!(email: user_email, name: user_name)
      company.subscriptions.create!(plan:)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end
end
搜索/筛选表单
class OrderSearch
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :status, :string
  attribute :customer_name, :string
  attribute :date_from, :date
  attribute :date_to, :date
  attribute :min_total, :decimal
  attribute :sort_by, :string, default: "created_at"
  attribute :sort_direction, :string, default: "desc"

  def results
    scope = Order.includes(:customer)
    scope = scope.where(status:) if status.present?
    scope = scope.joins(:customer).where("customers.name ILIKE ?", "%#{customer_name}%") if customer_name.present?
    scope = scope.where("orders.created_at >= ?", date_from) if date_from.present?
    scope = scope.where("orders.created_at <= ?", date_to) if date_to.present?
    scope = scope.where("orders.total >= ?", min_total) if min_total.present?
    scope = scope.order(sort_by => sort_direction)
    scope
  end
end
配置对象
class NotificationPreferences
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :email_enabled, :boolean, default: true
  attribute :sms_enabled, :boolean, default: false
  attribute :digest_frequency, :string, default: "daily"
  attribute :quiet_hours_start, :integer, default: 22
  attribute :quiet_hours_end, :integer, default: 8

  validates :digest_frequency, inclusion: { in: %w[realtime hourly daily weekly] }
  validates :quiet_hours_start, :quiet_hours_end,
            numericality: { in: 0..23 }

  def attributes
    {
      "email_enabled" => nil,
      "sms_enabled" => nil,
      "digest_frequency" => nil,
      "quiet_hours_start" => nil,
      "quiet_hours_end" => nil
    }
  end

  def quiet_now?
    hour = Time.current.hour
    if quiet_hours_start > quiet_hours_end
      hour >= quiet_hours_start || hour < quiet_hours_end
    else
      hour >= quiet_hours_start && hour < quiet_hours_end
    end
  end
end

反模式

  1. 对非持久化对象使用 Active Record — 如果没有数据表,就不要继承 ApplicationRecord
  2. 手动编写验证逻辑 — 已有 validates :name, presence: true 时,不要写 raise "Name required" if name.blank?
  3. 跳过 ActiveModel::Model — 不要手动实现通过哈希遍历的 initialize(attrs={});Model 会处理
  4. 包含所有模块 — 只包含实际使用的模块;大多数情况下,Model 就足够了
  5. 在 form_with 中忘记 url: — Active Model 对象不会自动解析路由
  6. 需要 include 时使用 extend — 回调使用 extend;其他情况使用 include
  7. 未在事务中封装多个模型的保存操作 — 创建多条记录的表单对象需要使用 ActiveRecord::Base.transaction
  8. 重新实现 assign_attributes — ActiveModel::Model 已通过初始化方法提供从哈希赋值属性的功能

参考

有关详细模式、边缘情况和高级用法,请参阅 references/ 目录:

  • references/api-and-attributes.md — Model 与 API、类型化属性、命名、翻译、转换
  • references/validations-and-callbacks.md — 所有验证器、自定义验证器、回调、错误处理
  • references/dirty-tracking.md — 手动和自动变更跟踪
  • references/serialization.md — serializable_hash、JSON 序列化
  • references/patterns.md — 表单对象、向导、服务对象、lint 测试、边缘情况

评论

支持 Markdown