← Blog

Seamless ActiveRecord Model extension

The Problem

What if you have one very bloated model, like User, that has a ton of fields you use only once or twice in your whole application and yet pay the price for it every time you fetch / create a new one.

In this case what you want is to move some of these fields to another model (e.g. profile or user details).

The Solution

The first step is to generate another model, let’s name it UserProfile and link it to User model.

class User < ActiveRecord::Base
  has_one :user_profile
  after_create :create_user_profile

  private
  def create_user_profile
    self.user_profile = UserProfile.new(user_id: self.id).tap(&:save!)
  end
end

class UserProfile < ActiveRecord::Base
  belongs_to :user
end

You would also have to run a migration that adds UserProfile to all existing users. Now what you can do is to write something like:

# assign field:
user = User.find(42)
user.user_profile.date_of_birth = 22.years.ago
user.user_profile.save!

# access field:
user = User.find(42)
puts user.user_profile.date_of_birth

# mass assign fields:
user = User.find(42)
user.user_profile.update_attributes(date_of_birth: 22.years.ago)

# set fields upon creation:
user = User.create(name: "Dmitry")
user.user_profile.update_attributes(date_of_birth: 22.years.ago)

Now, of course, we could stop there, but why miss all the fun? What we want to do instead is:

# assign field:
user = User.find(42)
user.date_of_birth = 22.years.ago
user.save!

# access field:
user = User.find(42)
puts user.date_of_birth

# mass assign fields:
user = User.find(42)
user.update_attributes(date_of_birth: 22.years.ago)

# set fields upon creation:
user = User.create(name: "Dmitry", date_of_birth: 22.years.ago)

So how do we do that?

class User < ActiveRecord::Base
  has_one :user_profile
  after_save :save_user_profile

  # custom accessor
  def user_profile
    if existing = super # if user_profile is already present then return it
      existing
    else # otherwise instantiate a new UserProfile
      @new_user_profile ||= UserProfile.new(user_id: self.id)
    end
  end

  # try delegating all unknown methods to UserProfile
  def method_missing name, *args, &block
    if user_profile.respond_to? name
      user_profile.send name, *args, &block
    else
      super name, *args, &block
    end
  end

  # all mass-assignment methods (new, create, update_attributes, attributes=)
  # internally use this method, so we overwrite it
  def assign_attributes(new_attributes, options = {})
    # first we want to get all the fields that are absent for User but present for UserProfile
    user_profile_attrs = {}
    new_attributes.keys.each do |key|
      if !self.respond_to?(key) && user_profile.respond_to?(key) # if it's UserProfile field
        user_profile_attrs[key] = new_attributes[key] # then assign it to UserProfile
        new_attributes.delete(key) # and don't assign to User
      end
    end
    user_profile.attributes = user_profile_attrs # set all UserProfile fields
    super(new_attributes, options) # call original assign_attributes to perform work for User
  end

  private
  def save_user_profile # called after User is created or updated
    if self.user_profile.user_id.nil? # User#id is only known after User was persisted to db
      self.user_profile.user_id = self.id # after he was persisted we can assign user_id to UserProfile
    end
    # save changes in user_profile if there were any (or create a new one).
    self.user_profile.save!
  end

end

Performance

When I measured how speed and memory footprint correspond to number of columns I found that both increase roughly linearly as the number of columns grows.

Precautions

When mass-assigning attributes from a controller while using the CanCan gem (tested in Rails 3.2), e.g. (User.new(params[:user])), CanCan tries to be smart and in “load_and_authorize_resource” tries to clean up all the unknown attributes from params[:user]. The solution is to set the @user instance variable before “load_and_authorize_resource” is called.

class UsersController < ApplicationController
  before_filter :new_user, only: [:new, :create]
  load_and_authorize_resource

  def create
    @user.do_some_stuff_with_it
  end

  private
  def new_user
    @user = User.new params[:user]
  end
end

p.s. in retrospect this blog post has a very ambiguous title, but that’s the best I could come up with.

p.p.s. Happy Spring everyone!