1
2

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 1 year has passed since last update.

1つのフォームから複数のテーブルに保存する方法

Last updated at Posted at 2022-10-30

はじめに

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
1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?