@home   @rss   @archive   @codeforpeople.com     @radio[:m3u|:pls|:ruby]   @family  

A DRYr acts_as_taggable?

The acts as taggable gem (http://taggable.rubyforge.org/) is very cool stuff. One file of highly reusable code. My only gripe with it is that each model you intend to tag requires it’s own join table for the HABTM relationship to function. Today I got to thinking about that and rolled out an approach that uses a single table and some STI (single table inheritence) trickery.

We start out with a schema for our tag table, which will contain all tags, and a tagged table, which will function as a join table on steriods. Mine looks like this:

  class Tag < ActiveRecord::Migration
    def self.up
      create_table tag, :primary_key => tag_id, :force => true do |t|
        t.column name, :text
        t.column created_at, :datetime
        t.column updated_at, :datetime
        t.column created_by, :integer, :limit => 8
        t.column updated_by, :integer, :limit => 8
      end
      %w[ name created_at updated_at created_by updated_by ].each do |column|
        add_index tag, column
      end

      create_table tagged, :primary_key => tagged_id, :force => true do |t|
        t.column type, :text
        t.column type_id, :integer, :limit => 8
        t.column tag_id, :integer, :limit => 8
        t.column created_at, :datetime
        t.column updated_at, :datetime
        t.column created_by, :integer, :limit => 8
        t.column updated_by, :integer, :limit => 8
      end
      %w[ type type_id tag_id created_at updated_at created_by updated_by ].each do |column|
        add_index tagged, column
      end
    end

    def self.down
      drop_table tag
      drop_table tagged
    end
  end

The tagged table will be our STI table. The type_id field is a generic foreign key pointing to an object of type type. All the timestamp fields aren’t required of course, but note that this isn’t a primary keyless join table but a full on join model.

Next we’ll setup our models for both Tag and Tagged classes. Check out the class generator in Tagged carefully - it’s this that holds the whole thing together:

  class Tag < ActiveRecord::Base
  end

  class Tagged < ActiveRecord::Base
    module By; end

    def self.by model
      @by ||= Hash.new
      return @by[model] if  @by.has_key? model

      namespace = model.name.split %r/::/
      const = namespace.pop
      tag = self

      @by[model] =
        Class.new(tag) do
          this = self
          by = By
          namespace.each do |n|
            m = by.const_get n
            unless m
              m = Module.new
              by.module_eval{ const_set n, m }
            end
            by = m
          end
          by.module_eval{ const_set const, this }
        end
    end
  end

The self.by method deserves a bit of explaination. Basically it defines ActiveRecord models under the By namespace to avoid possible name collisions. Note that the class defined is all setup for STI goodness by inherting from our Tagged class, which has the required type field.

The last thing is a bit of a tweak to the acts_as_taggable method itself - this one isn’t too generic, but you can imagine some code without all the hard variables ;-)

  def acts_as_taggable options = {}
    options = {
      :collection            => :tags,
      :tag_class_name        => Tag,
      :tag_class_column_name => name,
      :normalizer            => normalizer
    }.merge(options)

    collection_name = options[:collection]
    tag_model       = options[:tag_class_name].constantize
    tag_model_name  = options[:tag_class_column_name]
    normalizer      = options[:normalizer]

###
### pay attention starting here
###

    options[:join_table]              = tagged
    options[:foreign_key]             = type_id
    options[:association_foreign_key] = tag_id
  
    if 42
      join_model = Tagged.by self
      unless defined?(join_model::INITIALIZED)
        tagged = self
        join_model.class_eval do
          belongs_to :tag,
                     :class_name => tag_model.to_s,
                     :foreign_key => options[:association_foreign_key]

          belongs_to :tagged,
                     :class_name => ::#{ tagged.name.to_s },
                     :foreign_key => options[:foreign_key]

          define_method :normalizer, normalizer
          define_method(tag_model_name.to_sym){
            self[tag_model_name] ||= normalizer(tag.send(tag_model_name.to_sym))
          }
          const_set :INITIALIZED, true
        end
      end

###
### stop paying attention
###
      
      options[:class_name] ||= join_model.to_s
      tag_pk, tag_fk = tag_model.primary_key, options[:association_foreign_key]
      t, tn, jt = tag_model.table_name, tag_model_name, join_model.table_name
      options[:finder_sql] ||=
        SELECT #{jt}.*,
          #{t}.#{tn} AS #{tn} FROM #{jt},
          #{t} WHERE #{jt}.#{tag_fk} = #{t}.#{tag_pk} AND
          #{jt}.#{options[:foreign_key]} = \#{quoted_id}
      
    else
      join_model = nil
    end
    
    # set some class-wide attributes needed in class and instance methods                    
    write_inheritable_attribute(:tag_foreign_key, options[:association_foreign_key])                
    write_inheritable_attribute(:taggable_foreign_key, options[:foreign_key])                
    write_inheritable_attribute(:normalizer, normalizer)                
    write_inheritable_attribute(:tag_collection_name, collection_name)
    write_inheritable_attribute(:tag_model, tag_model)
    write_inheritable_attribute(:tag_model_name, tag_model_name)
    write_inheritable_attribute(:tags_join_model, join_model)
    write_inheritable_attribute(:tags_join_table, options[:join_table])                                      
    write_inheritable_attribute(:tag_options, options)
    
    [ :collection,
      :tag_class_name,
      :tag_class_column_name,
      :join_class_name,
      :normalizer].each do |key|
      options.delete(key)
    end # dont need this

    [ :join_table, :association_foreign_key ].each do |key|
      options.delete(key)
    end if join_model # dont need this for has_many

    # now, finally add the proper relationships          
    class_eval do
      include ActiveRecord::Acts::Taggable::InstanceMethods
      extend ActiveRecord::Acts::Taggable::SingletonMethods            
      
      class_inheritable_reader(
        :tag_collection_name, :tag_model, :tag_model_name, :tags_join_model,
        :tags_options, :tags_join_table,
        :tag_foreign_key, :taggable_foreign_key, :normalizer
      )

      if join_model
        has_many collection_name, options
      else
        has_and_belongs_to_many collection_name, options
      end
    end                    
  end

The end result is that any model we want to tag doesn’t need a new join table but can leverage the existing one via STI. DRY is our desire.

|