multimodel_transactions plugin: Saving and creating multiple model objects in a single controller action
In Ruby on Rails, it’s common to have a resource have a one-to-one correspondence with a model. Often you will have a create action that looks something like this:
class UsersController < ApplicationController
# ... other actions, etc
def create
User.transaction do
@user = User.new(params[:user])
if @user.save
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
else
render :action => "new"
end
end
end
# ... other actions
endNow what if we have another model object that is also managed by this action, perhaps a profile object? Perhaps using the fields_for helper in the view?
Then we might change our code so that it looks something like this:
class UsersController < ApplicationController
# ... other actions, etc
def create
User.transaction do
@user = User.new(params[:user])
@profile = Profile.new(params[:profile])
user.profile = @profile
if @user.save && @profile.save
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
else
User.connection.rollback_db_transaction
render :action => "new"
end
end
end
# ... other actions
endAnd our new.html.erb view will have a form that looks something like this:
<% form_for(@user) do |f| %>
<%= f.error_messages %>
<p>
<%= f.label :name %><br />
<%= f.text_field :name %>
</p>
<%# Other fields using the "f" builder %>
<% fields_for(@profile) do |builder| -%>
<%= builder.error_messages %>
<p>
<%= builder.label :favorite_movie %><br />
<%= builder.text_field :favorite_movie %>
</p>
<%# Other fields using the "builder" builder %>
<% end %>
<p>
<%= f.submit "Create" %>
</p>
<% end %>There should be no problems here right? Wrong. The problem is that in edit.html.erb, we are going to also have a form_for(@user) and fields_for(@profile). So how does new.html.erb and edit.html.erb know to create a form action of create_user_url and update_user_url, respectively? It does this by checking the new_record? method of the passed in model.
Here’s where a problem is created. Lets revisit part of our controller code for the create action:
if @user.save && @profile.save
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
else
User.connection.rollback_db_transaction
render :action => "new"
endIf @user.save fails, @user.new_record? will still be true. This is what we want since we will be calling render :action => 'new' and we want the form generated there to call create_user_url, not update_user_url.
But what happens when @user.save works and @profile.save fails? @profile.new_record? will still be true, which is good, but @user.new_record? will be false, because it successfully saved! The user row will not exist in the database, because we rolled back our transaction, but only @profile knew to revert to an unsaved active record state. @user has no idea what’s going on.
So then, when new.html.erb renders form_for(@user), it will create a form with the update_user_url instead of create_user_url! This will result in an exception like this:
ActiveRecord::RecordNotFound in UsersController#update Couldn't find User with ID=2
To understand how to fix this, we need to understand why it works with only one model. It works because @user.save calls rollback_active_record_state! which records the object’s state, with respect to things like new_recodr?, and takes a block that it executes in a begin/rescue block. If it catches an exception, it reverts to the state it was in before it yielded to the block, and reraises the exception. The exception is usually ActiveRecord::Rollback, which is gobbled up by a transaction that @user.save starts before calling rollback_active_record_state!
We can fix the problem by using this method on both objects, and throwing an exception if any of the models fail to save.
The above if statement becomes something like this:
#an anonymous Exception class, it's best to create a real class for use
#in multiple locations
ec = Class.new(:Exception)
begin
@user.rollback_active_record_state! do
@profile.rollback_active_record_state! do
if @user.save && @profile.save
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
else
User.connection.rollback_db_transaction
raise ec.new
end
end
end
rescue ec
render :action => "new"
endI have created a plugin that helps replace this type of code. It works with any number of model objects across any number of database connections.
It is at git://github.com/azimux/multimodel_transactions.git
With it installed, the above code becomes:
ax_multimodel_if([@user, @profile],
:if => proc {
@user.save && @profile
},
:is_true => proc {
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
},
:is_false => proc {
render :action => "new"
}
)Note to Smalltalk users: if you need an example of ruby’s block passing syntax making certain method calls look a little ugly, there you go :P
There is also another method in the plugin that helps with models being in multiple databases (or the future possibility of such a thing happening.): ax_multimodel_transaction
To apply it to the above call to ax_multimodel_if, it would look somewhat like this:
ax_multimodel_transaction [@profile], :already_in => @user do
ax_multimodel_if([@user, @profile],
:if => proc {
@user.save && @profile
},
:is_true => proc {
flash[:notice] = 'User was successfully created.'
redirect_to(@user)
},
:is_false => proc {
render :action => "new"
}
)
endh1. Gotchas
1) If @profile has the user_id foreign key, it’s important to do:
@user.profile = @profileand not:
@profile.user = @userWhen neither of the objects have been saved yet. I’m not exactly sure why this is, I just know from experience that if you do it the other way around, @profile will be saved with a nil user_id
2) Like always when doing things sensitive to transactions and rollbacks, you might wish to add a “self.use_transactional_fixtures = false” to the relevant functional test. This is really only necessary if your test runs commands that execute queries that are not expecting to be inside of a rolledback transaction after the call to “post :create” or “post :update” or whatever. It is only very rarely necessary to do, but it’s good to keep in mind when writing more complex functional tests.
Posted in Ruby, Ruby on Rails | no comments |
Trackbacks<
Use the following link to trackback from your own site:
http://nopugs.com/trackbacks?article_id=29