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の子供にします)
class Parent < ApplicationRecord
end
class ParentForm < Reform::Form
property :name
validates :name, length: { maximum: 5}
end
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
<%= 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モデルを作ります。
class Parent < ApplicationRecord
has_many :children
end
class Child < ApplicationRecord
belongs_to :parent
end
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
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
<%= 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を突合しながら保存していく必要があります。
そのために下記のプログラムを変更する必要があります。
class ParentController < ActionController::Base
# (中略)
def update_param
params.require(:parent).permit(:name, children_attributes: [:id, :name])
end
end
<%= 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 %>
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以外で突合することもできます。
例)
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の子要素を外に出すことも可能です。
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
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 をご覧ください。
そのほかの職種は、エイチームグループ採用サイトをご覧ください。