Multiple table inheritance with ActiveRecord

written by Maxim Chernyak on 21 Jan, 10

NOTICE: This article was written long ago as a proof of concept. Do not use this approach in production, because you will run into unexpected problems that will slow down your development. For this reason I have abandoned this method soon after I implemented it.

Imagine writing an online shop with different types of products. Normally all products would have common attributes such as title and price. Some attributes will likely differ. Tee may have size such as S, M, or L, while a Pen could have an ink_color. It’s easy to see that Tee is a Product, and so is Pen. We are looking at an is_a relationship. When I program this type of relationship I usually use inheritance.

class Product < ActiveRecord::Base
end

class Tee < Product
end

class Pen < Product
end

This inheritance looks reasonable, but now we have to come up with relational database structure. We need to find a way to store tee’s own attributes, pen’s own attributes, as well as their common (product’s) attributes without duplication. Some databases (PostgreSQL) provide support for table inheritance, but it’s a specialized feature which ties you down to the given db.

Single table inheritance

ActiveRecord provides only one way to handle a is_a relationship which is Single Table Inheritance. You’d have to create a table looking somewhat like the following.

id type price title size ink_color
1 Tee 1000 tie-dye t-shirt M  
2 Pen 500 ball pen   blue

The problem here is that all attributes are stored in the same table. It’s likely that soon the number of attributes will grow unmanageable, and most of them will always stay NULL since they’ll be specific to only one type.

Polymorphic has_one association

A has_one association allows us to split out tees, pens, and products into three different tables. In fact — as you’re about to see — this is the only way to get what we want. The problem is that it creates a has_a relationship, and we want is_a. Since there isn’t much choice, we can make it look like we have an is_a relationship, which I’m about to show.

Multiple table inheritance (simulated)

I was speaking with the awesome @fowlduck over at #railsbridge IRC channel about ways to achieve something like MTI with Active Record. He pointed me to a pastie where he implemented an MTI-like behavior and called it a “hydra” pattern, which I subsequently cleaned up a bit.

So we want to have 3 tables in the database.

class ProductProperties < ActiveRecord::Base
  belongs_to :sellable, :polymorphic => true, :dependent => :destroy
end

class Tee < ActiveRecord::Base
  has_one :product_properties, :as => :sellable, :autosave => true
end

class Pen < ActiveRecord::Base
  has_one :product_properties, :as => :sellable, :autosave => true
end

Immediately we can see duplicated code between Tee and Pen. This can be easily solved with a mixin.

module Sellable
  def self.included(base)
    base.has_one :product_properties, :as => :sellable, :autosave => true
  end
end

class Tee < ActiveRecord::Base
  include Sellable
end

class Pen < ActiveRecord::Base
  include Sellable
end

Now comes another issue. Every time we want to access price or title attributes (stored in product_properties) we have to call @tee.product_properties.price. This isn’t very convenient, especially considering that product_properties has to be built first in case it doesn’t exist. So let’s ensure it’s always built by updating the module.

module Sellable
  def self.included(base)
    base.has_one :product_properties, :as => :sellable, :autosave => true
    base.alias_method_chain :product_properties, :autobuild
  end
  
  def product_properties_with_autobuild
    product_properties_without_autobuild || build_product_properties
  end
end

Awesome, now product_properties is built automatically in case it doesn’t exist. We still have the method accessing issue though. For that I used method_missing.

module Sellable
  def self.included(base)
    base.has_one :product_properties, :as => :sellable, :autosave => true
    base.alias_method_chain :product_properties, :autobuild
  end
  
  def product_properties_with_autobuild
    product_properties_without_autobuild || build_product_properties
  end
  
  def method_missing(meth, *args, &blk)
    if product_properties.public_methods.include?(meth.to_s)
      product_properties.send(meth, *args, &blk)
    else
      super
    end
  end
end

Now if a method is missing from Tee or Pen instance it will be delegated to product_properties, which enables us to use @tee.price and @tee.title.

However, what about validations? Let’s say we want all products to always have a title, and we want to see an error appear on a Tee instance when ProductProperties#title is missing. Basically I want to completely remove product_properties from my sight as if it doesn’t exist, make it absolutely transparent. Let’s add the necessary validation in ProductProperties.

class ProductProperties < ActiveRecord::Base
  belongs_to :sellable, :polymorphic => true, :dependent => :destroy
  validates_presence_of :title
end

And now let’s make all Sellable models respect the validation as if it’s their own.

module Sellable
  def self.included(base)
    base.has_one :product_properties, :as => :sellable, :autosave => true
    base.validate :product_properties_must_be_valid
    base.alias_method_chain :product_properties, :autobuild
  end

  def product_properties_with_autobuild
    product_properties_without_autobuild || build_product_properties
  end

  def method_missing(meth, *args, &blk)
    if product_properties.public_methods.include?(meth.to_s)
      product_properties.send(meth, *args, &blk)
    else
      super
    end
  end

