LoginSignup
7
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-03

Ateam cyma Adevent Calendar 2019、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の子供にします)

app/models/parent.rb
class Parent < ApplicationRecord
end
app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end
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
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モデルを作ります。

app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children
end
app/models/child.rb
class Child < ApplicationRecord
  belongs_to :parent
end
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
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
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を突合しながら保存していく必要があります。
そのために下記のプログラムを変更する必要があります。

app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  # (中略)
  def update_param
    params.require(:parent).permit(:name, children_attributes: [:id, :name])
  end
end
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 %>
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以外で突合することもできます。

例)

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の子要素を外に出すことも可能です。

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
app/forms/child_form.rb
class ChildForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end

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

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

最後に

Ateam cyma Adevent Calendar 2019 の 4日目、いかがでしたか。
5日目は cymaのインフラつよつよエンジニアの @ihsiek がSQLのチューニング入門の記事を書くそうですよ!SQL苦手な人、早く動くSQLを書きたい人は必見ですよ!

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

エンジニアとしての働き方に興味を持たれた方はcymaの Qiita Jobs をご覧ください。

そのほかの職種は、エイチームグループ採用サイトをご覧ください。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2