Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails Active Record - How to model a user/profile scenario

I have a rails application that has three different types of users and I need them all to share the same common profile information. However, each different user also has unique attributes themselves. I'm not sure how to separate out the different fields.

  • Admin (site wide admin)
  • Owner (of a store/etc)
  • Member (such as a member of a co-op)

I'm using devise for authentication and cancan for authorization. Therefore I have a User model with a set of roles that can be applied to the user. This class looks this:

class User < ActiveRecord::Base
  # ... devise stuff omitted for brevity ...

  # Roles association
  has_many :assignments
  has_many :roles, :through => :assignments

  # For cancan: https://github.com/ryanb/cancan/wiki/Separate-Role-Model
  def has_role?(role_sym)
    roles.any? { |r| r.name.underscore.to_sym == role_sym }
  end
end

Each user has a profile that includes:

  • First & Last Name
  • Address Info (city/st/zip/etc)
  • Phone

I do not want to pollute the User model with this info so I'm throwing it into a Profile model. This part is fairly simple. This turns the User model into something like this:

class User < ActiveRecord::Base
  # ... devise stuff omitted for brevity ...
  # ... cancan stuff omitted for brevity ... 
  has_one :profile
end

The additional fields is where I have some uneasy feelings about how to model are ...

If a user is an admin, they'll have unique fields such as:

  • admin_field_a:string
  • admin_field_b:string
  • etc

If a user is a Owner they'll have unique fields ...

  • stripe_api_key:string
  • stripe_test_api_key:string
  • stripe_account_number:string
  • has_one :store # AR Refence to another model that Admin and Member do not have.

If a user is a member they'll have a few additional fields as such:

  • stripe_account_number:string
  • belongs_to :store # the store that they are a member of
  • has_many :note

...

and a Store model will contain a has_many on the members so we work the the members of the store.

The issue is around the additional fields. Do I set these up as different classes? Put them into a different I've currently tried a few different ways to set this up:

One way is to set up the User Model as aggregate root

class User < ActiveRecord::Base
  # ...

  # Roles association
  has_many :assignments
  has_many :roles, :through => :assignments

  # Profile and other object types
  has_one :profile
  has_one :admin
  has_one :owner
  has_one :member

  # ...
end

The benefit of this approach is the User model is the root and can access everything. The downfall is that if the user is a "owner" then the "admin" and "member" references will be nil (and the cartesian of the other possibilities - admin but not owner or member, etc).

The other option I was thinking of was to have each type of user inherit from the User model as such:

class User < ActiveRecord::Base
  # ... other code removed for brevity
  has_one :profile
end

class Admin < User
  # admin fields
end

class Owner < User
  # owner fields
end

class Member < User
  # member fields
end

Problem with this is that I'm polluting the User object with all kinds of nil's in the table where one type doesn't need the values from another type/etc. Just seems kind of messy, but I'm not sure.

The other option was to create each account type as the root, but have the user as a child object as shown below.

class Admin
  has_one :user
  # admin fields go here. 
end

class Owner
  has_one :user
  # owner fields go here. 
end

class Member
  has_one :user
  # member fields go here. 
end

The problem with the above is I'm not sure how to load up the proper class once the user logs in. I'll have their user_id and I'll be able to tell which role they are (because of the role association on the user model), but I'm not sure how to go from user UP to a root object. Methods? other?

Conclusion I have a few different ways to do this, but I'm not sure what the correct "rails" approach is. What is the correct way to model this in rails AR? (MySQL backend). If there is not a "right" approach, whats the best of the above (I'm also open to other ideas).

Thanks!

like image 944
Donn Felker Avatar asked Mar 22 '12 21:03

Donn Felker


1 Answers

My answer assumes that a given user can only be one type of user - e.g. ONLY an Admin or ONLY a Member. If so, this seems like a perfect job for ActiveRecord's Polymorphic association.

class User < ActiveRecord::Base
  # ...

  belongs_to :privilege, :polymorphic => true
end

This association gives User an accessor called 'privilege' (for lack of a better term and to avoid naming confusion which will become apparent later). Because it is polymorphic, it can return a variety of classes. The polymorphic relationship requires two columns on the corresponding table - one (accessor)_type and (accessor)_id. In my example, the User table would gain two fields: privilege_type and privilege_id which ActiveRecord combines to find the associated entry during lookups.

Your Admin, Owner and Member classes look like this:

class Admin
  has_one :user, :as => :privilege
  # admin fields go here. 
end

class Owner
  has_one :user, :as => :privilege
  # owner fields go here. 
end

class Member
  has_one :user, :as => :privilege
  # member fields go here. 
end

Now you can do things like this:

u = User.new(:attribute1 => user_val1, ...)
u.privilege = Admin.new(:admin_att1 => :admin_val1, ...)
u.save!
# Saves a new user (#3, for example) and a new 
# admin entry (#2 in my pretend world).

u.privilege_type  # Returns 'Admin'
u.privilege_id    # Returns 2

u.privilege       # returns the Admin#2 instance.
# ActiveRecord's SQL behind the scenes: 
#    SELECT * FROM admin WHERE id=2 

u.privilege.is_a? Admin   # returns true
u.privilege.is_a? Member  # returns false

Admin.find(2).user        # returns User#3
# ActiveRecord's SQL behind the scenes: 
#    SELECT * FROM user WHERE privilege_type='Admin' 
#    AND privilege_id=2

I would recommend you make the (accessor)_type field on the database an ENUM if you expect it to be a known set of values. An ENUM, IMHO, is a better choice than a VARCHAR255 which Rails would normally default to, is easier/faster/smaller to index but makes changes down the road more difficult/timeconsuming when you've got millions of users. Also, index the association properly:

add_column :privilege_type, "ENUM('Admin','Owner','Member')", :null => false
add_column :privilege_id, :integer, :null => false

add_index :user, [:privilege_type, :privilege_id], :unique => true
add_index :user, :privilege_type

The first index allows ActiveRecord to rapidly find the reverse association (e.g. find the user that has a privilege of Admin#2) and the second index allows you to find all Admins or all Members.

This RailsCast is a bit dated but a good tutorial on polymorphic relationships nonetheless.

One last note - in your question, you indicated Admin, Owner or Member was the user's type which is appropriate enough, but as you probably see, I'd have to explain that your user table would then have a user_type_type field.

like image 162
Jeffrey D. Hoffman Avatar answered Oct 13 '22 23:10

Jeffrey D. Hoffman