Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial import of acts_as_taggable_on.

git-svn-id: http://svn.intridea.com/svn/public/acts_as_taggable_on@25 0572c0d6-f9f1-4294-a55b-9f92c10757d9
  • Loading branch information...
commit bf007a81f9bd748b86dc110e6cb303c6bf7cd38b 0 parents
michael authored
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2007 Michael Bleigh and Intridea Inc.
+
+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.
51 README
@@ -0,0 +1,51 @@
+ActsAsTaggableOn
+================
+
+This plugin is heavily based on Acts as Taggable on Steroids by Jonathan Viney. While
+basic tagging functionality is handled expertly as well as tag cloud calculations, for
+many applications there may be a need to have several "tag fields" for a given model.
+
+For instance, in a social network, a user might have tags that are called skills,
+interests, sports, and more. There is no real way to differentiate between tags and
+so an implementation of this type is not possible with acts as taggable on steroids.
+
+Enter Acts as Taggable On. Rather than tying functionality to a specific keyword
+(namely "tags"), acts as taggable on allows you to specify an arbitrary number of
+tag "contexts" that can be used locally or in combination in the same way steroids
+was used.
+
+Installation
+============
+
+The simplest way is just to install straight from the SVN:
+
+script/plugin install http://svn.intridea.com/svn/public/acts_as_taggable_on
+
+Testing
+=======
+
+Acts As Taggable On uses RSpec for its test coverage. If you already have RSpec on your
+application, the specs will run while using:
+
+rake spec:plugins
+
+Example
+=======
+
+class User < ActiveRecord::Base
+ acts_as_taggable_on :skills, :interests
+end
+
+@user = User.new
+@user.skill_list = "ruby, rails"
+@user.save
+@user.skill_list # => ["ruby","rails"] (as TagList)
+@user.skills # => [<Tag name:"ruby">,<Tag name:"rails">]
+
+Caveats, Uncharted Waters
+=========================
+
+This plugin is still under active development. Plugin caching and tag cloud calculations
+have not been thoroughly (or even partially) tested and may not work as expected.
+
+Copyright (c) 2007 Michael Bleigh and Intridea Inc., released under the MIT license
2  init.rb
@@ -0,0 +1,2 @@
+# Include hook code here
+ActiveRecord::Base.send :include, ActiveRecord::Acts::TaggableOn
1  install.rb
@@ -0,0 +1 @@
+# Install hook code here
202 lib/active_record/acts/taggable_on.rb
@@ -0,0 +1,202 @@
+module ActiveRecord
+ module Acts
+ module TaggableOn
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+ def acts_as_taggable
+ acts_as_taggable_on :tags
+ end
+
+ def acts_as_taggable_on(*args)
+ self.class_eval do
+ @tag_types = args
+ def self.tag_types
+ @tag_types
+ end
+
+ before_save :save_cached_tag_list
+ after_save :save_tags
+ end
+
+ for tag_type in args
+ tag_type = tag_type.to_s
+ self.class_eval do
+ has_many "#{tag_type.singularize}_taggings".to_sym, :as => :taggable, :dependent => :destroy, :include => :tag, :conditions => ["context = ?",tag_type], :class_name => "Tagging"
+ has_many "#{tag_type}".to_sym, :through => "#{tag_type.singularize}_taggings".to_sym, :source => :tag
+ end
+
+ self.class_eval <<-RUBY
+ def self.caching_#{tag_type.singularize}_list?
+ column_names.include?("cached_#{tag_type.singularize}_list")
+ end
+
+ def #{tag_type.singularize}_list
+ return @#{tag_type.singularize}_list unless @#{tag_type.singularize}_list.nil?
+
+ if self.class.caching_#{tag_type.singularize}_list? and !(cached_value = cached_#{tag_type.singularize}_list).nil?
+ @#{tag_type.singularize}_list = TagList.from(cached_value)
+ else
+ @#{tag_type.singularize}_list = TagList.new(*#{tag_type}.map(&:name))
+ end
+ end
+
+ def #{tag_type.singularize}_list=(new_tags)
+ @#{tag_type.singularize}_list = TagList.from(new_tags)
+ end
+
+ def #{tag_type.singularize}_counts(*args)
+ options = args.empty? ? {} : args.first
+ #{tag_type.singularize}_counts({:conditions => ["#{Tag.table_name}.name IN (?)", #{tag_type.singularize}_list]}.reverse_merge!(options))
+ end
+ RUBY
+ end
+
+ include ActiveRecord::Acts::TaggableOn::InstanceMethods
+ extend ActiveRecord::Acts::TaggableOn::SingletonMethods
+
+ alias_method_chain :reload, :tag_list
+ end
+ end
+
+ module SingletonMethods
+ # Pass either a tag string, or an array of strings or tags
+ #
+ # Options:
+ # :exclude - Find models that are not tagged with the given tags
+ # :match_all - Find models that match all of the given tags, not just one
+ # :conditions - A piece of SQL conditions to add to the query
+ # :on - scopes the find to a context
+ def find_tagged_with(*args)
+ options = find_options_for_find_tagged_with(*args)
+ options.blank? ? [] : find(:all,options)
+ end
+
+ def find_options_for_find_tagged_with(tags, options = {})
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
+
+ return {} if tags.empty?
+
+ conditions = []
+ conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
+
+ unless (on = options.delete(:on)).nil?
+ conditions << sanitize_sql(["context = ?",on.to_s])
+ end
+
+ taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
+
+ if options.delete(:exclude)
+ tags_conditions = tags.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
+ conditions << sanitize_sql(["#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} LEFT OUTER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE (#{tags_conditions}) AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})", tags])
+ else
+ conditions << tags.map { |t| sanitize_sql(["#{tags_alias}.name LIKE ?", t]) }.join(" OR ")
+
+ if options.delete(:match_all)
+ group = "#{taggings_alias}.taggable_id HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
+ end
+ end
+
+ { :select => "DISTINCT #{table_name}.*",
+ :joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)} " +
+ "LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
+ :conditions => conditions.join(" AND "),
+ :group => group
+ }.update(options)
+ end
+
+ # Calculate the tag counts for all tags.
+ #
+ # 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 than the given value
+ # :on - Scope the find to only include a certain context
+ def tag_counts(options = {})
+ Tag.find(:all, find_options_for_tag_counts(options))
+ end
+
+ def find_options_for_tag_counts(options = {})
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit
+
+ scope = scope(:find)
+ start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
+ end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
+
+ conditions = [
+ "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}",
+ options[:conditions],
+ scope && scope[:conditions],
+ start_at,
+ end_at
+ ]
+
+ unless (on = options.delete(:on)).nil?
+ conditions << sanitize_sql(["context = ?",on])
+ end
+
+ conditions = conditions.compact.join(' AND ')
+
+ joins = ["LEFT OUTER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
+ joins << "LEFT OUTER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"
+ joins << scope[:joins] if scope && scope[:joins]
+
+ at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
+ at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
+ having = [at_least, at_most].compact.join(' AND ')
+ group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0"
+ group_by << " AND #{having}" unless having.blank?
+
+ { :select => "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count",
+ :joins => joins.join(" "),
+ :conditions => conditions,
+ :group => group_by
+ }.update(options)
+ end
+ end
+
+ module InstanceMethods
+ def save_cached_tag_list
+ self.class.tag_types.map(&:to_s).each do |tag_type|
+ if self.class.send("caching_#{tag_type.singularize}_list?")
+ self["cached_#{tag_type.singularize}_list"] = send("#{tag_type.singularize}_list").to_s
+ end
+ end
+ end
+
+ def save_tags
+ self.class.tag_types.map(&:to_s).each do |tag_type|
+ next unless instance_variable_get("@#{tag_type.singularize}_list")
+
+ new_tag_names = instance_variable_get("@#{tag_type.singularize}_list") - send(tag_type).map(&:name)
+ old_tags = send(tag_type).reject { |tag| instance_variable_get("@#{tag_type.singularize}_list").include?(tag.name) }
+
+ self.class.transaction do
+ send(tag_type).delete(*old_tags) if old_tags.any?
+ new_tag_names.each do |new_tag_name|
+ new_tag = Tag.find_or_create_with_like_by_name(new_tag_name)
+ Tagging.create(:tag_id => new_tag.id, :context => tag_type, :taggable_type => self.class.to_s, :taggable_id => self.id)
+ end
+ end
+ end
+
+ true
+ end
+
+ def reload_with_tag_list(*args)
+ self.class.tag_types.each do |tag_type|
+ self.instance_variable_set("@#{tag_type.to_s.singularize}_list", nil)
+ end
+
+ reload_without_tag_list(*args)
+ end
+ end
+ end
+ end
+end
23 lib/tag.rb
@@ -0,0 +1,23 @@
+class Tag < ActiveRecord::Base
+ has_many :taggings
+
+ validates_presence_of :name
+ validates_uniqueness_of :name
+
+ # LIKE is used for cross-database case-insensitivity
+ def self.find_or_create_with_like_by_name(name)
+ find(:first, :conditions => ["name LIKE ?", name]) || create(:name => name)
+ end
+
+ def ==(object)
+ super || (object.is_a?(Tag) && name == object.name)
+ end
+
+ def to_s
+ name
+ end
+
+ def count
+ read_attribute(:count).to_i
+ end
+end
85 lib/tag_list.rb
@@ -0,0 +1,85 @@
+class TagList < Array
+ cattr_accessor :delimiter
+ self.delimiter = ','
+
+ def initialize(*args)
+ add(*args)
+ end
+
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
+ #
+ # tag_list.add("Fun", "Happy")
+ #
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
+ #
+ # tag_list.add("Fun, Happy", :parse => true)
+ def add(*names)
+ extract_and_apply_options!(names)
+ concat(names)
+ clean!
+ self
+ end
+
+ # Remove specific tags from the tag_list.
+ #
+ # tag_list.remove("Sad", "Lonely")
+ #
+ # Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
+ #
+ # tag_list.remove("Sad, Lonely", :parse => true)
+ def remove(*names)
+ extract_and_apply_options!(names)
+ delete_if { |name| names.include?(name) }
+ self
+ end
+
+ # Transform the tag_list into a tag string suitable for edting in a form.
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
+ #
+ # tag_list = TagList.new("Round", "Square,Cube")
+ # tag_list.to_s # 'Round, "Square,Cube"'
+ def to_s
+ clean!
+
+ map do |name|
+ name.include?(delimiter) ? "\"#{name}\"" : name
+ end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
+ end
+
+ private
+ # Remove whitespace, duplicates, and blanks.
+ def clean!
+ reject!(&:blank?)
+ map!(&:strip)
+ uniq!
+ end
+
+ def extract_and_apply_options!(args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ options.assert_valid_keys :parse
+
+ if options[:parse]
+ args.map! { |a| self.class.from(a) }
+ end
+
+ args.flatten!
+ end
+
+ class << self
+ # Returns a new TagList using the given tag string.
+ #
+ # tag_list = TagList.from("One , Two, Three")
+ # tag_list # ["One", "Two", "Three"]
+ def from(string)
+ returning new do |tag_list|
+ string = string.to_s.dup
+
+ # Parse the quoted tags
+ string.gsub!(/"(.*?)"\s*#{delimiter}?\s*/) { tag_list << $1; "" }
+ string.gsub!(/'(.*?)'\s*#{delimiter}?\s*/) { tag_list << $1; "" }
+
+ tag_list.add(string.split(delimiter))
+ end
+ end
+ end
+end
6 lib/tagging.rb
@@ -0,0 +1,6 @@
+class Tagging < ActiveRecord::Base #:nodoc:
+ belongs_to :tag
+ belongs_to :taggable, :polymorphic => true
+
+ validates_presence_of :context
+end
11 lib/tags_helper.rb
@@ -0,0 +1,11 @@
+module TagsHelper
+ # See the README for an example using tag_cloud.
+ def tag_cloud(tags, classes)
+ max_count = tags.sort_by(&:count).last.count.to_f
+
+ tags.each do |tag|
+ index = ((tag.count / max_count) * (classes.size - 1)).round
+ yield tag, classes[index]
+ end
+ end
+end
35 spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb
@@ -0,0 +1,35 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe "acts_as_taggable_on" do
+ context "Taggable Method Generation" do
+ before(:each) do
+ @taggable = User.new(:name => "Bob Jones")
+ end
+
+ it "should create a class attribute for tag types" do
+ @taggable.class.should respond_to(:tag_types)
+ end
+
+ it "should generate an association for each tag type" do
+ @taggable.should respond_to(:tags, :skills, :languages)
+ end
+
+ it "should generate a cached column checker for each tag type" do
+ User.should respond_to(:caching_tag_list?, :caching_skill_list?, :caching_language_list?)
+ end
+
+ it "should add tagged_with and tag_counts to singleton" do
+ User.should respond_to(:find_tagged_with, :tag_counts)
+ end
+
+ it "should add saving of tag lists and cached tag lists to the instance" do
+ @taggable.should respond_to(:save_cached_tag_list)
+ @taggable.should respond_to(:save_tags)
+ end
+
+ it "should generate a tag_list accessor/setter for each tag type" do
+ @taggable.should respond_to(:tag_list, :skill_list, :language_list)
+ @taggable.should respond_to(:tag_list=, :skill_list=, :language_list=)
+ end
+ end
+end
41 spec/acts_as_taggable_on/tag_list_spec.rb
@@ -0,0 +1,41 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe TagList do
+ before(:each) do
+ @tag_list = TagList.new("awesome","radical")
+ end
+
+ it "should be an array" do
+ @tag_list.is_a?(Array).should be_true
+ end
+
+ it "should be able to be add a new tag word" do
+ @tag_list.add("cool")
+ @tag_list.include?("cool").should be_true
+ end
+
+ it "should be able to add delimited lists of words" do
+ @tag_list.add("cool, wicked", :parse => true)
+ @tag_list.include?("cool").should be_true
+ @tag_list.include?("wicked").should be_true
+ end
+
+ it "should be able to remove words" do
+ @tag_list.remove("awesome")
+ @tag_list.include?("awesome").should be_false
+ end
+
+ it "should be able to remove delimited lists of words" do
+ @tag_list.remove("awesome, radical", :parse => true)
+ @tag_list.should be_empty
+ end
+
+ it "should give a delimited list of words when converted to string" do
+ @tag_list.to_s.should == "awesome, radical"
+ end
+
+ it "should quote escape tags with commas in them" do
+ @tag_list.add("cool","rad,bodacious")
+ @tag_list.to_s.should == "awesome, radical, cool, \"rad,bodacious\""
+ end
+end
25 spec/acts_as_taggable_on/tag_spec.rb
@@ -0,0 +1,25 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe Tag do
+ before(:each) do
+ @tag = Tag.new
+ @user = User.create(:name => "Pablo")
+ end
+
+ it "should require a name" do
+ @tag.should have(1).errors_on(:name)
+ @tag.name = "something"
+ @tag.should have(0).errors_on(:name)
+ end
+
+ it "should equal a tag with the same name" do
+ @tag.name = "awesome"
+ new_tag = Tag.new(:name => "awesome")
+ new_tag.should == @tag
+ end
+
+ it "should return its name when to_s is called" do
+ @tag.name = "cool"
+ @tag.to_s.should == "cool"
+ end
+end
58 spec/acts_as_taggable_on/taggable_spec.rb
@@ -0,0 +1,58 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe "Taggable" do
+ before(:each) do
+ @taggable = User.new(:name => "Bob Jones")
+ end
+
+ it "should be able to create tags" do
+ @taggable.skill_list = "ruby, rails, css"
+ @taggable.instance_variable_get("@skill_list").instance_of?(TagList).should be_true
+ @taggable.save
+
+ Tag.find(:all).size.should == 3
+ end
+
+ it "should differentiate between contexts" do
+ @taggable.skill_list = "ruby, rails, css"
+ @taggable.tag_list = "ruby, bob, charlie"
+ @taggable.save
+ @taggable.reload
+ @taggable.skill_list.include?("ruby").should be_true
+ @taggable.skill_list.include?("bob").should be_false
+ end
+
+ it "should be able to remove tags through list alone" do
+ @taggable.skill_list = "ruby, rails, css"
+ @taggable.save
+ @taggable.reload
+ @taggable.should have(3).skills
+ @taggable.skill_list = "ruby, rails"
+ @taggable.save
+ @taggable.reload
+ @taggable.should have(2).skills
+ end
+
+ it "should be able to find by tag" do
+ @taggable.skill_list = "ruby, rails, css"
+ @taggable.save
+ User.find_tagged_with("ruby").first.should == @taggable
+ end
+
+ it "should be able to find by tag with context" do
+ @taggable.skill_list = "ruby, rails, css"
+ @taggable.tag_list = "bob, charlie"
+ @taggable.save
+ User.find_tagged_with("ruby").first.should == @taggable
+ User.find_tagged_with("bob", :on => :skills).first.should_not == @taggable
+ User.find_tagged_with("bob", :on => :tags).first.should == @taggable
+ end
+
+ it "should not care about case" do
+ bob = User.create(:name => "Bob", :tag_list => "ruby")
+ frank = User.create(:name => "Frank", :tag_list => "Ruby")
+
+ Tag.find(:all).size.should == 1
+ User.find_tagged_with("ruby").should == User.find_tagged_with("Ruby")
+ end
+end
7 spec/acts_as_taggable_on/tagging_spec.rb
@@ -0,0 +1,7 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe Tagging do
+ before(:each) do
+ @tagging = Tagging.new
+ end
+end
11,007 spec/debug.log
11,007 additions, 0 deletions not shown
18 spec/schema.rb
@@ -0,0 +1,18 @@
+ActiveRecord::Schema.define :version => 0 do
+ create_table :tags, :force => true do |t|
+ t.column :name, :string
+ end
+
+ create_table :taggings, :force => true do |t|
+ t.column :tag_id, :integer
+ t.column :taggable_id, :integer
+ t.column :taggable_type, :string
+ t.column :context, :string
+ t.column :created_at, :datetime
+ end
+
+ create_table :users, :force => true do |t|
+ t.column :name, :string
+ #t.column :cached_tag_list, :string
+ end
+end
10 spec/spec_helper.rb
@@ -0,0 +1,10 @@
+require File.dirname(__FILE__) + '/../../../../spec/spec_helper'
+
+plugin_spec_dir = File.dirname(__FILE__)
+ActiveRecord::Base.logger = Logger.new(plugin_spec_dir + "/debug.log")
+
+load(File.dirname(__FILE__) + '/schema.rb')
+
+class User < ActiveRecord::Base
+ acts_as_taggable_on :tags, :languages, :skills
+end
4 tasks/acts_as_taggable_on_tasks.rake
@@ -0,0 +1,4 @@
+# desc "Explaining what the task does"
+# task :acts_as_taggable_on do
+# # Task goes here
+# end
1  uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here
Please sign in to comment.
Something went wrong with that request. Please try again.