【Rails】FormObject を使って、複数のテーブル情報を同時保存

## はじめに
一つのフォームで複数のテーブル情報を保存したいときに``accepts_nested_attributes_for``というメソッドが用意されていますが、あまり推奨されていないみたいです。

このメソッドを使わずに実現したい場合は、**FormObject**を使います。

### 動作環境
``Ruby '2.6.6'``
``Rails '6.0.3.5'``
``SimpleForm '5.1.0'``
``CarrierWave '2.1.1'``
``MiniMagick '4.11.0'``

### DB構造
今回は投稿時に写真とカテゴリー、本文を同時登録するフォームを作成します。
![スクリーンショット 2021-03-04 18.41.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707098/54283336-0225-fec1-d22a-c968ed0dc0b3.png)
※ 事情により``post_images``テーブルの``caption``カラムは削除

### モデル
それぞれ以下のようにアソシエーションされています。

```app/models/post.rb
class Post < ApplicationRecord
  has_many :post_categories
  has_many :categories, through: :post_categories
  has_many :post_images, dependent: :destroy
  belongs_to :user
end
```

## 実装の流れ
今回は以下の流れで実装していきます。

1. 投稿用のFormObjectファイルを作成し、属性とバリデーションを設定
2.  PostsControllerで投稿処理のアクションを実装
3.   FormObjectに``save``メソッドの処理を追加
4.    投稿フォームのビューを実装

【追記】更新機能（update）の追加

## 実装したコード
### 1. FormObjectの作成
app/forms配下にFormObjectファイルを用意しました。
ここでフォームに対応する属性（``attribute``）や、独自のバリデーションを定義します。

