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
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
#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
#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
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
# include / extends
extend HasStiInheritance
...
end
class AmericanUser < User
...
end
end
class EuropeanUser < User
...
end
end
class AfricanUser < User
...
end
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
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