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 a base_class
class. This usually does not matter as most project use the same connection for all ActiveRecord
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.
Also, make sure to check out our latest book Domain-Driven Rails. Especially if you work with big, complex Rails apps.