Enabling Admin Users Using Single Table Inheritance in Rails
This guide will walk through how to implement the ability for our application to have Admin users and we're going to accomplish this by leveraging single table inheritance (STI).
Guide Tasks
  • Read Tutorial

This guide will walk through how to implement the ability for our application to have Admin users and we're going to accomplish this by leveraging single table inheritance (STI). After thinking about this pretty extensively I think I've decided against simply using the role attribute that we have and I want to refactor the application to use STI.

So what is single table inheritance? Single table inheritance is the process of creating a new class that inherits from an ActiveRecord model. This means that our AdminUser class will contain all of the same features as a regular User. However it's considered a better practice to create a new class that will encapsulate all of the behavior since it will let us organize our code better.

Technically it would be possible to use our column from the users table called role and then check throughout the application with code such as:

current_user.role == 'admin'

However this can get a little messy, and then what happens if we need to create different types of users, such as EditorUser or BannedUser? Having to check against a string value in the database isn't a great way to build the application, it's also creates more delicate tests. So with all of that being said, let's start by building this feature on a new branch, so let's run:

git checkout -b user-features

We need to start by removing our role attribute and adding a special column called type. Now type is a special name in Rails that specifically works with single table inheritance and it's what's going to make all of this work for us. Let's create a migration:

rails g migration swap_out_type_for_role_in_users

And then update the migration file that it generates to look something like this:

# db/migrate/...

class SwapOutTypeForRoleInUsers < ActiveRecord::Migration
  def change
    add_column :users, :type, :string
    remove_column :users, :role, :string
  end
end

After running rake db:migrate you'll see that if we run our specs we'll see that we just blew up out entire application with that change. Isn't it nice that we can know that there's an issue with our application before even running it? It's just another great benefit to using TDD to build an application. To get everything back up and running let's remove the calls to the role attribute in the model, specifically where it sets the defaults. Our User class should now look like this:

# app/models/user.rb

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable

  validates_presence_of :first_name, :last_name
  validates :username, uniqueness: true, presence: true, format: { with: /\A[a-zA-Z]+([a-zA-Z]|\d)*\Z/ }

  has_many :posts

  def full_name
    self.first_name + " " + self.last_name
  end
end

So already our refactor has cleaned up this file and removed a callback, which is always a nice bonus. Running the spec again will show that we only have one failure and this one is expected since it's in the User spec.

large

Let's remove this spec entirely since we'll create new specs for the inherited class, you can remove it from spec/models/user_spec.rb:13.

Running the tests again will show that we're back green and we can move forward with the feature build.

First let's by creating a new model spec file that will store what we're wanting to test. And one note here, I'm blurring the line between testing code and testing Rails itself, which is considered a poor way of performing TDD, however if you've never used single table inheritance before I think it's a good practice to become comfortable with how it works. If you're fluent with STI you probably wouldn't add these tests since they're ensuring behavior that ships with Rails. With all that being said I'll usually put a few tests in to ensure that I've properly structured the STI module. Let's start by creating a spec file for our AdminUser:

touch spec/models/admin_user_spec.rb

Now let's basic test for creating admin users:

# spec/models/admin_user_spec.rb

require 'rails_helper'

RSpec.describe AdminUser, type: :model do
  describe 'creation' do
    it 'can be created' do
      admin = AdminUser.create!(
        email: "admin@test.com",
        password: "asdfasdf",
        password_confirmation: "asdfasdf",
        first_name: "Jon",
        last_name: "Snow",
        username: "wallwatcher"
      )
      expect(admin).to be_valid
    end
  end
end

This gives us a failure because Rails can't find the class we've told it to look for, as shown here:

large

Let's fix this by creating an AdminUser class file and put it in the models directory:

touch app/models/admin_user.rb

Inside of our new file we can simply define a new class that inherits from the User class:

# app/models/admin_user.rb

class AdminUser < User
end

Now if you run the specs you'll see that everything is back to green, so let's refactor our creation call in the tests to a factory, first create the file:

touch spec/factories/admin_users.rb

And then let's define an AdminUser factory:

# spec/factories/admin_users.rb

FactoryGirl.define do
  factory :admin_user do
    email 'adminuser@example.com'
    password "password"
    password_confirmation "password"
    first_name "Jon"
    last_name "Snow"
    username "adminuser"
  end
end

Now we can refactor our spec and place the create call into a before block:

# spec/models/admin_user_spec.rb

require 'rails_helper'

RSpec.describe AdminUser, type: :model do
  describe 'creation' do
    before do
      @admin = FactoryGirl.create(:admin_user)
    end

    it 'can be created' do
      expect(@admin).to be_valid
    end
  end
end

That looks much better, now if you run all of the specs you'll see that everything is still passing. It's good that we can test using RSpec, however let's also test this in the rails console. We can test if this all worked in the rails console by running the following code:

AdminUser.create!(email: "test@test.com", password: "asdfasdf", password_confirmation: "asdfasdf", first_name: "Test", last_name: "Admin", username: "adminuser")

And you'll see that this processes properly. Now we can ensure that we can query the AdminUser model compared with the User model with the following queries:

User.count
AdminUser.count

This will show multiple users when we call User.count, but it will only show a single record when we call AdminUser.count, which means that it's working! It's important to note that the User model will contain all users, both regular and admins. If you wanted to take it another level you could create another user subclass called something like RegularUser and make the User class an abstract class that wouldn't contain any users. However I don't think our app needs that type of segmentation so we'll leave it like this.

Now if you run the query:

AdminUser.last

You'll get the following output:

#<AdminUser id: 6, email: "test@test.com", encrypted_password: "$2a$10$aj.sZ0AEZILvk7rPSrm7HOKZf5y9b06bXzHFQxYAdYa...", reset_password_token: nil, reset_password_sent_at: nil, remember_created_at: nil, sign_in_count: 0, current_sign_in_at: nil, last_sign_in_at: nil, current_sign_in_ip: nil, last_sign_in_ip: nil, first_name: "Test", last_name: "Admin", avatar: nil, username: "adminuser", created_at: "2016-06-07 23:19:21", updated_at: "2016-06-07 23:19:21", type: "AdminUser"> 

There are two keys here that are important to notice:

  1. Notice how the object type is showing AdminUser? That will give us a clear distinction between admin and regular users without having to create a separate ActiveRecord model.

  2. The type attribute is automatically set to AdminUser, as mentioned above, this type value is where the magic of Rails comes into play, STI wouldn't work without this column name. It's also important to know that this is a special keyword, so I wouldn't recommend using type as a column name unless you're using single table inheritance.

Now that we've implemented single table inheritance and tested it, we need to keep our seeds file up to date and I think our data needs to be reset, so we'll do that in the next guide.

Resources