6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RUNTEQAdvent Calendar 2024

Day 18

[Rails]Form Objectを実装したときに詰まったポイント集

Last updated at Posted at 2024-12-17

はじめに

こんにちは、プログラミングスクールRUNTEQにて学習しておりますEriと申します。

開発中のアプリにForm Objectを採用したのですが、色々とハマった点がありましたので記事にまとめました。

もし間違いや改善点があれば教えていただけると幸いです!

アプリの開発環境と構成

Ruby on Rails: 7.2.1

Ruby: 3.2.3

ER図(抜粋)

Image from Gyazo

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

すると、フォームのカラムは日本語化されますが、バリデーションにかかった際のエラーメッセージが日本語化されません。

Image from Gyazo

解決策

こちらもActiveModelとActiveRecordの違いに起因するものでした。

フォームのカラム名の表示が日本語化されていたのは、config/locales/activerecord/ja.ymlが探索されたため。バリデーションに関してはForm Objectで行っている処理なので、activemodelというキーが存在せず日本語化されていなかった、ということのようです。

そこでconfig/locales/activemodel/ja.ymlを生成し下記のように記述したところ、バリデーションも日本語化することができました。

ja:
  activemodel:
    attributes:
      post_form:
        title: タイトル

今回はactiverecordとactivemodelのディレクトリを分ける構成にしていますが、同ファイル内にまとめることもできます。i18nのファイル分割についてはこちらの記事をどうぞ!

【Rails】i18nのja.ymlを分割する理由について - Qiita

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
  1. initializeの引数には、既存の@postが渡されてきます。
  2. attributesはデフォルト値のnilなので、default_attributesメソッドが呼ばれます。
  3. default_attributesメソッドでは、渡された@postの情報を読み込みます。(読み取りメソッドattr_reader :postが定義されているので、渡された@postの情報を読み込むことができます。)
  4. 読み取った@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関連

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?