Saturday, January 7, 2012

Using STI on Rails. Tips

If you ever think of using Single Table Inheritance on Rails, forget about it. It will cause more headaches than benefits. It is simply that Rails ActiveRecord does not work as you can expect with all the possible combinations / associations you can set on your objects: there are bugs, 'features' and tricks that made me lose a lot of time these last days. However, if you still think it fits into your models, keep reading.

When To use STI ?

As introduction, I highly recommend reading this excellent compilation of tips about STI : http://code.alexreisner.com/articles/single-table-inheritance-in-rails.html .
Well, lets make is as painless as possible:

Quick recipe

Lets suppose that you have a User model, that will derive in Man, Child and Woman subclasses. As all share the same data (columns) but the behaviour is different it makes sense using STI in here.

1 - Make all users share the same controller
The first thing I want is that all users share the same controller: show, index, destroy, ... all the actions do the same for all Users.
However, paths automatically generated for a @child, @man, @woman, @user, are different by default: new_child_path, new_man_path, .... That is a pain in view helpers as form_for and  fields_for
This is solved overwriting the 'model_name' class method of the base class: make all the models in the inheritance chain share the same name

(in user.rb)
class << self
    def inherited(child)
      child.instance_eval do
        def model_name
          User.model_name
        end
      end
      super 

    end
end

now, all subclasses use the users resource paths in the view helpers
2 - Remove all those ugly 'where type = ' conditions in your SQL queries

By default, when you do a Man.find(id) it includes a 'type = 'Man'' condition in your queries. For example:

Man.find(3) -> "SELECT from users WHERE id = 3 and type = 'Man';"
This is true for finds, updates and destroys. Far from being useful, it can make your records are not found under certain conditions (i.e changing the type of the record after its creation). That errors can go unnoticed since they dont raise errors, but have no effect.
For example, if we try to promote a child to a man:

@child.type = 'Man' 
#now, Rails side considers it as of 'Man' type, but DB still has it as 'Child'
@child.save!

it translates to
UPDATE users SET type = 'Man' WHERE id = 3 AND type = 'Man';
As DB still has the record stored as a Child, it is not found, and so never gets updated. A subtle error, that can lead to future problems (remember that it wont raise any failure)

My solution makes these 'where type' conditions removed by default:


(in user.rb)

class << self
    def finder_needs_type_condition?
      #http://apidock.com/rails/ActiveRecord/Base/finder_needs_type_condition%3F/class
      false
    end
end

Note that as (an undesired) side effect,  SQL operations over derived classes  lose the 'type' conditions by default.

More specifically

User.count == Child.count == Man.count
are translated to the same query:
SELECT COUNT(*) from users;

Woman.all == User.all
are translated to the same query:
SELECT * from users;

You must include it explicitly
User.where(:type => 'Woman').all

you can alleviate the problem adding a scope on all the derived classe

(in user.rb)
class << self
    def inherited(child)
    #after the code on the step 1


      def of_same_type
        where({:type => self.name}) # type == class.name
      end

    end 
end

Now you can do

Child.of_same_type.all

3 -Make you can easily promote one user to other type

There is a litlle gem in ActiveRecord called 'becomes' method
It allows you to 'cast' your object to other type:

@grown = @child.becomes(Man) #now @grown is the @child, cast to a Man.

The new object will behave as a Man, while have the original data form the child (name, age, etc). Keep in mind that 'becomes' returns a newly instantiated object, that shares the variables with the original object, so you are only handling a 'Man' when manipulates this new object (@grown). The original child is not really casted (code) to a man.

To make the change permanent you still have to save the object (@grown.save). However under some circumstances you are not sure if save is invoked on the new instance (@grown) or in the original (@child): for example using autosave associations, or if it it re bound in a belongs. In that cases you want it to be saved with the new type (Man) whether the save is in the original or in the casted instance. You only have to 'patch' the instance method from ActiveRecord::Base

