LiBz Tech Blog

LiBの開発者ブログ

Railsで複数モデルを扱うフォームをすっきり書く(Formオブジェクト)

はじめに

Railsで1つのフォームで複数モデルを扱うときに、accepts_nested_attributes_forを使うサンプルをよく見るのですが、DHH氏が消したいと言っていたり バグが多かったりなど色々問題があるようです。

代わりにFormオブジェクトを使うのが良いと聞いたので、使ってみることにしました。

Formオブジェクトとは

自分の認識だと「サービス層の疑似ActiveRecord」です。 メドピアさんのブログだとこのように説明されていました。

form_withのmodelオプションにActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。

実際に使ってみる

ユーザに「希望年収」と「現在の年収」を入力させるフォームを作ってみます。

モデル

class User < ApplicationRecord
    has_one :desire
    has_one :history
end

class Desire < ApplicationRecord
    # 希望業種、希望職種、希望年収などのデータを保持
    belongs_to :user
end

class History < ApplicationRecord
    # 直近の業種、直近の職種、直近の年収などのデータを保持
    belongs_to :user
end

Formオブジェクト

class UserSalariesForm
include ActiveModel::Model
include ActiveModel::Attributes

attribute :user_id, :integer, default: nil
attribute :current_salary, :integer, default: nil
attribute :desired_salary, :integer, default: nil

validates :current_salary, presence: true, numericality: { less_than: 10_000 }
validates :desired_salary, presence: true, numericality: { less_than: 10_000 }

def save!
  raise ActiveRecord::RecordInvalid if invalid?
  ActiveRecord::Base.transaction do
    user = User.find(user_id)
    user.desire.save!(salary: desired_salary)
    user.history.save!(salary: current_salary)
  end

end

ビュー

= form_with model: @user_salaries_form, method: :post, url: user_salaries_path, local: true do |f|
    p = '現在の年収'
    = f.number_field :current_salary
    p = '万円'
    p = '希望年収'
    = f.number_field :desired_salary
    p = '万円'
    = f.submit '投稿'

コントローラ

class UserSalariesController < ApplicationController

def new
  @user_salaries_form = UserSalariesForm.new
end

def create
  @user_salaries_form = UserSalariesForm.new(params.require(:user_salaries_form).permit!.merge(user_id: current_user.id))
  @user_salaries_form.save!
  redirect_to hoge_path
rescue
    # エラー時の処理
end

どう便利なのか

フォームで扱うattributeを限定できる

accepts_nested_attributes_forを使ってform_withでuserオブジェクトを使うと、desireやhistoryの年収以外のattributeも扱うことができてしまいます。 しかしFormオブジェクトを使うと、Formオブジェクトで定義した年収以外のattributeを扱おうとするとエラーが起こるので、必要以上にデータをいじらなくて済みます。

= form_with model: @user_salaries_form, method: :post, url: user_salaries_path, local: true do |f|
    p = '現在の年収'
    = f.number_field :current_salary
    p = '万円'
    p = '希望年収'
    = f.number_field :desired_salary
    p = '万円'
  p = '希望職種'
    = f.text_field :desired_profession // Formオブジェクトで定義されていないのでエラー
    = f.submit '投稿'

入力チェックをFormオブジェクトにまとめられる

Formオブジェクトで存在チェックや数値チェックをすることで、コントローラはすっきり書けます。

# こういうコードを書かなくて済む
def create
    raise ArgumentError if params[:current_salary].blank? || params[:current_salary] > 10_000
    raise ArgumentError if params[:desired_salary].blank? || params[:desired_salary] > 10_000
    # こういう書き方でなくても、モデルのバリデーションエラーを配列にpushしていく処理など
    ...
rescue
    # エラー時の処理
end

おわりに

今回は「複数のモデルを扱うフォーム」を作りましたが、「対応するモデルが無いフォーム」を作る時にもFormオブジェクトは活用できるようです。 (「名前」「本文」の属性を持つFormオブジェクトを作って、お問い合わせフォームを作る など)

参考にさせていただきました