如何在 Rails Ruby 出错时回滚事务块中的所有事务

How to rollback all transactions in transaction block on error in Ruby on Rails

我有一个具有以下关联的 Mission 模型:

...
  # === missions.rb (model) ===
  has_many :addresses, as: :addressable, dependent: :destroy
  accepts_nested_attributes_for :addresses
  has_many :phones, as: :phoneable, dependent: :destroy
  accepts_nested_attributes_for :phones
  has_one :camera_spec, as: :camerable, dependent: :destroy
  accepts_nested_attributes_for :camera_spec
  has_one :drone_spec, as: :droneable, dependent: :destroy
  accepts_nested_attributes_for :drone_spec
...

当用户创建任务时,他们将任务的所有信息Phone、地址、CameraSpec 和 DroneSpec 输入到一个大表格中。当所有信息都正确时,我能够正确保存记录。但是,如果任何模型出现错误,我想回滚 ALL 事务并呈现有错误的表单。

该主题已在其他地方介绍过,但是,我无法使用我见过的方法回滚所有事务。目前,如果其中一个模型存在 DB/ActiveRecord 错误,比方说 CameraSpec,那么之前创建的 Mission、Address 和 Phone 不会回滚。我尝试过嵌套交易,例如:

Mission.transaction do
  begin
    # Create the mission
    Mission.create(mission_params)

    # Create the Address
    raise ActiveRecord::Rollback unless Address.transaction(requires_new: true) do
      Address.create(address_params)
      raise ActiveRecord::Rollback
    end

...

  rescue ActiveRecord::Rollback => e

...

  end
end

我试过抛出不同类型的错误,例如 ActiveRecord::Rollback。我总是能够捕获错误,但数据库不会回滚。我试过使用和不使用 begin-rescue 语句。我目前的尝试是不嵌套事务,而是将它们提交到单个事务块中,但这也行不通。这是我当前的代码。

# === missions_controller.rb ===
  def create
    # Authorize the user

    # Prepare records to be saved using form data
    mission_params = create_params.except(:address, :phone, :camera_spec, :drone_spec)
    address_params = create_params[:address]
    phone_params = create_params[:phone]
    camera_spec_params = create_params[:camera_spec]
    drone_spec_params = create_params[:drone_spec]

    @mission = Mission.new(mission_params)
    @address = Address.new(address_params)
    @phone = Phone.new(phone_params)
    @camera_spec = CameraSpec.new(camera_spec_params)
    @drone_spec = DroneSpec.new(drone_spec_params)

    # Try to save the company, phone number, and address
    # Rollback all if error on any save
    ActiveRecord::Base.transaction do
      begin
        # Add the current user's id to the mission
        @mission.assign_attributes({
          user_id: current_user.id
        })

        # Try to save the Mission
        unless @mission.save!
          raise ActiveRecord::Rollback, @mission.errors.full_messages
        end

        # Add the mission id to the address
        @address.assign_attributes({
          addressable_id: @mission.id,
          addressable_type: "Mission",
          address_type_id: AddressType.get_id_by_slug("takeoff")
        })
        
        # Try to save any Addresses
        unless @address.save!
          raise ActiveRecord::Rollback, @address.errors.full_messages
        end

        # Add the mission id to the phone number
        @phone.assign_attributes({
          phoneable_id: @mission.id,
          phoneable_type: "Mission",
          phone_type_id: PhoneType.get_id_by_slug("mobile")
        })

        # Try to save the phone
        unless @phone.save!
          raise ActiveRecord::Rollback, @phone.errors.full_messages
        end

        # Add the mission id to the CameraSpecs
        @camera_spec.assign_attributes({
          camerable_id: @mission.id,
          camerable_type: "Mission"
        })

        # Try to save any CameraSpecs
        unless @camera_spec.save!
          raise ActiveRecord::Rollback, @camera_spec.errors.full_messages
        end

        # Add the mission id to the DroneSpecs
        @drone_spec.assign_attributes({
          droneable_id: @mission.id,
          droneable_type: "Mission"
        })

        # Try to save any DroneSpecs
        unless @drone_spec.save!
          raise ActiveRecord::Rollback, @drone_spec.errors.full_messages
        end

      # If something goes wrong, render :new again
      # rescue ActiveRecord::Rollback => e
      rescue => e
        # Ensure validation messages exist on each instance variable
        @user = current_user
        @addresses = @user.addresses
        @phones = @user.phones
        @mission.valid?
        @address.valid?
        @phone.valid?
        @camera_spec.valid?
        @drone_spec.valid?

        render :new and return
      else
        # Everything is good, so redirect to the show page
        redirect_to mission_path(@mission), notice: t(".mission_created")
      end
    end
  end