(in user.rb)

    def becomes(klass)
      if self.class == klass
        return self
      else
        #backported patch from https://github.com/rails/rails/commit/e781853
        became = super(klass)
        became.type = klass.name unless klass.descends_from_active_record?
        became
      end
    end

4 - Consider polymorphic relationships

Whenever you are using a polymorphic relationship on a model with STI inheritance, there is a surprising thing (or not so surprising) that will happens where this relation is persisted.


Lets suppose that we have a has_pictures plugin that sets a polymorphic relationship. You can attach a picture to  User, Flat and Car models

(in picture.rb) 
belongs_to :picture, :polymorphic => true, :inverse_of => :item

You can have pictures for Users, Cars and Flats
has_one :item, :dependent => :destroy, :as => :posteable, :inverse_of => :picture

As in polymorphic relationships in Rails, Picture has two columns: item_type and type_id that are used to find the 'item' the picture owns: in type column you stores the class name of the item.

However Rails does a weird thing here: it stores the 'base' class in the type column of the item that is saved. That means that a Child, is stored with item_type set to 'User' .
It made me lose a day of work to realise that Rails works this way: it was not my fault.

If that does not suits you, there are plugins to store the very class of the item.
for Rails 3.0 , for 3.1 .


For other reasons I ended using an extra column to store the derived type (via :before_save) and kept the item_type storing the base class. But these plugins work well.


5 - Packing everything together
I finally wrote a module to reuse everything listed before.
Whenever I want to use Inheritance in AR models I just extend HasStiInheritance .

For example. Lets say that we have an User class, with AmericanUser, EuropeanUser and AfricanUser subclasses (every other user defaults to base User). These classes exist only to provide different implementation of some methods (i.e. the format addresses and phone numbers differently).

I declared User class as

class User < ActiveRecord::Base
  # include / extends
  extend HasStiInheritance
  ...
end

class  AmericanUser < User
   ...
end

class  EuropeanUser < User
   ...
end

class  AfricanUser < User
   ...
end

and I have only to remember using 'of_same_type' if I want to retrieve users of a given continent.

EuropeanUser.of_same_type.all #will list all europeans. OK

European.all #WRONG. retrieving ALL users, since we removed 'where type=XXX' clauses

I paste the module I am using now:

module HasStiInheritance
  #about structure, @see http://yehudakatz.com/2009/11/12/better-ruby-idioms/

  #constants here

  #class methods here

  def has_sti_inheritance
    include InstanceMethods
  end

  def define_class_method name, &blk
    #from http://blog.jayfields.com/2007/10/ruby-defining-class-methods.html
    (class << self; self; end).instance_eval { define_method name, &blk }
  end

  def finder_needs_type_condition?
    #do not include where type == klass_name in all the finders, even for subclases. That will helps 'AR.becomes' usage
    #http://apidock.com/rails/ActiveRecord/Base/finder_needs_type_condition%3F/class
    false
  end

  def of_same_type
    where(nil) # the base class does not need filtering by type
  end

  #As all users share the same controller / routes, we must patch the route generation for that resources:
  # Ex: => we want users_path , not european_users_path, american_users_path etc
  #http://code.alexreisner.com/articles/single-table-inheritance-in-rails.html
  def inherited(child)
    #here, self is the base class
    base_model_name = self.model_name
    child_model_name = child.name

    child.define_class_method :model_name do
      base_model_name
    end

    child.define_class_method :of_same_type do
      where({:type => child_model_name}) # type == class.name
    end
    super
  end
  #class methods above

  #instance methods here

  module InstanceMethods

    def becomes(klass)
      if self.class == klass
        return self
      else
        #backported patch from https://github.com/rails/rails/commit/e781853  for 'becomes' method
        became = super(klass)
        became.type = klass.name unless klass.descends_from_active_record?
        became
      end
    end

  end
  #instance methods above
end




FIN

No comments:

Post a Comment