記事の目的

1つのフォームから複数のテーブルに一気にデータを保存し、さらにバリデーションも通過させるようにしたい。
通常であれば、フォーム一つに対しテーブルは一つ(紐づくモデルも一つ)であるため、今回のような実装にはフォームが2つ必要になるが、フォームオブジェクトを使って複数のモデルの処理を一つにまとめることができる。

現状

モデルは以下のとおり2つ。
こっちは「誰がどの商品を買ったのか」を管理するためのモデル。

app/models/order.rb
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

こちらは商品の配送先を管理するモデル。

app/models/shipping.rb
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の記述

ファイル名とクラス名は関連するモデル名を連結してとりあえず作成。

app/models/order_shipping.rb
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。

②モデルのバリデーションをフォームオブジェクトクラスに移す

app/models/order_shipping.rb
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」メソッドは使えない。そのため、自分でクラス内にインスタンスメソッドとして定義する必要がある。

app/models/order_shipping.rb
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アクションを記述

フォームオブジェクトクラスからインスタンスを生成し、保存する処理を記述します。

app/controllers/orders_controller.rb
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メソッドや、エラーメッセージの表示に関する記述を行う。
※以下のファイルは、最低限の内容のみ載せています。

app/views/orders/index.html.erb
 <%= 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が従来どおり使える
②エラーメッセージを簡単に表示できるようになった

まだ理解が浅いので、今後も実装を通じて理解を深めたいです。