🦈

Formオブジェクトで新規投稿画面に複数のレコードを表示させる

2025/01/20に公開

はじめに

こんにちは。こんばんは。オンラインのプログラミングスクールに通うポルンと申します。
プログラミングぴよぴよ初心者の私がFormオブジェクトで盛大に躓き、色んな方にアドバイスをいただきながらなんとか解決したコードをここに晒します。
とはいえ未だ完全に理解できていないことと、コード自体にも助長な部分があるかと思いますので、アドバイスどしどし募集しています🫡

やりたいこと

  • 新規登録の際に、親モデル・子モデルの一部カラムに情報を入れた状態で新規登録画面を表示させたい
    • newアクションを経たinitializeメソッドの中で、親モデルの作成と子モデルのビルドを同時に行いたい(ただしnewアクション時点ではどれもまだデータベースに保存しない)
  • 日別カレンダーの中から編集したい日付の編集ボタンを押した時に、新規登録画面への遷移と同時にその日付の値を渡す
  • 親モデルのuser_idにログイン中のユーザーidを入れたい(gem 'devise'を利用)
  • 子モデルをbuildする際にデフォルト値を付与したい

使用バージョン

  • Rails: 7.2.2
  • Ruby: 3.3.6

Form Object導入経緯

元々親モデルに属している小モデルを同一レコードとして保存させるために、accepts_nested_attributes_forメソッドを利用していました。
ですがこのメソッドは評判が良くないらしく、使用非推奨という話もあるとか。
とはいえ有り体に言って絶対非推奨! というわけではないらしいので、参考になった記事を載せておきます。
https://moneyforward-dev.jp/entry/2018/12/15/formobject/
https://zenn.dev/ysi831/articles/0faa4c301e7d9f

また、コントローラーでの責務が多く、切り分けと個人的な勉強のためにもFormオブジェクトを導入することにしました。

ER図, Model

dbdiagram

  • 子モデルは全てhas_oneの関係

  • 親モデルapp/models/sleep_log.rb

    class SleepLog < ApplicationRecord
    belongs_to :user
    has_one :awakening, dependent: :destroy
    has_one :napping_time, dependent: :destroy
    has_one :comment, dependent: :destroy
    # この記述を削除 accepts_nested_attributes_for :awakening, :napping_time, :comment
    
    validates :user_id, presence: true
    validates :date, uniqueness: { scope: :user_id, message: "はすでに登録されています" }
    end
    
  • 子モデルその1app/models/awakening.rb

    class Awakening < ApplicationRecord
    belongs_to :sleep_log
    attribute :awakenings_count, :integer
    
    validates :awakenings_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
      end
    
  • 子モデルその2app/models/napping_time.rb

    class NappingTime < ApplicationRecord
    belongs_to :sleep_log
    
    validates :napping_time, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
      end
    
  • 子モデルその3app/models/comment.rb

    class Comment < ApplicationRecord
    belongs_to :sleep_log
    
    validates :comment, length: { maximum: 42 }
    end
    

日付カレンダーのテーブルファイル

このファイルの編集ボタンを押した時に、その日付レコードに含まれているの日付情報がcontrollerに送られるようにしています。
link_toで送信しているため、実際に送っているパラメーターはDate型ではなくString型らしいです。
app/views/sleep_logs/_logs_table.html.erb

<tbody>
  <% @sleep_logs.each do |sleep_log| %> <!-- 月初から月末までの日付を繰り返し表示する -->
    <tr class="border border-secondary bg-primary">
      <td class="p-2 border border-secondary bg-primary"><%= sleep_log.date.day %></td>

<!-- 中略 -->

      <td class="p-2 border border-secondary bg-primary">
          <% if sleep_log.persisted? %>
            <span class="material-symbols-outlined inline-flex">
              <%= link_to 'edit_square', edit_sleep_log_path(sleep_log) %>
            </span>
          <% else %>
            <span class="material-symbols-outlined inline-flex">
              <%= link_to 'edit_square', new_sleep_log_path(date: sleep_log.date.to_s) %>
            </span>
          <% end %>
      </td>
    </tr>
  <% end %>
</tbody>
  • date: sleep_log.date.to_s: Railsが勝手にDate型をString型に変えてくれるらしいのですが、念の為自前でString型に変換しています。
  • この時点ではまだcurrent_user情報は送らなくても問題ないようです。

Contorller

app/controllers/sleep_logs_controller.rb

class SleepLogsController < ApplicationController
  # ログインしていない場合はログイン画面にリダイレクト
  before_action :authenticate_user!, only: [ :index, :new, :edit, :update, :destroy ]

    # 中略

  def new
    @date = params[:date] # 送られてきたHashのパラメーターからstring型の日付を読み込む
    @user = current_user.id # ログイン中のユーザーidを取得
    # Formオブジェクトのインスタンスを作成
    @sleep_log_form = SleepLogForm.new(date: @date, user_id: @user) # 親モデル作成時に必要なため、日付とユーザーidも一緒に送る
  end

    # 後略
  • newアクションでFormオブジェクトを作成します。後述のFormオブジェクトファイルの名前(あるゐはクラス名?)をオブジェクト名とすることで、勝手にファイルを読み込んでくれるんですね! はぇ〜便利。
  • Formオブジェクトに送りたいキーと値を詰めこんで、いざ出発🛫

Formオブジェクト

  • まずはApplicationディレクトリの配下にFormsディレクトリを作成します。でないとnewアクションでFormオブジェクトを作った時に迷子になってしまうようです。
  • Formオブジェクトファイルを作成します。

app/forms/sleep_log_form.rb

class SleepLogForm
  include ActiveModel::Model # 通常のモデルと同じように振る舞う
  include ActiveModel::Attributes # 属性が使えるようになる

  # パラメータの読み書きを許可する。指定の属性に変換してくれる。デフォルト値も設定可能
  attribute :user_id, :integer
  attribute :date, :date
  attribute :go_to_bed_at, :datetime
  attribute :fell_asleep_at, :datetime
  attribute :woke_up_at, :datetime
  attribute :leave_bed_at, :datetime

  # 子モデルで扱いたいカラムの属性
  attribute :awakening_awakenings_count, :integer, default: 0 # モデルでデフォルト値を設定していないため、ここで設定しています
  attribute :napping_times_napping_time, :integer, default: 0
  attribute :comments_comment, :string

  # 初期化
  def initialize(attributes = nil, date:, user_id:) # 上で設定したパラメータにnilを入れる。newアクションから送られてきたHashのキーを指定

    # 親モデルを新規作成
    @sleep_log_form = SleepLog.new
    @sleep_log_form.date = date # 送られてきた日付を親モデルのdateカラムに入れる
    @sleep_log_form.user_id = user_id # 送られてきたユーザーidを親モデルのuser_idカラムに入れる

    # 子モデルの初期化
    initialize_associations

    attributes ||= default_attributes # パラメーターにnilが入っている時は、デフォルトを入れる。すでにnilが入っているため基本的にはdefault_attributesメソッドが発動する
    super(attributes) # 上で設定した属性などの設定を適用
  end

  private

  # editの際は元々の値を呼び出す
  attr_reader :sleep_log_form

  def default_attributes
    {
      # 親モデルのデフォルト属性
      user_id: @sleep_log_form.user_id,
      date: @sleep_log_form.date,
      go_to_bed_at: @sleep_log_form.go_to_bed_at,
      fell_asleep_at: @sleep_log_form.fell_asleep_at,
      woke_up_at: @sleep_log_form.woke_up_at,
      leave_bed_at: @sleep_log_form.leave_bed_at

      # 子モデルのデフォルト属性
      # ここも入れるべきなのでしょうが、buildとの相性が悪いため一旦省いています。現在Editアクション検証中なので、完了しましたら編集します。

    }
  end

  # 子モデルのビルド
  def initialize_associations
    @sleep_log_form.build_awakening unless @sleep_log_form.awakening.present? # 存在してなければbuild
    @sleep_log_form.build_napping_time unless @sleep_log_form.napping_time.present?
    @sleep_log_form.build_comment unless @sleep_log_form.comment.present?
  end
end
  • include ActiveModel::Modelについて
    モデルと同じような振る舞いをして欲しいので、導入しています
    属性への代入・型変換・今回は利用しませんでしたがバリデーションなどもやってくれるそうなのでFormオブジェクトを導入するならまず外せないモジュールかと思います。

    Railsガイド

  • include ActiveModel::Attributes
    attr_accessorで値を読み書きするだけなら、このモジュールは必要ありません。
    今回デフォルト値を指定の属性で入れておきたかったので、導入しました。

  • attribute :オブジェクトに設定しておきたい属性値, :属性の型, :オプションでdefault値
    こちらの記事が参考になりました。
    ActiveModel::Attributesを使う
    モデルファイルで子モデルの数値が必ず0以上のInteger型にするバリデーションを設けているので、ビルド時点でデフォルト値を付ける必要がありました。

  • attributes ||= default_attributes
    元々Editアクション用に入れたのですが、これがないと@sleep_log_formに入れたdate, user_idの情報が表示されませんでした。

    詳しく説明するため、以下にinitializeメソッド後、SleepLogFormオブジェクトをインスタンス変数@sleep_log_formに格納したときのログを載せます。
    大きく分けて@attributesと@sleep_logという値が存在します。一番左にインデントが寄っている2箇所にご注目ください。

23:06:33 web.1  | #<SleepLogForm:0x0000ffff8a9d0fa0
23:06:33 web.1  |  @attributes=
23:06:33 web.1  |   #<ActiveModel::AttributeSet:0x0000ffff92ff8678
23:06:33 web.1  |    @attributes=
23:06:33 web.1  |     {"user_id"=>
23:06:33 web.1  |       #<ActiveModel::Attribute::FromUser:0x0000ffff92e56680
23:06:33 web.1  |        @name="user_id",
23:06:33 web.1  |        @original_attribute=
23:06:33 web.1  |         #<ActiveModel::Attribute::WithCastValue:0x0000ffff92e569f0
23:06:33 web.1  |          @name="user_id",
23:06:33 web.1  |          @original_attribute=nil,
23:06:33 web.1  |          @type=
23:06:33 web.1  |           #<ActiveModel::Type::Integer:0x0000ffff90f39338
23:06:33 web.1  |            @limit=nil,
23:06:33 web.1  |            @precision=nil,
23:06:33 web.1  |            @range=-2147483648...2147483648,
23:06:33 web.1  |            @scale=nil>,
23:06:33 web.1  |          @value_before_type_cast=nil>,
23:06:33 web.1  |        @type=
23:06:33 web.1  |         #<ActiveModel::Type::Integer:0x0000ffff90f39338
23:06:33 web.1  |          @limit=nil,
23:06:33 web.1  |          @precision=nil,
23:06:33 web.1  |          @range=-2147483648...2147483648,
23:06:33 web.1  |          @scale=nil>,
23:06:33 web.1  |        @value_before_type_cast=1>,
23:06:33 web.1  |      "date"=>
23:06:33 web.1  |       #<ActiveModel::Attribute::FromUser:0x0000ffff92e565e0
23:06:33 web.1  |        @name="date",
23:06:33 web.1  |        @original_attribute=
23:06:33 web.1  |         #<ActiveModel::Attribute::WithCastValue:0x0000ffff92e56950
23:06:33 web.1  |          @name="date",
23:06:33 web.1  |          @original_attribute=nil,
23:06:33 web.1  |          @type=
23:06:33 web.1  |           #<ActiveModel::Type::Date:0x0000ffff8a9d3660
23:06:33 web.1  |            @limit=nil,
23:06:33 web.1  |            @precision=nil,
23:06:33 web.1  |            @scale=nil>,
23:06:33 web.1  |          @value_before_type_cast=nil>,
23:06:33 web.1  |        @type=
23:06:33 web.1  |         #

~~~ 中略 ~~~

23:06:33 web.1  |          @limit=nil,
23:06:33 web.1  |          @precision=nil,
23:06:33 web.1  |          @scale=nil>,
23:06:33 web.1  |        @value_before_type_cast=nil>,
23:06:33 web.1  |      "awakening_awakenings_count"=>
23:06:33 web.1  |       #<ActiveModel::Attribute::UserProvidedDefault:0x0000ffff92e56770
23:06:33 web.1  |        @name="awakening_awakenings_count",
23:06:33 web.1  |        @original_attribute=nil,
23:06:33 web.1  |        @type=
23:06:33 web.1  |         #<ActiveModel::Type::Integer:0x0000ffff90f37d08
23:06:33 web.1  |          @limit=nil,
23:06:33 web.1  |          @precision=nil,
23:06:33 web.1  |          @range=-2147483648...2147483648,
23:06:33 web.1  |          @scale=nil>,
23:06:33 web.1  |        @user_provided_value=0,
23:06:33 web.1  |        @value_before_type_cast=0>,
23:06:33 web.1  |      "napping_times_napping_time"=>
23:06:33 web.1  |       #<ActiveModel::Attribute::UserProvidedDefault:0x0000ffff92e56720
23:06:33 web.1  |        @name="napping_times_napping_time",
23:06:33 web.1  |        @original_attribute=nil,
23:06:33 web.1  |        @type=
23:06:33 web.1  |         #<ActiveModel::Type::Integer:0x0000ffff90f377b8
23:06:33 web.1  |          @limit=nil,
23:06:33 web.1  |          @precision=nil,
23:06:33 web.1  |          @range=-2147483648...2147483648,
23:06:33 web.1  |          @scale=nil>,
23:06:33 web.1  |        @user_provided_value=0,
23:06:33 web.1  |        @value_before_type_cast=0>,
23:06:33 web.1  |      "comments_comment"=>
23:06:33 web.1  |       #<ActiveModel::Attribute::WithCastValue:0x0000ffff92e566d0
23:06:33 web.1  |        @name="comments_comment",
23:06:33 web.1  |        @original_attribute=nil,
23:06:33 web.1  |        @type=
23:06:33 web.1  |         #<ActiveModel::Type::String:0x0000ffff90f37358
23:06:33 web.1  |          @false="f",
23:06:33 web.1  |          @limit=nil,
23:06:33 web.1  |          @precision=nil,
23:06:33 web.1  |          @scale=nil,
23:06:33 web.1  |          @true="t">,
23:06:33 web.1  |        @value_before_type_cast=nil>}>,
23:06:33 web.1  |  @sleep_log_form=
23:06:33 web.1  |   #<SleepLog:0x0000ffff8a9e31a0
23:06:33 web.1  |    id: nil,
23:06:33 web.1  |    user_id: 1,
23:06:33 web.1  |    go_to_bed_at: nil,
23:06:33 web.1  |    fell_asleep_at: nil,
23:06:33 web.1  |    woke_up_at: nil,
23:06:33 web.1  |    leave_bed_at: nil,
23:06:33 web.1  |    created_at: nil,
23:06:33 web.1  |    updated_at: nil,
23:06:33 web.1  |    date: "2025-01-01">>

