【Rails】Form Objectで複数のテーブルに保存するフォームを実装

こんにちは。

既存の投稿フォームからジャンル情報を保存できるように
form_withを加筆修正することになりました。

その際様々な記事を参考にさせていただきましたが、
中々に上手くいかず多くの時間を溶かしました。
**非常に非常に非常に**苦労を要したので、ここにアウトプットし
知識の整理と共に同じく苦労している人の一助となればいいなと思います！

尚、今回試行錯誤を繰り返して実装したので
指摘箇所がございましたらご教授いただけますと幸いです。

:::note info
インフォメーション
2022/04/18現在
下記、注意事項ですが対応し無事に
更新、削除することができるようになりました。
:::

:::note warn
注意
2022/04/18現在
~~更新、削除機能に不具合が生じております。
早急に対応しておりますので、申し訳ございませんが
他の記事を参考にしていただけますと幸いです。~~
:::

## :ok_woman: 対象

- 一つの`form_with`で複数のテーブルに保存をかけたい。
- `accepts_nested_attributes_for`を使用せずに実装したい。
- `to_model`を使用せずに実装したい。
- Form Objectで実装したいけど、詰まっている。

## :rolling_eyes: はじめに

前提として、通常の新規投稿と編集削除ができる状態からスタートします。
また、今回は`CarrierWave`と`Devise`を使用しており、それらの実装などは省略いたします。
Railsバージョンは`6.1.5`で実装していきます。

以下、ER図の一部です。
Genresテーブルは同じ`recipe_id`を複数持たない形で進めるため、RecipesテーブルとGenresテーブルは1対1の関係にしております。
![スクリーンショット 2022-04-10 14.22.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2459618/a4de17de-aba8-bd3d-2938-31022b165bff.png)

## :hammer_pick: どう実装していくのか

1つの`form_with`から複数のテーブルに保存する方法は様々あると思います。
特に`accepts_nested_attributes_for`を使用して実装していくものをよく見かけます。
**このメソッドはあまり良いものとされていません。** formの実装が泥臭くなり破綻への道へと誘われるようです。Rails開発者も抹殺したいと言うほどの問題児みたいです。もう少し知りたい場合は[こちらの記事](https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef)を見てください。

今回は、その中でもRailsのデザインパターンである**Form Object**を作成して保存できるようにしていきます。
ここでいうデザインパターンとは、実現したいゴールに対して最適な方法や手順をまとめたものです。

### :question: Form Objectとは？

では、この「Form Object」とはどういうものなのでしょうか？
これは、コントローラやビューに散らばるロジックを一つのカプセルのように集約することで可読性を上げる独立したクラスです。

### :information_desk_person: なぜForm Objectなのか？

先ほども述べたように大きくは可読性の良さにつながっていきます。
ひとつのフォームにひとつのモデルであれば、それほど導入するメリットはありませんが、
今回のように複数のモデルが関わってくるとコントローラとビューの記述が複雑化し肥大していきます。そうなると処理を理解するためにコントローラやビューを行ったり来たりすることになります。
ひとつのカプセルとして集約することで複雑な処理に対してもそこをみるだけで良いということになります。
またモデルから分離させることで、モデルとフォームの責務を切り分けられるということになります。

## :pick: 実装

では、実装をしていきます。
以降は、基本的にはコメント形式で必要な箇所を解説し、
長くなる場合は外部で解説いたします。

### `forms`ディレクトリとファイルの作成

まずは`app`配下に`forms`ディレクトリを作成します。これはRailsでも推奨されている方法です。
`***_form`には任意の名前を付けられますが接尾辞に`form`を付加するのが適切です。
今回は`make_recipe_form.rb`で進めていきます。

```terminal:ターミナル
# mkdir app/forms && touch app/forms/***_form.rb
$ mkdir app/forms && touch app/forms/make_recipe_form.rb
```

### Formオブジェクトの記述

ディレクトリとファイルが作成できたら早速記述していきます。
まずは通常の投稿ができるように実装していきます。

