ずみーBlog

元クラゲ研究者(見習い)の92年生まれがエンジニアを目指しながら日々寄り道するブログです。

【ポイント:擬似モデル】Formオブジェクトパターンを理解する

Railsデザインパターンの一つであるFormオブジェクトパターンを使って、フォームから一度の送信で複数のテーブルに安全に情報を送る方法について学びました。

フォームから複数モデルの情報を送る際の問題点

form_withメソッドに渡しているのは単一のモデルのインスタンスなので、他のモデルクラスがもつ属性の情報が送れません。

Formオブジェクトパターンを使わない場合の解決法

form_with:modelの指定をやめ、paramsに送信した全ての値が入るようにします。

before

<%= form_with(model: @user, url: donations_path, local: true) do |form| %>

after

<%= form_with(url: donations_path, local: true) do |form| %>

!注意!

:modelに指定していたモデルのバリデーションが活用できなくなる。(後ほど解消します)

それぞれのテーブルに必要な値をparamsから取り出す

以下の例では、3つの別々のprivateメソッドを定義し、paramsからusersaddressesdonationsのそれぞれのテーブルに必要な値のみを取り出して使うことができるようにしています。

 class DonationsController < ApplicationController
  def index
  end

  def new
    @user = User.new
  end

  def create
    user = User.create(user_params)
    Address.create(address_params(user))
    Donation.create(donation_params(user))
    redirect_to action: :index
  end

  private

  def user_params
    params.permit(:name, :name_reading, :nickname)
  end

  def address_params(user)
    params.permit(:postal_code, :prefecture, :city, :house_number, :building_name).merge(user_id: user.id)
  end

  def donation_params(user)
    params.permit(:price).merge(user_id: user.id)
  end
end

Formオブジェクトパターンを使う場合の解決法

それでは、Formオブジェクトを使って、上の方法でモデルのバリデーションが使えない問題を解消していきます。

Formオブジェクトパターン

大体以下のような流れで実装していきます。

  1. フォームから送られる全てのパラメータを属性として持つような擬似モデルクラスをmodels/直下に作成
  2. それぞれのカラムに対するバリデーションをここに書く(本物のモデルクラスではなく、のほうでバリデーションする)
  3. それぞれのテーブルにデータを保存する処理を書く(saveメソッドなど)
  4. フォームから直接アクセスされる方のコントローラのnewcreateで、擬似モデルクラスのインスタンスを作成するようにする
  5. コントローラのストロングパラメータも、擬似モデルクラスが必要とする属性値を渡すようにする

要するに何をするかというと、複数のモデルの情報をあたかも単一モデルの情報であるかのように扱える擬似モデルを使うということです。

ActiveModel::Model

クラスにincludeすることで、AcriveRecordを継承したクラスのインスタンス同様にform_withやrenderなどのヘルパーメソッドの引数になれたり、バリデーションが使えたりします。今回作成するFormオブジェクト(=擬似モデルクラス)も、ActiveModel::Modelをincludeすることで本物のモデル同様にバリデーションなどが使えるようになります。

class UserDonation

  include ActiveModel::Model
  attr_accessor :name, :name_reading, :nickname, :postal_code, :prefecture, :city, :house_number, :building_name, :price

# ここにバリデーションの処理を書く

  def save
    # 各テーブルにデータを保存する処理を書く
  end
end

例えば、userモデル、addressモデル、donationモデルの3つのモデルに対する擬似モデルは以下のような感じです。

app/models/user_donation.rb

class UserDonation
  include ActiveModel::Model
  attr_accessor :name, :name_reading, :nickname, :postal_code, :prefecture, :city, :house_number, :building_name, :price

  with_options presence: true do
    #userモデルに関するvalidation
    validates :name, format: { with: /\A[ぁ-んァ-ン一-龥]/, message: "is invalid. Input full-width characters." }
    validates :name_reading, format: { with: /\A[ァ-ヶー-]+\z/, message: "is invalid. Input full-width katakana characters." }
    validates :nickname, format: { with: /\A[a-z0-9]+\z/i, message: "is invalid. Input half-width characters." }
    #addressモデルに関するvalidation
    validates :postal_code, format: { with: /\A[0-9]{3}-[0-9]{4}\z/, message: "is invalid. Include hyphen(-)" }
  end
  #addressモデルのprefectureに関するvalidation
  validates :prefecture, numericality: { other_than: 0, message: "can't be blank" }
  #donationモデルに関するvalidation
  validates :price, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: "is out of setting range" }

  def save
    user = User.create(name: name, name_reading: name_reading, nickname: nickname)
    Address.create(postal_code: postal_code, prefecture: prefecture, city: city, house_number: house_number, building_name: building_name, user_id: user.id)
    Donation.create(price: price, user_id: user.id)
  end
end

次に、コントローラ側から擬似モデルのインスタンスを作成し、擬似モデルのバリデーションの実行やsaveメソッドの呼び出しを行います。

app/controllers/donations_controller.rb

class DonationsController < ApplicationController
  def index
  end

  def new
    @user_donation = UserDonation.new
  end

  def create
    @user_donation = UserDonation.new(donation_params)
    if @user_donation.valid?
      @user_donation.save
      redirect_to action: :index
    else
      render action: :new
    end
  end

  private

  def donation_params
    params.require(:user_donation).permit(:name, :name_reading, :nickname, :postal_code, :prefecture, :city, :house_number, :building_name, :price)
  end
end

あとは、newアクションのビューでform_withの:model属性に擬似モデルのインスタンスを指定してあげます。

app/views/donations/new.html.erb

<%= form_with(model: @user_donation, url: donations_path, local: true) do |form| %>
  <%= render 'error_messages', model: @user_donation %>
  #省略

これで、3つのモデルをまるで1つのモデルであるかのように扱えるようになりました。 あとは、Rspec等で

Formオブジェクトパターンの単体テストコード

Rspec、FactoryBot、Fakerのテストコード3種の神器(?)を使ってテストコードを記述していきます。(テストコードは省略) FactoryBotとFakerについての備忘録がありますので、そちらもご活用ください。

まずはRspec導入

Gemfile

group :development, :test do
# 中略
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'faker'
end
bundle install
rails g rspec:install

.rspecファイルに追記してテスト実行ログのフォーマットを整える。

--require spec_helper
--format document

擬似モデルuser_donationのテストコードファイル作成

rails g rspec:model user_donation

作成されたuser_donation_spec.rbファイルの中で、全てのバリデーションをテストするように書けば単体テスト完了です。