Ruby on Rails
ClassTableInheritanceInRails (Version #23)

Class table inheritance in Rails

The following hacks class table inheritance around the existing Rails inheritance mechanism, and seems to work pretty smoothly. It automatically creates proxy classes to hold the extra columns for each subclass, with has_one associations as appropriate. It adds eager loading for these proxy associations to your finds, and generates delegate methods for the subclass attributes.

One caveat is that it does still require you put a Rails-style ‘type’ column on the topmost base class.

I think it runs about as fast as is possible with that sort of schema… one minor irritation is that it still puts an extra condition on the ‘type’ column into a lot of the SELECTs, when it would be better if it just replaced the LEFT OUTER JOIN with a INNER JOIN.


class ActiveRecord::Base
  class << self
    alias_method :has_one_without_cti, :has_one
  end
  
  def self.class_table_inheritance(options = {})
    table_name = options[:subclass_table] || name.demodulize.tableize
    primary_key_name = options[:subclass_foreign_key] || "#{superclass.name.demodulize.underscore}_id"

    proxy_symbol = "extra_columns_for_#{name.demodulize.underscore}".to_sym
    class_name = '::' + self.name

    proxy_class = const_set('ExtraColumns', Class.new(ActiveRecord::Base))
  
    proxy_class.class_eval do
      set_table_name table_name
      set_primary_key primary_key_name
      belongs_to :base, :class_name => class_name, :foreign_key => primary_key_name
      def self.reloadable?; false; end
    end

    has_one_without_cti proxy_symbol, :class_name => proxy_class.name, :foreign_key => primary_key_name, :dependent => true

    # We need the after_save filter for this association to run /before/ any other after_save's already registered on the superclass,
    # and before any after_creates or after_updates. This calls for some hackery:
    proxy_save_callback = @inheritable_attributes[:after_save].pop
    @inheritable_attributes[:after_create] ||= []
    @inheritable_attributes[:after_create].unshift(proxy_save_callback)
    @inheritable_attributes[:after_update] ||= []
    @inheritable_attributes[:after_update].unshift(proxy_save_callback)

    class_eval <<-EOV
      def self.find(*params)
        if params.last.is_a?(Hash)
          opts = params.last
        else
          opts = {}
          params.push(opts)
        end
        opts[:include] ||= []
        opts[:include] = [opts[:include]] unless opts[:include].is_a?(Array)
        opts[:include] << :#{proxy_symbol}
        super(*params)
      end

      alias_method :#{proxy_symbol}_old, :#{proxy_symbol}
      def #{proxy_symbol}
        #{proxy_symbol}_old or self.#{proxy_symbol} = ExtraColumns.new
      end
      def save
        self.#{proxy_symbol} ||= ExtraColumns.new
        super
      end
      # this doesn't happen automatically on update, so we'll make it:
      after_update {|record| record.#{proxy_symbol}.save }
    
      # associations on this subclass get added to the proxy class, and then the relevant methods delegated to the proxy object
      def self.belongs_to(name, *params)
        ExtraColumns.belongs_to(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name}?".to_sym, "build_\#{name}".to_sym, "create_\#{name}".to_sym, :to => :#{proxy_symbol}
      end
    
      def self.has_one(name, *params)
        ExtraColumns.has_one(name, *params)
        delegate name, "\#{name}=".to_sym, "build_\#{name}".to_sym, "create_\#{name}".to_sym, :to => :#{proxy_symbol}
      end

      def self.has_many(name, *params)
        ExtraColumns.has_many(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name.to_s.singularize}_ids=".to_sym, :to => :#{proxy_symbol}
      end

      def self.has_and_belongs_to_many(name, *params)
        ExtraColumns.has_and_belongs_to_many(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name.to_s.singularize}_ids=".to_sym, :to => :#{proxy_symbol}
      end
    EOV
  
    delegate_methods = proxy_class.column_names + proxy_class.column_names.map {|name| "#{name}=".to_sym } + proxy_class.column_names.map {|name| "#{name}?".to_sym }
    delegate *(delegate_methods << {:to => proxy_symbol})
  end
end

Quick example of use:


create table animals (id int not null primary key, type varchar, name varchar);
create table pigs (animal_id int not null primary key references animals (id), piggyness int);
create table muddy_pigs (pig_id int not null primary key references pigs (id), muddyness int);

# note; subclass tables should not have an 'id'
# column, rather their primary key should be
# the foreign key column pointing to the 
# superclass row's primary key  

class Animal < ActiveRecord::Base
end

class Pig < Animal
  class_table_inheritance
end

class MuddyPig < Pig
  class_table_inheritance
end


MuddyPig.create(:name => 'betty', :piggyness => 3, :muddyness => 5)

s = MuddyPig.find(:first)
=> #<MuddyPig:0x12345678 @attributes={"name" => "betty"} @_muddy_pig_proxy=#<MuddyPig::Proxy @attributes={"piggyness" => 3}> @_pig_proxy=#<Pig::Proxy @attributes={"piggyness" => 3}>

s.piggyness = 567
s.save

# polymorphic find doesn't eager load the associations, since results may not all be of the same class,
# but does instantiate the right class if you include a 'type' column
Animal.find(:all)
=> [#<MuddyPig:0x12345678 @attributes={"name" => "betty", "type" => "MuddyPig"}>]

# Another query example - note that you need to qualify your column names.
# Because it adds eager loading you can use columns from all the parent tables
# in your query too (in MySQL this works at least)

Pig.find(:all, :conditions => "pigs.piggyness > 1 and animals.name like 'b%'")


# note that things like Pig.find_by_piggyness don't currently work, as piggyness isn't a column of the base table


Anyone interested in using this code or making into a plugin feel free. matt at state51 dot com

Or you can download it already as a plugin

Problems

While using this code I came across two problems:

- my subclass includes a habtm association. When adding a record to this collection, the foreign key in the DB does not get filled. However, no error is thrown - also I get an exception when dividing my model into modules. Calling find() will result in the message ‘ModuleName is not missing constant ClassName’ (with the appropriate names for class and module, of course)

Yep, am aware

Of a few issues with this. It is a quick hack rather than something that’s fully tested for compatibility with all of ActiveRecord.

I’m afraid any further effort on my part is likely to go into moving to DataMapper ( http://datamapper.org/ ) and working on CTI support for DataMapper.

-Matt

A Plugin for this…

is located here: http://github.com/kschiess/armit/tree/master

Class table inheritance in Rails

The following hacks class table inheritance around the existing Rails inheritance mechanism, and seems to work pretty smoothly. It automatically creates proxy classes to hold the extra columns for each subclass, with has_one associations as appropriate. It adds eager loading for these proxy associations to your finds, and generates delegate methods for the subclass attributes.

One caveat is that it does still require you put a Rails-style ‘type’ column on the topmost base class.

I think it runs about as fast as is possible with that sort of schema… one minor irritation is that it still puts an extra condition on the ‘type’ column into a lot of the SELECTs, when it would be better if it just replaced the LEFT OUTER JOIN with a INNER JOIN.


class ActiveRecord::Base
  class << self
    alias_method :has_one_without_cti, :has_one
  end
  
  def self.class_table_inheritance(options = {})
    table_name = options[:subclass_table] || name.demodulize.tableize
    primary_key_name = options[:subclass_foreign_key] || "#{superclass.name.demodulize.underscore}_id"

    proxy_symbol = "extra_columns_for_#{name.demodulize.underscore}".to_sym
    class_name = '::' + self.name

    proxy_class = const_set('ExtraColumns', Class.new(ActiveRecord::Base))
  
    proxy_class.class_eval do
      set_table_name table_name
      set_primary_key primary_key_name
      belongs_to :base, :class_name => class_name, :foreign_key => primary_key_name
      def self.reloadable?; false; end
    end

    has_one_without_cti proxy_symbol, :class_name => proxy_class.name, :foreign_key => primary_key_name, :dependent => true

    # We need the after_save filter for this association to run /before/ any other after_save's already registered on the superclass,
    # and before any after_creates or after_updates. This calls for some hackery:
    proxy_save_callback = @inheritable_attributes[:after_save].pop
    @inheritable_attributes[:after_create] ||= []
    @inheritable_attributes[:after_create].unshift(proxy_save_callback)
    @inheritable_attributes[:after_update] ||= []
    @inheritable_attributes[:after_update].unshift(proxy_save_callback)

    class_eval <<-EOV
      def self.find(*params)
        if params.last.is_a?(Hash)
          opts = params.last
        else
          opts = {}
          params.push(opts)
        end
        opts[:include] ||= []
        opts[:include] = [opts[:include]] unless opts[:include].is_a?(Array)
        opts[:include] << :#{proxy_symbol}
        super(*params)
      end

      alias_method :#{proxy_symbol}_old, :#{proxy_symbol}
      def #{proxy_symbol}
        #{proxy_symbol}_old or self.#{proxy_symbol} = ExtraColumns.new
      end
      def save
        self.#{proxy_symbol} ||= ExtraColumns.new
        super
      end
      # this doesn't happen automatically on update, so we'll make it:
      after_update {|record| record.#{proxy_symbol}.save }
    
      # associations on this subclass get added to the proxy class, and then the relevant methods delegated to the proxy object
      def self.belongs_to(name, *params)
        ExtraColumns.belongs_to(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name}?".to_sym, "build_\#{name}".to_sym, "create_\#{name}".to_sym, :to => :#{proxy_symbol}
      end
    
      def self.has_one(name, *params)
        ExtraColumns.has_one(name, *params)
        delegate name, "\#{name}=".to_sym, "build_\#{name}".to_sym, "create_\#{name}".to_sym, :to => :#{proxy_symbol}
      end

      def self.has_many(name, *params)
        ExtraColumns.has_many(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name.to_s.singularize}_ids=".to_sym, :to => :#{proxy_symbol}
      end

      def self.has_and_belongs_to_many(name, *params)
        ExtraColumns.has_and_belongs_to_many(name, *params)
        delegate name, "\#{name}=".to_sym, "\#{name.to_s.singularize}_ids=".to_sym, :to => :#{proxy_symbol}
      end
    EOV
  
    delegate_methods = proxy_class.column_names + proxy_class.column_names.map {|name| "#{name}=".to_sym } + proxy_class.column_names.map {|name| "#{name}?".to_sym }
    delegate *(delegate_methods << {:to => proxy_symbol})
  end
end

Quick example of use:


create table animals (id int not null primary key, type varchar, name varchar);
create table pigs (animal_id int not null primary key references animals (id), piggyness int);
create table muddy_pigs (pig_id int not null primary key references pigs (id), muddyness int);

# note; subclass tables should not have an 'id'
# column, rather their primary key should be
# the foreign key column pointing to the 
# superclass row's primary key  

class Animal < ActiveRecord::Base
end

class Pig < Animal
  class_table_inheritance
end

class MuddyPig < Pig
  class_table_inheritance
end


MuddyPig.create(:name => 'betty', :piggyness => 3, :muddyness => 5)

s = MuddyPig.find(:first)
=> #<MuddyPig:0x12345678 @attributes={"name" => "betty"} @_muddy_pig_proxy=#<MuddyPig::Proxy @attributes={"piggyness" => 3}> @_pig_proxy=#<Pig::Proxy @attributes={"piggyness" => 3}>

s.piggyness = 567
s.save

# polymorphic find doesn't eager load the associations, since results may not all be of the same class,
# but does instantiate the right class if you include a 'type' column
Animal.find(:all)
=> [#<MuddyPig:0x12345678 @attributes={"name" => "betty", "type" => "MuddyPig"}>]

# Another query example - note that you need to qualify your column names.
# Because it adds eager loading you can use columns from all the parent tables
# in your query too (in MySQL this works at least)

Pig.find(:all, :conditions => "pigs.piggyness > 1 and animals.name like 'b%'")


# note that things like Pig.find_by_piggyness don't currently work, as piggyness isn't a column of the base table


Anyone interested in using this code or making into a plugin feel free. matt at state51 dot com

Or you can download it already as a plugin

Problems

While using this code I came across two problems:

- my subclass includes a habtm association. When adding a record to this collection, the foreign key in the DB does not get filled. However, no error is thrown - also I get an exception when dividing my model into modules. Calling find() will result in the message ‘ModuleName is not missing constant ClassName’ (with the appropriate names for class and module, of course)

Yep, am aware

Of a few issues with this. It is a quick hack rather than something that’s fully tested for compatibility with all of ActiveRecord.

I’m afraid any further effort on my part is likely to go into moving to DataMapper ( http://datamapper.org/ ) and working on CTI support for DataMapper.

-Matt

A Plugin for this…

is located here: http://github.com/kschiess/armit/tree/master