はじめに
1つのフォームから複数のテーブルに保存する方法を記録に残します。
今回は、寄付金額と寄付者の住所を一つのフォームから入力させ、
それぞれdonationテーブルとaddressテーブルの2テーブルに保存したいとします。
1. 前提
以下の通り住所と寄付金額を入力するフォームを作成したとします。
<%= form_with url: donations_path, local: true do |f| %>
<h1>住所</h1>
<div class="field">
<%= f.label :postal_code, "郵便番号(ハイフンを含む)" %>
<%= f.text_field :postal_code %>
</div>
<div class="field">
<%= f.label :prefecture, "都道府県" %>
<%= f.collection_select :prefecture, Prefecture.all, :id, :name %>
</div>
<div class="field">
<%= f.label :city, "市町村(任意)" %>
<%= f.text_field :city %>
</div>
<div class="field">
<%= f.label :house_number, "番地(任意)" %>
<%= f.text_field :house_number %>
</div>
<div class="field">
<%= f.label :building_name, "建物名(任意)" %>
<%= f.text_field :building_name %>
</div>
<h1>寄付</h1>
<div class="field">
<%= f.label :price, "金額(¥1~1,000,000)" %>
<%= "¥" %>
<%= f.text_field :price %>
</div>
<div class="actions">
<%= f.submit "寄付する" %>
</div>
<% end %>
このままparams内に格納されているデータを見ると、以下の通り表示される。
1つのハッシュ内に2テーブルに関する情報が格納されていることがわかる。
[1] pry(#<DonationsController>)> params
=> <ActionController::Parameters {"authenticity_token"=>"9ap7qH1l9Ag6xdRnvaRNe9ssuypIZwyM25mtzBwg+T1xrpOlu8+d0lSkoc1sI0l6QlAAQdzmgFCpOw9ekDE83Q==", "postal_code"=>"146-0081", "prefecture"=>"13", "city"=>"", "house_number"=>"", "building_name"=>"", "price"=>"100", "commit"=>"寄付する", "controller"=>"donations", "action"=>"create"} permitted: false>
2. 一つ一つのパラメーターを丁寧に取り出す記述する方法
# 省略
def create
@donation = Donation.create(donation_params)
Address.create(address_params)
redirect_to root_path
end
private
def donation_params
params.permit(:price).merge(user_id: current_user.id)
end
def address_params
params.permit(:postal_code, :prefecture, :city, :house_number, :building_name).merge(donation_id: @donation.id)
end
end
しかし、この実装にはいくつかの重大な課題があります。
- それぞれのモデルにバリデーション設定がされている場合、一度のフォーム送信で、複数のモデルに設置されたバリデーションを通過する必要があります。
- 送信した内容がバリデーションを通過せず保存できなかった場合、複数のモデルについてのエラーメッセージを表示しなければなりません。
これは複雑かつ非効率な記述になってしまいます。
複数のモデルのインスタンスをコントローラーやビューで扱うためです。
この問題を解決する方法の一つにFormオブジェクトパターンというものがある。
3. Formオブジェクトパターンを用いた実装
-
Formオブジェクトパターン
1つのフォーム送信で複数のモデルを操作したい場合や、テーブルに保存しない情報にバリデーションを設定したい場合に使います。
つまり、モデルに近しい機能を持ったクラスを新たに作成し、そのクラスに複数のテーブルへ保存させる処理やバリデーションを記述する、という方法です。
すなわち、newアクションで生成したインスタンスをform_withのmodelオプションに指定することや、インスタンスに対してバリデーションを実行することができます。
Formオブジェクトは、以下の手順で実装します。
-
手順1
新たにmodelsディレクトリ直下にファイルを作成し、クラスを定義する -
手順2
作成したクラスにform_withメソッドに対応する機能とバリデーションを行う機能を持たせる -
手順3
保存したい複数のテーブルのカラム名全てを属性値として扱えるようにする -
手順4
バリデーションの処理を書く -
手順5
データをテーブルに保存する処理を書く -
手順6
コントローラーのnewアクション、createアクションでFormオブジェクトのインスタンスを生成するようにする -
手順7
フォーム作成の部分をFormオブジェクトのインスタンスを引数として渡す形に変更する# Formオブジェクトのイメージ図 class DonationAddress include ActiveModel::Model attr_accessor :postal_code, :prefecture, :city, :house_number, :building_name, :price, :user_id # ここにバリデーションの処理を書く def save # 各テーブルにデータを保存する処理を書く end end
-
ActiveModel::Model
クラスにActiveModel::Modelをincludeすると、そのクラスのインスタンスはActiveRecordを継承したクラスのインスタンスと同様に form_with や render などのヘルパーメソッドの引数として扱え、バリデーションの機能を使用できるようになります。
Formオブジェクトパターンを実装するためにActiveModel::Modelをincludeしたクラスのことを「Formオブジェクト」と呼ぶこともあります。
4. 新たにmodelsディレクトリ直下にファイルを作成し、クラスを定義
app/modelsディレクトリ配下にdonation_address.rbを作成しましょう。
<イメージ図>
app
|
models
|
donation_address.rb
作成したファイルにクラスを定義し、DonationAddressクラスにActiveModel::Modelをincludeする。
# app/models/donation_address.rb
class DonationAddress
include ActiveModel::Model
end
保存したいカラム名を属性値として扱えるようにします
DonationAddressクラス内でattr_accessorを使用し、
donationsテーブルとaddressesテーブルに保存したいカラム名を、すべて指定する。
# app/models/donation_address.rb
class DonationAddress
include ActiveModel::Model
attr_accessor :postal_code, :prefecture, :city, :house_number, :building_name, :price, :user_id ⇦追記箇所
end
続いて、バリデーションを同ファイル内に追記する
class DonationAddress
include ActiveModel::Model
attr_accessor :price, :user_id, :postal_code, :prefecture, :city, :house_number, :building_name
<追記箇所(ここから)>
with_options presence: true do
validates :price, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: 'is invalid'}
validates :user_id
validates :postal_code, format: {with: /\A[0-9]{3}-[0-9]{4}\z/, message: "is invalid. Include hyphen(-)"}
end
validates :prefecture, numericality: {other_than: 0, message: "can't be blank"}
<追記箇所(ここまで)>
end
user_idに対して、presence: trueのバリデーションを新たに追加しています。
donationsテーブルに保存されるuser_idには、本来belongs_to :userのアソシエーションにより、バリデーションが設定されています。
すなわち、Donationモデルがインスタンスを保存する際に紐付くユーザー情報がない場合、モデル内のアソシエーションの記述により保存できません。
しかし、donation_addressクラスにはアソシエーションを定義することはできないため、belongs_toによるバリデーションを行うことができません。
そこで、donation_addressクラスでuser_idに対してバリデーションを新たに設定しました。
(donation.rbとaddress.rbのバリデーションは不要になるため、削除する。)
続いて、Formオブジェクトに、フォームから送られてきた情報をテーブルに保存する処理を記述。
class DonationAddress
include ActiveModel::Model
attr_accessor :price, :user_id, :postal_code, :prefecture, :city, :house_number, :building_name
with_options presence: true do
validates :price, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: "is invalid"}
validates :user_id
validates :postal_code, format: {with: /\A[0-9]{3}-[0-9]{4}\z/, message: "is invalid. Include hyphen(-)"}
end
validates :prefecture, numericality: {other_than: 0, message: "can't be blank"}
<追記箇所(ここから)>
def save
# 寄付情報を保存し、変数donationに代入する
donation = Donation.create(price: price, user_id: user_id)
# 住所を保存する
# donation_idには、変数donationのidと指定する
Address.create(postal_code: postal_code, prefecture: prefecture, city: city, house_number: house_number, building_name: building_name, donation_id: donation.id)
end
<追記箇所(ここまで)>
end
5. コントローラーでFormオブジェクトのインスタンスを生成するようにする
new, createアクションで、Formオブジェクトのインスタンスを生成します。
理由は2つあります。
-
Formオブジェクトのインスタンスをform_withのmodelオプションに指定するため
newアクションで生成するインスタンス変数は、new.html.erb内でも使用できます。
すなわち、newアクションで生成したインスタンスは、form_withのmodelオプションに指定できるということです。
そうすることによって、Formオブジェクトのインスタンスに紐付いたフォームを作成することができます。 -
入力した内容やエラーメッセージをフォームで表示させるため
createアクションで生成したインスタンスと、理由1で説明したform_withのmodelオプションによって実現されます。
createアクションでエラーハンドリングしていた場合、ストロングパラメーターによって値を取得したインスタンスが、renderで表示されたビューのmodelオプションに指定されます。
エラーハンドリングで表示されるフォームは、理由1によってFormオブジェクトのインスタンスに紐付いているため、送信前に入力した内容や、インスタンスに起こっているエラーを表示できます。
さらに、フォームがFormオブジェクトのインスタンスに紐付くことにより、送信されるパラメーターは、donation_addressハッシュを含む二重構造になります。
したがって、requireメソッドの引数に:donation_addressを指定する必要があります。
# 記載例
class DonationsController < ApplicationController
before_action :authenticate_user!, except: :index
def index
end
def new
@donation_address = DonationAddress.new
end
def create
@donation_address = DonationAddress.new(donation_params)
if @donation_address.valid?
@donation_address.save
redirect_to root_path
else
render :new
end
end
private
def donation_params
params.require(:donation_address).permit(:postal_code, :prefecture, :city, :house_number, :building_name, :price).merge(user_id: current_user.id)
end
6. フォームのmodelオプションをFormオブジェクトのインスタンス変数に変更する
# 記述例(new.html.erb内)
<%= form_with model: @donation_address, url: donations_path, local: true do |f| %>
※送信先のURL情報にも注意
(7. エラーメッセージが表示されるようにする)
フォームのmodelオプションにインスタンス変数を指定したことで、バリデーションに失敗したとき、エラーメッセージを表示し、入力した値を持ち越せるようになりました。
インスタンスにエラーがある場合には、エラーメッセージが表示されるようにしましょう。
まずは、メッセージを表示させるための部分テンプレート_error_messages.html.erbを作成する。
<イメージ図>
app
|
views
|
donations
|
_error_messages.html.erb
作成した部分テンプレートを編集する
# 記載例
<% if model.errors.any? %>
<div class="error-alert">
<ul>
<% model.errors.full_messages.each do |message| %>
<li class="error-message">
<%= message %>
</li>
<% end %>
</ul>
</div>
<% end %>
続いて、新規寄付ページから先ほど作成した部分テンプレートを読み込めるようにする。
<%= form_with model: @donation_address, url: donations_path, local: true do |f| %>
<%= render 'error_messages', model: @donation_address %> ⇦追記箇所
<h1>住所</h1>
<div class="field">
<%= f.label :postal_code, "郵便番号(ハイフンを含む)" %>
<%= f.text_field :postal_code %>
</div>
<%# 省略 %>
補足
単体テストコードを作成する場合は、Userモデルと、formオブジェクトであるDonationAddressクラスに分けてテストを書く。
<注意点>
DonationAddressクラスのテストを行うときには、直前で生成したUserのインスタンスのidをuser_idに指定しましょう。
本来であれば、FactoryBot内でassociationメソッドを使用することにより、寄付情報に紐付くユーザーのインスタンスを自動的に生成できます。
しかし、DonationAddressクラスはモデルではないため、associationメソッドを使用することができません。
したがって、DonationAddressクラスのインスタンスを生成するときに、紐付くユーザー情報を指定するよう記述します。
# 記述例
before do
user = FactoryBot.create(:user)
@donation_address = FactoryBot.build(:donation_address, user_id: user.id)
end