```ruby:make_recipe_form.rb
class MakeRecipeForm
  include ActiveModel::Model    # バリデーションやレンダーなどが使えるようにするため
  extend CarrierWave::Mount    # モデル以外でCarrierWaveを使用するため

  # 使用したい属性を読み書きできるようにしてform_withの引数として利用する
  attr_accessor :title, :content, :menu_image, :user_id

  # バリデーションの設定(モデルに記述したものと合わせる)
  validates :title, presence: true, length: { maximum: 100 }
  validates :content, presence: true, length: { maximum: 500 }

  # CarrierWaveで使用するuploaderをマウントする
  mount_uploader :menu_image, MenuImageUploader

  # delegate :メソッド, to: :モデル名 / フォームのアクションのPOST, PATCHを自動で切り替えてくれるようにする
  delegate :persisted?, to: :recipe

  # Formオブジェクトの値の初期化
  def initialize(attributes = nil, recipe: Recipe.new)
    @recipe = recipe
    attributes ||= default_attributes
    super(attributes)
  end

  # ActiveRecordを継承していないため、saveメソッドを手入力で定義
  def save
    ActiveRecord::Base.transaction do
      Recipe.create(user_id:, title:, content:, menu_image:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end

  private

  attr_reader :recipe

  def default_attributes
    {
      user_id: recipe.user_id,
      title: recipe.title,
      content: recipe.content,
      menu_image: recipe.menu_image,
    }
  end
end
```

