LoginSignup
10
9

More than 3 years have passed since last update.

フォームオブジェクトで複数のモデルを操作する[脱 accepts_nested_attributes_for]

Last updated at Posted at 2021-01-12

かなり丁寧に書いたのでボリューム多めです。
分かっている部分はガンガン読み飛ばしてください:thumbsup:

やりたいこと

accepts_nested_attributes_forを使わず複数のモデルを操作したい。
・データの新規作成だけでなく、更新もしたい。

事前準備

accepts_nested_attributes_forとは?

関連付けられたモデルのレコードを一度に更新できるメソッドです。

例)メッセージに複数の画像をつけて送信する場合
er_for_message.png

上記の場合だと、メッセージとそれに紐づいた画像を保存できます。便利ですね。
ただ、実はこのメソッドはあまり評判が良くありません:joy:(詳しくはこちら)

そこで、代替案のフォームオブジェクトを紹介していきます。

フォームオブジェクトとは?

モデルから切り離され、フォームの処理用に独立したクラスです。

これを使うとaccepts_nested_attributes_forなしで複数のレコードを更新できます。
その他メリットや原理について詳しく知りたい方はこちらの記事を。説明がめちゃくちゃ分かりやすいです:pray:

本記事の目標

ユーザー(親)がメッセージ(子)に画像(孫)を紐づけて送信できる機能を実装します。

親、子、孫モデルは皆さんの状況に合わせて変えてください。

割愛した部分

・ルーティング設定
・画像投稿に必要なcarrierwaveの設定
・各モデルのテーブル作成

実装例

それでは見ていきましょう!

モデル

user.rb
class User < ApplicationRecord
  has_many :messages
end
message.rb
class Message < ApplicationRecord
  has_many :pictures
  belongs_to :user
  # ここに書いていたフォームのバリデーションは、フォームオブジェクトに移ります。
end
picture.rb
class Picture < ApplicationRecord
  belongs_to :message
  # 画像投稿しない方は、以下の記述は不要です。
  mount_uploader :picture, PictureUploader
end

バリデーションに関する記述がなくなりました。
スッキリして読みやすいですね:relaxed:

コントローラ

ここで注目して欲しいのは、下記の2点だけです。

assigns_attributesでパラメータから渡された値をもとに@messageの情報を更新。
MessageFormは、後ほど解説するフォームオブジェクト用のクラス。

messages_controller.rb
  # 新規作成画面
  def new
    @message = MessageForm.new
  end

  # 新規作成
  def create
    @message = MessageForm.new
    @message.assign_attributes(message_form_params)
    if @message.save
      # 成功・失敗の処理
    end
  end

  # 編集画面
  def edit
    # params[:id]の部分は、編集するオブジェクトを特定できる値を入れてください
    @message = MessageForm.new(message = Message.find(params[:id]))
  end

  # 編集
  def update
    @message = MessageForm.new(message = Message.find(params[:id]))
    @message.assign_attributes(message_form_params)
    if @message.save
      # 成功・失敗の処理
    end
  end

  private
    # ストロングパラメータ
    def message_form_params
      params.require(:message_form).permit(:body, pictures_attributes: [:picture]).merge(user_id: current_user)
    end

ストロングパラメータのpictures_attributesについては、次に説明していきます。

ビュー

メッセージの新規作成画面です。

new.html.erb
<%= form_with model: @message, url: メッセージ作成用のpath, local: true do |f| %>

  <%= f.text_area :body %>

  <%= f.fields_for :pictures do |picture| %>
    <%= picture.file_field :picture, multiple: "multiple", name: "message_form[pictures_attributes][][picture]" %>
  <% end %>

  <%= f.submit "送信" %>

<% end %>

パラメータを中心に解説します。

まず最初に、テキストエリアに「OK」と入力して送信したとします。
このときのパラメータは下記のようになります。(ターミナルで確認してみてください。)

Parameters: {"authenticity_token"=>"略", "message_form"=>{"body"=>"OK"}, "button"=>""}

bodyカラムに入力した値が入っていますね。

ここで、"message_form"となっているのは@messageMessageFormクラスから生成しているからです。(自動でこうなります。)

次に、これに画像情報を追加するなら、下記のような形にしたいですよね。

Parameters: {"authenticity_token"=>"略", "message_form"=>{画像情報, "body"=>"OK"}, "button"=>""}

そこで、file_fieldのname属性を使ってみます。

name: "message_form[pictures_attributes][][picture]"

こう書くと、画像を2枚投稿した場合のパラメータは下記になります。
(画像情報は長いので省略しています。)

 Parameters: {"authenticity_token"=>"略", "message_form"=>{"pictures_attributes"=>[{"picture"=>1枚目の画像情報}, {"picture"=>2枚目の画像情報}], "body"=>"OK"}, "button"=>""}

"message_form"下に画像情報が入ったのが分かると思います。

