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
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
<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 %>
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 以外のコード
Rails.application.routes.draw do
resources :users, only: [:edit, :update]
end
class User < ApplicationRecord
has_one :profile, foreign_key: "id", dependent: :destroy, inverse_of: :user
validates :email, presence: true
end
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=
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
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
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
<% 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 をシンプルに書けるようにしています。
<%= 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 です。
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 を実装する機会があるときは、このコードを使ってシンプルに書こうと思います。