这太复杂了,您完全误解了如何使用嵌套属性:

class MissionsController
  def create
    @mission = Mission.new(mission_attributes)
    if @mission.save
      redirect_to @mission
    else
      render :new
    end
  end

  ...

  private

  def mission_params
    params.require(:mission)
          .permit(
            :param_1, :param_2, :param3,
            addresses_attributes: [:foo, :bar, :baz],
            phones_attributes: [:foo, :bar, :baz],
            camera_spec_attributes: [:foo, :bar, :baz],
          ) 
  end
end

所有的工作实际上都是由accepts_nested_attributes声明的setter自动完成的。您只需将白名单参数的哈希值或哈希值数组传递给它,然后让它执行它的操作即可。

如果子对象无效,您可以通过使用validates_associated来阻止保存父对象:

class Mission < ApplicationRecord
  # ...
  validates_associated :addresses
end

这只是将错误键“Phone 无效”添加到不是很用户友好的错误中。如果您想显示每个嵌套记录的错误消息,您可以在使用 fields_for:

时获取由表单构建器包装的对象
# app/shared/_errors.html.erb
<div id="error_explanation">
  <h2><%= pluralize(object.errors.count, "error") %> prohibited this <%= object.model_name.singular %> from being saved:</h2>
  <ul>
  <% object.errors.full_messages.each do |msg| %>
    <li><%= msg %></li>
  <% end %>
  </ul>
</div>
...
<%= f.fields_for :address_attributes do |address_fields| %>
  <%= render('shared/errors', object: address_fields.object) if address_fields.object.errors.any? %>
<% end %>

查看了您的代码,我发现您在 begin rescue 块的帮助下使用了 ActiveRecord::Base.transaction 块。但是 ActiveRecord::Base.transaction 支持救援块,可以使用下面的代码

ActiveRecord::Base.transaction do
  # Add the current user's id to the mission
  @mission.assign_attributes({
    user_id: current_user.id
  })

  # Try to save the Mission
  unless @mission.save!
    raise ActiveRecord::Rollback, @mission.errors.full_messages
  end

  # Add the mission id to the address
  @address.assign_attributes({
    addressable_id: @mission.id,
    addressable_type: "Mission",
    address_type_id: AddressType.get_id_by_slug("takeoff")
  })
  
  # Try to save any Addresses
  unless @address.save!
    raise ActiveRecord::Rollback, @address.errors.full_messages
  end

  # Add the mission id to the phone number
  @phone.assign_attributes({
    phoneable_id: @mission.id,
    phoneable_type: "Mission",
    phone_type_id: PhoneType.get_id_by_slug("mobile")
  })

  # Try to save the phone
  unless @phone.save!
    raise ActiveRecord::Rollback, @phone.errors.full_messages
  end

  # Add the mission id to the CameraSpecs
  @camera_spec.assign_attributes({
    camerable_id: @mission.id,
    camerable_type: "Mission"
  })

  # Try to save any CameraSpecs
  unless @camera_spec.save!
    raise ActiveRecord::Rollback, @camera_spec.errors.full_messages
  end

  # Add the mission id to the DroneSpecs
  @drone_spec.assign_attributes({
    droneable_id: @mission.id,
    droneable_type: "Mission"
  })

  # Try to save any DroneSpecs
  unless @drone_spec.save!
    raise ActiveRecord::Rollback, @drone_spec.errors.full_messages
  end

  # If something goes wrong, render :new again
  # rescue ActiveRecord::Rollback => e
rescue Exception => e
  # Ensure validation messages exist on each instance variable
  @user = current_user
  @addresses = @user.addresses
  @phones = @user.phones
  @mission.valid?
  @address.valid?
  @phone.valid?
  @camera_spec.valid?
  @drone_spec.valid?

  render :new and return
else
  # Everything is good, so redirect to the show page
  redirect_to mission_path(@mission), notice: t(".mission_created")
end