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 |
Missing ExceptionNotifiable module with exception_notification plugin
It looks like the exception_notification plugin is undergoing a pretty big overhaul to get it working well with rails3. The official git repository for that plugin only has one branch, master. What should you do for your existing rails2 applications that are not ready for migration to rails3?
If you are getting something somewhat like this:
rake aborted! uninitialized constant ApplicationController::ExceptionNotifiable .../rails/activesupport/lib/active_support/dependencies.rb:102:in `const_missing'
You can fix it by using commit e8b603e523c14f145da7b3a1729f5cc06eba2dd1 of that plugin.
Something like this should do the trick:
cd vendor/plugins/exception_notification git checkout -b rails2 e8b603e523c14f145da7b3a1729f5cc06eba2dd1
That particular commit is from November 13, 2008. It is the last commit not having anything to do with rails3, and it is a very stable commit and unlikely to need modification for existing projects.
if you are using externals to manage your subprojects, you can quickly fix it like this (from the main project directory):
ext freeze exception_notification e8b603e523c14f145da7b3a1729f5cc06eba2dd1
Posted in externals, Ruby, Ruby on Rails | 3 comments |