#394 STI and Polymorphic Associations pro
- Download:
- source codeProject Files in Zip (112 KB)
- mp4Full Size H.264 Video (29.5 MB)
- m4vSmaller H.264 Video (15.8 MB)
- webmFull Size VP8 Video (19.7 MB)
- ogvFull Size Theora Video (33.8 MB)
In the previous episode we showed how to set up guest users so that people can try an application without signing up first. Guest users can try the all of the application’s functionality then later on become a full member by completing the signup process. Everything they’ve done up to then will then be persisted to their new permanent account. In this episode we’ll build upon this application and looking at some ways that we can improve its code. At the end of the last episode our User model looked like this:
class User < ActiveRecord::Base has_many :tasks, dependent: :destroy attr_accessible :username, :email, :password, :password_confirmation validates_presence_of :username, :email, :password_digest, unless: :guest? validates_uniqueness_of :username, allow_blank: true validates_confirmation_of :password # override has_secure_password to customize validation until Rails 4. require 'bcrypt' attr_reader :password include ActiveModel::SecurePassword::InstanceMethodsOnActivation def self.new_guest new { |u| u.guest = true } end def move_to(user) tasks.update_all(user_id: user.id) end def name guest ? "Guest" : username end end
What we want to draw attention to are the checks to see if the user is a guest. The first of these is in the validations so that the username
, email
and password_digest
fields aren’t required when a guest user is created. We also check this when we display the current user’s name. This in itself isn’t too bad but as our application grows this check will be used more and more in the User
model. For example we might add methods to handle restricting the number of tasks that a guest user can create and whether tasks can be shared while a method that sends a password reset to a user will also need to check to see if the current user is a guest.
def task_limit guest ? 10 : 1000 end def can_share_task?(task) task.user_id == id && !guest? end def send_password_reset UserMailer.password_reset(self).deliver unless guest? end
Using Single Table Inheritance
All this behaviour should make us think about putting this logic into separate classes that speak the same interface and one way to do this is to use Single Table Inheritance. This works by adding a type
column to the database which stores the name of a class for each record and this means that we can use inheritance to differentiate behaviour. STI has got a bad reputation over the years. There are some good use cases for it but it can easily be abused. We’ll try it in our application to see how it fits and start by creating a two new migrations: one to add a type
column to our users table and another to remove the guest column.
$ rails g migration add_type_to_users type $ rails g migration remove_guest_from_users guest:boolean $ rake db:migrate
Now we need to split the code in the User
model into separate classes. Most of this code won’t be shared between our new Member
and Guest
classes; all we’ll leave here is the tasks
association.
class User < ActiveRecord::Base has_many :tasks, dependent: :destroy end
We can now start putting our two new classes together, starting with Guest
. We won’t need any of the validation or authorization code here so we can remove this. While we need a way to create a new guest user we won’t use the new_guest
method to do this so it can be removed. The code to move a guest user’s data when they register belongs here so we’ll keep that and we can simplify the name
, task_limit
and can_share_task?
methods to return the correct values for guest users. Sending a password reset will do nothing for guests so we’ll leave this method empty. Finally, as some of the view logic is dependent of whether the current user is a guest or not we’ve added a guest?
method here to check.
class Guest < User def guest? true end def move_to(user) tasks.update_all(user_id: user.id) end def name "Guest" end def task_limit 10 end def can_share_task?(task) false end def send_password_reset end end
This class is a lot cleaner and it’s easier now to see the behaviour for a guest user. The Member
class will be similar, although it will have more code in it as it needs the validations and the authorization behaviour. That said we can simplify it a little by returning to has_secure_password
as no longer need to make the validations dynamic based on whether the user is a guest or not. We can remove the new_guest
and move_to
methods from this class and simplify the name, task_limit
and can_share_task?
and send_password_reset
methods. Again we’ve added a guest?
method in this class but this time it returns false
.
class User < ActiveRecord::Base has_many :tasks, dependent: :destroy attr_accessible :username, :email, :password, :password_confirmation validates_presence_of :username, :email validates_uniqueness_of :username, allow_blank: true has_secure_password def guest? false end def name username end def task_limit 1000 end def can_share_task?(task) task.user_id == id end def send_password_reset UserMailer.password_reset(self).deliver end end
Our code is now a lot cleaner. Although we have two classes they’re both much simpler than our old User
class with no conditionals. We’re not done yet with our refactoring, though, we also need to change the way the UsersController
works so that when a user signs up a new Member
is created instead of a User
. When we create a guest user here we now call Guest.new
instead of the class method we had earlier. If we’re creating a guest member we call Member.new
and pass in the parameters from the form but we need to pass :member
parameters instead of :user
parameters as that’s what will be submitted by the form.
class UsersController < ApplicationController def new @user = Member.new end def create @user = params[:member] ? Member.new(params[:member]) : Guest.new if @user.save current_user.move_to(@user) if current_user && current_user.guest? session[:user_id] = @user.id redirect_to root_url else render "new" end end end
We can try out what we’ve done now to see if it works. When we click the “Try it for free” button we’re taken to our task list as a guest and this works as we’d expect. When we try to become a member, however, we get an error message about an undefined method called members_path
. It looks like the action is trying to redirect to a route that we haven’t defined. If we look at our routes file we’ll see that we have a :users
resource but when we create a Member
model it will look for a :members
resource instead. We could add a :members
resource and set it up so that it redirects to the users controller so that we don’t have to set up separate controllers for members and guests but this depends on how we want our application to work and how separate the controller behaviour is for members and guests. For our application creating a guest is quite different from creating a member so we’ll split the UsersController
up into two new controllers and modify the routes file appropriately.
Checklist::Application.routes.draw do get 'signup', to: 'users#new', as: 'signup' get 'login', to: 'sessions#new', as: 'login' get 'logout', to: 'sessions#destroy', as: 'logout' resources :members resources :guests resources :sessions resources :tasks root to: 'tasks#index' end
Next we’ll rename the UsersController
to MembersController
and remove the code in it that creates guest users. We no longer need to check for :member
parameters to determine if we’re creating a guest user or not as this now happens elsewhere. This makes sense as the rest of the code here validates the form parameters and redirects back to the form if the validation fails and we don’t want to do this for guest users as these should never fail. It makes sense to move this logic into another controller so this is what we’ll do.
class MembersController < ApplicationController def new @user = Member.new end def create @user = Member.new(params[:member]) if @user.save current_user.move_to(@user) if current_user && current_user.guest? session[:user_id] = @user.id redirect_to root_url else render "new" end end end
Next we’ll create a GuestsController
. This will only have a create
action that creates a guest user, sets a session variable and redirects back to the home page.
class GuestsController < ApplicationController def create guest = Guest.create! session[:user_id] = guest.id redirect_to root_url end end
This is much simpler than the equivalent action in the MembersController
and it makes sense to keep these separate. There are other places where we need to rename users to members but we won’t show those changes are they’re not that interesting.
Now when we use the application as a guest and click “Become a member” we’re redirected to the signup form instead of getting an exception as this is properly handled by the MembersController
. When we complete the signup process we’re taken back to our to-do list but signed in as a member.
Polymorphic Associations
The behaviour of our application hasn’t changed but the code is cleaner now that we’re using STI. It’s hard to say the same thing about the database, though. If we look at our schema we’ll see that the users table has three columns that only apply to members.
ActiveRecord::Schema.define(:version => 20121212202638) do create_table "tasks", :force => true do |t| t.integer "user_id" t.string "name" t.boolean "complete", :default => false, :null => false t.datetime "created_at", :null => false t.datetime "updated_at", :null => false end create_table "users", :force => true do |t| t.string "username" t.string "email" t.string "password_digest" t.datetime "created_at", :null => false t.datetime "updated_at", :null => false t.string "type" end end
This doesn’t look too bad in our application but it can quickly get out of hand as our application grows and we add more columns that apply to one type of user or the other the users
table can become the dumping ground for a lot of different fields that take up space because it isn’t properly normalized. A general rule of thumb is that we have several fields in a table that aren’t shared by all the records then Single Table Inheritance isn’t the best approach. If we’re in this situation a good alternative to STI is Polymorphic Associations. This allows one model to belong to multiple other types of model. The end result is similar to STI but it allows each different type to have its own model and database table.
There are a couple of different ways that we can use a polymorphic association in our application. One is where a model relates to the users. A Task
belongs to a User
and has a user_id
column. We could add a user_type
column here so that it can belong to a either a guest or a member user and have these as separate tables but this means that whenever we add another model that needs to relate to a user this also needs to be a polymorphic association and this is best avoided. A better approach is to keep the users
table but to move the columns specific to a member out into a member_profile
table. We can then also have a guest_profile
table and have a polymorphic association between users and profiles. To do this we’ll start by generating a migration to remove the member-related columns from the users table along with the type
column as we’re no longer using STI.
$ rails g migration remove_member_from_users username email password_digest type
Next we’ll create two new models, one called MemberProfile
with username
, email
and password_digest
fields, as these contain the authentication logic.
$ rails g model MemberProfile username email password_digest
We’ll also need a GuestProfile
model. This won’t have any database fields as we won’t store any data specific to guests.
$ rails g model GuestProfile
Now that we have these two models we’ll need to generate another migration to add two fields to associate them with a user. We can then migrate the database.
$ rails g migration add_profile_to_users profile_id:integer profile_type $ rake db:migrate
We should add an index to these database columns to improve performance but we won’t do that here. We can now set up the associations. A User should belong to a profile and this needs to be a polymorphic association.
class User < ActiveRecord::Base has_many :tasks, dependent: :destroy belongs_to :profile, polymorphic: true end
A GuestProfile
should have one user.
class GuestProfile < ActiveRecord::Base has_one :user, as: :profile, dependent: :destroy end
We’ve used dependent: :destroy
here so that when we delete a profile the related User record is deleted too. We’ll also do this in the MemberProfile.
class MemberProfile < ActiveRecord::Base has_one :user, as: :profile, dependent: :destroy attr_accessible :email, :password_digest, :username end
Guest
and Member
classes that we used with STI. First we’ll move the Guest
methods over.
class GuestProfile < ActiveRecord::Base has_one :user, as: profile, dependent: :destroy def guest? true end def move_to(user) tasks.update_all(user_id: user.id) end def name "Guest" end def task_limit 10 end def can_share_task?(task) false end def send_password_reset end end
Then the Member
methods.
class MemberProfile < ActiveRecord::Base has_one :user, as: :profile, dependent: :destroy attr_accessible :username, :email, :password, :password_confirmation validates_presence_of :username, :email validates_uniqueness_of :username, allow_blank: true has_secure_password def guest? false end def name username end def task_limit 1000 end def can_share_task?(task) task.user_id == id end def send_password_reset UserMailer.password_reset(self).deliver end end
We have to watch out when we do this as we might access user-specific attributes. One example here is in the can_share_task?
method where we should check the user.id
rather than the id
, which will now be a profile’s id
.
def can_share_task?(task) task.user_id == user.id end
In the GuestProfile
class we have a move_to
method which uses the tasks
association which isn’t present in the profile. Before we change this let’s consider what we need to do here. Do we still need to create a separate User
record when a guest user becomes a member? Instead of updating all the user ids for the associated records we could just change the profile that’s associated with the user record to make them a member. We’ll rename this method to become_member
and modify it to look like this:
def become_member(member_profile) user.profile = member_profile user.save! end
We now have a lot of methods that we can call on a profile but in our app we’re calling them against User
records. We’ll modify the User
class to delegate these.
class User < ActiveRecord::Base has_many :tasks, dependent: :destroy belongs_to :profile, polymorphic: true delegate :guest?, :name, :can_share_task?, :send_password_reset, :become_member, to: :profile end
There are other changes that we’ll need to make throughout this application. For example the GuestsController
creates a new Guest
record but it should now create a User
and assign a guest profile to them.
class GuestsController < ApplicationController def create guest = User.create! { |u| u.profile = GuestProfile.create! } session[:user_id] = guest.id redirect_to root_url end end
There are similar changes that we’ll need to make throughout the application but we won’t show those there. The end result is an application that behaves the same way but which uses a polymorphic association. This feels like a cleaner solution as we no longer have all those NULL values in the database and transitioning a user from a guest to a member is a lot cleaner, too.
There are many other solutions to this problem. The guests_profiles
table might not be necessary at all. It doesn’t contain any special data in our application and we could have a NULL profile_id
to represent a guest user. In this case we wouldn’t need to use polymorphic association and we could use a simple Ruby class to handle guest profiles. This is an approach that’s worth considering if we only have two different types in our polymorphic association.
Another alternative to Single Table Inheritance is the State Machine gem which we showed in episode 392. This allows us to define specific validations and methods for each state which is useful if we need to transition from one type to another frequently. In our situation the polymorphic solution is much nicer.