Seamless ActiveRecord Model extension
- Databases
- Programming
- Ruby on Rails
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!