はじめに
はじめまして、yoshiと申します
現在未経験からエンジニアになることを目指して勉強中です。
今開発中のWebアプリ(JustBe U)で、RailsのFormオブジェクト使用する機会があったので備忘録として残します。
プログラミング初心者のため、技術的な部分で誤りを含む可能性があります。間違っている箇所を見つけた場合はお手数ですがご指摘いただけると幸いです。
やりたいこと
1日のよかったことを3つ記入して、form objectを使用して記入された3つのコンテンツを一括で保存できるようにする。
日記の新規作成と日記の編集ができるようにします。
参考記事
前提
Ruby: 3.2.2
Rails: 7.0.8
Devise *deviseについての説明は今回は省略させていただきます。
form object とは
RailsのFormオブジェクトは、フォームからデータを受け取るための独立したクラスです。これにより、単一のフォームで複数のモデルを扱うような複雑なデータの操作を簡単にすることができます。
form_withとは
Railsでフォームを作成するためのヘルパーメソッドです。このメソッドは、モデルを直接受け取るだけでなく、Formオブジェクトを引数として受け取ることもできます。Formオブジェクトをform_with
に渡すと、そのオブジェクト内で定義された属性に基づいてフォームフィールドを作成できます。
テーブル
テーブルは以下のような感じです。
usersテーブルがあり、usersテーブルと1対多の関係にあるdiariesテーブルには日記の作成日をdateカラムに保存しています。そして、diariesテーブルと1対多の関係にあるdiary_entriesテーブルには、日記の内容を保存するcontentカラムがあります。
今回は、一つの日記に対して3つのコンテンツを保存するので日付を保存するdiariesテーブルとコンテンツを保存するdiary_entriesテーブルの2つに分けています。
実装
日記機能の実装をしていきます。
基本的には、まず全体的なコードを提示してから細かな流れや説明を外部の方で行います。
1.モデル
has_many
やbelongs_to
を使ってモデルの関連付けをしていきます。さらにバリデーションも書いていきます。
class User < ApplicationRecord
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
enum role: { general: 0, guest: 1, admin: 2 }
has_many :diaries, dependent: :destroy
validates :name, presence: true, length: { maximum: 255 }
end
class DiaryEntry < ApplicationRecord
belongs_to :diary
validates :content, presence: true
end
class Diary < ApplicationRecord
belongs_to :user
has_many :diary_entries, dependent: :destroy
validates :date,
uniqueness: {
scope: :user_id,
message: lambda { |_object, data|
" #{data[:value].to_date}の日記はすでに作成済みです"
}
} #date 属性が user_id スコープ内で一意であることを確認する
end
2. Formオブジェクト
まず初めに、appディレクトリーの配下にformsディレクトリーを作成する。
formsディレクトリー内に、今回はdiary_form.rbという名前のファイルを作成します。ファイル名は、<任意の名前>_form.rbとする必要があります。
class DiaryForm
include ActiveModel::Model #バリデーションが使えるようになる
attr_accessor :user_id, :entries_contents, :entries_ids #定義した属性がform_withで引数として使用できるなる
def initialize(attributes = nil, diary: Diary.new)
@diary = diary
# attributesがnilまたは、falseであれば、||=の右の値が変数に代入されます。
attributes ||= default_attributes
super(attributes)
end
validate :all_entries_must_be_present
def save
return false unless valid?
ActiveRecord::Base.transaction do # トランザクション内の処理が一つでも失敗すれば、ロールバックされます。
diary = Diary.create!(user_id:, date: Time.current.to_date)
entries_contents.each do |content|
diary.diary_entries.create!(content:)
end
end
rescue ActiveRecord::RecordInvalid => e #処理が失敗した時の処理を書く
e.record.errors.full_messages.each do |message|
errors.add(:base, message)
end
false
end
def update_diary
return false unless valid?
ActiveRecord::Base.transaction do #トランザクション内の処理が一つでも失敗すれば、ロールバックされます。
entries_ids.each_with_index do |id, index| #要素をループしながら、それぞれの要素が元の配列の何番目にあるかを簡単に知ることができます。
entry = DiaryEntry.find(id)
entry.update!(content: entries_contents[index])
end
end
true
rescue ActiveRecord::RecordInvalid
false
end
private
attr_reader :diary #上記の@diaryインスタンス変数に読み取り専用でアクセスするために使うことができる。
private
attr_reader :diary
def default_attributes
# @diaryインスタンス変数を直接参照するのではなく、
# attr_readerで定義したdiaryメソッドを通して参照している
{
user_id: diary.user_id,
entries_contents: diary.diary_entries.map(&:content),
entries_ids: diary.diary_entries.map(&:id)
}
end
def all_entries_must_be_present
errors.add(:base, 'すべてのフォームに入力してください') if entries_contents.any?(&:blank?)
end
end
2.1 include ActiveModel::Modelについて
RailsのFormオブジェクトにinclude ActiveModel::Model
を書くと、そのオブジェクトはRailsのモデルのように振る舞えるようになります。
具体的には、以下の機能が使えるようになります:
-
バリデーション: データが正しいかどうかをチェックする機能です。例えば、メールアドレスが本当にメールアドレスが一意であるか、またはユーザー名が必ず入力されているかなどをチェックできます。
-
フォームヘルパー: フォームを簡単に作成するためのヘルパーメソッドが使えます。これにより、
form_with
のようなメソッドを使って、フォームの入力欄を作ることができます。 -
トランザクション: 複数の変更を一つの作業としてまとめて、もし何か一部がうまくいかなかった場合には、その変更をすべて元に戻すことができます。
などなど
ActiveModel::Modelをincludeすると、ActiveModel::APIのすべての機能を利用できるようになります。
モデル名のイントロスペクション
変換
翻訳(i18n)
バリデーション
また、Active Recordオブジェクトと同様に、属性のハッシュを持つオブジェクトを初期化する機能も使えます。
ActiveModel::APIをincludeしたクラスは、Active Recordオブジェクトと同様に、form_withやrenderなどのAction Viewヘルパーでも利用できます。
Railsガイド
2.2 attr_accessor について
attr_accessor :user_id, :entries_contents, :entries_ids
attr_accessorはatter_readerとattr_writerを合体させたメソッドです。
attr_accessorを使用することで、オブジェクトの特定の属性に簡単にアクセスしたり、その属性の値を設定したりすることができるようになります。
2.2.1 atter_readerとatter_writerについて
例えばDiaryというクラスがあったとします。
class Diary
def text
@text
end
def text=(value)
@text = value
end
end
diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"
atter_readerを使用すると上記のコードを以下のように省略するすることができます。
class Diary
atter_reader :text
def text=(value)
@text = value
end
end
diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"
そして、atter_writerを使うとさらに以下のように省略できるようになります。
class Diary
atter_reader :text
atter_writer :text
end
diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"
上記のatter_readerとatter_writerを一つにまとめると、attr_accessor :text
になります。
これでdiaryオブジェクトが持つ@text
をオブジェクトの外からも取得できるようになります。
2.3 initializeメソッド
initializeメソッドは、DiaryFormオブジェクトの新しいインスタンスが作成されるときに呼び出されます。
メソッドはデフォルトでattributes = nil と diary: Diary.newを引数に取っています。
attributesがnilの場合はdefault_attributesメソッドが呼び出され、diaryオブジェクトにデフォルト値がセットされます。
2.3.1 super(attributes)について
super(attributes)というコード行は、DiaryFormクラスが初期化される際に、ActiveModel::Modelから継承したinitializeメソッドを呼び出しています。ここでattributesという引数を親クラスのメソッドに渡すことにより、DiaryFormの属性が設定されます。
[superについての説明がわかりやすく書かれています]
https://tomo-bb-aki0117115.hatenablog.com/entry/2020/11/01/010733
2.4 トランザクション
saveメソッド内で、ActiveRecord::Base.transactionブロックを使ってデータベースのトランザクションを開始します。トランザクションを使用する理由は、複数の更新操作を一つの作業単位として扱い、何か問題が発生した場合には全ての変更を元に戻すためです。
3 コントローラとビュー
日記の新規作成と編集の処理はDiariesControllerの方に書いていきます。
class DiariesController < ApplicationController
before_action :set_diary, only: [:show, :edit, :update, :destroy]
def new
@diary_form = DiaryForm.new
end
def create
@diary_form = DiaryForm.new(diary_form_params.merge(user_id: current_user.id))
if @diary_form.save
redirect_to diaries_path, success: t('.success')
else
flash.now[:danger] = t('.fail')
render :new, status: :unprocessable_entity
end
end
def edit
@diary_form = DiaryForm.new(diary: @diary)
end
def update
@diary_form = DiaryForm.new(diary_form_params, diary: @diary)
if @diary_form.update_diary
redirect_to diary_path(@diary.id), success: t('.success')
else
flash.now[:danger] = t('.fail')
render :edit, status: :unprocessable_entity
end
end
private
def set_diary
@diary = current_user.diaries.find(params[:id])
end
def diary_form_params #フォームで入力された情報がパラメータに格納されている
params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
end
end
日記新規作成のビュー:
<%= render 'form', diary_form: @diary_form, form_url: diaries_path, form_method: :post %>
日記編集のビュー:
<%= render 'form', diary_form: @diary_form, form_url: diary_path(@diary), form_method: :patch %>
部分テンプレート:
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
<%= render 'shared/error_messages', object: diary_form %>
<% total_entries.times do |index| %>
<% if diary_form.entries_ids.present? %>
<%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
<% end %>
<%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
<% end %>
<%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>
ヘルパー:
module DiariesHelper
def total_entries
3
end
end
3.1 日記作成
3.1.1 newアクション
ユーザーが新規作成ページにアクセスすると、DiariesControllerのnewアクションが呼ばれます。
newアクションではDiaryFormの新しいインスタンスが作成され、@diary_form
変数に代入されます。
class DiariesController < ApplicationController
before_action :set_diary, only: [:show, :edit, :update, :destroy]
def new
@diary_form = DiaryForm.new
end
#省略
end
上記のコードが実行されると、 class DiaryForm内のinitializeメソッドが呼び出されます。
newアクションのDiaryForm.newでは引数を渡していないので、デフォルトのattributes = nil と diary: Diary.newが使用されます。
attributesがnilなので、default_attributesメソッドが呼び出されます。
class DiaryForm
include ActiveModel::Model
attr_accessor :user_id, :entries_contents, :entries_ids
def initialize(attributes = nil, diary: Diary.new)
@diary = diary
attributes ||= default_attributes
binding.pry
super(attributes)
end
#省略
private
attr_reader :diary
def default_attributes
{
user_id: diary.user_id,
entries_contents: diary.diary_entries.map(&:content),
entries_ids: diary.diary_entries.map(&:id)
}
end
#省略
end
この段階では、日記はまだ作成されていなないため、default_attributesメソッドは以下のようなハッシュを返すことになります。
#default_attributesの返り値
{
user_id: nil,
entries_contents: [],
entries_ids: []
}
newアクションの処理が終わると、日記新規作成ページが表示されます。
#form_witnに渡す、urlとmethodをform_urlとform_method変数に代入
<%= render 'form', diary_form: @diary_form, form_url: diaries_path, form_method: :post %>
diary_form: @diary_form
は、コントローラーで定義された@diary_form
インスタンス変数を、diary_formという名前でフォームのパーシャルに渡しています。
今回は、パーシャルに渡すローカル変数としてdiary_form以外に以下の2つも渡していきます。form_urlとform_methodというローカル変数をパーシャルに渡すことで、フォームが新規作成(create)なのか更新(update)なのかを区別しています。
-
form_url:
これはフォームがデータを送信する先のURLです。diaries_path は新しい日記のデータをアプリケーションに送信するためのパスを生成するヘルパーメソッドです。 -
form_method:
これはフォームがデータをサーバーにどのHTTPメソッドで送信するかを指定します。今回は新規作成(create)なので:postを指定しています。
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
<%= render 'shared/error_messages', object: diary_form %>
<% total_entries.times do |index| %>
<% if diary_form.entries_ids.present? %>
<%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
<% end %>
<%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
<% end %>
<%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>
フォームの表示:
次に、_form.html.erb パーシャル内で form_with ヘルパーを使ってフォームを生成します。@diary_form
オブジェクトをモデルとして利用します。そして先ほど new.html.erb から渡された form_url と form_method がフォームの送信先のURLと使用するHTTPメソッドを設定します。
データの送信:
ユーザーがフォームに入力し、'登録する'ボタンをクリックすると、フォームのデータはcreateアクションに送信されます。
3.1.2 createアクション
class DiariesController < ApplicationController
before_action :set_diary, only: [:show, :edit, :update, :destroy]
#省略
def create
#フォームから送信されたパラメータ(diary_form_params)にユーザーidをマージします。
@diary_form = DiaryForm.new(diary_form_params.merge(user_id: current_user.id))
if @diary_form.save
redirect_to diaries_path, success: t('.success')
else
flash.now[:danger] = t('.fail')
render :new, status: :unprocessable_entity
end
end
#省略
DiaryForm.newでinitializeメソッドが呼び出されます。
- 引数には、ユーザーがフォームに入力した情報(diary_form_params)に現在ログインしているユーザーのIDを追加したものがattributes渡されます。 なので、今回の場合attributesはnilではないので、default_attributesメソッドは呼び出されません。
- diary:の引数は指定されていないので、デフォルトのdiary: Diary.newが使用されます。
例えば:
ユーザーid:2のユーザーが、日記作成のフォームに、"AAA"、"BBB"、"CCC"の3つを入力した場合、attributesは以下のようになります。
[2] pry(#<DiaryForm>)> attributes
=> #<ActionController::Parameters {"entries_contents"=>["AAA", "BBB", "CCC"], "user_id"=>2}
次に、コントローラでsaveメソッドが呼ばれると、DiaryForm内で定義されたsaveメソッドが実行され、データベースへの保存が試みられます。
保存が成功すると、ユーザーは日記リストページにリダイレクトされます。失敗すると、エラーメッセージが表示され、新規作成ページが再表示されます。
class DiaryForm
include ActiveModel::Model
attr_accessor :user_id, :entries_contents, :entries_ids
def initialize(attributes = nil, diary: Diary.new)
@diary = diary
attributes ||= default_attributes
binding.pry
super(attributes)
end
validate :all_entries_must_be_present
def save
return false unless valid?
ActiveRecord::Base.transaction do
#ログイン中のユーザーのidと作成した日付を保存
diary = Diary.create!(user_id:, date: Time.current.to_date)
#フォームで入力された3つの内容をeachメソッドを使用して一つずつ、diary_entriesテーブルに保存していきます。
entries_contents.each do |content|
diary.diary_entries.create!(content:)
end
end
rescue ActiveRecord::RecordInvalid => e
e.record.errors.full_messages.each do |message|
errors.add(:base, message)
end
false
end
#省略
3.2 日記編集
3.2.1 editアクション
編集ページへのアクセス:
ユーザーが日記の編集ページにアクセスすると、editアクション前にbefore_action :set_diary
の部分で、編集する日記のデータを取得し、@diary
インスタンス変数に代入されます。
そしてeditアクションが呼ばれます。
class DiariesController < ApplicationController
before_action :set_diary, only: [:show, :edit, :update, :destroy]
#省略
def edit
@diary_form = DiaryForm.new(diary: @diary)
end
#省略
private
def set_diary
@diary = current_user.diaries.find(params[:id])
end
def diary_form_params #フォームで入力された情報がパラメータに格納されている
params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
end
end
editアクションでDiaryForm.newをしinitializeメソッドが呼び出されます。
今回は引数としてset_diaryメソッド内の@diary
を渡しています。
そうすることで、default_attributesメソッドでデフォルトのフォーム属性が自動的に設定されます。このメソッドは@diary
からユーザーID、日記の内容、そしてそれらの日記の内容に対応するIDを取得し、DiaryFormオブジェクトの属性として割り当てます。これにより、フォームは編集を始める時点で既存の日記のデータを持っていることになり、ユーザーが以前に入力した内容を見たり、必要に応じて変更したりすることができるようになります。
例えば:
set_diary
で以下の日記のデータを取得し@diaryに代入したとします。
[2] pry(#<DiariesController>)> @diary
=> #<Diary:0x00000001099c0fe0
id: 7,
date: Mon, 30 Oct 2023,
user_id: 2,
created_at: Mon, 30 Oct 2023 21:14:37.964134000 JST +09:00,
updated_at: Mon, 30 Oct 2023 21:14:37.964134000 JST +09:00>
上記のDiaryのid: 7に対応する3つの日記の内容は以下のようになっているとします。
[2] pry(#<DiaryForm>)> @diary.diary_entries
=> [#<DiaryEntry:0x0000000107ad1c38
id: 17,
content: "AAA",
diary_id: 7,
created_at: Mon, 30 Oct 2023 21:14:37.971044000 JST +09:00,
updated_at: Wed, 08 Nov 2023 13:31:35.852307000 JST +09:00>,
#<DiaryEntry:0x0000000107ad1af8
id: 18,
content: "BBB",
diary_id: 7,
created_at: Mon, 30 Oct 2023 21:14:37.973529000 JST +09:00,
updated_at: Wed, 08 Nov 2023 13:31:35.858614000 JST +09:00>,
#<DiaryEntry:0x0000000107ad1878
id: 16,
content: "CCC",
diary_id: 7,
created_at: Mon, 30 Oct 2023 21:14:37.967653000 JST +09:00,
updated_at: Wed, 08 Nov 2023 13:31:35.863195000 JST +09:00>]
default_attributesメソッドでは以下のようなハッシュを返すことになります。
{:user_id=>2, :entries_contents=>["AAA", "BBB", "CCC"], :entries_ids=>[17, 18, 16]}
フォームの表示:
<%= render 'form', diary_form: @diary_form, form_url: diary_path(@diary), form_method: :patch %>
-
form_url: diary_path(@diary)
とform_method: :patch
をパーシャルに渡し日記の更新(update)として処理されるようにしています。
日記の新規作成の時と同じ_form.html.erbパーシャルが呼び出され、@diary_form
に編集する日記のデータが渡されます。
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
<%= render 'shared/error_messages', object: diary_form %>
<% total_entries.times do |index| %>
<% if diary_form.entries_ids.present? %>
<%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
<% end %>
<%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
<% end %>
<%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>
データの送信:
ユーザーがフォームを編集し、'編集する'ボタンをクリックすると、フォームのデータはupdateアクションに送信されます。
3.2.2 updateアクション
class DiariesController < ApplicationController
before_action :set_diary, only: [:show, :edit, :update, :destroy]
#省略
def update
@diary_form = DiaryForm.new(diary_form_params, diary: @diary)
if @diary_form.update_diary
redirect_to diary_path(@diary.id), success: t('.success')
else
flash.now[:danger] = t('.fail')
render :edit, status: :unprocessable_entity
end
end
private
def set_diary
@diary = current_user.diaries.find(params[:id])
end
def diary_form_params #フォームで入力された情報がパラメータに格納されている
params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
end
end
DiaryForm.newを使ってDiaryFormのインスタンスを作成します。
この際、initializeメソッドが呼び出され、フォームから送信されたパラメータdiary_form_params
と、編集対象の日記オブジェクト@diary
が引数として渡されます。
diary_form_params
にはユーザーがフォームに入力した内容が含まれており、これによりDiaryFormオブジェクトはフォームの入力値を属性として持つことになります。
class DiaryForm
#省略
def initialize(attributes = nil, diary: Diary.new)
@diary = diary
attributes ||= default_attributes
super(attributes)
end
validate :all_entries_must_be_present
#省略
def update_diary
return false unless valid?
ActiveRecord::Base.transaction do
entries_ids.each_with_index do |id, index|
entry = DiaryEntry.find(id)
entry.update!(content: entries_contents[index])
end
end
true
rescue ActiveRecord::RecordInvalid
false
end
private
attr_reader :diary #上記の@diaryインスタンス変数に読み取り専用でアクセスするために使うことができる
#default_attributesメソッド内でattr_reader :diaryが呼び出されています。
def default_attributes
{
user_id: diary.user_id,
entries_contents: diary.diary_entries.map(&:content),
entries_ids: diary.diary_entries.map(&:id)
}
end
def all_entries_must_be_present
errors.add(:base, 'すべてのフォームに入力してください') if entries_contents.any?(&:blank?)
end
end
次にDiaryForm内のupdate_diaryメソッドを呼び出します。
-
バリデーション:
作成されたDiaryFormオブジェクトは、valid?メソッドを使ってバリデーションを実行します。このメソッドは、フォームの入力がすべてのバリデーション条件を満たしているかどうかをチェックします。もしバリデーションに失敗すると、処理はそこで中断され、エラーメッセージと共に編集フォームが再表示されます。 -
データの更新:
トランザクション内で、entries_idsに含まれる各IDに対応する日記のコンテンツをDiaryEntry.find(id)で探しし、update!メソッドを使ってその内容(content)をentries_contents配列から取得した新しい内容で更新します。update!メソッドは、更新が成功するか、例外が発生するかのどちらかです。例外が発生した場合、トランザクションはロールバックされ、エラーメッセージが返されます。
これで、日記作成と日記編集のができるようになりました。
今回はデータの一括保存で、form objectを使用しましたがコレクションモデルを使用してデータの一括保存を実装している記事もありました。見た感じform objectと似たような感じがしてまだこの二つの違いがよく分かっていません
記事も、古いものが多かったので今回はform objectを使用しました。
データを一括保存で保存する他の方法を知っている方がいましたら、教えてくださると助かります。
おわりに
今回初めて記事を書いたのですが、思った以上に大変でした。頭では分かっていても、いざ言葉で説明するとなるとうまく語源化することができず苦戦しました。
苦戦した分、少し苦手意識のあったform objectについて理解を深めることができたと思います。
最後まで読んでいただきありがとうございました。