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

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

# 環境

```bash
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.14.6
BuildVersion:	18G84
```

```bash
$ docker --version
Docker version 18.09.2, build 6247962
```

```bash
$ docker-compose --version
docker-compose version 1.23.2, build 1110ad01
```

```bash
$ docker-compose run --rm web ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
```

```bash
$ docker-compose run --rm web rails -v
Rails 5.2.3
```

# 前提

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

# いきなり結論

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

## FormObject

```ruby: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
```

```erb: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 %>
```

```ruby: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 以外のコード

```ruby:config/routes.rb
Rails.application.routes.draw do
  resources :users, only: [:edit, :update]
end
```

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_one :profile, foreign_key: "id", dependent: :destroy, inverse_of: :user

  validates :email, presence: true
end
```

```ruby: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=

```ruby: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

```ruby: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

```ruby: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](https://revs.runtime-revolution.com/validating-form-objects-8058fefc7b89)

## app/views/users/edit.html.erb

FormObject を view で扱う部分です。

### errors

```erb: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 をシンプルに書けるようにしています。

```erb: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 と同じようにアクセス可能です（`label` や `text_field` など）。

## app/controllers/users_controller.rb

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

```ruby: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 を実装する機会があるときは、このコードを使ってシンプルに書こうと思います。

# 参考

- [プライマリキーを使った1:1関連でカラム数の多いテーブルを分割する - Hidden in Plain Sight](http://kenn.hatenablog.com/entry/2014/03/05/081525)