```app/forms/posts_form.rb
class PostsForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend CarrierWave::Mount

  # PostImagesモデルのimageカラムにアップローダを適用している 
  mount_uploader :image, PostImageUploader

  # bodyとuser_idは型を指定しているが、imagesとcategory_idsは配列を持たすため指定していない
  attribute :body, :string
  attribute :images
  attribute :category_ids
  attribute :user_id, :integer

  validates :images, presence: :true
  validates :category_ids, presence: :true

  # 画像に対するカスタムバリデーション
  validate :image_content_type
  validate :image_size

  private

  # カスタムバリデーション
  def image_content_type
    # この判定式がない場合、画像が添付されていないとnilのオブジェクトに対してeachを使用することになりエラーになる
    return false if images.blank?
    extension_whitelist = %w[image/jpg image/jpeg image/png]
    images.each do |image|
      errors.add(:images, 'は jpg/jpeg/png が許可されています') unless extension_whitelist.include?(image.content_type)
    end
  end

  def image_size
    # この判定式がない場合、画像が添付されていないとnilのオブジェクトに対してeachを使用することになりエラーになる
    return false if images.blank?
    images.each do |image|
      errors.add(:images, 'は5MB以下のファイルまでアップロードできます') if image.size > 5.megabytes
    end
  end
end
```
``class``名を定義し、[ActiveModel::Model](https://railsguides.jp/active_model_basics.html#model%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB)を``include``することによって、以下の機能などが使えるようになります。

- フォーム専用のバリデーションが設定できる
- フォームのクラスをインスタンスとして扱える

さらに[ActiveModel::Attributes](https://qiita.com/dy36/items/617ae9af81b50baab98a)を``include``することで、ActiveRecordのような属性（``attribute``）を持たせることができます。
### 2. Controllerの実装
次にPostsControllerのnewアクションとcreateアクションを実装します。
パラメータからは``images``と``category_ids``を配列で受け取れるようにしています。

```app/controllers/posts_controller.rb
  # PostsFormクラスでActiveModel::Modelをincludeしているので、インスタンスとして扱えている
  def new
    @form = PostsForm.new
  end

  def create
    @form = PostsForm.new(post_params)

    if @form.save
      redirect_to posts_path, success: t('defaults.message.created', item: Post.model_name.human)
    else
      flash.now['danger'] = t('defaults.message.not_created', item: Post.model_name.human)
      render :new
    end
  end

  private

  def post_params
    params.require(:posts_form).permit(
      :body,
      { images: [] },
      { category_ids: [] }
    ).merge(user_id: current_user.id)
  end
```


### 3. FormObjectでsaveメソッドの追加
次にcreateアクションで実行される``save``メソッドを定義します。
[**トランザクション**](https://qiita.com/tomokiyao/items/8697b523a0a5b9cf0bec)を使用しているので、どれか一つでもデータの保存に失敗した場合はトランザクション内の処理は実行されず、ロールバックされます。

```app/forms/posts_form.rb
  def save
    # 有効でない値の場合はこの時点でfalseを返す
    return false if invalid?
    
    # トランザクションを使用し、データを保存
    ActiveRecord::Base.transaction do
      post = Post.new(post_params)
      post.save!

      images.each do |image|
        post.post_images.create!(image: image)
      end

      category_ids.each do |category_id|
        post.post_categories.create!(category_id: category_id)
      end
    end
    # saveメソッドの返り値はboolean型を返すためtrueを明示
    true
  end

  private

  def post_params
    {
      body: body,
      user_id: user_id
    }
  end
```

### 4. View（投稿フォーム）の実装

フォームには[**simple_form**](https://github.com/heartcombo/simple_form)を利用しました。
``form_for``の引数にはControllerで定義した変数``@form``を渡しています。
urlの指定は現時点では必要ですが、追記の更新機能を実装する際には必要ありません。

```app/views/posts/_form.html.slim
.box.box-primary
  / 引数にはControllerで定義した変数をセット
  = simple_form_for @form, url: posts_path do |f|
    .box-body
      = f.input :images, as: :file, input_html: { multiple: true }
      = f.input :body, as: :text

      / include_hidden: false を指定することで、配列に空のデータが入らないようにしている
      = f.input :category_ids, as: :check_boxes, collection: Category.all, include_hidden: false

    .box-footer
      = f.button :submit, '投稿する', class: %w[btn btn-primary]
```

### 【追記】更新機能（update）の追加
コントローラでは、フォームから送られてきた値を``post_params``で、既に存在する``@post``を``@form``に引き渡しています。

```app/controllers/posts_controller.rb
  before_action :post_set, only: %i[edit update destroy]

  def update
    @form = PostsForm.new(post_params, post: @post)

    if @form.update
      redirect_to @post, success: t('defaults.message.updated', item: Post.model_name.human)
    else
      flash.now['danger'] = t('defaults.message.not_updated', item: Post.model_name.human)
      render :edit
    end
  end

  def post_set
    @post = current_user.posts.find(params[:id])
  end
```

FormObjectでは以下の項目を追加しています。

```app/forms/posts_form.rb
  # 作成・更新に応じてフォームのアクションをPOST・PATCHに切り替える
  delegate :persisted?, to: :@post

  # アクションのURLを適切な場所（posts_pathやpost_path(:id)）に切り替える
  def to_model
    @post
  end

  # 編集で入力された値（attributes）があれば、既に存在するdefault_attributesにマージして更新している
  def initialize(attributes = {}, post: Post.new)
    @post = post
    new_attributes = default_attributes.merge(attributes)
    super(new_attributes)
  end

  # 更新内容は@post.bodyと@post.category_ids
  def update
    return false if invalid?

    ActiveRecord::Base.transaction do
      @post.update!(post_params)

      @post.category_ids = category_ids
    end
  end

  private

  # 既存の値を取得
  def default_attributes
    {
      body: @post.body,
      user_id: @post.user_id,
      images: @post.post_images.map(&:image),
      category_ids: @post.post_categories
    }
  end
```
### 完成したフォームのプレビュー
![form_object_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707098/40ec1f7b-9ecc-b73b-749e-05306dfb1849.gif)

## 終わりに
以上で実装終了です。

一応どの機能も正常に動くものの、心残りなのは``update``メソッド。
メソッド内1行目で有効じゃない値を``false``で返す設定をしていますが、``save``メソッドと違って**既に全ての値が入っている状態で判定しているため、正しい判定ができていない**のが現状です。

なので``rescue``を使ってエラーをキャッチするように設定していますが、もっといい処理方法がありそうです。

もし改善した方がいい点などあればコメントで教えてください。

### 参考記事
- [form objectを使ってみよう - メドピア開発者ブログ](https://tech.medpeer.co.jp/entry/2017/05/09/070758)
- [accepts_nested_attributes_forを使わず、複数の子レコードを保存する | Money Forward Engineers' Blog](https://moneyforward.com/engineers_blog/2018/12/15/formobject/)
- [1.7 Modelモジュール | Active Model の基礎 - Railsガイド](https://railsguides.jp/active_model_basics.html#model%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB)
- [ActiveModel::Attributes が最高すぎるんだよな。 - Qiita](https://qiita.com/alpaca_taichou/items/bebace92f06af3f32898)