STI Education- Rails Edition
Single Table Inheritance and/or Polymorphism can be a daunting feat to take on if it’s not part of the curriculum. Hopefully this read can help clear things up a bit, and give you more of an idea of how to apply it to your project.
For my Rails app, I incorporated STI [the abbreviation for single table inheritance, yes] into my program. I had a Host
and a Guest
subclass inherit from the User
superclass. ActiveRecord
supports this inheritance by having a column labeled as type
as a string. These subclasses are not migrated onto the database, it’s being shared underUser
. Subclasses under STI share database tables.
It looked something like this:
class User < ApplicationRecordend
class Guest < User
endclass Host < User
end
I decided to do a STI in my app because I wanted two different types of Users in my program to serve different functions, but still sharing a lot of other attributes.
Both users needed to have CRUD functionality, be able to log in, and post and delete/edit comments. However, there were certain capabilities that I didn’t want both types of users to share.
Since this was a party planning app, a Party can only belong_to
one Host, but Guests can belong_to
and have_many
parties.
I can make a direct relation with Party and Host, but since a party can have_many
Guests, and a Guest can have_many
parties, I created a join table called RSVP
. There a guest can RSVP to a party ( they have the option to remove themselves as a function in the views as well).
When using STI with database relationships, you have to state the foreign keys that are being used from the superclass, Rails will look for the guest_id
if you don’t, and it’ll throw you an error.
Here’s what this relationship looks like:
class Party < ApplicationRecord
belongs_to :host, :foreign_key=> :user_id
has_many :rsvps, dependent: :destroy
has_many :guests, through: :rsvps, :foreign_key=> :user_id
endclass User < ApplicationRecord
has_many :rsvps, dependent: :destroy
endclass Host < User
has_many :parties, :foreign_key=> :user_id, dependent: :destroy
endclass Guest < User
has_many :rsvps, :foreign_key=> :user_id, dependent: :destroy
has_many :parties, through: :rsvps
endclass Rsvp < ApplicationRecord
belongs_to :guest, :foreign_key=> :user_id
belongs_to :party
end
To keep things DRY, if both subclasses share the join table, you can put that table on the superclass- but make sure to name the foreign_key associations in the join table, and whenever you declare the association under your subclass. Rails will throw you an unknown guest_id
or host_id
error if you don’t.
Don’t forget to destroy your dependents! You wouldn’t want an orphan RSVP floating around if your Guest deletes their account, or if a Host deletes their account, how will you continue the party without them?
For routing, make sure you declare what controllers your subclasses are using in your config/routes.rb
file.
resources :hosts, :controller => 'users', type: "Host"
resources :guests, :controller => 'users', type: "Guest"
Whenever you want to create a Guest or Host, don’t forget to create it with Guest.create(attributes)
or Host.create(attributes)
, if you started off with User.create(attributes)
, it won’t have any of the STI magic that you intended your program to have. After you created your guest or host, if you ran User.all
in your console, the user class will return all of the created users in your program, and the type
field in each instance will return either guest if you created a guest object, or host if you created a host object.
Be careful! It’s really easy to run into bugs if you’re used to simple associations!
In my User table, i included a column for admin
with a boolean value, that results in true or false if the user creates a host. This is where things got tricky upon object creation.
For this project, I needed to have user validations, and for error messages to be printed out when incorrect data is being entered. So, I had to re-render the :new
page, and have the user re-enter the correct data.
This was my original code that did not work when I entered invalid data while creating a new user:
def create
@user = params[:user][:admin]== "1" ? Host.create(user_params) : Guest.create(user_params)
if @user.save
session[:user_id] = @user.id
redirect_to "/home"
else
render :new
end
end
What happened? The :new page was rendered, the errors were being printed out, but when I went back to correct my issues, the app already assumed I was creating a new Guest (or Host), and tried to route me along the /guest pathway while throwing me a nil:NilClass empty []
error. Since the boolean was being filled out in my params- which was a check here if you are a party host field, it wanted to start on creating that Host or Guest object- but since our controller was set up to only accept params for the :user
class, our create method was no longer recognizing the form params.
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :admin)
end
Say, if you wanted to only create a guest at that instance, you would have to change your params to params.require(:guest).permit(:name, :email, :password, :password_confirmation
to avoid that validation error.
Here was my fix:
def create
@user = User.new(user_params)
if @user.valid?
@user = params[:user][:admin]== "1" ? Host.create(user_params) : Guest.create(user_params)
session[:user_id] = @user.id
redirect_to "/home"
else
render :new
end
end
I created a new User without persisting it to the database. If those user attributes could pass my class validations, I would use the params from the form to finally be able to create my Guest or Host. I overwrote the @user
variable so that I didn’t break any code in my views, and keep things simple. If my validations didn’t pass, the page would be re-rendered under the user’s path without getting too complicated and tricking our already set methods.
This took some time to finally get the hang of, but I feel good that I picked this up. Playing around with the errors and finally fixing them made me feel more confident in grasping the concept of STI.