- 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.
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:
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:
Notice how the
object
type is showingAdminUser
? That will give us a clear distinction between admin and regular users without having to create a separateActiveRecord
model.The
type
attribute is automatically set toAdminUser
, as mentioned above, thistype
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 usingtype
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.