protected
  def product_properties_must_be_valid
    unless product_properties.valid?
      product_properties.errors.each do |attr, message|
        errors.add(attr, message)
      end
    end
  end
end

Notice that I’m including an additional validator with the Sellable module. The validator collects all the errors on ProductProperties and adds them to parent class as if the errors are on a Tee or Pen itself.

As a nice finishing touch we can put this snippet into a Rails initializer.

class ActiveRecord::Base
  def self.acts_as_product
    include Sellable
  end
end

# now we can say

class Tee < ActiveRecord::Base
  acts_as_product
end

Although that’s a matter of taste.

Fixing method_missing

There is a problem with method_missing. It checks the array of public_methods on product_properties to find out if delegation should occur. This check will fail in cases like @tee.title_changed?. That’s a magic method and therefore will not be part of static method array. Well, this is an easy fix.

# Replace old method_missing with this one:
def method_missing(meth, *args, &blk)
  product_properties.send(meth, *args, &blk)
rescue NoMethodError
  super
end

As you can see, even magic methods will work this way. Only if a NoMethodError is thrown we withdraw back into super.

Handling attributes hash

In the comments Austin brought up a case where initializing new models like Tee.new(:title => "foo") will throw an unknown attribute error. That’s expected since we rely on method_missing for accessing ProductProperties attributes. Instead we should define accessor methods explicitly in our individual products. Thankfully, it’s not too hard to accomplish with our Sellable mixin. First we need to add a submodule ClassMethods with a class method that uses class_eval to magically generate missing attributes.

module ClassMethods
  def define_product_properties_accessors
    all_attributes = ProductProperties.content_columns.map(&:name)
    ignored_attributes = ["created_at", "updated_at", "sellable_type"]
    attributes_to_delegate = all_attributes - ignored_attributes
    attributes_to_delegate.each do |attrib|
      class_eval <<-RUBY
        def #{attrib}
          product_properties.#{attrib}
        end
        
        def #{attrib}=(value)
          self.product_properties.#{attrib} = value
        end
        
        def #{attrib}?
          self.product_properties.#{attrib}?
        end
      RUBY
    end
  end
end

I’ll walk through this code quickly. First we’re extracting only the columns that we want to access. When we call content_columns in the first line of the method, it already excludes a bunch of special columns such as id and type. We then manually subtract more columns we’d like to ignore, such as timestamps, and polymorphic type.

Next we iterate over each remaining attribute and creating instance methods for it, such as title, title= and (for completeness) title?. Having these accessors defined explicitly is enough for ActiveRecord to see them when performing mass assignment, etc. We can now do something like Tee.new(:title => "foo") without any problems. The extra cases such as @tee.title_changed? are still handled by method_missing so we’re good.

One more thing left. We need to run this method on the base class into which we include Sellable. Just need to add a couple of lines to the self.included hook.

def self.included(base)
  base.has_one :product_properties, :as => :sellable, :autosave => true
  base.validate :product_properties_must_be_valid
  base.alias_method_chain :product_properties, :autobuild
  
  # Add these two lines:
  base.extend ClassMethods
  base.define_product_properties_accessors
end

And we’re all set.

All together now

Here’s the full picture of everything we just did.

class ActiveRecord::Base
  def self.acts_as_product
    include Sellable
  end
end

class ProductProperties < ActiveRecord::Base
  belongs_to :sellable, :polymorphic => true, :dependent => :destroy
  validates_presence_of :title # for example
end

module Sellable
  def self.included(base)
    base.has_one :product_properties, :as => :sellable, :autosave => true
    base.validate :product_properties_must_be_valid
    base.alias_method_chain :product_properties, :autobuild
    base.extend ClassMethods
    base.define_product_properties_accessors
  end

  def product_properties_with_autobuild
    product_properties_without_autobuild || build_product_properties
  end

  def method_missing(meth, *args, &blk)
    product_properties.send(meth, *args, &blk)
  rescue NoMethodError
    super
  end

  module ClassMethods
    def define_product_properties_accessors
      all_attributes = ProductProperties.content_columns.map(&:name)
      ignored_attributes = ["created_at", "updated_at", "sellable_type"]
      attributes_to_delegate = all_attributes - ignored_attributes
      attributes_to_delegate.each do |attrib|
        class_eval <<-RUBY
          def #{attrib}
            product_properties.#{attrib}
          end

          def #{attrib}=(value)
            self.product_properties.#{attrib} = value
          end

          def #{attrib}?
            self.product_properties.#{attrib}?
          end
        RUBY
      end
    end
  end

protected
  def product_properties_must_be_valid
    unless product_properties.valid?
      product_properties.errors.each do |attr, message|
        errors.add(attr, message)
      end
    end
  end
end

class Tee < ActiveRecord::Base
  acts_as_product
end

class Pen < ActiveRecord::Base
  acts_as_product
end

This can be easily adapted for any other use case besides products in a store. In fact, with some meta magic or code generation this can easily be made into a plugin which I encourage you to try and send me the link when you’re done. :)

comments powered by Disqus