Help us understand the problem. What is going on with this article?

複数個の model を扱うシンプルな FormObject の書き方(Rails 5.2)

Rails で FormObject の実装方法はいくつかパターンがありますが、先日、業務で実装するときにコード量を減らせた書き方ができました。
今回は、そのシンプルなコードの書き方について紹介します。

環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G84
$ docker --version
Docker version 18.09.2, build 6247962
$ docker-compose --version
docker-compose version 1.23.2, build 1110ad01
$ docker-compose run --rm web ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
$ docker-compose run --rm web rails -v
Rails 5.2.3

前提

user と profile テーブルがあって、それぞれ user.email やら profile.name, profile.address みたいに情報を分割しているケース。
これをひとつのフォームで扱おうとしたとき FormObject を使うと便利なので、それをシンプルに書く方法について書きます。

いきなり結論

こうすると、シンプルに書けます。
シンプルに表現するため edit 以外の action は削ってあります。

FormObject

app/models/users/edit_form.rb
class Users::EditForm
  include ActiveModel::Model

  attr_reader :user

  delegate :attributes=, to: :user, prefix: true
  delegate :attributes=, to: :profile, prefix: true

  validate :validate_children

  def initialize(user, attributes = {})
    @user = user
    @user.build_profile unless @user.profile

    super(attributes)
  end

  def profile
    user.profile
  end

  def save
    return false if invalid?

    ActiveRecord::Base.transaction do
      user.save! && profile.save!
    end
  end

  private

    def validate_children
      if user.invalid?
        promote_errors(user.errors)
      end

      if profile.invalid?
        promote_errors(profile.errors)
      end
    end

    def promote_errors(child_errors)
      child_errors.each do |attribute, message|
        errors.add(attribute, message)
      end
    end
end
app/views/users/edit.html.erb
<h1>Users#edit</h1>

<% if @form.errors.any? %>
  <ul>
    <% @form.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

<%= form_with model: @form, url: user_path, method: "put", class: "row", local: true do |f| %>
  <%= f.fields_for :user do |user_fields| %>
    <p>
      <div><%= user_fields.label :email, "Email" %></div>
      <div><%= user_fields.email_field :email %></div>
    </p>
  <% end %>

  <%= f.fields_for :profile do |profile_fields| %>
    <p>
      <div><%= profile_fields.label :name, "Name" %></div>
      <div><%= profile_fields.text_field :name %></div>
    </p>
    <p>
      <div><%= profile_fields.label :address, "Address" %></div>
      <div><%= profile_fields.text_field :address, include_blank: true %></div>
    </p>
  <% end %>

  <p>
    <%= f.button "Save" %>
  </p>
<% end %>
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :user_by_id, only: [:edit, :update]

  def edit
    @form = Users::EditForm.new(@user)
  end

  def update
    @form = Users::EditForm.new(@user, user_params)

    if @form.save
      redirect_to user_edit_url
    else
      render :edit
    end
  end

  private

    def user_params
      params.require(:users_edit_form).permit(
        user_attributes: [
          :email,
        ],
        profile_attributes: [
          :name,
          :address,
        ],
      )
    end

    def user_by_id
      @user = User.find_by!(id: params[:id])
    end
end

FormObject 以外のコード

config/routes.rb
Rails.application.routes.draw do
  resources :users, only: [:edit, :update]
end
app/models/user.rb
class User < ApplicationRecord
  has_one :profile, foreign_key: "id", dependent: :destroy, inverse_of: :user

  validates :email, presence: true
end
app/models/profile.rb
class Profile < ApplicationRecord
  belongs_to :user, foreign_key: "id", inverse_of: :profile

  validates :name, presence: true
  validates :address, presence: true
end

Source code

以下のソースコード全体をアップしてあります。
https://github.com/phayacell/simple-formobject-rails5

コードの説明

以下で各コード部分について、説明していきます。

app/models/users/edit_form.rb

FormObject 本体となる model です。
いくつかテクニックを使っているので、それぞれ分割して説明していきます。

*_attributes=

app/models/users/edit_form.rb
class Users::EditForm
  attr_reader :user

  delegate :attributes=, to: :user, prefix: true
  delegate :attributes=, to: :profile, prefix: true

delegate を使って user_attributes=profile_attributes= メソッドを提供しています。
なぜ *_attributes= メソッドを提供するかというと、以下のあたりを参考にしています。

