頑張るときはいつも今

自称エンジニアのゴリラです。日々精進。

【Rails】 Formオブジェクトを使って、has_manyを一括登録する

だいぶサボりすぎてしまいました。

NuxtのAsyncDataに苦戦しまくっていて、記事を書くやる気がなくなっていました。

記事に書くこと

RailsのFormObjectを使って、has_many関係があるデータを一括で登録する方法を残します。

Todo登録アプリを想定して、下記のようなクラス間の関係があることを前提とします。

f:id:wa_football_1120:20200322141416p:plain

この時に、下記のような処理を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に記述する必要があります。

f:id:wa_football_1120:20200322144236p:plain

Form Objectを使った場合の処理フロー

Controllerがよしなに対応してくれた処理を、Form::Todoに委譲させた処理フローが画像のような形になります。

f:id:wa_football_1120:20200322145554p:plain

処理フローが多くなっている気がしますが、コントローラーで行う処理としては、

  • Form::Todoのnew

  • Form::Todoのsave

  • saveの結果に応じたレスポンスを返す

だけになっています。

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が良さげだなと思いましたので、こちらを利用した方法でリファクタをかけて行きたいと思います。