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:
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:
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 ;-)
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.