active-model について
データベースに永続化せず、Rails のフォーム、バリデーション、シリアライズと連携する Ruby オブジェクトを作る Active Model スキルです。フォームや検索、設定用オブジェクトの実装を支援します。
主な機能
- 01Rails 互換の非永続モデル
- 02型付き属性とデフォルト値
- 03バリデーションとライフサイクルコールバック
- 04変更追跡とシリアライズ
- 05フォームおよび検索オブジェクトのパターン
活用シーン
- 01複数モデルを扱うフォームオブジェクトの作成
- 02検索・絞り込みフォームの構築
- 03設定オブジェクトの検証
- 04API 応答オブジェクトのシリアライズ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill active-modelインストール後、Claude Code で/active-modelを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/api-and-attributes.mddirty-tracking.mdpatterns.mdserialization.mdvalidations-and-callbacks.md詳細
Active Model エキスパート
データベースに接続せずに、Rails のフォーム、バリデーション、シリアライズに対応する、モデルのように振る舞う Ruby クラスを作成します。
主要な判断:Active Model と Active Record のどちらを使うか
次の場合は Active Model を使います:
- オブジェクトの元になるデータベーステーブルがない
- 複数のモデルを集約するフォームオブジェクト
- 検索/絞り込みフォーム
- API のリクエスト/レスポンスをラップするオブジェクト
- 設定や環境設定のオブジェクト
- お問い合わせフォーム、招待フォーム、オンボーディングウィザード
- バリデーションが必要なデコレーターやプレゼンター
- バリデーションとエラーメッセージが必要なサービスオブジェクト
次の場合は Active Record を使います:
- データをデータベースに永続化する必要がある
- アソシエーション、スコープ、クエリーインターフェースが必要
- マイグレーションとスキーマ管理が必要
エージェントが最もよく犯す間違い: Active Model で Rails のフォームやコントローラーが期待する機能をすべて無料で利用できるのに、Active Record(または独自にバリデーションを実装した素の PORO)を選んでしまうことです。
モジュール階層
どのモジュールをインクルードするかを理解することが重要です。
| モジュール | 得られるもの | 使用する場面 |
|---|---|---|
ActiveModel::Model | API と将来の拡張機能 | デフォルトの選択肢 — これを使います |
ActiveModel::API | バリデーション、命名、変換、翻訳、属性代入 | Model の軽量な代替 |
ActiveModel::Attributes | 型変換とデフォルト値に対応した型付き属性 | 型の強制変換(日付、真偽値、整数)が必要な場合 |
ActiveModel::Validations | バリデーションのみ | 任意のオブジェクトにバリデーションを追加する場合 |
ActiveModel::Callbacks | ライフサイクルフック(before/after/around) | コールバックチェーンが必要な場合 |
ActiveModel::Dirty | 変更の追跡 | 属性の変更を追跡する場合 |
ActiveModel::Serialization | serializable_hash | ハッシュ/JSON 出力が必要な場合 |
ActiveModel::SecurePassword | bcrypt によるパスワード処理 | 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
アンチパターン
- 永続化されないオブジェクトに Active Record を使用する — テーブルがない場合は、
ApplicationRecordをサブクラス化しないでください - バリデーションを独自に実装する —
validates :name, presence: trueが使える場合に、raise "Name required" if name.blank?と書かないでください ActiveModel::Modelを省略する — ハッシュを反復処理するinitialize(attrs={})を手動で実装しないでください。Modelが処理します- あらゆるモジュールをインクルードする — 使用するモジュールだけをインクルードしてください。ほとんどの場合、
Modelで十分です form_withでurl:を指定し忘れる — Active Model オブジェクトはルートを自動解決しませんincludeが必要な場所でextendを使用する — コールバックにはextendを使用し、それ以外にはincludeを使用します- 複数のモデルの保存をトランザクションで囲まない — 複数のレコードを作成するフォームオブジェクトでは、
ActiveRecord::Base.transactionが必要です 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— フォームオブジェクト、ウィザード、サービスオブジェクト、リンターテスト、エッジケース