おいしい健康 開発者ブログ

株式会社おいしい健康で働くエンジニア・デザイナーが社内の様子をお伝えします。

【Rails】ActiveModelを使ってFormObjectなるものをつくるぞ

こんにちは。 おいしい健康でwebエンジニアをしている安達です🍙

今日はRailsのForm周りをシンプルに書くためのFormObjectについてご紹介します。

accepts_nested_attributes_for だるすぎませんか

こんなことを思ったことある方多いんじゃないでしょうか?

accepts_nested_attributes_forめんどくせえ!!

僕はよく思います。

理由としては以下の3つが多いんじゃないかなと

  • Modelで同時に保存されるものがあるかどうかで書き方変えたくない

    Model層でFormの書き方(view層)に依存した書き方をしたくない

  • 独自の書き方が必要になってくる

    form.fields_forとかめんどくさい

  • validationよくわからん

    リレーションある場合のフォームで保存される時のみ…みたいなことしようとするとめんどくさい

Railsを作成したDHHもaccepts_nested_attributes_forについては以前から苦言を呈しており、廃止したいという旨のコメントがあったりします。

I'd actually like to kill accepts_nested_attributes_for in due time. Don't think we should promote it for this new API. Rather, let's just show how to do it by hand in the controller.

https://github.com/rails/rails/pull/26976#discussion_r87855694

こんな問題を考えなくて良いように今回はform_objectをActiveModelを用いて作成します!

やってみよう

ActiveModel書いてみるよ

シンプルに、今回はmodelsディレクトリに作成します。

いろいろ種類が出てくれば、form_objectsディレクトリを作成しても良いかもしれませんね。

以下のようなシンプルなモデルがあったとします。

class User < ApplicationRecord
  has_one :address, dependent: :destroy

  validates :name, presence: true
end
class Address < ApplicationRecord
  belongs_to :user

  validates :prefecture, presence: true
end

Userモデルにはname、Addressモデルにはprefectureというattributesがありますね。

今回は、これらをまとめて作成するためのform_object CreateUserFormを作成してみます。

class CreateUserForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations

  # 個人的にはこうやってストロングパラメーターを指定しておくのが好きです
  PERMIT_PARAMS = %i[
    name
    prefecture
  ]

  attribute :name, :string, default: ''
  attribute :prefecture, :string, default: ''

  validates :name, presence: true
  validates :prefecture, presence: true

  def save!
    ActiveRecord::Base.transaction do
      user = user.create!(name: name)
      address.create!(user: user, prefecture: prefecture)
    end
  end
end

こんな感じで作成してみました。

ポイントは、 ActiveModel::Modelなどをimportしておくことで通常のModelライクに書くことができている点ですね。

attributeとして定義しておくことで、stringのnameというattributeをもちます、みたいなことが簡単にできるのが個人的にすごく好みです。 default値を指定しておくことで、initializeする(この場合だとCreateUserForm.newする)ことでdefault値を持つ、みたいなことができますね。

validationはもはやそのまま書けます。 例えば他のフォームではnameはなくてもいい、みたいなときもform_objectごとにvalidationを書いておくことで煩雑なvalidationを書く必要がない、というのがイチオシポイントです。

また、DBに保存する用のsave!メソッドを定義しておきました。 この中で、user, addressをそれぞれ保存していることがわかります。

テストも簡単だよ

このような形でform_objectを作成しておくと、テストが書きやすいというのも非常に大きなメリットなんですよね。

複数モデルを更新するテストは、accepts_nested_attributes_forで書いた場合はcontrollerに対してspecを書くことになるので、ユーザーのログイン情報が…とか返り値が…とかいう、実際の利用シーンを組み合わせたテストを書く必要があるので煩雑になりがちです。

しかし、form_objectで管理する場合だとmodelと同じような書き方ができるので、あくまでsave!メソッドが正しく動いているかを確認するためだけのテストが書けます✨

簡単にですがこんなイメージですね。

(factory_botは入っていい感じに設定している前提です)

require 'rails_helper'

RSpec.describe CreateUserForm, type: :model do
  describe 'validations' do
    subject { form.valid? }

    it 'accepts fulfilled object' do
      let(:form) { build(:create_user_form, name: '田中 太郎', prefecture: '北海道')
      is_expected.to be_truthy
    end
    
    it 'does not accept nil name object' do
      let(:form) { build(:create_user_form, name: nil, prefecture: '北海道')
      is_expected.to be_falsey
    end
    
    it 'does not accept nil prefecture object' do
      let(:form) { build(:create_user_form, name: '田中 太郎', prefecture: nil)
      is_expected.to be_falsey
    end
  end
  
  describe '.save!' do
    subject { form.save! }
    
    context 'when valid case' do
      let(:form) { build(:create_user_form, name: '田中 太郎', prefecture: '北海道')
      it 'save a user and a address' do
        is_expected.to change(User, :count).from(0).to(1)
        is_expected.to change(Address, :count).from(0).to(1)
      end
    end
    
    context 'when invalid case' do
      let(:form) { build(:create_user_form, name: nil, prefecture: '北海道')
      it 'does not save user and address' do
        is_expected.not_to change(User, :count)
        is_expected.not_to change(Address, :count)
      end
    end
  end
end

こんな感じですね。(ちゃんと書いてるわけじゃないのであくまでニュアンスとして受け取ってください🙏)

controllerまでシンプルになるよ

参考までに、このActiveModelを作成した場合のcontrollerも書いてみます。

class UsersController < ApplicationController

  def new
    @form = CreateUserForm.new
  end

  def create
    @form = CreateUserForm.new(user_params)
    if @form.save!
      redirect_to root_path
    else
      render :new
    end
  end

  private

  def user_params
    params.permit(CreateUserForm::PERMIT_PARAMS)
  end
end

こんな感じですごくシンプルに書けますね。

ちゃんとエラーも@formに渡されるのでエラー処理も可能です。

こんな感じでスッキリシンプルに書けるので、ぜひ使ってみてください〜

====== 😇ここから宣伝です 😇======

実はこんなこと書いておきながら、おいしい健康ではほぼほぼActiveModelやform_objectはほぼほぼ使われていません😇

正確には、新しい実装箇所では取り入れられているところもありますが、古くからのコードをリファクタするというのはほとんどできていない状態です😭

スタートアップとしてどんどん新機能や改善をしていくフェーズなので、チャレンジにリソースを割くというのは当然です。

ある程度割り切りは必要だと思いつつ、一方で「なんとかしたいなー!」という気持ちを抱えながら新機能を作っている状態です🔥

こんな状態を「なんとかしたるぜー!😎🤙」という方、「リファクタは嫌だけどチャレンジするからその分リファクタの時間とってね💪」という方、どちらも大歓迎ですのでぜひ一度下記からおいしい健康の求人をご確認ください✨

https://www.wantedly.com/projects/603130