参考:
https://github.com/rails/rails/blob/v5.2.3/actionview/lib/action_view/helpers/form_helper.rb#L824

view で fields_for を使うにあたって、読み取りには model 名のメソッド、書き込みには model 名と組み合わせた *_attributes= メソッドが参照されます。
愚直に *_attributes= メソッドを定義しても良いのですが、ここではシンプルに書くために delegate を使用しました。
delegate を使うと、短く書けてよいですね。

initialize

app/models/users/edit_form.rb
class Users::EditForm
  include ActiveModel::Model

  def initialize(user, attributes = {})
    @user = user
    @user.build_profile unless @user.profile

    super(attributes)
  end

ActiveModel::Model を include して、model, attribuets を受け取る初期化メソッドを実装しています。
model を受け取るため、以下をオーバーライドしています。

参考:
https://github.com/rails/rails/blob/v5.2.3/activemodel/lib/active_model/model.rb#L80-L84

今回は user を受け取ればあわせて profile も参照できるので、受け取る model は user だけになっています。
また、profile が作成されていない可能性を考慮して build_profile しておくと、以降でこの form を扱うときに undefined になる危険を防止できますね。

そして super(attributes) で継承元の同メソッドを呼び出すことで ActiveModel::Model の初期化メソッドの機能を損なわないようにしています。
今回は対象外ですが、form に model 外の attributes をもたせる場合は特に有用です。

save

app/models/users/edit_form.rb
class Users::EditForm
  validate :validate_children

  def save
    return false if invalid?

    ActiveRecord::Base.transaction do
      user.save! && profile.save!
    end
  end

  private

    def validate_children
      if user.invalid?
        promote_errors(user.errors)
      end

      if profile.invalid?
        promote_errors(profile.errors)
      end
    end

    def promote_errors(child_errors)
      child_errors.each do |attribute, message|
        errors.add(attribute, message)
      end
    end
end

バリデーションを各 model に実装していて、それを使って form のバリデーションをおこないたい場合に有用な書き方です。
validate_children で model それぞれのバリデーションを実施しています。
もし、バリデーションエラーが発生した場合は promote_errors でそのエラーを form の errors に代入し、エラーを form から扱えるようにしています。

こちらのコードは以下のページを参考にさせていただいております。

参考:
Validating Form Objects – Runtime Revolution

app/views/users/edit.html.erb

FormObject を view で扱う部分です。

errors

app/views/users/edit.html.erb
<% if @form.errors.any? %>
  <ul>
    <% @form.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

先述の validate_children によって、各 model で発生したバリデーションエラーを @form.errors で参照できるようになっています。
このロジックを model に寄せていることで view をシンプルに書けるようにしています。

app/views/users/edit.html.erb
<%= form_with model: @form, url: user_path, method: "put", class: "row", local: true do |f| %>
  <%= f.fields_for :user do |user_fields| %>
    <p>
      <div><%= user_fields.label :email, "Email" %></div>
      <div><%= user_fields.email_field :email %></div>
    </p>
  <% end %>
<% end %>

各 model の項目を描画する際は f.fields_for を使います。
これで form からネストした model の attributes にアクセスすることができます。
f.fields_for 以下は、普通に FormHelper と同じようにアクセス可能です(labeltext_field など)。

app/controllers/users_controller.rb

FormObject を new したり save したりして扱う controller です。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  def edit
    @form = Users::EditForm.new(@user)
  end

  def update
    @form = Users::EditForm.new(@user, user_params)

    if @form.save
      redirect_to user_edit_url
    else
      render :edit
    end
  end

  private

    def user_params
      params.require(:users_edit_form).permit(
        user_attributes: [
          :email,
        ],
        profile_attributes: [
          :name,
          :address,
        ],
      )
    end
end

FormObeject を、普通のテーブルに紐づく model と同じように扱えるようにしています。

new については少し特殊です。
GET では Users::EditForm.new(@user) ですが、PATCH/PUT では Users::EditForm.new(@user, user_params) と、第2引数にパラメータを受け渡しています。
これは先述の initialize のとおりで、パラメータは第2引数の attributes に受け渡すための実装です。

params.require(:users_edit_form):users_edit_form については、作成した FormObject の class 名によって変動します。
今回は Users::EditForm という名前で作成したので :users_edit_form となります。

おわりに

主に model の delegate を使ったり validate_children を実装しているあたりで、コード量を減らすことができました。
今後、また FormObject を実装する機会があるときは、このコードを使ってシンプルに書こうと思います。

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした