Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 7.1で form_with に入った小さくて大きな変更

🔗 前提となる環境

以下の環境で動作確認をしています。

  • Ruby 3.3.7 + Rails 7.0
  • Ruby 3.3.7 + Rails 7.1

🔗 概要

ナウでイケてる皆さんは最新の Rails 8.0を使っていることかと思いますが、現実には今だ古い Rails で動いているシステムが存在しています。今回はあるプロジェクトの Rails をアップグレードしたときに form_with で発生した問題を紹介します。

🔗 TL;DR

form_withmodel パラメータに Active Model オブジェクトを渡す場合、そのオブジェクトの #to_model メソッドは ActiveModel::Model のデフォルト通り self を返すべき。

🔗 やりたかったこと

ある Active Record オブジェクトの編集フォームを実装する必要があったが、以下のような事情で Active Record オブジェクトをラップするフォームオブジェクトを使用したかった。

  • ARRAY型などの複雑な入力ロジックを持つ属性が存在し、追加のメソッド(例えば #fields_for を使用するための #hoges#hoges_attributes=)が必要になった。しかしこれらはあくまで編集のためだけに必要なものなので、元のモデルクラスには実装したくない。
  • 関連するレコードが複数存在し、バリデーションや #save メソッドでの保存を一元的に管理したい。フォームからどのような形で関連レコードの情報を入力するかはフォームのUIに依存するため、元のモデルクラスには実装したくない。
  • 操作しているユーザーの情報に依存するバリデーションを設定したい。ユーザーによる操作が行われるという事情は元のモデルの関心事ではないため、モデルクラスには手を加えたくない。

🔗 Rails 7.0以前で動いていたコード

アップグレードする前は以下のように、フォームオブジェクトの #to_model メソッドに操作対象の Active Record オブジェクトを返させることで、form_withmodel パラメータを渡すだけで自動的に元のモデル名に準ずるパスが設定されるようになっていました。

モデル

class MyRecord < ApplicationRecord
  # 省略
end

🔗 フォームオブジェクト

class MyRecordForm
  include ActiveModel::Model

  attr_reader :record
  delegate :persisted?, to: :record

  def initialize(record, attrs = {})
    @record = record
    super(attrs)
  end

  def hoge
    # 省略
  end

  def hoge=(value)
    # 省略
  end

  def save
    # 省略
  end

  def to_model
    record
  end
end

🔗 コントローラ

class Somewhere::MyRecordsController < ApplicationController
  def index
  end

  def edit
    @form = MyRecordForm.new(MyRecord.find(params[:id]))
  end

  def update
    @form = MyRecordForm.new(MyRecord.find(params[:id]), update_params)
    if @form.save
      flash[:notice] = 'Succeeded!'
      redirect_to action: :index
    else
      render :edit
    end
  end

  private

  def update_params
    # 省略
  end
end

🔗 ビュー

= form_with model: [:somewhere, @form] do |f|
  = f.text_field :hoge

このビューで、リクエスト先のパスは somewhere_my_record_path(@form.record) と同等となり、hoge フィールドのデフォルトの値は @form.hoge になります。HTTPリクエストのパラメータ上のキーは my_record[hoge] です。また、f.object@form を返します。

🔗 Rails 7.1で動かない!

hoge フィールドのデフォルト値が空になってしまいました。また、f.object@form ではなく @form.to_model つまり @form.record を返します。f.object.hoge などと書いていた場合は NoMethodError になってしまいます。

🔗 Rails 7.1で動くようにしたコード

仕方がないので、フォームオブジェクトとビューを以下のように変更しました。

🔗 フォームオブジェクト

class MyRecordForm
  include ActiveModel::Model

  attr_reader :record
  delegate :model_name, :persisted?, to: :record

  def initialize(record, attrs = {})
    @record = record
    super(attrs)
  end

  def hoge
    # 省略
  end

  def hoge=(value)
    # 省略
  end

  def save
    # 省略
  end
end

Rails 7.0以前のバージョンから、#to_model を削除しています。また、#model_name を Active Record オブジェクトに委譲することで、form_withscope: :my_record と指定しなくても済むようにしています。

🔗 ビュー

= form_with model: @form, url: [:somewhere, @form.record] do |f|
  = f.text_field :hoge

こちらは url パラメータの追加を避けることができませんでした。

🔗 なぜ変わったか

以下のPRがこの挙動変更の原因です。

CHANGELOG には以下の記載があります。

  • Move convert_to_model call from form_for into form_with

    Now that form_for is implemented in terms of form_with, remove the
    convert_to_model call from form_for.

たったこれだけです。被った影響に対してあまりに説明が少ない気がしてしまいますが、この根底にはそもそも #to_model とは何のために使うものかについての認識の乖離がありそうです。#to_model については ActiveModel::Conversion のドキュメントに以下の記載があります。

If your model does not act like an Active Model object, then you should define :to_model yourself returning a proxy object that wraps your object with Active Model compliant methods.

これを見る限りだと、元から Active Model オブジェクトであるものについて #to_model を実装すること自体が間違っていたと言えなくもありません。ですが、"Active Model compliant" とは何でしょうか?検索して見つかるのは当のこの記述と、ActionView::ModelNaming#convert_to_model メソッドに付随するコメントのみなので良く分かりません。

FormHelper の側には #to_model についての記載は見つけられませんでした。#to_modelform_with がどう使うかについて明文化された仕様の記述はなさそうです。

🔗 結論

Rails は便利なフレームワークですが、その動作の仕様は完璧に定義されているわけではありません。アップグレードに伴ってどのような変更が来るか分からないので、テストコードを書いて備えておきましょう。

関連記事

Rails 5.1〜8.0: 'form_with' APIドキュメント(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。