Alex Stockwell

UX, DevOps, Fatherhood, Miscellany. Not in that order.

Polymorphic Associations in Rails 4

Adding a Polymorphic Association to Devise User Accounts

Update: See Part 2 of this article for some additional examples and gotchas around handling Cannot build association errors.

Problem (example)

Our application is going to have multiple types of users (e.g. subscribers, partners and vanilla users [like admins]). Subscribers and partners each have distinct attributes (database columns), but both are a type of user.

Goal

After ruling out Single table inheritance due to the pollution of our database with null values, we want to build a polymorphic association with each of these user types, and their underlying user record.

Execution

If you’re starting from scratch, you’ll want to bundle and install Rails 4 and the Devise gem. Their docs will get you going but to bootstrap Devise for a User model, you would run:

$ rails generate devise:install

$ rails generate devise User

$ rake db:migrate

Then if you’ll use them, go ahead and generate the views for Devise as well:

$ rails generate devise:views

User Model

We’ll start by adding the association to the user model side: belongs_to :meta, polymorphic: true. I chose the convention of calling the association meta, but you can choose whatever you’d like. I initially went with ‘type’, but it gets sort of confusing because Rails uses ‘type’ as an internal convention, so you would end up with things like user_type_type. Not ideal.

At this point your User model should resemble this (I’m also using Rolify for authorization):

# models/user.rb
class User < ActiveRecord::Base
    rolify
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable

    belongs_to :meta, polymorphic: true
end

You’ll also want to add the association to the User table, which stores the foreign key of the association and the type. You can run:

$ rails g migration add_meta_to_users meta_id:integer meta_type

$ rake db:migrate

The migration will resemble the following (with an index for performance):

# db/migrate/20140303123456_add_meta_to_users.rb
class AddMetaToUsers < ActiveRecord::Migration
    def change
        add_column :users, :meta_id, :integer
        add_column :users, :meta_type, :string

        add_index :users, [:meta_id, :meta_type]
    end
end

Note that neither of the new columns is a reference (Rails enforced foreign key). This is because Rails doesn’t know which User type to use (and thus which table to lookup the foreign key in) without you explicity providing it in the meta_type column. More on that in a minute.

Subscriber (associated) Model

At this point you can create your Subscriber classes:

$ rails g scaffold Subscriber region referrer signup_date:datetime discount:float

$ rake db:migrate

Then you can update your Subscriber model to reflect the polymorphic association by adding has_one :user, as: :meta, dependent: :destroy. It will resemble this:

# models/subscriber.rb
class Subscriber < ActiveRecord::Base
    has_one :user, as: :meta, dependent: :destroy
end

Note that the reference :meta doesn’t have to match what you put in the User model, but for your sanity it should. Also, dependent: :destroy ensures if the user record is deleted, the associated subscriber record is also deleted.

Follow the same steps to setup the ‘partners’ association as well.

$ rails g scaffold Partner vendor_code billing_address

$ rake db:migrate

Real World Wrinkles

So now you can create user records, create subscriber and partner records, and link a user record to one of either. The first thing I ran into when doing this was wanting to create a user record at the same time I create the subscriber/partner record, preferably using the same constructor/create method.

To do that you need to enable subscriber/partner actions to accept attributes for their related user record, via accepts_nested_attributes_for :user:

# models/subscriber.rb
class Subscriber < ActiveRecord::Base
    has_one :user, as: :meta, dependent: :destroy
    accepts_nested_attributes_for :user
end

Not done yet, thanks to Rails 4’s strong parameters, you need to also add the nested user-model attributes to the subscriber controller’s white-listed parameters list. This is done by adding an attributes array that matches the model (in this case, User) to the end of the controller’s permit action (provided by the Rails 4 scaffold by default): user_attributes: [ :id, :email, :name, :password ]. Your subscriber controller should now look similar to this:

# controllers/subscriber_controller.rb
class SubscriberController < ApplicationController

    ...

    def create
        @subscriber = Subscriber.new(subscriber_params)
        ...
    end

    ...

    def subscriber_params
        params.require(:subscriber).permit(:region, :referrer, :signup_date, :discount, user_attributes: [ :id, :email, :name, :password ])
    end

I’d suggest starting out by changing the params.require(:subscriber).permit(:region ... to simply params.require(:subscriber).permit! and getting things to work at all first. Then you can go back and properly scope your white-list. I say that because this can be tricky since there are instances where you have to specify if the nested attribute has further nested attrs via a hash or array. I’d refer anyone to the strong parameters docs and the overall action controller params docs for any clarification. I found these SO answers useful for hash/array and key/hash edge cases.

NOTE! I initially forgot to include :id in the :user_attributes array, and that tripped me up for quite a while. Don’t let it happen to you!

Useage

Now that all that is done, you can use them like so:

# Create a record with nested attributes
Subscriber.create(
    :region          => 'West',
    :referrer        => 'John Smith',
    :user_attributes => {
        :email    => 'jd@example.com',
        :name     => 'Jane Doe',
        :password => 'foobar',
    }
)
  => #<Subscriber>

# Access the related user record from the subscriber record
s = Subscriber.first
  => #<Subscriber>
s.user
  => #<User>
s.user.email
  => "jd@example.com"

# Access the related subscriber/partner from the user record
u = User.first
  => #<User>
u.meta
  => #<Subscriber>
u.meta.region
  => "West"
u.meta_type
  => "Subscriber"

The beauty of this approach is that you don’t need to know if the user is a subscriber or partner or anything else, you can just access user.meta and retrieve the related information, or use user.meta_type to determine the class of the related record.

Overall this approach is awesome for keeping your database normalized and keeping a good separation of objects and concerns. Happy coding!