エンジニア転職日記

エンジニア転職に向けての日記です

How to Formオブジェクト

概要

入力フォームなどのデータを、一括で複数のテーブルに保存できる「Formオブジェクト」についてアウトプットします。

Formオブジェクトとは

1回のフォーム送信で複数のモデルを更新することができる仕組みです。Formオブジェクトは、ActiveModel::Modelというモジュールを読み込むことで利用することができます。

ActiveModel::Modelをincludeすると、ActiveRecordと同様に「form_for」や「render」などのヘルパーメソッドを使えるようになります。

バリデーションの設定に関しても、ActiveModel::Modelを設定したモデルに複数のテーブルに対する制約を記述できるため、可読性も向上します。

使い方

今回は擬似的に寄付アプリを制作します。

ユーザー、住所、寄付金の情報を一度で全て保存するように設定していきます。

 

事前準備

まずはアプリケーションの土台を構築します。

% rails _6.0.0_ new donation_app -d mysql

% cd donation_app

% rails db:create

 

ルーティングを設定します。

Rails.application.routes.draw do
  root "donations#index"
  resources :donations, only: [:index, :new, :create]
end

 

コントローラーを作成します。

% rails g controller donations index

 

モデルを作成します。今回はユーザー、住所、寄付金の3つです。

% rails g model user
% rails g model address
% rails g model donation

 

アソシエーションを組みます。

class User < ApplicationRecord
  has_one :address
  has_one :donation
end
class Address < ApplicationRecord
  belongs_to :user
end
class Donation < ApplicationRecord
  belongs_to :user
end

 

マイグレーションファイルを編集し、データベースに反映します。

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name,           null: false
      t.string :name_reading,   null: false
      t.string :nickname,       null: false
      t.timestamps
    end
  end
end
class CreateAddresses < ActiveRecord::Migration[6.0]
  def change
    create_table :addresses do |t|
      t.string :postal_code,    default: "",  null: false
      t.integer :prefecture,                  null: false
      t.string :city,           default: ""
      t.string :house_number,   default: ""
      t.string :building_name,  default: ""
      t.references :user,                     null: false,  foreign_key: true
      t.timestamps
    end
  end
end
class CreateDonations < ActiveRecord::Migration[6.0]
  def change
    create_table :donations do |t|
      t.integer :price,   index: true,  null: false
      t.references :user,               null: false,  foreign_key: true
      t.timestamps
    end
  end
end
% rails db:migrate

これで事前準備は完了です。

 

Formオブジェクトを用いた実装

まず、Formオブジェクトを記述するディレクトリ、ファイルを作成します。

app/forms/user_donation.rbとなるようにformsディレクトリとuser_donation.rbファイルを作成します。

 

user_donation.rbを編集します。

class UserDonation

  include ActiveModel::Model
  attr_accessor :name, :name_reading, :nickname, :postal_code, :prefecture, :city, :house_number, :building_name, :price

  with_options presence: true do
    validates :name, format: { with: /\A[ぁ-んァ-ン一-龥]/, message: "is invalid. Input full-width characters."}
    validates :name_reading, format: { with: /\A[ァ-ヶー-]+\z/, message: "is invalid. Input full-width katakana characters."}
    validates :nickname, format: { with: /\A[a-z0-9]+\z/i, message: "is invalid. Input half-width characters."}
    validates :postal_code, format: {with: /\A[0-9]{3}-[0-9]{4}\z/, message: "is invalid. Include hyphen(-)"}
    validates :price, format: {with: /\A[0-9]+\z/, message: "is invalid. Input half-width characters."}
  end
  validates :prefecture, numericality: { other_than: 0, message: "can't be blank" }
  validates :price, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: "is out of setting range"}

  def save
    # ユーザーの情報を保存し、「user」という変数に入れている
    user = User.create(name: name, name_reading: name_reading, nickname: nickname)
    # 住所の情報を保存
    Address.create(postal_code: postal_code, prefecture: prefecture, city: city, house_number: house_number, building_name: building_name,user_id: user.id)
    # 寄付金の情報を保存
    Donation.create(price: price, user_id: user.id)
  end
end

 

構造は以下のようになっています。

include ActiveModel::Model

ActiveModel::Modelをincludeし、form_forの受け取りやバリデーション設定を可能にします。

  def save
    # ユーザーの情報を保存し、「user」という変数に入れている
    user = User.create(name: name, name_reading: name_reading, nickname: nickname)
    # 住所の情報を保存
    Address.create(postal_code: postal_code, prefecture: prefecture, city: city, house_number: house_number, building_name: building_name,user_id: user.id)
    # 寄付金の情報を保存
    Donation.create(price: price, user_id: user.id)
  end

user = User.create...の部分は、AdressとDonationのカラムにuser_idを設定しているため、user_id: user.idとして保存できるようにするために変数化しています。

 

コントローラーを編集します。

class  DonationsController < ApplicationController
 def index
 end

 def new
   @donation = UserDonation.new 
 end

 def create
   @donation = UserDonation.new(donation_params)
   
   if @donation.valid?
     @donation.save  # バリデーションをクリアした時
     return redirect_to root_path
   else
     render "new"    # バリデーションに弾かれた時
   end
 end 
private
def donation_params
params.require(:user_donation).permit(:name, :name_reading, :nickname, :postal_code, :prefecture, :city, :house_number, :building_name, :price)
end
end

@donation.valid?とすることで、バリデーションを手動で起動させています。この記述がないと、バリデーションが効かず、空欄のままでも保存できてしまいます。 

 

 

最後に、ビューを作成します。

