Reformで親子関係のあるフォームオブジェクトを作ってみる

[Ateam cyma Adevent Calendar 2019](https://qiita.com/advent-calendar/2019/ateam-cyma)、4日目です！
本日は株式会社エイチームでcymaのエンジニアの @bayasist が務めさせていただきます。

Ruby on Railsで書かれたプログラムでは、フォームオブジェクトを利用することで、分かりやすく書くことができる場合があります。RailsでFormObjectを簡単に作れるtrailblazerというgemのReformというものがあり、使いこなせばなかなか便利です。
フォームオブジェクトが楽に作れるようにいくつか機能はありますが、ドキュメント(特に日本語)が少ないので、今回は特に親子関係をもつデータのReformを用いたフォームオブジェクトの作り方を解説できたらと思います。

## フォームオブジェクトを利用する利点

フォームオブジェクトはmodelをそのままformにするのではなく、バリデートなどformに関係する処理を行うオブジェクトです。これらのオブジェクトを作成することで、以下のようなメリットがあります。

- 複数モデルをまたがったFormの処理をコントローラに書かなくても済む
- ActiveRecordのモデル以外のデータの更新などでも同じようなお作法でView,Controllerが書ける

## Reformのメリット

Reformは下記のような特徴があります

フォームオブジェクトを簡易的に作成できるtrailblazerというgemのReformというものがあります。それを用いると下記のようなメリットを受けることができます。

- ActiveModelと同じような記法でValidateや要素などが記載できる
- ActiveModel以外のデータの読み書きでも同様の記法で扱える
- 親子関係など多少データ構造が多少複雑になってもプログラムが複雑になることはない

## まずはReformでフォームオブジェクトの基本形を作る

まずは、一つのmodelのみでReformを使ったフォームオブジェクトを作っていきます。nameカラムを持ったparentというモデルを更新するだけのものを作ります。(あとでchildというモデルをparentの子供にします)

```rb:app/models/parent.rb
class Parent < ApplicationRecord
end
```

```rb:app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end
```

```rb:app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  def edit
    @form = form
  end

  def update
    @form = form
    if @form.validate(update_param)
      @form.save
    end
  end

  private 

  def form
    ParentForm.new(Parent.find(params[:id]))
  end

  def update_param
    params.require(:parent).permit(:name)
  end
end
```

```erb:app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %>
  <%= form.submit %>
<% end %>
```

formオブジェクトができました。
Controllerを見ていただきたいのですが、ほとんどActiveRecordのお作法で書くことができます。
一点大きく違うところはvalidateの部分。Reformでは、validateメソッドでパラメータのバリデートをし、フォームオブジェクトの各要素ににパラメータを渡していきます。
またsaveメソッドでは、フォームオブジェクトの値をもとのモデルに渡してから、モデルのsaveメソッドが呼び出されています。モデルのsaveメソッドを呼び出さず、元のモデルへのデータの移動のみをやりたい場合はsyncメソッドを用います。

## parentモデルにchildという子要素を作る

parentモデルの子要素としてchildモデルを作ります。

```rb:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children
end
```

```rb:app/models/child.rb
class Child < ApplicationRecord
  belongs_to :parent
end
```

```rb:app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child do
    property :name
    validates :name, length: { maximum: 5}
  end
end
```

```rb:app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  def edit
    @form = form
  end

  def update
    @form = form
    if @form.validate(update_param)
      @form.save
    end
  end

  private 

  def form
    ParentForm.new(Parent.includes(:children).find(params[:id]))
  end

  def update_param
    params.require(:parent).permit(:name, children_attributes: [:name])
  end
end
```

```erb:app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %><br />
  <%= form.fields_for :children do |child_form| %>
    <%= child_form.text_field :name %><br />
  <% end %>
  <%= form.submit %>
<% end %>
```

Formオブジェクトでcollectionを使うこと以外はActiveRecordを使用したときとほとんど変わらずに実装できます。
複数モデルのバリデーションや保存処理はフォームオブジェクトが引き受けるため、モデルが複数になってもControllerが散らかったりすることなく記述できます。また、Reformではそれらの処理をほとんど書くことなく行えます。

## 上記プログラムの重要な問題点

上記プログラムではChildのアップデートの際に、Textboxの値を上からDBで検索された順にあてはめていきます。/editの表示からアップデートまでにほかのブラウザなどでChildの一部要素がdeleteやinsert等されると予期せぬ動作につながります。
そこで、/editの表示の際にHiddenFieldにchildのidを入れておき、保存の際にChildのidとPOSTで送られてきたidを突合しながら保存していく必要があります。
そのために下記のプログラムを変更する必要があります。

```rb:app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  # (中略)
  def update_param
    params.require(:parent).permit(:name, children_attributes: [:id, :name])
  end
end
```

```erb:app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %><br />
  <%= form.fields_for :children do |child_form| %>
    <%= child_form.text_field :name %><br />
    <%= child_form.hidden_field :id %>
  <% end %>
  <%= form.submit %>
<% end %>
```

```rb:app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find_by(id: fragment["id"].to_i)
    } do
    property :name
    validates :name, length: { maximum: 5}
  end
end
```

これを行うことでHiddenFieldのidとDBのIDが同一のものを更新するようになります。ほかのブラウザで該当レコードが削除されていた際はvalidateを行う際にエラーとなり、ほかの関係ないデータを更新しに行くということはありません。
ここではfragmentはvalidateの際に送られてきたデータ(今回でいうとformで入力したデータ)、childrenはDBから持ってきたデータとなります。
この機能を用いれば、複合キーなどID以外で突合することもできます。

例)

```rb:app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find(***_id: fragment["***_id"].to_i, ~~~_code: fragment["~~~_code"].to_i)
    } do
    property :name
    validates :name, length: { maximum: 5}
  end
end
```

## ChildのFormを外だしする

Childが複雑になってきたら、Childのフォームを別のファイルに移したくなるかもしれません。そのようにFormObjectの子要素を外に出すことも可能です。

```rb:app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find_by(id: fragment["id"].to_i)
    }, form: ChildForm
end
```

```rb:app/forms/child_form.rb
class ChildForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end
```

## 他にもいろいろなことができます

Reformを使うことで、子要素の追加や削除、デフォルト値の設定等がModel,Controllerを大きく汚すことなく比較的簡単に行えます。また、ActiveRecord以外のインスタンスにも利用できるため、FormからDBと関係ないインスタンスにデータを移すときに重宝します。
もっと調べてみたい人は[公式ドキュメント](http://trailblazer.to/gems/reform/index.html)を見てみてくださいね。

# 最後に

[Ateam cyma Adevent Calendar 2019](https://qiita.com/advent-calendar/2019/ateam-cyma) の 4日目、いかがでしたか。
5日目は cymaのインフラつよつよエンジニアの @ihsiek がSQLのチューニング入門の記事を書くそうですよ！SQL苦手な人、早く動くSQLを書きたい人は必見ですよ！

株式会社エイチームでは、一緒に働けるチャレンジ精神旺盛な仲間を募集しています。

エンジニアとしての働き方に興味を持たれた方はcymaの [Qiita Jobs](http://bit.ly/35E1I08) をご覧ください。

そのほかの職種は、[エイチームグループ採用サイト](http://bit.ly/2XQpd3q)をご覧ください。