Dave Jones

My Place On The Net

Fun With Rails Single Table Inheritance

This is a blog post about my experiences with Rails Single Table Inheritance and how the greatest of intentions produced the biggest of headaches.

The Challenge…

One of my projects involves the collation and management of “Resources”. A Resource is a video, news article, podcast etc - Something that can be used to supplement learning. Here’s the original Resource model with some simple validations:

# Represents an item on the resources page such as a video, article, podcasts etc.
class Resource < ActiveRecord::Base
  CATEGORY_OPTIONS = %w(Videos Articles Podcasts Reading Links)
  
  validates_presence_of :name, :description, :url
  validates_inclusion_of :category, in: CATEGORY_OPTIONS,
                                    message: 'not a valid option'
end

Combine the above with a regular CRUD (Create, Read, Update & Destroy) controller and you’ve got a system to view, edit and delete resources. Job done.

Now, fast forward a few months and imagine the following additional requirement: To supplement our offering of “free” resources, we’d like to introduce “member-only” resources that can only be accessed by users that are registered with the website.

How would you model the difference between and public and member-only resource? I wonder how many people jumped straight to adding a boolean to Resource.rb to denote whether something is public? or private? Yeah, I probably should have done that…


The Code…

Keen to learn something new, I thought I’d dabble with a bit of Single Table Inheritance. My problem seemed to fit the solution perfectly - Two objects that are used in a subtly different way but are modelled identically at the database layer. That’s the golden use-case, right?

Say hello to our MemberResource and PublicResource models. Both of these just inherit the attributes and validations from Resource.rb - It doesn’t get much simpler than this!

class MemberResource < Resource
end

class PublicResource < Resource
end

As an aside, to leverage STI in Rails, you’ll need to add a type column to the Resources DB table.

class AddTypeToResources < ActiveRecord::Migration
  def change
    add_column :resources, :type, :string
  end
end

And if you’re using strong params, you’ll need to add the :type symbol to your list of whitelisted attributes.

def resource_params
  params.require(:resource)
    .permit(:category, :name, :description, :url, :image, :type)
end

So now we have 2 new models, MemberResource and PublicResource, both of which inherit all their attributes and validations from Resource.rb.

Next, we need to wire things together at the controller level. Since the data-layer is identical, so too are the CRUD actions in the controller, right? I’ve already got all the CRUD logic defined (and tested) in ResourcesController.
Let’s declare 2 new controllers that simply extend ResourcesController to inherit it’s functionality:

class MemberResourcesController < ResourcesController
end

class PublicResourcesController < ResourcesController
end

Finally, let’s update our routes file so that the new sub-controllers are exposed to the outside world and our old super-controller can just hide in the background.

# resources :resources
resources :public_resources
resources :member_resources

The Problems…

Now that I’ve made my changes and described the motivation behind them, let’s take a look at the problems they introduced.

#####Problem 1: Which model should be created?

In ResourcesController.rb, we have a hard-coded constant to build, update and destroy instances of Resource.rb. For example, take a look at the #new action, below:

class ResourcesController < ApplicationController
  #...
  def new
    @resource = Resource.new
  end
  #...
end

We need to change this so that a PublicResource or a MemberResource is instantiated/updated, as required. This rather ugly hack solves the problem by instantiating the correct object based on the controller handling the request:

class ResourcesController < ApplicationController
  #... 
  def new
    @resource = klass.new
  end
  #... 

  private

  def klass
    Object.const_get params[:controller].classify
  end
end



Problem 2: Which parameter should be whitelisted?

Similar to the problem outlined above, our Rails 4 Strong Parameters are hard-coded to require the :resource key:

def resource_params
  params.require(:resource)
    .permit(:category, :name, :description, :url, :image, :type)
end

Instead, we need to intelligently permit the :member_resource or :public_resource key, depending on the controller being hit. We can re-use the klass method from Problem 1, adding some ugly string manipulation to convert the klass to a symbol:

def resource_params
  params.require(klass.name.underscore.to_sym)
    .permit(:category, :name, :description, :url, :image, :type)
end



Problem 3: RSpec Missing Routes

Here’s what happens when you try to run the spec for the old ResourcesController:

rspec spec/controllers/resource_controller_spec.rb
Failure/Error: get :edit, id: resource
     ActionController::UrlGenerationError:
       No route matches {:action=>"edit", :controller=>"resources", :id=>"1"}

We’re seeing this error because I’ve removed the routes for ResourcesController (remember that I commented out the line in routes.rb ?). RSpec can no longer reach the CRUD actions defined on ResourcesController and is therefore throwing an error.

Rather than re-introducing the routes across the entire app, let’s just inject them at run-time, for the purpose of the spec:

# Define any spec-specific routes, here
inject_test_routes = Proc.new do
  resources :resources
end

# This runs before all specs and injects any routes defined in the proc above.
before(:all) do
  Rails.application.routes.eval_block(inject_test_routes)
end



Problem 4: Missing Views & DoubleRender errors

Here comes another error:

rspec spec/controllers/resource_controller_spec.rb
Failure/Error: post :create, resource: invalid_params
     ActionView::MissingTemplate:

RSpec is throwing a MissingTemplate error because, after processing the “create” action, it’s trying to render the resources#create view, rather than public_resources#create view or member_resources#create view, as expected.

To fix this, let’s tell resources_controller#create to render nothing and define appropriate redirects in the sub-controllers.

 class ResourcesController < ApplicationController
  def create
    render nothing: true
    @resource = klass.new(resource_params)
    @resource.save
  end
end

class PublicResourcesController < ResourcesController
  def create
    if super
      redirect_to controller: 'page', action: 'resources'
    else
      render :new
    end
  end
end

OK, we’re back to a passing test-suite! Let’s verify the app still works in the browser:

AbstractController::DoubleRenderError (Render and/or redirect were called multiple times in this action

Argh! Now we’re getting an error because we’re rendering “nothing” in the super-controller and then redirecting in the sub-controller. That’s 2 renders for a single request - not allowed!

The Conclusion…

It seems that Single Table Inheritance is viewed, by many, to be more hassle than it’s worth. I think I agree. By mapping multiple models to a single database table, you’re asserting that the attributes and requirements of your models are identical today and will always be so in the future. That’s quite a gamble and it’s a lot of hassle if, further down the line, the models diverge and you need to split them out into their own tables.

If I’ve learnt one thing from this experience, however, it’s that STI at the database layer is one thing, but inheritance at the controller layer is something very different.
Trying to define shared CRUD behaviour in one controller, whilst seeming very DRY (Don’t Repeat Yourself), lead to horrible controller hacks, strange routing and bizarre tests.

Perhaps moving the CRUD logic to a shared “factory” or “service” model, used by all controllers is a better idea. At this point, though, it’s not worth the additional complexity and I think the following commit says it all:

commit 423fc7f97d9b049b276bca420890ed7ed198b25f
Author: Dave Jones
Date:   Sat Nov 22 17:41:15 2014 +0000

    Undoing STI Cleverness

Thanks to @relativesanity for talking through the code and markglenfletcher for proof-reading an early version of this post.