DZone Snippets is a public source code repository. Easily build up your personal collection of code snippets, categorize them with tags / keywords, and share them with the world

Snippets has posted 5883 posts at DZone. View Full User Profile

Extending Acts_as_taggable For Real-world Requirements

10.28.2006
| 5466 views |
  • submit to reddit
        acts_as_taggable is cool, but it doesn't easily support limits, offsets, tag intersections, arbitrary conditions, etc.. so I created some unit tests and started to extend it. This is where I'm at so far:

module ActiveRecord
  module Acts #:nodoc:
    module Taggable #:nodoc:
      def self.included(base)
        base.extend(ClassMethods)  
      end
      
      module ClassMethods
        def acts_as_taggable(options = {})
          write_inheritable_attribute(:acts_as_taggable_options, {
            :taggable_type => ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s,
            :from => options[:from]
          })
          
          class_inheritable_reader :acts_as_taggable_options

          has_many :taggings, :as => :taggable, :dependent => true
          has_many :tags, :through => :taggings

          include ActiveRecord::Acts::Taggable::InstanceMethods
          extend ActiveRecord::Acts::Taggable::SingletonMethods          
        end
      end
      
      module SingletonMethods
        def find_tagged_with(list, options = {})
          local_options = { :limit => 1000, :offset => 0 }.merge(options)
          find_by_sql([
            "SELECT DISTINCT #{table_name}.* FROM #{table_name}, tags, taggings " +
            "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
            "AND taggings.taggable_type = ? " +
            "AND taggings.tag_id = tags.id AND tags.name IN (?) #{"AND (#{local_options[:conditions]})" if local_options[:conditions]} LIMIT ? OFFSET ?",
            acts_as_taggable_options[:taggable_type], list, local_options[:limit], local_options[:offset]
          ])
        end
        
        def count_tagged_with(list, options = {})
          local_options = {}.merge(options)
          find_by_sql([
            "SELECT COUNT(DISTINCT #{table_name}.#{primary_key}) AS cnt FROM #{table_name}, tags, taggings " +
            "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
            "AND taggings.taggable_type = ? " +
            "AND taggings.tag_id = tags.id AND tags.name IN (?) #{"AND (#{local_options[:conditions]}) " if local_options[:conditions]}",
            acts_as_taggable_options[:taggable_type], list
          ]).first.cnt.to_i
        end

        def find_tagged_with_intersecting(list, options = {})
          local_options = { :limit => 1000, :offset => 0 }.merge(options)
          find_by_sql([
            "SELECT DISTINCT #{table_name}.* FROM #{table_name}, tags, taggings " +
            "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
            "AND taggings.taggable_type = ? " +
            "AND taggings.tag_id = tags.id AND tags.name IN (?) #{"AND (#{local_options[:conditions]}) " if local_options[:conditions]} GROUP BY #{table_name}.id HAVING COUNT(#{table_name}.id) = #{list.size} LIMIT ? OFFSET ?",
            acts_as_taggable_options[:taggable_type], list, local_options[:limit], local_options[:offset]
          ])
        end

        def count_tagged_with_intersecting(list, options = {})
          local_options = {}.merge(options)
          find_by_sql([
            "SELECT COUNT(*) AS cnt FROM (SELECT #{table_name}.#{primary_key} AS cnt FROM #{table_name}, tags, taggings " +
            "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
            "AND taggings.taggable_type = ? " +
            "AND taggings.tag_id = tags.id AND tags.name IN (?) " +
            "#{"AND (#{local_options[:conditions]})" if local_options[:conditions]} " +
            "GROUP BY taggings.taggable_id HAVING COUNT(taggings.taggable_id) = #{list.size}) AS x",
            acts_as_taggable_options[:taggable_type], list
          ]).first.cnt.to_i
        end               
      end
      
      module InstanceMethods
        def tag_with(list)
          Tag.transaction do
            taggings.destroy_all

            Tag.parse(list).each do |name|
              if acts_as_taggable_options[:from]
                send(acts_as_taggable_options[:from]).tags.find_or_create_by_name(name).on(self)
              else
                Tag.find_or_create_by_name(name).on(self)
              end
            end
          end
        end

        def tag_list
          tags.collect { |tag| tag.name.include?(" ") ? "'#{tag.name}'" : tag.name }.join(" ")
        end
      end
    end
  end
end

These are the sorts of tests I'm then doing:

assert_equal 2, Post.find_tagged_with_intersecting(%w{TagOne TagTwo}).size
assert_equal 2, Post.count_tagged_with_intersecting(%w{TagOne TagTwo})
assert_equal 1, Post.find_tagged_with_intersecting(%w{TagOne TagTwo}, :conditions => 'status = 1').size
assert_equal 1, Post.count_tagged_with_intersecting(%w{TagOne TagTwo}, :conditions => 'status = 1')
p = Post.find_tagged_with("TagOne", :conditions => 'status = 1')
    

Comments

Snippets Manager replied on Thu, 2010/08/12 - 9:14am

I think your query for find_tagged_with_intersecting does an OR, meaning it returns records that have one of the given tags. Not records that have all tags. I don't think that is called "intersecting". Here's what I got for intersection: joins = [] query_conditions = [] query_params = {} # One inner join per tag @tags.each_with_index do |tag, index| joins << "inner join taggings taggings#{index} on taggings#{index}.taggable_id = model_names.id " joins << "inner join tags tags#{index} on taggings#{index}.tag_id = tags#{index}.id" query_conditions << "tags#{index}.name = :tag#{index}" query_params["tag#{index}".to_sym] = tag end records = ModelName.find(:all, :joins => joins, :conditions => [ query_conditions.join(' and '), query_params]) Sorry, couldn't fit it into your context. I also left out the taggable_type condition. Stephan

Snippets Manager replied on Mon, 2012/05/07 - 1:30pm

Testing

Snippets Manager replied on Thu, 2006/10/26 - 12:36pm

The problem is that the method find_tagged_with does not work with with_scope.

Snippets Manager replied on Thu, 2006/10/26 - 12:36pm

Pretty good modification. Thank you.

Snippets Manager replied on Sun, 2007/01/07 - 2:07pm

Thanks for adding functionality to this great problem. I am having issues combining acts_as_versioned and acts_as_taggable -> the versioning won't work if I am using the acts_as_taggable plugin. Will your updates help with this? Thanks. - Robert Dempsey