mcpField
A
active-model

active-model

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

active-model について

データベースに永続化せず、Rails のフォーム、バリデーション、シリアライズと連携する Ruby オブジェクトを作る Active Model スキルです。フォームや検索、設定用オブジェクトの実装を支援します。

主な機能

  1. 01Rails 互換の非永続モデル
  2. 02型付き属性とデフォルト値
  3. 03バリデーションとライフサイクルコールバック
  4. 04変更追跡とシリアライズ
  5. 05フォームおよび検索オブジェクトのパターン

活用シーン

  1. 01複数モデルを扱うフォームオブジェクトの作成
  2. 02検索・絞り込みフォームの構築
  3. 03設定オブジェクトの検証
  4. 04API 応答オブジェクトのシリアライズ

インストール

コマンドをコピーしてターミナルで実行します。

$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 エキスパート

データベースに接続せずに、Rails のフォーム、バリデーション、シリアライズに対応する、モデルのように振る舞う Ruby クラスを作成します。

主要な判断:Active Model と Active Record のどちらを使うか

次の場合は Active Model を使います:

  • オブジェクトの元になるデータベーステーブルがない
  • 複数のモデルを集約するフォームオブジェクト
  • 検索/絞り込みフォーム
  • API のリクエスト/レスポンスをラップするオブジェクト
  • 設定や環境設定のオブジェクト
  • お問い合わせフォーム、招待フォーム、オンボーディングウィザード
  • バリデーションが必要なデコレーターやプレゼンター
  • バリデーションとエラーメッセージが必要なサービスオブジェクト

次の場合は Active Record を使います:

  • データをデータベースに永続化する必要がある
  • アソシエーション、スコープ、クエリーインターフェースが必要
  • マイグレーションとスキーマ管理が必要

エージェントが最もよく犯す間違い: 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 %>

重要: Active Model オブジェクトはデフォルトではルーティング可能ではないため(persisted? が true を返さず、id もありません)、form_with に url: を指定する必要があります。

ステップ 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

重要: コールバックでは include ではなく extend を使います。また、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 には、命名と翻訳の機能が自動的に含まれています。

モデル名をカスタマイズする(名前空間付きクラスで便利です):

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 — フォームオブジェクト、ウィザード、サービスオブジェクト、リンターテスト、エッジケース

コメント