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.

comments powered by Disqus