Handling Transactions in Rails

I’ve had a few opportunities lately to work with transactions in Rails. Transactions are useful when you have more than one model that needs to be saved in one pass, but you don’t want to have to deal with deleting one if the other doesn’t save successfully, etc. Basically, if you save a model within a transaction and it fails, any other models that were saved in that transaction will be rolled back.

Here’s a quick example:

# posts_controller.rb
class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    @tag = Tag.new(tag_params)

    Post.transaction do
      @post = Post.create!(post_params)
      @tag = Tag.create!(tag_params)
    end
  end

  private

  def post_params
    params.require(:post).permits(:title, :body)
  end

  def tag_params
    params.require(:tag).permits(:name)
  end
end

So, if either @post.save! or @tag.save! fails, the entire transaction will be rolled back. Notice that I’m using save! and not save. This is because you have to throw an exception for the transaction block to realize the the transaction has failed. The problem with this, though, is that the transaction doesn’t automatically rescue the error. You have to do that yourself. I’ll leave that as an exercise for the reader. Just kidding! Check this out:

# posts_controller.rb
# ...
  respond_to :html

  def create
    @post = Post.new(post_params)
    @tag = Tag.new(tag_params)

    begin
      Post.transaction do
        @post.save!
        @tag.save!
      end
    rescue => e
    end

    respond_with @post
  end
# ...

Note: check out this best practice for an explanation of the rescue syntax: Don’t rescue Exception, rescue StandardError

Notice anything wrong? I didn’t at first, but what you may not notice is that if the post is created successfully, but the tag is not, the following quirkiness ensues: the post is rolled back, but the instance retains the id=1 attribute. This causes the respond_with @post to fail because there isn’t a Post with id=1 — it got rolled back! So what do we do to compensate?

I don’t know the best way to deal with this, so if you have better ideas, please leave them in the comments and I’ll update this post. Having said that, here’s an idea that works:

# posts_controller.rb - Re-instantiate @post in rescue
# ...
  def create
    @post = Post.new(post_params)
    @tag = Tag.new(tag_params)

    begin
      Post.transaction do
        @post.save!
        @tag.save!
      end
    rescue => e
      @post = Post.new(post_params)
      @post.valid?
      render :new
      return
    end

    respond_with @post
  end
# ...

Running @post.valid? on the newly instantiated object will build all of the model’s errors for display on the form, but it will no longer have the id that was messing things up before.

I’m not sure if I have to use render :new because I’m using Rails 4.0 or if respond_with always renders :index when the creation fails. I’ll do some more research and update the post later.