また、"pictures_attributes"=>[{"picture"=>がコントローラのストロングパラメータに対応しています。

ひとまず、これでコントローラに値を渡せそうですね:relaxed:

ちなみに、編集画面の場合は注意が必要です。
理由はこちらの記事に書いていますので、時間があれば参考にしてください。

フォームオブジェクト用クラス

ここが一番ややこしい部分です。
4つに分けて解説しますので、気楽に読んでください:ok_hand:

message_form.rb
class MessageForm

  # part-1
  include ActiveModel::Model
  include Virtus.model
  extend CarrierWave::Mount

  validates :body,   presence: true

  attribute :body, String
  attribute :user_id, Integer

  mount_uploader :picture, PictureUploader
  attr_accessor :pictures

  # part-2
  def initialize(message = Message.new)
    @message = message
    self.attributes = @message.attributes if @message.persisted?
  end

  # part-3
  def assign_attributes(params = {})
    @params = params
    pictures_attributes = params[:pictures_attributes]
    @pictures ||= []
    pictures_attributes&.map do |pictures_attribute|
      picture = Picture.new(pictures_attribute)
      @pictures.push(picture)
    end
    @params.delete(:pictures_attributes)
    @message.assign_attributes(@params) if @message.persisted?
    super(@params)
  end

  # part-4
  def save
    return false if invalid?
    if @message.persisted?
      @message.pictures = pictures if pictures.present?
      @message.save!
    else
      message = Message.new(user_id: user_id,
                            body: body)
      message.pictures = pictures if pictures.present?
      message.save!
    end
  end

end

part-1(モジュールの導入)

  include ActiveModel::Model
  include Virtus.model
  extend CarrierWave::Mount

  validates :body, presence: true

  attribute :body, String
  attribute :user_id, Integer

  mount_uploader :picture, PictureUploader
  attr_accessor :pictures

ここはインクルードしたモジュールなどをまとめています。

ActiveModel::Modelは、MessageFormオブジェクトを保存する際にバリデーションを使えるようにしています。validatesの部分。ActiveRecordを継承していなくてもバリデーションできるのが便利。

Virtus.modelは、このクラスの属性名と型を定義しています。attributeの部分。
何をパラメータとして受け取るのか明示しています。ちなみに、gemのvirtusをインストールしています。

CarrierWave::Mountは画像のアップロード用です。mount_uploaderの部分。

attr_accessorはメッセージに画像情報をセットするためのものです。(part-4で使います。)

part-2(オブジェクトの初期化)

オブジェクトにプロパティを持たせます。

  def initialize(message = Message.new)
    @message = message
    self.attributes = @message.attributes if @message.persisted?
  end

ここはinitializeの引数がポイント。
コントローラのnewメソッドに引数がある場合、それを受け取っています。

  def edit
    @message = MessageForm.new(message = Message.find(params[:id]))
  end

つまり、newでオブジェクトを生成した際に、すでに作成したメッセージの情報があれば、それをもとにフォームオブジェクトの属性を書き換えています。
(self.attributesの部分)

part-3(オブジェクトの更新)

そろそろ疲れてきた方はすみません。もう少しの辛抱です。。。

  def assign_attributes(params = {})
    @params = params
    pictures_attributes = params[:pictures_attributes]
    # picturesと書けば、画像情報が呼び出せる
    @pictures ||= []
    pictures_attributes&.map do |pictures_attribute|
      picture = Picture.new(pictures_attribute)
      @pictures.push(picture)
    end
    # パラメータから画像情報を削除
    @params.delete(:pictures_attributes)
    # すでに作成したメッセージの情報を更新
    @message.assign_attributes(@params) if @message.persisted?
    # フォームオブジェクトの情報を更新
    super(@params)
  end

ここではassign_attributes@messageにパラメータの値をセットしています。

assign_attributesメソッドはもともとデータ更新用のメソッドなのですが、

・フォームオブジェクトが持つ編集用の情報を、すでに作成したメッセージに反映させたい
picturesと書けば画像情報を取得orセットできるようにしたい(part-2のattr_accessor)

という理由から少し改造しています:sunglasses:

part-4(変換作業)

いよいよラスト!Messageモデルへの変換作業です。

  def save
    # 保存前にバリデーションをかける
    return false if invalid?
    # 編集する場合の処理
    if @message.persisted?
      @message.pictures = pictures if pictures.present?
      @message.save!
    else
    # 新規作成の場合の処理
      message = Message.new(user_id: user_id,
                            body: body)
      message.pictures = pictures if pictures.present?
      message.save!
    end
  end

編集の場合は、普通の保存と変わりないですね。
このときの@messageは、part-2のmessageと同じです。

大事なのは、下記のMessageオブジェクトへの変換。

      message = Message.new(user_id: user_id,
                            body: body)

フォームオブジェクトが持ってきた値をカラムに渡しています。
これでMessageFormからMessageオブジェクトへの変換が完了しました。

      message.pictures = pictures if pictures.present?

最後に、画像情報をメッセージにセットして終了です。
お疲れ様でした:clap:

環境

ruby: 2.7.1
rails: 6.0.3.3

10
9
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
10
9