@attributes=にはActiveModelで管理されているデータが入っています。デフォルトの属性値を入れ込んだ後、super(attributes)で冒頭に指定した属性の型などに変換(オーバーライド)しているようです。
ちょっとこの辺りの理解が甘いのと、default_attributesに子モデルの属性を入れていないので、その他createアクション以降を作っていくにあたり、改善する予定です(実装途中でこの記事を書いています汗)。
@sleep_log_form=にはinitializeで作成したSleepLogモデルのオブジェクトが入っています。
新規登録画面には@sleep_log_formを渡していますが、これは上記のSleepLogモデルではなく@attibutesに入っているデータを使っているようです。つまり、ビルドした子モデルも一緒に渡してくれます。

View

app/views/sleep_logs/new.html.erb

<!-- 新規作成画面 -->
<div class="fixed flex justify-center items-center z-50 mt-8">
  <%= form_with(model: @sleep_log_form, url: sleep_logs_path, method: :post, local: true, data: { turbo: false }) do |f| %>
    <div>
      <%= f.label :date, '起きた日付' %>
      <%= f.date_field :date, value: f.object.date, readonly: true %>
    </div>

    <div>
      <%= f.label :go_to_bed_at, "昨夜布団に入った時刻" %>
      <%= f.time_field :go_to_bed_at %>
    </div>

    <div>
      <%= f.label :fell_asleep_at, "昨夜寝た時刻" %>
      <%= f.time_field :fell_asleep_at %>
    </div>

    <div>
      <%= f.label :woke_up_at, "今朝目覚めた時刻" %>
      <%= f.time_field :woke_up_at  %>
    </div>

    <div>
      <%= f.label :leave_bed_at, "今朝布団から出た時刻" %>
      <%= f.time_field :leave_bed_at  %>
    </div>

    <div>
      <%= f.label :awakenings_count, "中途覚醒(回数)" %>
      <%= f.number_field :awakenings_count, value: @sleep_log_form.attributes['awakening_awakenings_count'], min: 0 %> <!-- デフォルトで0が入力されている -->
    </div>

    <div>
      <% f.label :napping_time, "昼寝時間(分)" %>
      <%= f.number_field :napping_time, value: @sleep_log_form.attributes['napping_times_napping_time'], min: 0 %> <!-- デフォルトで0が入力されている -->
    </div>

    <div>
      <%= f.label :comment, "備考(42文字まで)" %>
      <%= f.text_area :comment, value: @sleep_log_form.attributes['comments_comment'], max: 42 %>
    </div>

    <div class="flex justify-end">
      <%= f.submit "登録", class: "bg-accent hover:bg-blue-600 text-primary font-bold py-2 px-4 rounded"%>
    </div>
  <% end %>
