Jamie's Blog

Lessons from a life of startups, coding, countryside, and kids

Default has_one instances in Rails without the performance hit

2015 01 23 at 22.07

My first ill-fated attempt

A while ago, I added an optional profile object for our users. I thought, in my haste, that the easiest way to provide an default profile instance would be like this:

class User < ActiveRecord::Base
  has_one :profile
  after_initialize :default_profile

  def default_profile
    self.build_profile unless self.profile
  end
end

This would build a new Profile object if one didn’t exist. Easy, right? Wrong.

What’s the problem?

You see, I was having some terrible problems running a query like

users = User.eager_load(:profile).where(profiles: {division: 'GROUP'})
users.each do |user|
  puts user.profile.title
end

Using eager_load should avoid doing N+1 queries by joining the users and profiles tables. But my logs were filled with queries for individual profile records.

Profile Load (1.4ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 1 LIMIT 1
Profile Load (0.9ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 2 LIMIT 1
Profile Load (1.0ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 3 LIMIT 1
Profile Load (0.9ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 4 LIMIT 1
Profile Load (0.9ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 4 LIMIT 1
...
Profile Load (1.1ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 961 LIMIT 1
Profile Load (1.0ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 962 LIMIT 1
Profile Load (1.1ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 963 LIMIT 1
Profile Load (0.9ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 964 LIMIT 1

Finding it

After much searching, I couldn’t find the source of these queries. The breakthrough came after installing the active_record_query_trace gem which appends the caller of each query to the Rails log. Now I could quite easily find the problem:

Profile Load (6.0ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = 4 LIMIT 1
Called from: app/models/user.rb:310:in `default_profile'

Once I knew where the problem was, it became quite apparent what was causing it. Each time a User object was instantiated, default_profile was called. It would then check if the profile association existed but, to do this, it had to query the profile table. Literally every single time a User object was created it would involve querying the database.

def default_profile
  self.build_profile unless self.profile
end

Solving it

What was I trying to accomplish here? I wanted to make sure that every user had a default profile object, if they needed it. So really, I only needed to create that default object if it was being accessed. And the easiest was of doing that is using alias_method_chain.

def profile_with_default
  profile_without_default || build_profile
end
alias_method_chain :profile, :default

This moves the existing profile accessor method to profile_without_default and binds our profile_with_default method to profile. Quite simply now, when you call user.profile it will fetch the association as usual, and if that’s null it will build a new Profile.

Simple! I hope