こんにちは。 おいしい健康で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はほぼほぼ使われていません😇
正確には、新しい実装箇所では取り入れられているところもありますが、古くからのコードをリファクタするというのはほとんどできていない状態です😭
スタートアップとしてどんどん新機能や改善をしていくフェーズなので、チャレンジにリソースを割くというのは当然です。
ある程度割り切りは必要だと思いつつ、一方で「なんとかしたいなー!」という気持ちを抱えながら新機能を作っている状態です🔥
こんな状態を「なんとかしたるぜー!😎🤙」という方、「リファクタは嫌だけどチャレンジするからその分リファクタの時間とってね💪」という方、どちらも大歓迎ですのでぜひ一度下記からおいしい健康の求人をご確認ください✨