12
16

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 3 years have passed since last update.

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

Last updated at Posted at 2021-03-06

はじめに

一つのフォームで複数のテーブル情報を保存したいときに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
※ 事情により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::Modelincludeすることによって、以下の機能などが使えるようになります。

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

さらにActiveModel::Attributesincludeすることで、ActiveRecordのような属性(attribute)を持たせることができます。

2. Controllerの実装

次にPostsControllerのnewアクションとcreateアクションを実装します。
パラメータからはimagescategory_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メソッドを定義します。
トランザクションを使用しているので、どれか一つでもデータの保存に失敗した場合はトランザクション内の処理は実行されず、ロールバックされます。

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を利用しました。
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

終わりに

以上で実装終了です。

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

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

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

参考記事

12
16
0

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
12
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?