Single Table Inheritance - Problems and solutions
… and check why 5600+ Rails engineers read also this
Single Table Inheritance - Problems and solutions
As a consulting agency we are often asked to help with projects which embrace lots of typical Rails conventions. One of the most common of them is the usage of STI (Single Table Inheritance). It is even considered best practice by some people for some use cases [YMMV]. I would like to show some typical problems related to STI usage and propose different solutions and perhaps workarounds.
Story
It is very common for US-related projects to store customer billing and shipping address. In Poland, you might have multiple addresses also such as registered address, home address, mailing address. So I will use address as an example. Although I have mostly seen STI usage for different kind of notifications and for events (such as meeting, concert, movie, etc).
Note
Remember that this code is purely for demonstration of problems and solutions. Using STI to implement billing and shipping address is just wrong. There are many better solutions.
Starting point
Let’s say that your user can have multiple addresses.
class User < ActiveRecord::Base
has_many :addresses
end
class Address < ActiveRecord::Base
belongs_to :user
end
class BillingAddress < Address
end
class ShippingAddress < Address
end
In the beginning everything always works. Things get complicated with time
when you start adding new features. Obviously we are missing some validation.
For whatever reason, let’s assume that they need to differ between types. In
our example ShippingAddress
we would like to restrict number of countries.
class Address < ActiveRecord::Base
validates_presence_of :full_name, :city, :street, :postal_code
end
class BillingAddress < Address
validates_presence_of :country
end
class ShippingAddress < Address
validates_inclusion_of :country, in: %w(USA Canada)
end
Of course, this is trivial example, and probably nobody would write it this way. But it will suit our needs and I have seen similar (in the technological sense of using STI and different validations per type) code in many reviewed projects.
u = User.new(login: "rupert")
u.save!
a = u.addresses.build(type: "BillingAddress", full_name: "Robert Pankowecki", city: "Wrocław", country: "Poland")
a.save!
This code is possible in Rails 4 where building association with STI type was fixed. When using Rails 3 you will have to use workaround discussed in next paragraph also when creating new record.
Type Change
STI is problematic when there is possibility of type change. And usually there is. Frontend is displaying some kind of form and is responsible for toggling visible fields depending on selected type of object and user can update object type. Very useful in case of user mistakes.
Let’s see the problem in action:
a.update_attributes(type: "ShippingAddress", country: "Spain") # => true # but should be false
a.class.name # => "BillingAddress" # But we wanted ShippingAddress
a.valid? # => true # but should be false, we ship only to USA and Canada
a.reload # => ActiveRecord::RecordNotFound:
# Couldn't find BillingAddress with id=1 [WHERE "addresses"."type" IN ('BillingAddress')]
# Because we changed the type in DB to Shipping but ActiveRecord is not aware
The problem is that we cannot change object class in runtime. This problem is not limited to ruby, many object oriented programming languages suffer from it. And when you think about it, it makes a lot of sense.
I think this tells us something about inheritance in general. It is very powerful mechanism but you should avoid it when there is possibility of type or behavior change. And favor other solutions such as delegation, strategy or roles. Whenever I want to use inheritance I ask myself is it possible that such statement will no longer be truthful? If it is, avoid inheritance.
Example: Admin < User
. Is it possible that my
User
will no longer be an Admin
. Yes! Ah, so being admin is more likely
a role that you have in organization. Inheritance won’t do.
In fact I think there is very little place for inheritance when modeling real world. Whenever your object changes properties at runtime and its behavior must also change because of such fact, you will be better with delegation and strategy (or creating new object). But, there are areas of code when I never had problem with inheritance such as GUI components. It turns out that buttons rarely change into pop-ups :) .
Workaround
The workaround requires fixing Rails in two places. First the update_record
method
must execute the query without restricting SQL update to the type of object
because we want to change it.
We also need a second method (metamorphose
) that heavily relies on
little known ActiveRecord#becomes
method which deals with copying all the Active Record variables from one object to another.
module ActiveRecord
module StiFriendly
# Rails 3.2
def update(attribute_names = @attributes.keys)
attributes_with_values = arel_attributes_values(false, false, attribute_names)
return 0 if attributes_with_values.empty?
klass = self.class.base_class # base_class added
stmt = klass.unscoped.where(klass.arel_table[klass.primary_key].eq(id)).arel.compile_update(attributes_with_values)
klass.connection.update stmt
end
# Rails 4.0
def update_record(attribute_names = @attributes.keys)
attributes_with_values = arel_attributes_with_values_for_update(attribute_names)
if attributes_with_values.empty?
0
else
klass = self.class
column_hash = klass.connection.schema_cache.columns_hash klass.table_name
db_columns_with_values = attributes_with_values.map { |attr,value|
real_column = column_hash[attr.name]
[real_column, value]
}
bind_attrs = attributes_with_values.dup
bind_attrs.keys.each_with_index do |column, i|
real_column = db_columns_with_values[i].first
bind_attrs[column] = klass.connection.substitute_at(real_column, i)
end
# base_class added
stmt = klass.base_class.unscoped.where(klass.arel_table[klass.primary_key].eq(id_was || id)).arel.compile_update(bind_attrs)
klass.connection.update stmt, 'SQL', db_columns_with_values
end
end
def metamorphose(klass)
obj = becomes(klass)
obj.type = klass.name
return obj
end
end
end
class Address < ActiveRecord::Base
include ActiveRecord::StiFriendly
end
Let’s see it in action:
u = User.last
a = u.addresses.last # => BillingAddress instance
a.country # => Poland
a = a.metamorphose(ShippingAddress) # => ShippingAddress instance
# new object
a.update_attributes(full_name: "RP") # => false
# Stopped by validation
# Validation worked properly
# We only ship to USA and Canada
a.errors => #<ActiveModel::Errors:0x0000000352f0f0 @base=#<BillingAddress id: 1, ... >,
# @messages={:country=>["is not included in the list"]} >
# Yay!
a.update_attributes(full_name: "RP", country: "USA") # => true
a.reload # => ShippingAddress
There are two potential problems here:
- virtual attributes are not copied, rails does not know about them, and
chances are you are not storing them in
@attributes
instance variable - as you can see in the monkey patching code (for rails 3.2) we are using
connection
from abase_class
class. This usually does not matter as most project use the same connection for allActiveRecord
classes. It is hard to say which class’ connection should be used when changing the object type from one to another.
Would I recommend using such hack in production? Hell no! You can see in the output that there is something wrong and check it easily:
a.class # => ShippingAddress
a.errors # => #<ActiveModel::Errors:0x0000000352f0f0 @base=#<BillingAddress id: 1 ... >>
a.errors.instance_variable_get(:@base).object_id == a.object_id # => false
When going such route (but without hacking rails),
I would probably create a new record with #metamorphose
,
save it, and destroy the old record if saving succeeded. All in transaction,
obviously. But this might be even harder when there are lot of associations
that would also require fixing foreign key. Maybe destroying old record first, and
creating a new one with same id (instead of relaying on auto increment)
is some kind of solution? What do you think?
But finding workarounds for such Rails problems is a good exercise. Mostly through such debugging and looking at Rails internals I got better in understanding it and its limitations. I no longer believe that throwing more and more logic into AR classes is a good solution. And the more you throw (STI, state_machine, IdentityMap, attachments), the more likely you will experience corner cases and troubles with migrations to new Rails version.
OK, now that we know the solution that we don’t like, let’s look into something more favorable.
Delegation
Instead of inheriting type, which prevents us from dynamically changing object behavior, we are going to use delegation, which makes it trivial.
class Billing
def validate_address(address)
country_validator.validate(address)
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::PresenceValidator.new(
attributes: :country,
)
end
end
class Shipping
def validate_address(address)
country_validator.validate(address)
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::InclusionValidator.new(
attributes: :country,
in: %w(USA Canada)
)
end
end
class Address < ActiveRecord::Base
belongs_to :user
validates_presence_of :full_name, :city
validate :type_specific_validation
def type
case address_type
when 'shipping'
Shipping.new
when
'billing'
Billing.new
else
raise "Unknown address type"
end
end
def type_specific_validation
type.validate_address(self)
end
end
Let’s see it in action:
a = Address.last
a.address_type = "billing"
a.valid? # => true
a.country = nil
a.valid? # => false
a.errors # => #<ActiveModel::Errors:0x000000047d1528 @base=#<Address id: 1 ... >,
# @messages={:country=>["can't be blank"]}>
a.country = "Poland"
a.address_type = "shipping"
a.valid? # => false
a.errors # => #<ActiveModel::Errors:0x000000047d1528 @base=#<Address id: 1 ...>,
# @messages={:country=>["is not included in the list"]}>
# Yay! Just like we wanted, different validations and different behavior
# depending on address_type which can be set based on form attributes.
In our little example we are only delegating some validation aspects but
in real life you would usually delegate much more. In some cases it might
be even worth to create the delegate with delegator as
a constructor argument, that will be used later in methods (#to_s
).
class Shipping
attr_reader :address
delegate :country, :city, :full_name, to: :address
def initialize(address)
@address = address
end
def validate_address
country_validator.validate(address)
end
def to_s
"Ship me to: #{country.upcase} #{city}"
end
private
def country_validator
@country_validator ||= ActiveModel::Validations::InclusionValidator.new(
attributes: :country,
in: %w(USA Canada)
)
end
end
class Address < ActiveRecord::Base
validate :type_specific_validation
def type
case address_type
when 'shipping'
Shipping.new(self)
when 'billing'
Billing.new(self)
else
raise "Unknown address type"
end
end
def type_specific_validation
type.validate_address
end
def to_s
type.to_s
end
end
So our two objects change the role of delegate and delegator depending on the task they need to accomplish, playing little ping-pong with each other. That was fancy way of saying that we created Circular dependency.
Conclusion
Delegation is one of many techniques that we can apply in such case.
Perhaps you would prefer DCI
or Aspects instead.
The choice is always yours. If you feel the pain of having STI in your
code, switching to delegation might be simpler than you think. And if
you were to remember only one thing from this long post, remember that
there is #becomes
and it might help you with creating different ActiveRecord object with the
same attributes.
Would you like to continue learning more?
If you enjoyed the article, subscribe to our newsletter so that you are always the first one to get the knowledge that you might find useful in your everyday Rails programmer job.
Content is mostly focused on (but not limited to) Ruby, Rails, Web-development and refactoring Rails applications.