こんにちは。
既存の投稿フォームからジャンル情報を保存できるように
form_withを加筆修正することになりました。
その際様々な記事を参考にさせていただきましたが、
中々に上手くいかず多くの時間を溶かしました。
非常に非常に非常に苦労を要したので、ここにアウトプットし
知識の整理と共に同じく苦労している人の一助となればいいなと思います!
尚、今回試行錯誤を繰り返して実装したので
指摘箇所がございましたらご教授いただけますと幸いです。
インフォメーション
2022/04/18現在
下記、注意事項ですが対応し無事に
更新、削除することができるようになりました。
注意
2022/04/18現在
更新、削除機能に不具合が生じております。
早急に対応しておりますので、申し訳ございませんが
他の記事を参考にしていただけますと幸いです。
対象
- 一つの
form_with
で複数のテーブルに保存をかけたい。 -
accepts_nested_attributes_for
を使用せずに実装したい。 -
to_model
を使用せずに実装したい。 - Form Objectで実装したいけど、詰まっている。
はじめに
前提として、通常の新規投稿と編集削除ができる状態からスタートします。
また、今回はCarrierWave
とDevise
を使用しており、それらの実装などは省略いたします。
Railsバージョンは6.1.5
で実装していきます。
以下、ER図の一部です。
Genresテーブルは同じrecipe_id
を複数持たない形で進めるため、RecipesテーブルとGenresテーブルは1対1の関係にしております。
どう実装していくのか
1つのform_with
から複数のテーブルに保存する方法は様々あると思います。
特にaccepts_nested_attributes_for
を使用して実装していくものをよく見かけます。
このメソッドはあまり良いものとされていません。 formの実装が泥臭くなり破綻への道へと誘われるようです。Rails開発者も抹殺したいと言うほどの問題児みたいです。もう少し知りたい場合はこちらの記事を見てください。
今回は、その中でもRailsのデザインパターンであるForm Objectを作成して保存できるようにしていきます。
ここでいうデザインパターンとは、実現したいゴールに対して最適な方法や手順をまとめたものです。
Form Objectとは?
では、この「Form Object」とはどういうものなのでしょうか?
これは、コントローラやビューに散らばるロジックを一つのカプセルのように集約することで可読性を上げる独立したクラスです。
なぜForm Objectなのか?
先ほども述べたように大きくは可読性の良さにつながっていきます。
ひとつのフォームにひとつのモデルであれば、それほど導入するメリットはありませんが、
今回のように複数のモデルが関わってくるとコントローラとビューの記述が複雑化し肥大していきます。そうなると処理を理解するためにコントローラやビューを行ったり来たりすることになります。
ひとつのカプセルとして集約することで複雑な処理に対してもそこをみるだけで良いということになります。
またモデルから分離させることで、モデルとフォームの責務を切り分けられるということになります。
実装
では、実装をしていきます。
以降は、基本的にはコメント形式で必要な箇所を解説し、
長くなる場合は外部で解説いたします。
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オブジェクトの記述
ディレクトリとファイルが作成できたら早速記述していきます。
まずは通常の投稿ができるように実装していきます。
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
attributes
とrecipe
を引数で受け取れるようにしています。attributes
はデフォルト値としてnil
を代入しています。
attributes ||= default_attributes
の||=
は「nilガード」と呼ばれるイディオムです。左辺の変数がnil
もしくはfalse
だった場合に右辺を代入するというものです。今回であれば、変数attributes
がnil
だった場合、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
は処理に失敗した際に別の処理に切り替える例外処理です。
コントローラの記述
では、次にコントローラを編集していきます。
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
を使用することはできないのでコントローラから渡してあげます。
ビューの記述
<%# 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 %>
これで、無事新規投稿ができるようになりました。
編集機能が使えるようにする
このままでは編集して更新をしようとするとエラーが出るはずです。
なので、更新できるよう実装していきます。
編集機能は新規の時とあまり変わらないので、サクッと進めていきます。
Formオブジェクトの記述
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
コントローラの記述
次にコントローラを編集していきます。(一部省略)
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_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?
を使えるようにしたのが、ここで活きてきます。
これで編集して更新をすることができると思います。
Genreテーブルも保存できるようにする
では、Formオブジェクトのファイルから加筆修正していきます。
Formオブジェクトの記述
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
コントローラの記述
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_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
を付けてあげれば、パラメータで渡っていきます。
これで、無事にジャンルも投稿と同時に保存されたかと思います。
終わりに
いや〜長かったですね笑 お疲れ様です!
いかがだったでしょうか。
気づいたら、こんなにも書いてしまいました。
今回で、様々なエラーと出会えることができ、処理の流れを読み解くことがいかに大切かを感じ
エラーに対してどのように対処していくかの力を伸ばせたのではないかと思います。
ここまでお付き合いくださった方々、ありがとうございます。