Trailblazer Reform

モデルから分離されたRuby用フォームオブジェクトライブラリ。dry-validationと統合した柔軟なバリデーション機能を提供

validationformstrailblazerdry-validationrubyform-objects

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アプリケーションにおいて、その真価を発揮するライブラリです。