🐕

複数のモデルにまたがる更新をするフォームオブジェクトのサンプル

2023/11/11に公開

accepts_nested_attributes_for と似たような機能を自前で実装してみるサンプル

前提

  1. ユーザーは1つのプロフィールを持つ
  2. ユーザーは複数のメールアドレスを持つ

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