記事の目的
1つのフォームから複数のテーブルに一気にデータを保存し、さらにバリデーションも通過させるようにしたい。
通常であれば、フォーム一つに対しテーブルは一つ(紐づくモデルも一つ)であるため、今回のような実装にはフォームが2つ必要になるが、フォームオブジェクトを使って複数のモデルの処理を一つにまとめることができる。
現状
モデルは以下のとおり2つ。
こっちは「誰がどの商品を買ったのか」を管理するためのモデル。
class Order < ApplicationRecord belongs_to :user belongs_to :item has_one :shipping end
データベースは以下のとおり。
class CreateOrders < ActiveRecord::Migration[6.0] def change create_table :orders do |t| t.references :user, null: false, foreign_key: true t.references :item, null: false, foreign_key: true t.timestamps end end end
こちらは商品の配送先を管理するモデル。
class Shipping < ApplicationRecord belongs_to :order end
データベースは以下のとおり。
class CreateShippings < ActiveRecord::Migration[6.0] def change create_table :shippings do |t| t.string :postal_code, null: false t.string :city, null: false t.string :address, null: false t.string :building, null: false t.string :phone_number, null: false t.integer :prefecture_id, null: false t.references :order, null: false, foreign_key: true t.timestamps end end end
現状の課題
このように、今はモデルが2つあるため、form_withを使ってビューでフォームを作った時に、バリデーションを両方のモデルに対して実行する必要がある。
フォームオブジェクトを作る
フォームオブジェクトとは、Railsのモデルとは異なり、Active Modelというライブラリを取り組んだクラスのこと。
作ったフォームオブジェクトに先述のモデルに書くべきバリデーションを全て記述しておき、ビューで使うform_withのmodelオプションにフォームオブジェクトクラスのインスタンスを渡しておく。
バリデーションは、フォームオブジェクトクラスのインスタンスに対して行うことで、エラーメッセージの表示も簡単になる。
参考:Railsガイドv6.1
フォームオブジェクトクラスを作成
①ライブラリ取り込み、attr_accessorの記述
ファイル名とクラス名は関連するモデル名を連結してとりあえず作成。
class OrderShipping # Active Model::Modelを取り込む。 include ActiveModel::Model # ordersテーブルとshippingsテーブルに保存したいカラムを全て記述 attr_accessor :postal_code, :prefecture_id, :city, :address, :building, :phone_number, :order_id, :user_id, :item_id end
attr_accessorは、このクラスで扱うデータ属性を全て記述する。
保存したいテーブルのカラム名を全て記載しておけばOK。
②モデルのバリデーションをフォームオブジェクトクラスに移す
class OrderShipping # Active Model::Modelを取り込む。 include ActiveModel::Model # ordersテーブルとshippingsテーブルに保存したいカラムを全て記述 attr_accessor :postal_code, :prefecture_id, :city, :address, :building, :phone_number, :order_id, :user_id, :item_id with_options presence: true do validates :postal_code , format: { with: /\A[0-9]{3}[-][0-9]{4}\z/ } validates :city validates :address validates :phone_number, format: { with: /\A[0-9]{10,11}\z/} validates :prefecture_id, numericality: { other_than: 0, message: 'Select' } # フォームオブジェクトクラスではアソシエーションを定義できないため、Shippingモデルのbelongs_toと同じバリデーションが必要 validates :order_id end end
元のモデルで書いていたバリデーションを全てフォームオブジェクトクラスに移動させる。
この時、フォームオブジェクトクラスにはアソシエーションを記述できないことから、アソシエーションによるデフォルトのバリデーションについては、自分で書き加えなければならない。
※ここでは、外部キーの存在姓(presence: true)についてのみ書き加えています。
③テーブルへデータを保存するためのメソッドを記述
フォームオブジェクトクラスはRailsのモデルと違ってApplicationRecordやActiveRecordを継承していないので、モデルがデフォルトで使っている「save」メソッドは使えない。そのため、自分でクラス内にインスタンスメソッドとして定義する必要がある。
class OrderShipping # Active Model::Modelを取り込む。 include ActiveModel::Model # ordersテーブルとshippingsテーブルに保存したいカラムを全て記述 attr_accessor :postal_code, :prefecture_id, :city, :address, :building, :phone_number, :order_id, :user_id, :item_id with_options presence: true do validates :postal_code , format: { with: /\A[0-9]{3}[-][0-9]{4}\z/ } validates :city validates :address validates :phone_number, format: { with: /\A[0-9]{10,11}\z/} validates :prefecture_id, numericality: { other_than: 0, message: 'Select' } # フォームオブジェクトクラスではアソシエーションを定義できないため、Shippingモデルのbelongs_toと同じバリデーションが必要 validates :order_id end # それぞれのモデルに対してcreateメソッドでインスタンスの作成・保存をする処理を書く。 def save order = Order.create(item_id: params[:item_id].to_i, user_id: current_user.id) # 先に変数orderを作成しておくのがキモ Shipping.create(postal_code: postal_code, prefecture_id: prefecture_id, city: city, address: address, building: building, phone_number: phone_number, order_id: order.id) end end
④コントローラーのcreateアクションを記述
フォームオブジェクトクラスからインスタンスを生成し、保存する処理を記述します。
class OrdersController < ApplicationController def index # ルーティングのparamsから商品のidを取得している @item = Item.find(params[:item_id]) # form_withに渡す、フォームオブジェクトの空のインスタンスを生成 @ordershipping = OrderShipping.new end def create @ordershipping = OrderShipping.new(ordershipping_params) # フォームオブジェクトクラスはActiveRecordを継承していないのでvalid?を実行してバリデーションを実行する必要がある。 if @ordershipping.valid? # バリデーションを通過すればフォームオブジェクトで定義したsaveメソッドを実行 @ordershipping.save redirect_to root_path else @item = Item.find(params[:item_id]) render :index end end private def ordershipping_params params.require(:order_shipping).permit(:postal_code, :prefecture_id, :city, :address, :building, :phone_number).merge(user_id: current_user.id, item_id: params[:item_id].to_i) end end
ポイントとしては、
①newアクション(ここではindexアクションで代用)で空のインスタンスを作り、ビューのform_withメソッドのmodelオプションにわたす準備をしていること。
②フォームを送信したときのcreateアクション内でインスタンスを生成した後、手動で「valid?」を適用してバリデーションを実行する必要があること。
③フォームオブジェクトクラスで定義済みのsaveメソッドを②の後に実行していること。
④フォームオブジェクトクラスを作ったことでストロングパラメーターのrequireメソッドの中身が変更になること。
⑤ビューファイルを整える
最後に、ビューファイルのform_withメソッドや、エラーメッセージの表示に関する記述を行う。
※以下のファイルは、最低限の内容のみ載せています。
<%= form_with model: @ordershipping, url: item_orders_path(@item), method: :post, id: 'charge-form', class: 'transaction-form-wrap',local: true do |f| %> <%# フォームオブジェクトクラスのエラーメッセージを表示 %> <%= render 'shared/error_messages', model: @ordershipping %> # フォームの中身をここに記載するが、今回は省略 <% end %>
ポイントは、
①form_withのモデルオプションに渡すインスタンス変数を、フォームオブジェクトクラスのインスタンスにしていること。
②urlオプションも記述していること。
③エラーメッセージのパーシャル側で使っている「model」という変数を、フォームオブジェクトクラスのインスタンスに置き換えていること。
まとめ
フォームオブジェクトクラスを作成したことで、一つのフォームから複数のテーブルへデータを保存する際に、
①form_withが従来どおり使える
②エラーメッセージを簡単に表示できるようになった
まだ理解が浅いので、今後も実装を通じて理解を深めたいです。