はじめに
こんにちは、プログラミングスクールRUNTEQにて学習しておりますEriと申します。
開発中のアプリにForm Objectを採用したのですが、色々とハマった点がありましたので記事にまとめました。
もし間違いや改善点があれば教えていただけると幸いです!
アプリの開発環境と構成
Ruby on Rails: 7.2.1
Ruby: 3.2.3
ER図(抜粋)
Form Objectとはなんぞや
form_withにActiveRecord以外のオブジェクトを渡すことで、モデルとフォームの責務を切り分ける仕組みです。
使い所としては、
-
1つのフォームで複数のモデルを操作したい時
通常のフォームだと、コントローラー内で他のモデルの登録・更新処理も行うなどコードが肥大化/複雑化しやすいから
-
検索フォーム、ログインフォームなど
特定のフォームでしか行わない処理を切り出すことで、コードの可読性が上がるから
などが挙げられます。
今回は複数モデルの更新を行うフォームを作成するために、Form Objectを採用しました。
基本の使い方
ざっくりいうと、app/formsディレクトリ配下にForm Object用のファイルを生成し、バリデーションや保存用のメソッドなどを記述していきます。今回は投稿を作成、更新するフォームのためapp/forms/post_form.rbとしました。
class PostForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :user_id, :integer
attribute :title, :string
attribute :description, :string
attribute :mode, :integer
attribute :status, :integer
attribute :serving, :integer
attribute :ingredients_name
attribute :ingredients_quantity
attribute :steps_instruction
validates :user_id, presence: true
validates :title, presence: true, length: { maximum: 255 }
validates :description, length: { maximum: 65_535 }
def save
処理
end
end
ActiveModel::Modelをincludeしています。これにより、バリデーションを設定するなどオブジェクトをActiveRecordのモデルのように扱うことができます。
その他詳しい使い方については、参考文献をご覧ください!
(個人的)詰まりポイント集
enumが使えない
今回、投稿にレシピの情報を含めるかどうかをmodeというカラムで管理するため、enumを利用しています。
# app/models/post.rb
enum :mode, { without_recipe: 0, with_recipe: 10 }, validate: true
# デフォルト値は0を設定ずみ
投稿フォームのビューでセレクトボックスを表示して、
<%= f.select :mode, Post.modes.keys.map {|k| [k, k]}, {} %>
いざ投稿してみると、エラーは起きませんが「with_recipe」を選択しても「without_recipe」で保存されてしまっていました。
なぜなら、enumの仕組みはActiveRecordによるものだから。。
Form ObjectでincludeしているのはActiveModel::Modelです。enumに対応していなかったため、上記のselectboxの書き方では値が保存できず、デフォルト値の「without_recipe」が保存されていたようです。
解決策
gem enumeriseを利用する、ActiveModelにenumを適用させるモジュールを自作する、などの手があるようですが、今回はselectboxに渡す値を変更する方針としました。
enumに対応していないということは、入力フォームでは「without_recipe」のような定数名ではなく、実際にDBに保存する0や10といった定数自体を渡す必要があります。
実装には下記の記事を参考に対応しました。enumの日本語化をするついでに(?)、selectboxに渡すハッシュを整形するメソッドを定義しています。
# app/config/locales/activerecord/ja.yml
ja:
activerecord:
attributes:
post/mode:
without_recipe: レシピなし
with_recipe: レシピ付き
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
# 日本語化したenumの値を返すメソッドを定義
def self.human_attribute_enum_value(attr_name, value)
return if value.blank?
human_attribute_name("#{attr_name}.#{value}")
end
def human_attribute_enum(attr_name)
self.class.human_attribute_enum_value(attr_name, self.send("#{attr_name}"))
end
# selectboxに渡すハッシュを整形するメソッドを定義
def self.enum_options_for_select(attr_name)
self.send(attr_name.to_s.pluralize).map { |key, value| [ self.human_attribute_enum_value(attr_name, key), value ] }.to_h
end
end
ビュー
<%= f.select :mode, Post.enum_options_for_select(:mode), {} %>
<% # <%= f.select :mode, {"レシピなし" => 0, "レシピ付き" => 10}, {} %> と書いたのと同義になる %>
gem deviseのcurrent_userが使えない
認証機能でよく使用されるgem deviseのcurrent_userメソッド。現在ログイン中のユーザーを特定してくれる便利なメソッドですが、Form Object内では使用できません。
今回のアプリでは、UserモデルとPostモデルが1対多の関係で、Postの外部キーとしてuser_idを必須にしていたため、新規Postを保存する際にログイン中ユーザーの情報を受け取る必要がありました。
そこでログイン中ユーザーに紐づいた投稿をForm Objectで保存しようと下記のようなコードを書くと、
# app/forms/post_form.rb
def save
post = current_user.posts.build(title: title, description: description, …)
…省略
end
undefined local variable or method `current_user'
NameError発生です。「current_user」なんて変数もメソッドも定義されてませんよ、と怒られました。
理由は深掘りできていないのですが、これもおそらくActiveRecordの仕組みを利用しているからではないかと考えています。
deviseのcurrent_userのuser部分はモデルの名前が適用されており、例えばユーザー管理用のモデル名をUserではなくMemberにした場合は、current_userではなくcurrent_memberというメソッド名になります。
このようにモデルやActiveRecordに紐づいた仕組みなので、ActiveRecord::Baseを継承していないForm Objectでは使用できないということなのだと思います。
解決策
今回は、postsコントローラーのcreateアクションの中で@post_form.save
を行っています。つまりフォームから送信されたparamsは一旦postsコントローラーで受け取り、それをForm Objectに渡すという流れです。
ということで、postsコントローラーのStrong Parameterを設定するところで、current_userの情報をmergeしておくことにしました。
# app/controllers/posts_controller.rb
def post_params
params.require(:post_form).permit(:title, :description,…省略).merge(user_id: current_user.id)
end
日本語化
gem rails-i18nを利用し以下のファイルで日本語化を行いました。
- config/locales/activerecord/ja.yml
- config/locales/views/ja.yml
すると、フォームのカラムは日本語化されますが、バリデーションにかかった際のエラーメッセージが日本語化されません。
解決策
こちらもActiveModelとActiveRecordの違いに起因するものでした。
フォームのカラム名の表示が日本語化されていたのは、config/locales/activerecord/ja.ymlが探索されたため。バリデーションに関してはForm Objectで行っている処理なので、activemodelというキーが存在せず日本語化されていなかった、ということのようです。
そこでconfig/locales/activemodel/ja.ymlを生成し下記のように記述したところ、バリデーションも日本語化することができました。
ja:
activemodel:
attributes:
post_form:
title: タイトル
今回はactiverecordとactivemodelのディレクトリを分ける構成にしていますが、同ファイル内にまとめることもできます。i18nのファイル分割についてはこちらの記事をどうぞ!
edit/updateアクションの実装
Form Objectでのedit/updateアクションについては、なかなか仕組みが理解できず、自分のアプリに落とし込むのに時間がかかりました。
全体像については参考文献のサイト等をご覧いただき、ここでは個人的に理解が難しいと感じた箇所の処理の流れをみていきたいと思います。
Form Objectの初期化
投稿の更新では、既存の投稿データをForm Objectに渡す必要があるため、initializeメソッドのオーバーライドとdefault_attributesメソッドの定義で対応します。
# app/forms/post_form.rb
def initialize(attributes = nil, post: Post.new)
@post = post
attributes ||= default_attributes
super(attributes)
end
private
attr_reader :post
def default_attributes
{
user_id: post.user_id,
title: post.title,
description: post.description,
…省略
}
end
ここでは、PostFormが初期化された時の処理を記述しています。例えば下記posts#editでPostFormを初期化すると、
# app/controllers/posts/controller.rb
def edit
@post_form = PostForm.new(post: @post)
end
- initializeの引数には、既存の
@post
が渡されてきます。 - attributesはデフォルト値のnilなので、default_attributesメソッドが呼ばれます。
- default_attributesメソッドでは、渡された
@post
の情報を読み込みます。(読み取りメソッドattr_reader :post
が定義されているので、渡された@post
の情報を読み込むことができます。) - 読み取った
@post
の情報を、初期化したPostFormの属性(attribute)として適用します。
一方posts#updateでは、
# app/controllers/posts/controller.rb
def update
@post_form = PostForm.new(post_params, post: @post)
…省略
end
引数としてpost_paramsも一緒に渡しています。これにより、今度はフォームに入力された値(post_params)をattributeとしてPostFormの初期化が行われます。
ちなみに、投稿の新規作成(new,createアクション)のみであればActiveModel::Modelのinitializeメソッドで対応してくれるため、上記メソッドの定義は不要です。
delegate
Form Objectに、下記のような記述を追加しています。
# app/forms/post_form.rb
delegate :persisted?, to: :post
【delegateとは】
delegateにより、persisted?メソッドの呼び出し元を、postに委譲しています。ここではPostFormオブジェクトがpersisted?メソッドを呼ぶと、Postオブジェクトのpersisted?メソッドが呼び出されるということになります。
【persisted?メソッドとは】
レシーバーのオブジェクトが保存済みかどうかをチェックするメソッドです。form_withがHTTPリクエストのPOSTとPATCHを切り替える仕組みに利用されています。
PostFormというオブジェクトはDBに保存されていないので、これに対してpersisted?されても正しい値が返りません。
つまり上記の記述によって、新規作成・更新に応じフォームのアクションをPOST・PATCHに切り替えてもらえるようになります。
終わりに
振り返ってみると、どのポイントもActiveRecordとActiveModelの違いを把握していないことに起因していたようです。いつもRailsがよしなにやってくれるので忘れてしまいがちですが、「どうしてこういう動きになるのか?」と背後の仕組みを探っていくことが大切だなと改めて感じました。
さて、最後にアドカレのテーマを回収します。エラーの解決、それは私にとって、今回のAdvent Calendarのテーマ「プログラミングでの"ワクワク"」の一つです。仮説を立てて、検証して、だんだんと真相に近づき遂にコードが動き出す。このワクワクを忘れず、そして山を越えるたびに少しずつ強くなっていることを信じ、これからもコードを書いていきたいと思います。
参考文献
Form Object全般
edit/update関連