🐕
複数のモデルにまたがる更新をするフォームオブジェクトのサンプル
accepts_nested_attributes_for
と似たような機能を自前で実装してみるサンプル
前提
- ユーザーは1つのプロフィールを持つ
- ユーザーは複数のメールアドレスを持つ
ActiveRecord
app/models/user.rb
class User < ApplicationRecord
has_one :profile
has_many :mail_addresses
validates :name, presence: true
end
app/models/profile.rb
class Profile < ApplicationRecord
belongs_to :user
validates :bio, presence: true
end
app/models/mail_address.rb
class MailAddress < ApplicationRecord
MAX_COUNT = 5
belongs_to :user
validates :address, presence: true
end
ユーザーとプロフィールを更新するフォームオブジェクト
app/models/forms.rb
module Forms
def self.use_relative_model_naming?
true
end
end
-
use_relative_model_naming?
は使わなくてもいいがフォームビルダーで生成されるHTMLタグのname
/id
が長くなる
app/models/forms/update_user_and_profile_form.rb
module Forms
class UpdateUserAndProfileForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attr_reader :profile
def initialize(user:, attributes: {})
@user = user
# 入力欄を出す為にダミーのオブジェクトとして生成して、フォームから入力があったら保存をしたい
# なので、この時点ではDBに保存せずに以降で変更があったかどうかをチェックする為に changes_applied をしておく
@profile = user.profile || Profile.new(user_id: user.id).tap(&:changes_applied)
super(default_attributes.merge(attributes))
end
def save!
save_user!
save_profile!
end
def profile_attributes=(attributes)
# bioが空の場合はレコードごと消したい場合は、ここでマーキングする
# 空文字でアップデートするとかの場合はいらない
# nested_attributes だと _destroy = 1 を使う
profile.mark_for_destruction if attributes[:bio].blank?
# accepts_nested_attributes_for の reject_if 相当の処理
return if attributes[:id].blank? && attributes[:bio].blank?
# bioが設定される
profile.attributes = attributes
end
class << self
def build(user:, params:)
new(user: user).tap do |obj|
obj.assign_attributes(permitted_params(params: params))
end
end
def permitted_params(params:)
return {} if params[model_name.param_key].blank?
params
.require(model_name.param_key)
.permit(
:name,
profile_attributes: %i[
id bio
],
)
end
end
private
attr_reader :user
def default_attributes
{
name: user.name
}.compact
end
def save_user!
user.name = name
user.save!
end
def save_profile!
return unless profile.changed?
return profile.destroy! if profile.marked_for_destruction?
profile.save!
end
end
end
app/controllers/aggregated_profiles_controller.rb
class AggregatedProfilesController < ApplicationController
def edit
@user = User.find(params[:id])
@form = Forms::UpdateUserAndProfileForm.build(user: @user, params: params)
end
def update
ApplicationRecord.transaction do
user = User.find(params[:id])
form = Forms::UpdateUserAndProfileForm.build(user: user, params: params)
form.save!
end
flash[:notice] = 'プロフィールを更新しました'
redirect_to action: :edit
end
end
app/views/aggregated_profiles/edit.html
<% if flash[:notice] %>
<p><%= flash[:notice] %></p>
<% end %>
<%= form_with url: aggregated_profile_path(@user),
model: @form,
method: :put do |f| %>
<p><%= f.text_field :name %></p>
<%= f.fields_for :profile do |profile_form| %>
<%= profile_form.hidden_field :id %>
<p><%= profile_form.text_area :bio %></p>
<%# update_user_and_profile_form[bio] という name になる %>
<%# use_relative_model_naming? を使わないと forms_update_user_and_profile_form[bio] という name になる %>
<% end %>
<%= f.submit '更新する' %>
<% end %>
ユーザーとメールアドレスを更新するフォームオブジェクト
app/models/forms/update_user_and_mail_addresses_form.rb
module Forms
class UpdateUserAndMailAddressesForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attr_reader :mail_addresses
def initialize(user:, attributes: {})
@user = user
@mail_addresses = build_mail_addresses
super(default_attributes.merge(attributes))
end
def save!
save_user!
save_mail_addresses!
end
# has_manyなものは以下のようなハッシュが送られる
# {
# '0' => {
# 'id' => '', 'bio' => 'bioの内容',
# },
# '1' => { ... },
# ...
# }
def mail_addresses_attributes=(attributes_hash)
attributes_hash.each do |index, attributes|
record = mail_addresses[index.to_i]
next unless record
record.mark_for_destruction if attributes[:address].blank?
next if attributes[:id].blank? && attributes[:address].blank?
record.attributes = attributes
end
end
class << self
def build(user:, params:)
new(user: user).tap do |obj|
obj.assign_attributes(permitted_params(params: params))
end
end
def permitted_params(params:)
return {} if params[model_name.param_key].blank?
params
.require(model_name.param_key)
.permit(
:name,
mail_addresses_attributes: %i[
id address
],
)
end
end
private
attr_reader :user
def build_mail_addresses
mail_addresses = user.mail_addresses.to_a
rest_count = [MailAddress::MAX_COUNT - mail_addresses.size, 0].max
new_records = (0...rest_count).map { MailAddress.new(user_id: user.id).tap(&:changes_applied) }
mail_addresses + new_records
end
def default_attributes
{
name: user.name
}.compact
end
def save_user!
user.name = name
user.save!
end
def save_mail_addresses!
mail_addresses.each do |record|
next unless record.changed?
next record.destroy! if record.marked_for_destruction?
record.save!
end
end
end
end
app/controllers/aggregated_mail_addresses_controller.rb
class AggregatedMailAddressesController < ApplicationController
def edit
@user = User.find(params[:id])
@form = Forms::UpdateUserAndMailAddressesForm.build(user: @user, params: params)
end
def update
ApplicationRecord.transaction do
user = User.find(params[:id])
form = Forms::UpdateUserAndMailAddressesForm.build(user: user, params: params)
form.save!
end
flash[:notice] = 'メールアドレスを更新しました'
redirect_to action: :edit
end
end
app/views/aggregated_mail_addresses/edit.html
<% if flash[:notice] %>
<p><%= flash[:notice] %></p>
<% end %>
<%= form_with url: aggregated_mail_address_path(@user),
model: @form,
method: :put do |f| %>
<p><%= f.text_field :name %></p>
<%= f.fields_for :mail_addresses do |mail_address_form| %>
<%= mail_address_form.hidden_field :id %>
<p><%= mail_address_form.email_field :address, placeholder: "メールアドレス#{mail_address_form.options[:child_index] + 1}" %></p>
<% end %>
<%= f.submit '更新する' %>
<% end %>
has_one / has_many through
そのうち別の記事で書こうと一工夫必要そう
わかっている範囲だと、値をアサインした時点で中間テーブルにINSERTが実行されてしまうのが非常に面倒くさい
Discussion