#app/view/donations/new.html.erb
<%=
form_with(model: @donation, url: donations_path, local: true) do |form| %>
<%= render 'error_messages', donation: @donation %>
<h1>ユーザー名を入力</h1>
<div class="field"> <%= form.label :name, "名前(全角)" %> <%= form.text_field :name %> </div> <div class="field"> <%= form.label :name_reading, "フリカナ(全角カタカナ)" %> <%= form.text_field :name_reading %> </div> <div class="field"> <%= form.label :nickname, "ニックネーム(半角英数)" %> <%= form.text_field :nickname %> </div> <div class="actions"> <%= form.submit "寄付する" %> </div> <% end %>


<h1>住所を入力</h1>
<div class="field"> <%= form.label :postal_code, "郵便番号(ハイフンを含む)" %> <%= form.text_field :postal_code %> </div>

 <div class="field">
   <%= form.label :prefecture, "都道府県" %>
   <%= form.collection_select :prefecture, Prefecture.all, :id, :name, {include_blank: "---"} %>
 </div>
 <div class="field">  <%= form.label :city, "市町村(任意)" %>  <%= form.text_field :city %>  </div>  <div class="field">  <%= form.label :house_number, "番地(任意)" %>  <%= form.text_field :house_number %>  </div>  <div class="field">  <%= form.label :building_name, "建物名(任意)" %>  <%= form.text_field :building_name %>  </div>   <h1>寄付金</h1>  <div class="field">  <%= form.label :price, "いくら寄付しますか?(1〜1000000円、半角)" %>  <%= form.text_field :price %>  <%= "円" %>  </div>   <div class="actions"> <%= form.submit "寄付する" %>  </div> <% end %>

都道府県の選択フォームにはActive::Hashを利用しています。

Active::Hashについては別の記事でまとめています。

 

エラーメッセージ

# app/views/donations/_error_messages.html.erb
<
% if donation.errors.any? %>
<div class="error-alert"> <ul> <% donation.errors.full_messages.each do |message| %> <li class="error-message"> <%= message %> </li> <% end %> </ul> </div> <% end %>

 

モデル単体テスト

gemを導入します。

group :development, :test do
# 中略
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end

 

.rspecを編集します。

--require spec_helper
--format documentation

 

テストファイルを生成します。

% rails g rspec:model user_donation

 

FactoryBotを作成します。spec/factories/user_donations.rbとなるようにディレクトリとファイルを作成します。

user_donations.rbを編集します。

FactoryBot.define do
  factory :user_donation do
    name { '鈴木' }
    name_reading { 'スズキ' }
    nickname { 'suzuki' }
    postal_code { '123-4567' }
    prefecture { 1 }
    city { '東京都' }
    house_number { '1-1' }
    building_name { '東京ハイツ' }
    price { 2000 }
  end
end

 

テストを記述します。

require 'rails_helper'

RSpec.describe UserDonation, type: :model do
  describe '寄付情報の保存' do
    before do
      @user_donation = FactoryBot.build(:user_donation)
    end

    it 'すべての値が正しく入力されていれば保存できること' do
      expect(@user_donation).to be_valid
    end
    it 'nameが空だと保存できないこと' do
      @user_donation.name = nil
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Name can't be blank")
    end
    it 'nameが全角日本語でないと保存できないこと' do
      @user_donation.name = "suzuki"
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Name is invalid. Input full-width characters.")
    end
    it 'name_readingが空だと保存できないこと' do
      @user_donation.name_reading = nil
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Name reading can't be blank")
    end
    it 'name_readingが全角日本語でないと保存できないこと' do
      @user_donation.name_reading = "スズキ"
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Name reading is invalid. Input full-width katakana characters.")
    end
    it 'nicknameが空だと保存できないこと' do
      @user_donation.nickname = nil
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Nickname can't be blank")
    end
    it 'nicknameが半角でないと保存できないこと' do
      @user_donation.nickname = "すずき"
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Nickname is invalid. Input half-width characters.")
    end
    it 'postal_codeが空だと保存できないこと' do
      @user_donation.postal_code = nil
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Postal code can't be blank")
    end
    it 'postal_codeが半角のハイフンを含んだ正しい形式でないと保存できないこと' do
      @user_donation.postal_code = '1234567'
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Postal code is invalid. Include hyphen(-)")
    end
    it 'prefectureを選択していないと保存できないこと' do
      @user_donation.prefecture = 0
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Prefecture can't be blank")
    end
    it 'cityは空でも保存できること' do
      @user_donation.city = nil
      expect(@user_donation).to be_valid
    end
    it 'house_numberは空でも保存できること' do
      @user_donation.house_number = nil
      expect(@user_donation).to be_valid
    end
    it 'building_nameは空でも保存できること' do
      @user_donation.building_name = nil
      expect(@user_donation).to be_valid
    end
    it 'priceが空だと保存できないこと' do
      @user_donation.price = nil
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Price can't be blank")
    end
    it 'priceが全角数字だと保存できないこと' do
      @user_donation.price = '2000'
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Price is invalid. Input half-width characters.")
    end
    it 'priceが1円未満では保存できないこと' do
      @user_donation.price = 0
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Price is out of setting range")
    end
    it 'priceが1,000,000円を超過すると保存できないこと' do
      @user_donation.price = 1000001
      @user_donation.valid?
      expect(@user_donation.errors.full_messages).to include("Price is out of setting range")
    end
  end
end

 

テストコードを実行します。

% bundle exec rspec spec/models/user_donation_spec.rb 

 

テストが無事パスすれば、Formオブジェクトによる一括保存が実装できました。