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
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 %>