はじめに
一つのフォームで複数のテーブル情報を保存したいときに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構造
今回は投稿時に写真とカテゴリー、本文を同時登録するフォームを作成します。
※ 事情によりpost_images
テーブルのcaption
カラムは削除
モデル
それぞれ以下のようにアソシエーションされています。
class Post < ApplicationRecord
has_many :post_categories
has_many :categories, through: :post_categories
has_many :post_images, dependent: :destroy
belongs_to :user
end
実装の流れ
今回は以下の流れで実装していきます。
- 投稿用のFormObjectファイルを作成し、属性とバリデーションを設定
- PostsControllerで投稿処理のアクションを実装
- FormObjectに
save
メソッドの処理を追加 - 投稿フォームのビューを実装
【追記】更新機能(update)の追加
実装したコード
1. FormObjectの作成
app/forms配下にFormObjectファイルを用意しました。
ここでフォームに対応する属性(attribute
)や、独自のバリデーションを定義します。
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をinclude
することによって、以下の機能などが使えるようになります。
- フォーム専用のバリデーションが設定できる
- フォームのクラスをインスタンスとして扱える
さらにActiveModel::Attributesをinclude
することで、ActiveRecordのような属性(attribute
)を持たせることができます。
2. Controllerの実装
次にPostsControllerのnewアクションとcreateアクションを実装します。
パラメータからはimages
とcategory_ids
を配列で受け取れるようにしています。
# 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
メソッドを定義します。
トランザクションを使用しているので、どれか一つでもデータの保存に失敗した場合はトランザクション内の処理は実行されず、ロールバックされます。
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を利用しました。
form_for
の引数にはControllerで定義した変数@form
を渡しています。
urlの指定は現時点では必要ですが、追記の更新機能を実装する際には必要ありません。
.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
に引き渡しています。
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では以下の項目を追加しています。
# 作成・更新に応じてフォームのアクションを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
完成したフォームのプレビュー
終わりに
以上で実装終了です。
一応どの機能も正常に動くものの、心残りなのはupdate
メソッド。
メソッド内1行目で有効じゃない値をfalse
で返す設定をしていますが、save
メソッドと違って既に全ての値が入っている状態で判定しているため、正しい判定ができていないのが現状です。
なのでrescue
を使ってエラーをキャッチするように設定していますが、もっといい処理方法がありそうです。
もし改善した方がいい点などあればコメントで教えてください。