Issue with Rails’s restrict_with_exception and counter_cache

2 minute read

When building Rails applications, a lot of focus goes into optimisation. One of those optimizations is using Rails counter_cache columns. The reasoning behind it is simple, you have a relation e.g. Post to Comments and you want to display the comments count for each post. One option is to always count the comments for each post with something like @post.comments.count of course, this is a really expensive operation, and if you are displaying more than one post on the page, it will cause an N+1 issue. Also going to the database just to count how many rows a relation has, isn’t really the best idea.

Luckily Rails has its own optimisation, called counter_cache. It is very simple to include in the model, and Rails manages all of the magic for you behind the scenes. Below is the example of Post and the cached comments count. The only prerequisite that you have to do, is to add a comments_count integer column, with default value of 0.

#in a migration
def change
  add_column :posts, :comments_count, :integer, default: 0
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post, counter_cache: true
end

This way, via the Rails Magic™, you will get the count column incremented whenever a new comment is posted. And decremented when a comment is destroyed (if you support such a thing). Now you don’t have to count all of the comments when showing the number next to the post, which will be a pretty big performance boost (relatively speaking).

That is the first part of the equation, as the title clearly states this post has something to do about restrict_with_exception. That is the dependency management type, which disallows you to destroy a record that has child records, with that orphaning those records, and creating database inconsistencies. In our case, a post with comments.

We have to modify the Post class to accomplish this:

class Post < ActiveRecord::Base
  has_many :comments, dependent: :restrict_with_exception
end

Now every time you call destroy on a Post model that has comments, Rails won’t let you do that, and it will raise an exception, which is the desired behaviour.

Now, we avoid rails and use regular SQL when the situation needs us to. Let’s say moving comments from one post to another. It can be done with

@post.comments.each do |comment|
  comment.update_attributes(post_id: @new_post.id)
end

But for a post with a lot of comments this operation can be too slow. Moving comments with a sql update is much easier and faster way to accomplish the same thing, but not without it’s own pitfalls. @post.comments.update_all(post_id: @new_post.id) will update your comments and set them to the new post, and you will probably want to refresh the counter_cache on the @new_post, so you have to do something like this Post.reset_counters(@new_post.id, :comments).

Now if you forget doing the same thing on the old post, and just try to destroy it, you will fins yourself chasing ghosts. Rails somehow takes the counter cache column as an authority, before even looking if there are associated records there. This is a cool performance hack, and by going only through Rails, and not pure SQL, this situation wouldn’t happen at all.

Sometimes we can’t or don’t need Rails call backs and validations, just to transfer the ownership from one model to another. Then weird things start to happen, and you don’t know why, so you lose a day debugging what should be a simple thing.

Comments