【Rails】 Formオブジェクトを使って、has_manyを一括登録する
だいぶサボりすぎてしまいました。
NuxtのAsyncDataに苦戦しまくっていて、記事を書くやる気がなくなっていました。
記事に書くこと
RailsのFormObjectを使って、has_many関係があるデータを一括で登録する方法を残します。
Todo登録アプリを想定して、下記のようなクラス間の関係があることを前提とします。
この時に、下記のような処理をFormObjectを利用して処理を実現したいと思います。
todoのcreateアクション(POST /todos/)のリクエストパラメータには、menuを生成するパラメータが存在する
todoを保存する際に、menuについても同時に保存する
なぜ記事を書くのか
FormObjectを使わなくても、Modelのrelationに
accepts_nested_attributes_for
を追加してあげればTodoと同時に関連するMenuも保存することができます。
最初はこちらの方法を利用していくつもりだったのですが、
どうやらRails界隈では評判がよろしく内容で(特にRailsの生みの親が消したいと言っている)
今後リファクタ要件になっていく可能性があります。
そのため、代替手段としてFormObjectを使って、今回はコードを書いていくことにしました。
# File app/models/todos.rb class Todo < ApplicationRecord # relation belongs_to :user has_many :menus accepts_nested_attributes_for :menus end # File app/models/menu.rb class Menu < ApplicationRecord # relation belongs_to :todo end
FormObjectって何?
単体のモデルに依存しない場合(複数モデルにまたがる処理など)や
フォーム専用の特別な処理をモデルに書きたくない場合に用いる1種のデザインパターンのようなものになります。
ActiveModel::Model
をincludeするので、バリデーション処理を記述することも可能になります。
使うと何が嬉しいのか
1つのフォームで、複数のModelを更新する場合、FormObjectを1つのModelとして扱うことができる
- 処理が分散しない
Fat ControllerやFat Modelになりにくくなる
DBに依存しないインスタンスでも、ActiveRecorと同じインタフェースを介してアクセスができる
実際に使ってみる
Form Objectを使わない場合の処理フロー
まずは比較のため、FormObjectを使わない場合の処理フローを考えていきます。 (設計くそ雑魚なのでそこら辺は置いといてください。。。)
TodoとMenuそれぞれを生成するため、コントローラ内の処理がとても多い印象です。
また、実際にはsave!
実行時にvalidate処理も必要になるのでそれらの処理をModelに記述する必要があります。
Form Objectを使った場合の処理フロー
Controllerがよしなに対応してくれた処理を、Form::Todo
に委譲させた処理フローが画像のような形になります。
処理フローが多くなっている気がしますが、コントローラーで行う処理としては、
Form::Todo
のnewForm::Todo
のsavesaveの結果に応じたレスポンスを返す
だけになっています。
FormObjectで実装する
設計方針
設計にあたって、TodoのFormObjectとMenuのFormObjectを作成します。
Form::TodoはForm::Menuとhas_manyの関係になっています。
Gem(Virtus)の導入
FormObjectの導入にあたって便利なVirtusを導入します。
DBに紐づかない属性をattribute
を使って便利に宣言できます。
Form::Todoの作成
attirbuteを使って、Formから渡されるパラメータを宣言します。
また、attribute :menus, Array[Form::Menu]
を記述して、複数のForm::Menu
を保持するようにしています。
ネストしたForm::Menu
についてはvalid
の対象外となってしまうため、
プライベートメソッドのcheck_menus_validation
でバリデーションをかけています。
class Form::Todo include Virtus.model include ActiveModel::Model attribute :name, String attribute :planning_date, DateTime attribute :clear_date, DateTime attribute :menus, Array[Form::Menu] validates :name, presence: true validates :planning_date, presence: true, on: :create validate :check_menus_validatation attr_accessor :user def initialize(current_user, params) super(params) self.user = current_user end def save if valid? persist! true else false end end private def persist! todo = Todo.new(user_id: user.id, name: name, planning_date: planning_date) todo_menus = menus.map do |menu| Menu.new(name: menu.name, set_count: menu.set_count, weight: menu.weight) end todo.menus = todo_menus todo.save! end def check_menus_validatation menus.each do |menu_form| errors.add(:base, menu_form.errors.full_messages) unless menu_form.valid? end throw(:abort) if errors.any? end end
Form::Menuの実装
Form::Menu
については、今のところ単体でNewされることは想定していないので、
フォームから渡されるパラメータとバリデーションのみ宣言しています。
class Form::Menu include Virtus.model include ActiveModel::Model attribute :name, String attribute :set_count, Integer attribute :weight, Integer validates :name, presence: true validates :set_count, presence: true, numericality: { greater_than: 0 } validates :weight, presence: true, numericality: { greater_than: 0 } end
コントローラの実装
上記の処理フローと同じように、Form::Todo
オブジェクト生成後にsaveをかけているだけです。
保存されたTodoの取得が少しナンセンスな気がしますが、N+1対策を含めてTodoオブジェクトかた直接検索をかけています。
通常のモデル単体と同じような記述量でTodoとMenuの保存ができるようになりました。
def create todo = Form::Todo.new(current_api_user, create_todo_params) if todo.save @todo = Todo.with_menus.where(user: current_api_user).first render :json => @todo, :serializer => TodoSerializer, status: :created else response_unprocessable(class_name = self, details = todo.errors.full_messages) end end private def create_todo_params params.require(:todos).permit(:name, :planning_date, menus: [:name, :set_count, :weight]) end
最後に
かなり一部分のみを切り取った記事になりましたが、
FormObjectを利用した複数モデルの登録について学ぶことができました。
今回、FormObjectのGemではVirtus
を利用しましたが、
Githubを見たところ数年近く更新されていないようでした。 (実装してから気づいてしまった)
他のGemを見たところ、
ActiveType
が良さげだなと思いましたので、こちらを利用した方法でリファクタをかけて行きたいと思います。