🔗 前提となる環境
以下の環境で動作確認をしています。
- Ruby 3.3.7 + Rails 7.0
- Ruby 3.3.7 + Rails 7.1
🔗 概要
ナウでイケてる皆さんは最新の Rails 8.0を使っていることかと思いますが、現実には今だ古い Rails で動いているシステムが存在しています。今回はあるプロジェクトの Rails をアップグレードしたときに form_with
で発生した問題を紹介します。
🔗 TL;DR
form_with
の model
パラメータに 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_with
に model
パラメータを渡すだけで自動的に元のモデル名に準ずるパスが設定されるようになっていました。
モデル
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_with
に scope: :my_record
と指定しなくても済むようにしています。
🔗 ビュー
= form_with model: @form, url: [:somewhere, @form.record] do |f|
= f.text_field :hoge
こちらは url
パラメータの追加を避けることができませんでした。
🔗 なぜ変わったか
以下のPRがこの挙動変更の原因です。
CHANGELOG には以下の記載があります。
- Move
convert_to_model
call fromform_for
intoform_with
Now that
form_for
is implemented in terms ofform_with
, remove the
convert_to_model
call fromform_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_model
を form_with
がどう使うかについて明文化された仕様の記述はなさそうです。
🔗 結論
Rails は便利なフレームワークですが、その動作の仕様は完璧に定義されているわけではありません。アップグレードに伴ってどのような変更が来るか分からないので、テストコードを書いて備えておきましょう。