Trailblazer Reform
モデルから分離されたRuby用フォームオブジェクトライブラリ。dry-validationと統合した柔軟なバリデーション機能を提供
Trailblazer Reform
Trailblazer Reformは、モデルから分離されたRuby用の強力なフォームオブジェクトライブラリです。フレームワークに依存せず、dry-validationやActiveModel::Validationsと統合して柔軟なバリデーション機能を提供します。
特徴
- モデルとの分離: フォームロジックをモデルから完全に分離
- ネストしたフォーム: 複雑なフォーム構造をサポート
- フレームワーク非依存: Rails、Sinatra等どのフレームワークでも使用可能
- dry-validation統合: 高度なバリデーションルールの定義が可能
- コレクション処理: 配列データの処理とバリデーション
- カスタムプロパティ: 仮想フィールドと型変換をサポート
- 条件付きバリデーション: 複雑なビジネスロジックに対応
インストール
Gemfileに追加:
gem 'reform'
gem 'reform-rails' # Rails統合用(オプション)
またはgemで直接インストール:
gem install reform
dry-validation統合の設定
dry-validationを使用する場合(推奨):
# config/initializers/reform.rb(Railsの場合)
require 'reform/form/dry'
Reform::Form.class_eval do
include Reform::Form::Dry
end
基本的な使い方
シンプルなフォーム定義
require 'reform'
require 'reform/form/dry'
class AlbumForm < Reform::Form
feature Reform::Form::Dry
property :title
property :artist_name
property :release_date, type: Types::Params::Date
validation do
required(:title).filled(:string)
required(:artist_name).filled(:string)
optional(:release_date).filled(:date)
end
end
# 使用方法
album = Album.new
form = AlbumForm.new(album)
# バリデーションと保存
if form.validate(title: "Abbey Road", artist_name: "The Beatles")
form.save # データをモデルに同期して保存
puts "Album created successfully!"
else
puts form.errors.messages
end
ActiveModel::Validationsの使用
class AlbumForm < Reform::Form
property :title
property :artist_name
validates :title, presence: true, length: { minimum: 2 }
validates :artist_name, presence: true
end
高度な使い方
ネストしたフォーム
class AlbumForm < Reform::Form
feature Reform::Form::Dry
property :title
property :release_date
# ネストしたプロパティ
property :artist do
property :full_name
property :email
validation do
required(:full_name).filled(:string)
optional(:email).filled(:string)
end
end
# コレクション
collection :songs do
property :name
property :duration
validation do
required(:name).filled(:string)
optional(:duration).filled(:integer)
end
end
validation do
required(:title).filled(:string)
optional(:release_date).filled(:date)
end
end
# 使用例
album = Album.new
form = AlbumForm.new(album)
params = {
title: "Abbey Road",
release_date: "1969-09-26",
artist: {
full_name: "The Beatles",
email: "[email protected]"
},
songs: [
{ name: "Come Together", duration: 260 },
{ name: "Something", duration: 183 }
]
}
if form.validate(params)
form.save
puts "Album with artist and songs created!"
end
仮想フィールドとカスタムバリデーション
class UserRegistrationForm < Reform::Form
feature Reform::Form::Dry
property :username
property :email
property :password, virtual: true
property :password_confirmation, virtual: true
validation do
required(:username).filled(:string)
required(:email).filled(:string)
required(:password).filled(:string)
required(:password_confirmation).filled(:string)
end
validation do
configure do
def email_format?(value)
/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
end
def username_unique?(value)
!User.exists?(username: value)
end
end
required(:email).filled(:email_format?)
required(:username).filled(:username_unique?)
end
# パスワード確認のバリデーション
validation do
rule(:password_confirmation) do
key.failure('パスワードが一致しません') if values[:password] != values[:password_confirmation]
end
end
# カスタム保存ロジック
def save
return false unless valid?
# パスワードをハッシュ化
encrypted_password = BCrypt::Password.create(password)
# モデルに値を設定(仮想フィールドは除く)
sync
model.password_digest = encrypted_password
model.save
end
end
バリデーショングループ
class ProductForm < Reform::Form
feature Reform::Form::Dry
property :name
property :price
property :category_id
# デフォルトバリデーション
validation :default do
required(:name).filled(:string)
required(:price).filled(:decimal)
required(:category_id).filled(:integer)
end
# 価格チェック(デフォルトが成功した場合のみ)
validation :price_check, if: :default do
configure do
def positive_price?(value)
value > 0
end
end
required(:price).filled(:positive_price?)
end
# カテゴリ存在チェック
validation :category_check, if: :default do
configure do
def category_exists?(value)
Category.exists?(id: value)
end
end
required(:category_id).filled(:category_exists?)
end
end
フォームアクセス付きバリデーション
class OrderForm < Reform::Form
feature Reform::Form::Dry
property :user_id
property :product_id
property :quantity
validation :default, with: { form: true } do
required(:user_id).filled(:integer)
required(:product_id).filled(:integer)
required(:quantity).filled(:integer)
end
validation :business_rules, with: { form: true } do
configure do
def max_quantity_allowed?(value)
product = Product.find(form.product_id)
value <= product.stock_quantity
end
def user_can_order?(value)
user = User.find(form.user_id)
user.active? && !user.banned?
end
end
required(:quantity).filled(:max_quantity_allowed?)
required(:user_id).filled(:user_can_order?)
end
end
コレクションとネストしたバリデーション
動的コレクション
class PlaylistForm < Reform::Form
feature Reform::Form::Dry
property :name
collection :tracks, populate_if_empty: Track do
property :title
property :artist_name
property :duration
validation do
required(:title).filled(:string)
required(:artist_name).filled(:string)
optional(:duration).filled(:integer)
end
end
validation do
required(:name).filled(:string)
end
# コレクション全体のバリデーション
validation do
rule(:tracks) do
key.failure('少なくとも1つのトラックが必要です') if value.empty?
key.failure('最大50トラックまでです') if value.size > 50
end
end
end
# 使用例
playlist = Playlist.new
form = PlaylistForm.new(playlist)
params = {
name: "My Favorites",
tracks: [
{ title: "Song 1", artist_name: "Artist 1", duration: 210 },
{ title: "Song 2", artist_name: "Artist 2", duration: 190 }
]
}
if form.validate(params)
form.save
end
条件付きコレクション
class InvoiceForm < Reform::Form
feature Reform::Form::Dry
property :customer_name
property :invoice_date
property :discount_rate, default: 0.0
collection :line_items, populate_if_empty: LineItem do
property :product_name
property :quantity
property :unit_price
validation do
required(:product_name).filled(:string)
required(:quantity).filled(:integer)
required(:unit_price).filled(:decimal)
end
validation do
configure do
def positive_value?(value)
value > 0
end
end
required(:quantity).filled(:positive_value?)
required(:unit_price).filled(:positive_value?)
end
end
validation do
required(:customer_name).filled(:string)
required(:invoice_date).filled(:date)
optional(:discount_rate).filled(:decimal)
end
validation do
rule(:discount_rate) do
key.failure('割引率は0%から50%の間である必要があります') unless (0..0.5).cover?(value)
end
end
end
永続化とレンダリング
カスタム保存ロジック
class ArticleForm < Reform::Form
feature Reform::Form::Dry
property :title
property :content
property :published
property :tags, default: []
validation do
required(:title).filled(:string)
required(:content).filled(:string)
optional(:published).filled(:bool)
optional(:tags).filled(:array)
end
# カスタム保存処理
def save
return false unless valid?
# 自動タイムスタンプ
if published && !model.published_at
model.published_at = Time.current
end
# タグの正規化
model.tags = tags.map(&:strip).map(&:downcase).uniq
# スラッグの生成
model.slug = title.parameterize if title_changed?
sync
model.save
end
private
def title_changed?
model.title != title
end
end
ブロック形式の保存
class UserForm < Reform::Form
feature Reform::Form::Dry
property :name
property :email
property :role
def save
sync do |hash|
# 保存前の処理
hash[:created_at] = Time.current if model.new_record?
hash[:updated_at] = Time.current
# ロールに基づく権限設定
hash[:permissions] = Permission.for_role(hash[:role])
end
model.save
end
end
Trailblazer統合
オペレーション内でのReform使用
class Album::Create < Trailblazer::Operation
class Form < Reform::Form
feature Reform::Form::Dry
property :title
property :artist_name
validation do
required(:title).filled(:string, min_size?: 2)
required(:artist_name).filled(:string)
end
end
step Model(Album, :new)
step Contract::Build(constant: Form)
step Contract::Validate()
step Contract::Persist()
end
# 使用例
result = Album::Create.(params: { title: "New Album", artist_name: "Artist" })
if result.success?
album = result[:model]
puts "Album created: #{album.title}"
else
puts "Validation failed: #{result['contract.default'].errors.messages}"
end
複雑なビジネスロジック
class Order::Process < Trailblazer::Operation
class Form < Reform::Form
feature Reform::Form::Dry
property :customer_id
property :items
property :shipping_address
property :payment_method
validation :default do
required(:customer_id).filled(:integer)
required(:items).filled(:array, min_size?: 1)
required(:shipping_address).filled(:string)
required(:payment_method).filled(:string)
end
validation :business_rules, with: { form: true } do
configure do
def customer_active?(value)
Customer.find(value).active?
end
def valid_payment_method?(value)
%w[credit_card paypal bank_transfer].include?(value)
end
def items_available?(items)
items.all? { |item| Product.find(item[:product_id]).in_stock? }
end
end
required(:customer_id).filled(:customer_active?)
required(:payment_method).filled(:valid_payment_method?)
required(:items).filled(:items_available?)
end
end
step Model(Order, :new)
step Contract::Build(constant: Form)
step Contract::Validate()
step :calculate_total
step :process_payment
step Contract::Persist()
step :send_confirmation
def calculate_total(ctx, **)
items = ctx['contract.default'].items
ctx[:total] = items.sum { |item| item[:price] * item[:quantity] }
end
def process_payment(ctx, **)
# 支払い処理ロジック
PaymentService.charge(
amount: ctx[:total],
method: ctx['contract.default'].payment_method,
customer: ctx['contract.default'].customer_id
)
end
def send_confirmation(ctx, **)
OrderMailer.confirmation(ctx[:model]).deliver_later
end
end
エラーハンドリング
詳細なエラー処理
class ContactForm < Reform::Form
feature Reform::Form::Dry
property :name
property :email
property :subject
property :message
validation do
required(:name).filled(:string, min_size?: 2)
required(:email).filled(:string)
required(:subject).filled(:string)
required(:message).filled(:string, min_size?: 10)
end
validation do
configure do
def email_format?(value)
/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
end
end
required(:email).filled(:email_format?)
end
# カスタムエラーメッセージ
def error_messages
errors.messages.transform_values do |messages|
messages.map do |message|
case message
when /must be filled/
"必須項目です"
when /size cannot be less than/
"文字数が不足しています"
else
message
end
end
end
end
end
# 使用例
form = ContactForm.new(Contact.new)
unless form.validate(params)
render json: { errors: form.error_messages }, status: :unprocessable_entity
end
テスト例
require 'spec_helper'
RSpec.describe AlbumForm do
let(:album) { Album.new }
let(:form) { described_class.new(album) }
describe 'バリデーション' do
context '有効なパラメータの場合' do
let(:valid_params) do
{
title: "Abbey Road",
artist_name: "The Beatles",
release_date: "1969-09-26"
}
end
it 'バリデーションが成功する' do
expect(form.validate(valid_params)).to be_truthy
expect(form.errors).to be_empty
end
it 'モデルに値が設定される' do
form.validate(valid_params)
expect(form.title).to eq("Abbey Road")
expect(form.artist_name).to eq("The Beatles")
end
end
context '無効なパラメータの場合' do
let(:invalid_params) do
{
title: "",
artist_name: "The Beatles"
}
end
it 'バリデーションが失敗する' do
expect(form.validate(invalid_params)).to be_falsy
expect(form.errors[:title]).to include("must be filled")
end
end
end
describe 'ネストしたフォーム' do
let(:nested_params) do
{
title: "Album Title",
artist: {
full_name: "Artist Name",
email: "[email protected]"
},
songs: [
{ name: "Song 1", duration: 180 },
{ name: "Song 2", duration: 210 }
]
}
end
it 'ネストしたデータのバリデーションが成功する' do
expect(form.validate(nested_params)).to be_truthy
expect(form.artist.full_name).to eq("Artist Name")
expect(form.songs.size).to eq(2)
end
end
describe '保存処理' do
let(:valid_params) do
{
title: "New Album",
artist_name: "New Artist"
}
end
it '正常に保存される' do
form.validate(valid_params)
expect { form.save }.to change { Album.count }.by(1)
saved_album = Album.last
expect(saved_album.title).to eq("New Album")
expect(saved_album.artist_name).to eq("New Artist")
end
end
end
実用的な例
APIリクエスト処理
# app/controllers/api/albums_controller.rb
class Api::AlbumsController < ApplicationController
def create
album = Album.new
form = AlbumForm.new(album)
if form.validate(album_params)
form.save
render json: AlbumSerializer.new(album).as_json, status: :created
else
render json: { errors: form.errors.messages }, status: :unprocessable_entity
end
end
def update
album = Album.find(params[:id])
form = AlbumForm.new(album)
if form.validate(album_params)
form.save
render json: AlbumSerializer.new(album).as_json
else
render json: { errors: form.errors.messages }, status: :unprocessable_entity
end
end
private
def album_params
params.require(:album).permit(:title, :artist_name, :release_date,
artist: [:full_name, :email],
songs: [:name, :duration])
end
end
バッチ処理での使用
class BulkProductImporter
def initialize(csv_data)
@csv_data = csv_data
end
def import
results = { success: [], errors: [] }
@csv_data.each_with_index do |row, index|
product = Product.new
form = ProductForm.new(product)
if form.validate(row.to_h)
form.save
results[:success] << { line: index + 1, product: product }
else
results[:errors] << {
line: index + 1,
errors: form.errors.messages,
data: row.to_h
}
end
end
results
end
end
# 使用例
csv_data = CSV.read('products.csv', headers: true)
importer = BulkProductImporter.new(csv_data)
results = importer.import
puts "Successfully imported: #{results[:success].count} products"
puts "Failed to import: #{results[:errors].count} products"
まとめ
Trailblazer Reformは、Rubyアプリケーションにおいて強力で柔軟なフォーム処理機能を提供します。主な利点:
- 明確な責任分離: フォームロジックとモデルの分離
- 高度なバリデーション: dry-validationとの統合による強力な検証機能
- ネストした構造: 複雑なフォーム構造への対応
- フレームワーク非依存: 様々なRubyフレームワークで使用可能
- Trailblazer統合: オペレーションパターンとの優れた連携
特に、複雑なフォーム処理やビジネスロジックを含むWebアプリケーションにおいて、その真価を発揮するライブラリです。