Skip to content
This repository has been archived by the owner on Jan 2, 2018. It is now read-only.

Commit

Permalink
Browse files Browse the repository at this point in the history
…able_on_steroids@130 20afb1e0-9c0e-0410-9884-91ed27886737
  • Loading branch information
jonathan committed Oct 12, 2006
0 parents commit 2638ef4
Show file tree
Hide file tree
Showing 23 changed files with 2,492 additions and 0 deletions.
20 changes: 20 additions & 0 deletions MIT-LICENSE
@@ -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.
60 changes: 60 additions & 0 deletions README
@@ -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
22 changes: 22 additions & 0 deletions Rakefile
@@ -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
4 changes: 4 additions & 0 deletions init.rb
@@ -0,0 +1,4 @@
require File.dirname(__FILE__) + '/lib/acts_as_taggable'

require File.dirname(__FILE__) + '/lib/tagging'
require File.dirname(__FILE__) + '/lib/tag'
134 changes: 134 additions & 0 deletions lib/acts_as_taggable.rb
@@ -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)
39 changes: 39 additions & 0 deletions lib/tag.rb
@@ -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
17 changes: 17 additions & 0 deletions lib/tag_counts_extension.rb
@@ -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
4 changes: 4 additions & 0 deletions lib/tagging.rb
@@ -0,0 +1,4 @@
class Tagging < ActiveRecord::Base
belongs_to :tag
belongs_to :taggable, :polymorphic => true
end
67 changes: 67 additions & 0 deletions test/abstract_unit.rb
@@ -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

0 comments on commit 2638ef4

Please sign in to comment.