ここで中には、`to_model`メソッドを定義する記事もございますが、ActiveRecordの属性とフォームにずれが生じた際の対応が困難になってくるので、**導入は控えたほうがよさそうです。**- [参考](https://techracho.bpsinc.jp/gengen/2021_08_24/110885)
ちなみに`to_model`は、簡単に言うとFormオブジェクトのインスタンスをRecipeインスタンスに変換してくれるもので、自動的にアクション先を適切な場所に持っていってくれるものです。

```ruby
  def initialize(attributes = nil, recipe: Recipe.new)
    @recipe = recipe
    attributes ||= default_attributes
    super(attributes)
  end
```

`attributes`と`recipe`を引数で受け取れるようにしています。`attributes`はデフォルト値として`nil`を代入しています。

`attributes ||= default_attributes`の`||=`は「**nilガード**」と呼ばれるイディオムです。左辺の変数が`nil`もしくは`false`だった場合に右辺を代入するというものです。今回であれば、変数`attributes`が`nil`だった場合、`default_attributes`を代入するというものになります。

`super`はActiveModel::Modelの`initialize`を呼び出して、書き込みメソッドで値を代入するため書き込みメソッドを定義する必要がありました。

```ruby
def save
    ActiveRecord::Base.transaction do
      Recipe.create(user_id:, title:, content:, menu_image:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end
```

`ActiveRecord::Base.transaction`は`モデル.transaction(引数)`で使うことができ、複数の処理を一つのかたまりとして、まとめて処理をしてくれるものです。([Railsドキュメント](https://railsdoc.com/page/transaction))
なので、複数のうち一つの処理が失敗するとロールバックするようになっています。現時点ではブロック内が1行しかありませんが、今後追加していきます。
`rescue`は処理に失敗した際に別の処理に切り替える例外処理です。

### コントローラの記述

では、次にコントローラを編集していきます。

```ruby:recipes_controller.rb
class RecipesController < ApplicationController
  # 省略

  def new
    # MakeRecipeFormクラスのインスタンスを呼び出し
    @recipe_form = MakeRecipeForm.new
  end

  def create
    @recipe_form = MakeRecipeForm.new(recipe_params)
    if @recipe_form.valid?
      @recipe_form.save
      redirect_to recipes_path, notice: "投稿しました!"
    else
      render :new
    end
  end

 # 省略

  private

  def recipe_params
    # mergeメソッドでログインユーザが入力したパラメータにログインユーザのidを追加する
    params.require(:make_recipe_form).permit(:title, :content, :menu_image).merge(user_id: current_user.id)
  end
  
  # 自身のIDに対応する投稿を取得するメソッド
  def set_recipe
    @recipe = current_user.recipes.find_by(id: params[:id])
    redirect_to recipes_path, alert: "権限がありません" if @recipe.nil?
  end
end
```

先ほども記述しましたが、ActiveRecordを継承していないため`forms`では`current_user`を使用することはできないのでコントローラから渡してあげます。

### ビューの記述

```erb:_form.html.erb
<%# modelにはformオブジェクトのインスタンスを渡している。urlは手入力で指定する必要がある。%>
<%= form_with model: @recipe_form, url: recipes_path, local: true do |form| %>
  <div>
    <%= form.text_field :title, required: true, placeholder: 'タイトル' %>
  </div>
  <div class="flex field">
    <div>
      <%= form.label :content %><br>
      <%= form.text_area :content, required: true %>
    </div>
    <div class="field">
      <%= form.label :menu_image %>
      <%= form.file_field :menu_image, accept: "image/png,image/jpeg,image/gif" %>
    </div>
  </div>
  <div><%= form.submit '投稿する' %></div>
<% end %>
```

これで、無事新規投稿ができるようになりました。

## :pencil2: 編集機能が使えるようにする

このままでは編集して更新をしようとするとエラーが出るはずです。
なので、更新できるよう実装していきます。
編集機能は新規の時とあまり変わらないので、サクッと進めていきます。

### Formオブジェクトの記述

```diff_ruby:make_recipe_form.rb
class MakeRecipeForm
 # 省略
 
  mount_uploader :menu_image, MenuImageUploader
  
  delegate :persisted?, to: :recipe  

  def initialize(attributes = nil, recipe: Recipe.new)
    @recipe = recipe
    attributes ||= default_attributes  
    super(attributes)
  end

  def save
    ActiveRecord::Base.transaction do
      Recipe.create(user_id:, title:, content:, menu_image:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end
  
+ def update_recipe
+   ActiveRecord::Base.transaction do
+     recipe.update(user_id:, title:, content:, menu_image:)
+   end
+ rescue ActiveRecord::RecordInvalid
+   false
+ end

  private
  # 省略

end
```

### コントローラの記述

次にコントローラを編集していきます。(一部省略)

```diff_ruby:recipes_controller.rb
class RecipesController < ApplicationController
  # 省略

  def edit
    @recipe_form = MakeRecipeForm.new(recipe: @recipe)
  end

  def update
    @recipe_form = MakeRecipeForm.new(recipe_params, recipe: @recipe)   # ストロングパラメータに加えて事前に取得した@recipeを渡す
    @recipe_form.update_recipe
    redirect_to @recipe
  end

  # 省略

  private

  def recipe_params
    params.require(:make_recipe_form).permit(:title, :content, :menu_image).merge(user_id: current_user.id)
  end
  
  def set_recipe
    @recipe = current_user.recipes.find_by(id: params[:id])
    redirect_to recipes_path, alert: "権限がありません" if @recipe.nil?
  end
end
```

### ビューの記述

```diff_erb:_form.html.erb
- <%= form_with model: @recipe_form, url: recipes_path, local: true do |form| %>
+ <%= form_with model: @recipe_form, url: @recipe_form.persisted? ? recipe_path : recipes_path, local: true do |form| %>
   <div>
     <%= form.text_field :title, required: true, placeholder: 'タイトル' %>
   </div>
   <div class="flex field">
     <div>
       <%= form.label :content %><br>
       <%= form.text_area :content, required: true %>
     </div>
     <div class="field">
       <%= form.label :menu_image %>
       <%= form.file_field :menu_image, accept: "image/png,image/jpeg,image/gif" %>
     </div>
   </div>
   <div><%= form.submit '投稿する' %></div>
  <% end %>
```

ここでのポイントはURLの値を条件分岐している点です。
`to_model`を控えたので、アクション先を手動で変える必要があります。`@recipe_form`はFormオブジェクトのインスタンスなので、Formオブジェクトファイル内で`persisted?`を使えるようにしたのが、ここで活きてきます。

これで編集して更新をすることができると思います。

## :gift: Genreテーブルも保存できるようにする

では、Formオブジェクトのファイルから加筆修正していきます。

### Formオブジェクトの記述

```diff_ruby:make_recipe_form.rb
class MakeRecipeForm
  # 省略

- attr_accessor :title, :content, :menu_image, :user_id
+ attr_accessor :title, :content, :menu_image, :user_id, :staple_food,
                :main_dish, :side_dish, :country_dish

  validates :content, presence: true, length: { maximum: 500 }
+ validates :staple_food, :main_dish, :side_dish, :country_dish, presence: true

  # 省略

- def initialize(attributes = nil, recipe: Recipe.new)
+ def initialize(attributes = nil, recipe: Recipe.new, genre: Genre.new)
    @recipe = recipe
+   @genre = genre
    attributes ||= default_attributes  
    super(attributes)
  end

  def save
    ActiveRecord::Base.transaction do
-     Recipe.create(user_id:, title:, content:, menu_image:)
+     recipe = Recipe.create(user_id:, title:, content:, menu_image:, cooking_time:, cooking_cost:, calorie:)
+     Genre.create(recipe_id: recipe.id, staple_food:, main_dish:, side_dish:, country_dish:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end
  
  def update_recipe
    ActiveRecord::Base.transaction do
     recipe.update(user_id:, title:, content:, menu_image:)
+    Genre.update(recipe_id: recipe.id, staple_food:, main_dish:, side_dish:, country_dish:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end

  private

- attr_reader :recipe
+ attr_reader :recipe, :genre

  def default_attributes
    {
      user_id: recipe.user_id,
      title: recipe.title,
      content: recipe.content,
      menu_image: recipe.menu_image,
+     staple_food: genre.staple_food,
+     main_dish: genre.main_dish,
+     side_dish: genre.side_dish,
+     country_dish: genre.country_dish
    }
  end
end
```

ここでも新規投稿と考え方は同じです。
更新をかけたいものを参照・更新できるように定義して必要な値を引数にセットしてあげるだけです。

:::note warn
注意
2022/04/18現在
上記のupdate_recipeメソッドに関して、全ての投稿を上書きしてしまう可能性がございますので下記のようにwhereでrecipe_idを明示してあげてください。
:::

```diff_ruby
def update_recipe
    ActiveRecord::Base.transaction do
     recipe.update(user_id:, title:, content:, menu_image:)
+    Genre.where(recipe_id: recipe.id).update(staple_food:, main_dish:, side_dish:, country_dish:)
    end
  rescue ActiveRecord::RecordInvalid
    false
  end
```

### コントローラの記述

```diff_ruby:recipes_controller.rb
class RecipesController < ApplicationController

  # 省略

  private

  def recipe_params
-   params.require(:make_recipe_form).permit(:title, :content, :menu_image).merge(user_id: current_user.id)
+   params.require(:make_recipe_form).permit(
+     :title, :content, :menu_image,
+     :staple_food, :main_dish, :side_dish, :country_dish
+   ).merge(user_id: current_user.id)
  end
  
  def set_recipe
    @recipe = current_user.recipes.find_by(id: params[:id])
    redirect_to recipes_path, alert: "権限がありません" if @recipe.nil?
  end
end
```

ここでは保存許可をする属性を定義します。

### ビューの記述

```diff_erb:_form.html.erb
  <%= form_with model: @recipe_form, url: @recipe_form.persisted? ? recipe_path : recipes_path, local: true do |form| %>
   <div>
     <%= form.text_field :title, required: true, placeholder: 'タイトル' %>
   </div>
   <div class="flex field">
     <div>
       <%= form.label :content %><br>
       <%= form.text_area :content, required: true %>
     </div>
     <div class="field">
       <%= form.label :menu_image %>
       <%= form.file_field :menu_image, accept: "image/png,image/jpeg,image/gif" %>
     </div>
   </div>
   <div class="field">
     <%= form.label :staple_food_rice, "ごはん" %>
     <%= form.radio_button :staple_food, :rice %>
   </div>
   <div class="field">
     <%= form.label :main_dish_meat, "肉料理" %>
     <%= form.radio_button :main_dish, :meat %>
   </div>

   <%# 省略 %>

   <div><%= form.submit '投稿する' %></div>
  <% end %>
```

実は、Genreテーブルはenum型で定義しており、値ももっとあるのですが、ここは見やすさ重視で絞っています。
ここは通常通り先頭に`form`を付けてあげれば、パラメータで渡っていきます。

これで、無事にジャンルも投稿と同時に保存されたかと思います。

## :bow: 終わりに

いや〜長かったですね笑　お疲れ様です！
いかがだったでしょうか。
気づいたら、こんなにも書いてしまいました。

今回で、様々なエラーと出会えることができ、処理の流れを読み解くことがいかに大切かを感じ
エラーに対してどのように対処していくかの力を伸ばせたのではないかと思います。

ここまでお付き合いくださった方々、ありがとうございます。

## :blue_book: 参考

- [【Rails】accepts_nested_attributes_forを使用しない方が良いワケとその代替方法](https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef)
- [フォームオブジェクトで複数のモデルを操作する[脱 accepts_nested_attributes_for]](https://qiita.com/keisuke_x_japan/items/2e82a3a8f8b1ae609469)
- [詳解Railsデザインパターン：Formオブジェクト](https://takaokouji.github.io/output/form-object/)
- [【Rails】Form Objectを使ってModelに依存しないFormを作成する](https://tech.mof-mof.co.jp/blog/rails-form-object/)
- [RailsのForm Objectについて](https://zenn.dev/d0ne1s/scraps/303a255d892ab0)
- [【Rails】フォームオブジェクトを使った複数タグ機能](https://post-output.com/252-2/)
- [Railsのデザインパターン: Formオブジェクト](https://applis.io/posts/rails-design-pattern-form-objects)
- [【Rails】Formオブジェクトパターンでの実装](https://programmingnuoh.hatenablog.com/entry/2021/03/27/180908)