Permalink
Browse files

No commit message

  • Loading branch information...
0 parents commit 1e813ae2f4ce7a0fa70bd45df98d984916fd4e98 @jviney committed Oct 12, 2006
@@ -0,0 +1,20 @@
+Copyright (c) 2006 Jonathan Viney
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,60 @@
+= acts_as_taggable_on_steroids
+
+This plugin is based on acts_as_taggable by DHH but includes extras
+such as tests, smarter tag assignment, and tag cloud calculations.
+
+Thanks to www.fanacious.com for allowing this plugin to be released. Please check out
+their site to show your support.
+
+== Resources
+
+Install
+ * script/plugin install http://svn.viney.net.nz/things/rails/plugins/acts_as_taggable_on_steroids
+
+== Usage
+
+=== Basic tagging
+
+Using the examples from the tests, let's suppose we have users that have many posts and we want those
+posts to be able to be tagged by the user.
+
+As usual, we add +acts_as_taggable+ to the Post class:
+
+ class Post < ActiveRecord::Base
+ acts_as_taggable
+
+ belongs_to :user
+ end
+
+We can now use the tagging methods provided by acts_as_taggable, <tt>tag_list</tt> and <tt>tag_list=</tt>. Both these
+methods work like regular attribute accessors.
+
+ p = Post.find(:first)
+ p.tag_list # ""
+ p.tag_list = "Funny, Silly"
+ p.save
+ p.reload.tag_list # "Funny, Silly"
+
+=== Tag cloud calculations
+
+To construct tag clouds, the frequency of each tag needs to be calculated.
+Because we specified +acts_as_taggable+ on the <tt>Post</tt> class, we can
+get a calculation of all the tag counts by using <tt>Post.tag_counts</tt>. But what if we wanted a tag count for
+an individual user's posts? To achieve this we extend the <tt>:posts</tt> association like so:
+
+ class User < ActiveRecord::Base
+ has_many :posts, :extend => TagCountsExtension
+ end
+
+This extension now allows us to do the following:
+
+ User.find(:first).posts.tag_counts
+
+This can be used to construct a tag cloud for the posts of an individual user. The +TagCountsExtension+ should
+only be used on associations that have +acts_as_taggable+ defined.
+
+Problems, comments, and suggestions all welcome. jonathan.viney@gmail.com
+
+== Credits
+
+www.fanacious.com
@@ -0,0 +1,22 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+desc 'Test the acts_as_taggable_on_steroids plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the acts_as_taggable_on_steroids plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'Acts As Taggable On Steroids'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
@@ -0,0 +1,4 @@
+require File.dirname(__FILE__) + '/lib/acts_as_taggable'
+
+require File.dirname(__FILE__) + '/lib/tagging'
+require File.dirname(__FILE__) + '/lib/tag'
@@ -0,0 +1,134 @@
+module ActiveRecord
+ module Acts #:nodoc:
+ module Taggable #:nodoc:
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+ def acts_as_taggable(options = {})
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
+ has_many :tags, :through => :taggings
+
+ after_save :save_tags
+
+ include ActiveRecord::Acts::Taggable::InstanceMethods
+ extend ActiveRecord::Acts::Taggable::SingletonMethods
+
+ alias_method :reload_without_tag_list, :reload
+ alias_method :reload, :reload_with_tag_list
+ end
+ end
+
+ module SingletonMethods
+ # Pass either a tag string, or an array of strings or tags
+ def find_tagged_with(tags, options = {})
+ tags = Tag.parse(tags) if tags.is_a?(String)
+ return [] if tags.empty?
+ tags.map!(&:to_s)
+
+ conditions = sanitize_sql(['tags.name in (?)', tags])
+ conditions << "and #{sanitize_sql(options.delete(:conditions))}" if options[:conditions]
+
+ find(:all, { :select => "DISTINCT #{table_name}.*",
+ :joins => "left outer join taggings on taggings.taggable_id = #{table_name}.#{primary_key} and taggings.taggable_type = '#{name}' " +
+ "left outer join tags on tags.id = taggings.tag_id",
+ :conditions => conditions }.merge(options))
+ end
+
+ # Options:
+ # :start_at - Restrict the tags to those created after a certain time
+ # :end_at - Restrict the tags to those created before a certain time
+ # :conditions - A piece of SQL conditions to add to the query
+ # :limit - The maximum number of tags to return
+ # :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
+ # :at_least - Exclude tags with a frequency less than the given value
+ # :at_most - Exclude tags with a frequency greater then the given value
+ def tag_counts(options = {})
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit
+
+ start_at = sanitize_sql(['taggings.created_at >= ?', options[:start_at]]) if options[:start_at]
+ end_at = sanitize_sql(['taggings.created_at <= ?', options[:end_at]]) if options[:end_at]
+ options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
+
+ conditions = [options[:conditions], start_at, end_at].compact.join(' and ')
+
+ at_least = sanitize_sql(['count >= ?', options[:at_least]]) if options[:at_least]
+ at_most = sanitize_sql(['count <= ?', options[:at_most]]) if options[:at_most]
+ having = [at_least, at_most].compact.join(' and ')
+
+ order = "order by #{options[:order]}" if options[:order]
+ limit = sanitize_sql(['limit ?', options[:limit]]) if options[:limit]
+
+ Tag.find_by_sql <<-END
+ select tags.id, tags.name, count(*) as count
+ from tags left outer join taggings on tags.id = taggings.tag_id
+ left outer join #{table_name} on #{table_name}.id = taggings.taggable_id
+ where taggings.taggable_type = "#{name}"
+ #{"and #{conditions}" unless conditions.blank?}
+ group by tags.id
+ having count(*) > 0 #{"and #{having}" unless having.blank?}
+ #{order}
+ #{limit}
+ END
+ end
+ end
+
+ module InstanceMethods
+ attr_writer :tag_list
+
+ def tag_list
+ defined?(@tag_list) ? @tag_list : read_tags
+ end
+
+ def save_tags
+ if defined?(@tag_list)
+ write_tags(@tag_list)
+ remove_tag_list
+ end
+ end
+
+ def write_tags(list)
+ new_tag_names = Tag.parse(list)
+ old_tagging_ids = []
+
+ Tag.transaction do
+ taggings.each do |tagging|
+ index = new_tag_names.index(tagging.tag.name)
+ index ? new_tag_names.delete_at(index) : old_tagging_ids << tagging.id
+ end
+
+ Tagging.delete_all(['id in (?)', old_tagging_ids]) unless old_tagging_ids.empty?
+
+ # Create any new tags/taggings
+ new_tag_names.each do |name|
+ Tag.find_or_create_by_name(name).tag(self)
+ end
+
+ taggings.reset
+ tags.reset
+ end
+ true
+ end
+
+ def read_tags
+ tags.collect do |tag|
+ tag.name.include?(',') ? "\"#{tag.name}\"" : tag.name
+ end.join(', ')
+ end
+
+ def reload_with_tag_list(*args)
+ remove_tag_list
+ reload_without_tag_list(*args)
+ end
+
+ private
+ def remove_tag_list
+ remove_instance_variable(:@tag_list) if defined?(@tag_list)
+ end
+ end
+ end
+ end
+end
+
+ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)
@@ -0,0 +1,39 @@
+class Tag < ActiveRecord::Base
+ has_many :taggings
+
+ def self.parse(list)
+ tags = []
+
+ return tags if list.blank?
+ list = list.dup
+
+ # Parse the quoted tags
+ list.gsub!(/"(.*?)"\s*,?\s*/) { tags << $1; "" }
+
+ # Strip whitespace and remove blank tags
+ (tags + list.split(',')).map!(&:strip).delete_if(&:blank?)
+ end
+
+ # A list of all the objects tagged with this tag
+ def tagged
+ taggings.collect(&:taggable)
+ end
+
+ # Tag a taggable with this tag
+ def tag(taggable)
+ Tagging.create :tag_id => id, :taggable => taggable
+ taggings.reset
+ end
+
+ def ==(object)
+ super || (object.is_a?(Tag) && name == object.name)
+ end
+
+ def to_s
+ name
+ end
+
+ def count
+ @attributes['count'].to_i if @attributes.include?('count')
+ end
+end
@@ -0,0 +1,17 @@
+module TagCountsExtension
+ # Options will be passed to the tag_counts method on the association's class
+ def tag_counts(options = {})
+ load_target
+ return [] if target.blank?
+
+ key_condition = "#{@reflection.table_name}.#{@reflection.primary_key_name} = #{target.first.send(@reflection.primary_key_name)}"
+
+ options[:conditions] = if options[:conditions]
+ sanitize_sql(options[:conditions]) + " and #{key_condition}"
+ else
+ key_condition
+ end
+
+ @reflection.klass.tag_counts(options)
+ end
+end
@@ -0,0 +1,4 @@
+class Tagging < ActiveRecord::Base
+ belongs_to :tag
+ belongs_to :taggable, :polymorphic => true
+end
@@ -0,0 +1,67 @@
+require 'test/unit'
+
+begin
+ require File.dirname(__FILE__) + '/../../../../config/boot'
+ require 'active_record'
+rescue LoadError
+ require 'rubygems'
+ require_gem 'activerecord'
+end
+
+require 'active_record/fixtures'
+
+require File.dirname(__FILE__) + '/../lib/acts_as_taggable'
+require File.dirname(__FILE__) + '/../lib/tag'
+require File.dirname(__FILE__) + '/../lib/tagging'
+require File.dirname(__FILE__) + '/../lib/tag_counts_extension'
+
+config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
+ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + '/debug.log')
+ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'mysql'])
+
+load(File.dirname(__FILE__) + '/schema.rb')
+
+Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + '/fixtures/'
+$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path)
+
+class Test::Unit::TestCase #:nodoc:
+ def create_fixtures(*table_names)
+ if block_given?
+ Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
+ else
+ Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names)
+ end
+ end
+
+ # Turn off transactional fixtures if you're working with MyISAM tables in MySQL
+ self.use_transactional_fixtures = true
+
+ # Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
+ self.use_instantiated_fixtures = false
+
+ def assert_equivalent(expected, actual, message = nil)
+ if expected.first.is_a?(ActiveRecord::Base)
+ assert_equal expected.sort_by(&:id), actual.sort_by(&:id), message
+ else
+ assert_equal expected.sort, actual.sort, message
+ end
+ end
+
+ def assert_tag_counts(tags, expected_values)
+ # Map the tag fixture names to real tag names
+ expected_values = expected_values.inject({}) do |hash, (tag, count)|
+ hash[tags(tag).name] = count
+ hash
+ end
+
+ tags.each do |tag|
+ value = expected_values.delete(tag.name)
+ assert_not_nil value, "Expected count for #{tag.name} was not provided" if value.nil?
+ assert_equal value, tag.count, "Expected value of #{value} for #{tag.name}, but was #{tag.count}"
+ end
+
+ unless expected_values.empty?
+ assert false, "The following tag counts were not present: #{expected_values.inspect}"
+ end
+ end
+end
Oops, something went wrong.

0 comments on commit 1e813ae

Please sign in to comment.