3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-04-10

こんにちは。

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

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

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

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

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

:ok_woman: 対象

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

:rolling_eyes: はじめに

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

以下、ER図の一部です。
Genresテーブルは同じrecipe_idを複数持たない形で進めるため、RecipesテーブルとGenresテーブルは1対1の関係にしております。
スクリーンショット 2022-04-10 14.22.18.png

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

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

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

:question: Form Objectとは?

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

:information_desk_person: なぜForm Objectなのか?

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

:pick: 実装

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

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

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

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

Formオブジェクトの記述

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

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の属性とフォームにずれが生じた際の対応が困難になってくるので、導入は控えたほうがよさそうです。- 参考
ちなみにto_modelは、簡単に言うとFormオブジェクトのインスタンスをRecipeインスタンスに変換してくれるもので、自動的にアクション先を適切な場所に持っていってくれるものです。

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

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

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

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

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ドキュメント)
なので、複数のうち一つの処理が失敗するとロールバックするようになっています。現時点ではブロック内が1行しかありませんが、今後追加していきます。
rescueは処理に失敗した際に別の処理に切り替える例外処理です。

コントローラの記述

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

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を使用することはできないのでコントローラから渡してあげます。

ビューの記述

_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オブジェクトの記述

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

コントローラの記述

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

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

ビューの記述

_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オブジェクトの記述

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

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

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

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

コントローラの記述

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

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

ビューの記述

_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: 参考

3
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?