Search code examples
ruby-on-railsrubydatabaseactiverecordtransactions

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


I have a Mission model which has the following associations:

...
  # === 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
...

When a user creates a Mission, they input all of the information for the Mission, Phone, Address, CameraSpec, and DroneSpec into one large form. I'm able to save records correctly when all of the information is correct. However, if there is an error in any of the models, I want to rollback ALL transactions and render the form with errors.

This topic has been covered in other places, however, I'm unable to rollback all transactions using the methods I've seen. Currently, if there is a DB/ActiveRecord error one of the models, let's say CameraSpec, then the previously created Mission, Address, and Phone are not rolled back. I've tried nested transactions like:

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

I've tried throwing different kinds of errors such as ActiveRecord::Rollback. I'm always able to catch the error, but the DB doesn't rollback. I've tried both using and not using a begin-rescue statement. My current attempt is to not nest the transactions and instead commit them in a single transaction block, but this also is not working. Here is my current code.

# === 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

Solution

  • This is insanely overcomplicated and you have completely missunderstood how to use nested attributes:

    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
    

    All the work is actually done automatically by the setters declared by accepts_nested_attributes. You just pass the hash or array of hashes of whitelisted parameters to it and let it do its thing.

    You can prevent the parent object from being saved if the child object is invalid by using validates_associated:

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

    This just adds the error key “Phone is invalid” to the errors which isn't very user friendly. If you want to display the error messages per nested record you can get the object wrapped by the form builder when using 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 %>