</div>
  • model: @sleep_log_form
    newアクションでFromオブジェクトの戻り値を@sleep_log_formに代入したものを使用しています。
  • accepts_nested_attributes_forメソッド使用時に使っていたfields_forヘルパーは廃止しました。1つのモデルであるかのように振る舞うFormオブジェクトならではですね。
  • 子モデルのvalue設定について
    value: @sleep_log_form.attributes['awakening_awakenings_count']
    という記述がありますが、ここに辿り着くのはなかなかに鬼門でした。見つけてくださった方に感謝を。
    先ほどのログで、@attributes内にある以下の記述に注目します。
23:42:58 web.1  |        @name="awakening_awakenings_count",
23:42:58 web.1  |        @original_attribute=nil,
23:42:58 web.1  |        @type=
23:42:58 web.1  |         #<ActiveModel::Type::Integer:0x0000ffff8b436148
23:42:58 web.1  |          @limit=nil,
23:42:58 web.1  |          @precision=nil,
23:42:58 web.1  |          @range=-2147483648...2147483648,
23:42:58 web.1  |          @scale=nil>,
23:42:58 web.1  |        @user_provided_value=0,
23:42:58 web.1  |        @value_before_type_cast=0>,

Formオブジェクトのファイルでattribute :awakening_awakenings_count, :integer, default: 0と設定した部分が反映されています。
そこから欲しい"子モデル_カラム名"のお名前(@name)を指定することで、デフォルト値@user_provided_value=0(ユーザーが入力した値。ここではあらかじめ設定した値)を入手できているようです。

さいごに

initializeメソッドすらまともに理解できていなかった人間からすると、Formオブジェクトは難しすぎました。もはや簡単な複数のモデル管理ならaccepts_nested_attributes_forメソッドで良い気がします。今後何かしらカスタマイズしていく予定がなく、今回のようにnewアクション時点で色々加工する必要がないなら、なおさらです。
それからcreateでデータベースに保存する工程はまだ未挑戦なので、また何かあれば追記・修正するかもしれません。

参考記事

[Rails]Form Objectを実装したときに詰まったポイント集: 同じRUNTEQスクール生として尊敬の眼差しです・・・! edit, updateアクションを実装したい方、ログイン機能にdeviseを導入している方は必見です👀
[Rails] 1つのフォームから複数のテーブルに情報を保存する: そもそもFormオブジェクトってなによ? ということから初心者にも優しい文章で書かれていて助かりました。
【ポイント:擬似モデル】Formオブジェクトパターンを理解する: Formオブジェクトとはいわゆる「擬似モデル」を作ること、と捉えると、確かにストンと腑に落ちました。

Discussion