├── .rspec ├── Gemfile ├── lib ├── acts_as_taggable_on │ ├── version.rb │ ├── tags_helper.rb │ ├── utils.rb │ ├── tagging.rb │ ├── acts_as_taggable_on │ │ ├── compatibility.rb │ │ ├── dirty.rb │ │ ├── cache.rb │ │ ├── related.rb │ │ ├── ownership.rb │ │ ├── collection.rb │ │ └── core.rb │ ├── tag_list.rb │ ├── tagger.rb │ ├── tag.rb │ └── taggable.rb ├── generators │ └── acts_as_taggable_on │ │ └── migration │ │ ├── templates │ │ └── active_record │ │ │ └── migration.rb │ │ └── migration_generator.rb └── acts-as-taggable-on.rb ├── Appraisals ├── .gitignore ├── gemfiles ├── rails_3.gemfile └── rails_4.gemfile ├── Guardfile ├── .travis.yml ├── Rakefile ├── spec ├── database.yml.sample ├── acts_as_taggable_on │ ├── utils_spec.rb │ ├── tagging_spec.rb │ ├── tags_helper_spec.rb │ ├── caching_spec.rb │ ├── acts_as_tagger_spec.rb │ ├── tag_list_spec.rb │ ├── related_spec.rb │ ├── tagger_spec.rb │ ├── tag_spec.rb │ ├── single_table_inheritance_spec.rb │ ├── acts_as_taggable_on_spec.rb │ └── taggable_spec.rb ├── generators │ └── acts_as_taggable_on │ │ └── migration │ │ └── migration_generator_spec.rb ├── models.rb ├── bm.rb ├── schema.rb └── spec_helper.rb ├── LICENSE.md ├── acts-as-taggable-on.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --backtrace 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'appraisal' 6 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/version.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | VERSION = '2.4.2.pre' 3 | end 4 | 5 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-3" do 2 | gem "rails", "3.2.13" 3 | end 4 | 5 | appraise "rails-4" do 6 | gem "rails", "4.0.0.beta1" 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.sqlite3 3 | /pkg/* 4 | .bundle 5 | .ruby-version 6 | spec/database.yml 7 | tmp*.sw? 8 | *.sw? 9 | tmp 10 | *.gem 11 | *.lock 12 | -------------------------------------------------------------------------------- /gemfiles/rails_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rails", "3.2.13" 7 | 8 | gemspec :path=>"../" -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec' do 2 | watch(%r{^spec/.+_spec\.rb}) 3 | watch(%r{^lib/(.+)\.rb}) { |m| "spec/lib/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | end 6 | -------------------------------------------------------------------------------- /gemfiles/rails_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rails", :github => 'rails/rails' 7 | 8 | gemspec :path=>"../" -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: "cp spec/database.yml.sample spec/database.yml && bundle install && bundle exec rake" 2 | rvm: 3 | - 1.8.7 4 | - 1.9.2 5 | - 1.9.3 6 | env: 7 | - DB=sqlite3 8 | - DB=mysql 9 | - DB=postgresql -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'appraisal' 4 | 5 | desc 'Default: run specs' 6 | task :default => :spec 7 | 8 | require 'rspec/core/rake_task' 9 | RSpec::Core::RakeTask.new do |t| 10 | t.pattern = "spec/**/*_spec.rb" 11 | end 12 | 13 | Bundler::GemHelper.install_tasks 14 | -------------------------------------------------------------------------------- /spec/database.yml.sample: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: acts_as_taggable_on.sqlite3 4 | 5 | mysql: 6 | adapter: mysql2 7 | hostname: localhost 8 | username: root 9 | password: 10 | database: acts_as_taggable_on 11 | charset: utf8 12 | 13 | postgresql: 14 | adapter: postgresql 15 | hostname: localhost 16 | username: postgres 17 | password: 18 | database: acts_as_taggable_on 19 | encoding: utf8 20 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/tags_helper.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | module TagsHelper 3 | # See the README for an example using tag_cloud. 4 | def tag_cloud(tags, classes) 5 | return [] if tags.empty? 6 | 7 | max_count = tags.sort_by(&:count).last.count.to_f 8 | 9 | tags.each do |tag| 10 | index = ((tag.count / max_count) * (classes.size - 1)) 11 | yield tag, classes[index.nan? ? 0 : index.round] 12 | end 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActsAsTaggableOn::Utils do 4 | describe "like_operator" do 5 | before(:each) do 6 | clean_database! 7 | TaggableModel.acts_as_taggable_on(:tags, :languages, :skills, :needs, :offerings) 8 | @taggable = TaggableModel.new(:name => "Bob Jones") 9 | end 10 | 11 | it "should return 'ILIKE' when the adapter is PostgreSQL" do 12 | TaggableModel.connection.stub(:adapter_name).and_return("PostgreSQL") 13 | TaggableModel.send(:like_operator).should == "ILIKE" 14 | end 15 | 16 | it "should return 'LIKE' when the adapter is not PostgreSQL" do 17 | TaggableModel.connection.stub(:adapter_name).and_return("MySQL") 18 | TaggableModel.send(:like_operator).should == "LIKE" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/generators/acts_as_taggable_on/migration/migration_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Generators are not automatically loaded by Rails 4 | require 'generators/acts_as_taggable_on/migration/migration_generator' 5 | 6 | describe ActsAsTaggableOn::MigrationGenerator do 7 | # Tell the generator where to put its output (what it thinks of as Rails.root) 8 | destination File.expand_path("../../../../../tmp", __FILE__) 9 | 10 | before do 11 | prepare_destination 12 | Rails::Generators.options[:rails][:orm] = :active_record 13 | end 14 | describe 'no arguments' do 15 | before { run_generator } 16 | 17 | describe 'db/migrate/acts_as_taggable_on_migration.rb' do 18 | subject { file('db/migrate/acts_as_taggable_on_migration.rb') } 19 | it { should be_a_migration } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb: -------------------------------------------------------------------------------- 1 | class ActsAsTaggableOnMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table :tags do |t| 4 | t.string :name 5 | end 6 | 7 | create_table :taggings do |t| 8 | t.references :tag 9 | 10 | # You should make sure that the column created is 11 | # long enough to store the required class names. 12 | t.references :taggable, :polymorphic => true 13 | t.references :tagger, :polymorphic => true 14 | 15 | # Limit is created to prevent MySQL error on index 16 | # length for MyISAM table type: http://bit.ly/vgW2Ql 17 | t.string :context, :limit => 128 18 | 19 | t.datetime :created_at 20 | end 21 | 22 | add_index :taggings, :tag_id 23 | add_index :taggings, [:taggable_id, :taggable_type, :context] 24 | end 25 | 26 | def self.down 27 | drop_table :taggings 28 | drop_table :tags 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/tagging_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActsAsTaggableOn::Tagging do 4 | before(:each) do 5 | clean_database! 6 | @tagging = ActsAsTaggableOn::Tagging.new 7 | end 8 | 9 | it "should not be valid with a invalid tag" do 10 | @tagging.taggable = TaggableModel.create(:name => "Bob Jones") 11 | @tagging.tag = ActsAsTaggableOn::Tag.new(:name => "") 12 | @tagging.context = "tags" 13 | 14 | @tagging.should_not be_valid 15 | 16 | @tagging.errors[:tag_id].should == ["can't be blank"] 17 | end 18 | 19 | it "should not create duplicate taggings" do 20 | @taggable = TaggableModel.create(:name => "Bob Jones") 21 | @tag = ActsAsTaggableOn::Tag.create(:name => "awesome") 22 | 23 | lambda { 24 | 2.times { ActsAsTaggableOn::Tagging.create(:taggable => @taggable, :tag => @tag, :context => 'tags') } 25 | }.should change(ActsAsTaggableOn::Tagging, :count).by(1) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/utils.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | module Utils 3 | def self.included(base) 4 | 5 | base.send :include, ActsAsTaggableOn::Utils::OverallMethods 6 | base.extend ActsAsTaggableOn::Utils::OverallMethods 7 | end 8 | 9 | module OverallMethods 10 | def using_postgresql? 11 | ::ActiveRecord::Base.connection && ::ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' 12 | end 13 | 14 | def using_sqlite? 15 | ::ActiveRecord::Base.connection && ::ActiveRecord::Base.connection.adapter_name == 'SQLite' 16 | end 17 | 18 | def sha_prefix(string) 19 | Digest::SHA1.hexdigest("#{string}#{rand}")[0..6] 20 | end 21 | 22 | private 23 | def like_operator 24 | using_postgresql? ? 'ILIKE' : 'LIKE' 25 | end 26 | 27 | # escape _ and % characters in strings, since these are wildcards in SQL. 28 | def escape_like(str) 29 | str.gsub(/[!%_]/){ |x| '!' + x } 30 | end 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/tagging.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | class Tagging < ::ActiveRecord::Base #:nodoc: 3 | attr_accessible :tag, 4 | :tag_id, 5 | :context, 6 | :taggable, 7 | :taggable_type, 8 | :taggable_id, 9 | :tagger, 10 | :tagger_type, 11 | :tagger_id if defined?(ActiveModel::MassAssignmentSecurity) 12 | 13 | belongs_to :tag, :class_name => 'ActsAsTaggableOn::Tag' 14 | belongs_to :taggable, :polymorphic => true 15 | belongs_to :tagger, :polymorphic => true 16 | 17 | validates_presence_of :context 18 | validates_presence_of :tag_id 19 | 20 | validates_uniqueness_of :tag_id, :scope => [ :taggable_type, :taggable_id, :context, :tagger_id, :tagger_type ] 21 | 22 | after_destroy :remove_unused_tags 23 | 24 | private 25 | 26 | def remove_unused_tags 27 | if ActsAsTaggableOn.remove_unused_tags 28 | if tag.taggings.count.zero? 29 | tag.destroy 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | __Copyright (c) 2007 Michael Bleigh and Intridea Inc.__ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/compatibility.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Compatibility 2 | def has_many_with_compatibility(name, options = {}, &extention) 3 | if ActiveRecord::VERSION::MAJOR >= 4 4 | scope, opts = build_scope_and_options(options) 5 | has_many(name, scope, opts, &extention) 6 | else 7 | has_many(name, options, &extention) 8 | end 9 | end 10 | 11 | def build_scope_and_options(opts) 12 | scope_opts, opts = parse_options(opts) 13 | 14 | unless scope_opts.empty? 15 | scope = lambda do 16 | scope_opts.inject(self) { |result, hash| result.send *hash } 17 | end 18 | end 19 | 20 | [defined?(scope) ? scope : nil, opts] 21 | end 22 | 23 | def parse_options(opts) 24 | scope_opts = {} 25 | [:order, :having, :select, :group, :limit, :offset, :readonly].each do |o| 26 | scope_opts[o] = opts.delete o if opts[o] 27 | end 28 | scope_opts[:where] = opts.delete :conditions if opts[:conditions] 29 | scope_opts[:joins] = opts.delete :include if opts [:include] 30 | scope_opts[:distinct] = opts.delete :uniq if opts[:uniq] 31 | 32 | [scope_opts, opts] 33 | end 34 | end -------------------------------------------------------------------------------- /lib/generators/acts_as_taggable_on/migration/migration_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | 4 | module ActsAsTaggableOn 5 | class MigrationGenerator < Rails::Generators::Base 6 | include Rails::Generators::Migration 7 | 8 | desc "Generates migration for Tag and Tagging models" 9 | 10 | def self.orm 11 | Rails::Generators.options[:rails][:orm] 12 | end 13 | 14 | def self.source_root 15 | File.join(File.dirname(__FILE__), 'templates', (orm.to_s unless orm.class.eql?(String)) ) 16 | end 17 | 18 | def self.orm_has_migration? 19 | [:active_record].include? orm 20 | end 21 | 22 | def self.next_migration_number(dirname) 23 | if ActiveRecord::Base.timestamped_migrations 24 | migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i 25 | migration_number += 1 26 | migration_number.to_s 27 | else 28 | "%.3d" % (current_migration_number(dirname) + 1) 29 | end 30 | end 31 | 32 | def create_migration_file 33 | if self.class.orm_has_migration? 34 | migration_template 'migration.rb', 'db/migrate/acts_as_taggable_on_migration' 35 | end 36 | end 37 | end 38 | end 39 | 40 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Dirty 3 | def self.included(base) 4 | base.extend ActsAsTaggableOn::Taggable::Dirty::ClassMethods 5 | 6 | base.initialize_acts_as_taggable_on_dirty 7 | end 8 | 9 | module ClassMethods 10 | def initialize_acts_as_taggable_on_dirty 11 | tag_types.map(&:to_s).each do |tags_type| 12 | tag_type = tags_type.to_s.singularize 13 | context_tags = tags_type.to_sym 14 | 15 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 16 | def #{tag_type}_list_changed? 17 | changed_attributes.include?("#{tag_type}_list") 18 | end 19 | 20 | def #{tag_type}_list_was 21 | changed_attributes.include?("#{tag_type}_list") ? changed_attributes["#{tag_type}_list"] : __send__("#{tag_type}_list") 22 | end 23 | 24 | def #{tag_type}_list_change 25 | [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list") 26 | end 27 | 28 | def #{tag_type}_list_changes 29 | [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list") 30 | end 31 | RUBY 32 | 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/models.rb: -------------------------------------------------------------------------------- 1 | class TaggableModel < ActiveRecord::Base 2 | acts_as_taggable 3 | acts_as_taggable_on :languages 4 | acts_as_taggable_on :skills 5 | acts_as_taggable_on :needs, :offerings 6 | has_many :untaggable_models 7 | 8 | attr_reader :tag_list_submethod_called 9 | def tag_list=v 10 | @tag_list_submethod_called = true 11 | super 12 | end 13 | end 14 | 15 | class CachedModel < ActiveRecord::Base 16 | acts_as_taggable 17 | end 18 | 19 | class OtherCachedModel < ActiveRecord::Base 20 | acts_as_taggable_on :languages, :statuses, :glasses 21 | end 22 | 23 | class OtherTaggableModel < ActiveRecord::Base 24 | acts_as_taggable_on :tags, :languages 25 | acts_as_taggable_on :needs, :offerings 26 | end 27 | 28 | class InheritingTaggableModel < TaggableModel 29 | end 30 | 31 | class AlteredInheritingTaggableModel < TaggableModel 32 | acts_as_taggable_on :parts 33 | end 34 | 35 | class User < ActiveRecord::Base 36 | acts_as_tagger 37 | end 38 | 39 | class Student < User 40 | end 41 | 42 | class UntaggableModel < ActiveRecord::Base 43 | belongs_to :taggable_model 44 | end 45 | 46 | class NonStandardIdTaggableModel < ActiveRecord::Base 47 | primary_key = "an_id" 48 | acts_as_taggable 49 | acts_as_taggable_on :languages 50 | acts_as_taggable_on :skills 51 | acts_as_taggable_on :needs, :offerings 52 | has_many :untaggable_models 53 | end 54 | 55 | class OrderedTaggableModel < ActiveRecord::Base 56 | acts_as_ordered_taggable 57 | acts_as_ordered_taggable_on :colours 58 | end 59 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/tags_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActsAsTaggableOn::TagsHelper do 4 | before(:each) do 5 | clean_database! 6 | 7 | @bob = TaggableModel.create(:name => "Bob Jones", :language_list => "ruby, php") 8 | @tom = TaggableModel.create(:name => "Tom Marley", :language_list => "ruby, java") 9 | @eve = TaggableModel.create(:name => "Eve Nodd", :language_list => "ruby, c++") 10 | 11 | @helper = class Helper 12 | include ActsAsTaggableOn::TagsHelper 13 | end.new 14 | end 15 | 16 | it "should yield the proper css classes" do 17 | tags = { } 18 | 19 | @helper.tag_cloud(TaggableModel.tag_counts_on(:languages), ["sucky", "awesome"]) do |tag, css_class| 20 | tags[tag.name] = css_class 21 | end 22 | 23 | tags["ruby"].should == "awesome" 24 | tags["java"].should == "sucky" 25 | tags["c++"].should == "sucky" 26 | tags["php"].should == "sucky" 27 | end 28 | 29 | it "should handle tags with zero counts (build for empty)" do 30 | bob = ActsAsTaggableOn::Tag.create(:name => "php") 31 | tom = ActsAsTaggableOn::Tag.create(:name => "java") 32 | eve = ActsAsTaggableOn::Tag.create(:name => "c++") 33 | 34 | tags = { } 35 | 36 | @helper.tag_cloud(ActsAsTaggableOn::Tag.all, ["sucky", "awesome"]) do |tag, css_class| 37 | tags[tag.name] = css_class 38 | end 39 | 40 | tags["java"].should == "sucky" 41 | tags["c++"].should == "sucky" 42 | tags["php"].should == "sucky" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /acts-as-taggable-on.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'acts_as_taggable_on/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "acts-as-taggable-on" 8 | gem.version = ActsAsTaggableOn::VERSION 9 | gem.authors = ["Michael Bleigh", "Joost Baaij"] 10 | gem.email = ["michael@intridea.com", "joost@spacebabies.nl"] 11 | gem.description = %q{With ActsAsTaggableOn, you can tag a single model on several contexts, such as skills, interests, and awards. It also provides other advanced functionality.} 12 | gem.summary = "Advanced tagging for Rails." 13 | gem.homepage = 'https://github.com/mbleigh/acts-as-taggable-on' 14 | gem.license = "MIT" 15 | 16 | gem.files = `git ls-files`.split($/) 17 | gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 19 | gem.require_paths = ["lib"] 20 | 21 | if File.exists?('UPGRADING') 22 | gem.post_install_message = File.read('UPGRADING') 23 | end 24 | 25 | gem.add_runtime_dependency 'rails', ['>= 3', '< 5'] 26 | 27 | gem.add_development_dependency 'rspec-rails', '2.13.0' # 2.13.1 is broken 28 | gem.add_development_dependency 'rspec', '~> 2.6' 29 | gem.add_development_dependency 'ammeter' 30 | gem.add_development_dependency 'sqlite3' 31 | gem.add_development_dependency 'mysql2', '~> 0.3.7' 32 | gem.add_development_dependency 'pg' 33 | gem.add_development_dependency 'guard' 34 | gem.add_development_dependency 'guard-rspec' 35 | end 36 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Cache 3 | def self.included(base) 4 | # Skip adding caching capabilities if table not exists or no cache columns exist 5 | return unless base.table_exists? && base.tag_types.any? { |context| base.column_names.include?("cached_#{context.to_s.singularize}_list") } 6 | 7 | base.send :include, ActsAsTaggableOn::Taggable::Cache::InstanceMethods 8 | base.extend ActsAsTaggableOn::Taggable::Cache::ClassMethods 9 | 10 | base.class_eval do 11 | before_save :save_cached_tag_list 12 | end 13 | 14 | base.initialize_acts_as_taggable_on_cache 15 | end 16 | 17 | module ClassMethods 18 | def initialize_acts_as_taggable_on_cache 19 | tag_types.map(&:to_s).each do |tag_type| 20 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 21 | def self.caching_#{tag_type.singularize}_list? 22 | caching_tag_list_on?("#{tag_type}") 23 | end 24 | RUBY 25 | end 26 | end 27 | 28 | def acts_as_taggable_on(*args) 29 | super(*args) 30 | initialize_acts_as_taggable_on_cache 31 | end 32 | 33 | def caching_tag_list_on?(context) 34 | column_names.include?("cached_#{context.to_s.singularize}_list") 35 | end 36 | end 37 | 38 | module InstanceMethods 39 | def save_cached_tag_list 40 | tag_types.map(&:to_s).each do |tag_type| 41 | if self.class.send("caching_#{tag_type.singularize}_list?") 42 | if tag_list_cache_set_on(tag_type) 43 | list = tag_list_cache_on(tag_type).to_a.flatten.compact.join(', ') 44 | self["cached_#{tag_type.singularize}_list"] = list 45 | end 46 | end 47 | end 48 | 49 | true 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/acts-as-taggable-on.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "active_record/version" 3 | require "action_view" 4 | 5 | require "digest/sha1" 6 | 7 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 8 | 9 | module ActsAsTaggableOn 10 | mattr_accessor :delimiter 11 | @@delimiter = ',' 12 | 13 | mattr_accessor :force_lowercase 14 | @@force_lowercase = false 15 | 16 | mattr_accessor :force_parameterize 17 | @@force_parameterize = false 18 | 19 | mattr_accessor :strict_case_match 20 | @@strict_case_match = false 21 | 22 | mattr_accessor :remove_unused_tags 23 | self.remove_unused_tags = false 24 | 25 | def self.glue 26 | delimiter = @@delimiter.kind_of?(Array) ? @@delimiter[0] : @@delimiter 27 | delimiter.ends_with?(" ") ? delimiter : "#{delimiter} " 28 | end 29 | 30 | def self.setup 31 | yield self 32 | end 33 | end 34 | 35 | 36 | require "acts_as_taggable_on/utils" 37 | 38 | require "acts_as_taggable_on/taggable" 39 | require "acts_as_taggable_on/acts_as_taggable_on/compatibility" 40 | require "acts_as_taggable_on/acts_as_taggable_on/core" 41 | require "acts_as_taggable_on/acts_as_taggable_on/collection" 42 | require "acts_as_taggable_on/acts_as_taggable_on/cache" 43 | require "acts_as_taggable_on/acts_as_taggable_on/ownership" 44 | require "acts_as_taggable_on/acts_as_taggable_on/related" 45 | require "acts_as_taggable_on/acts_as_taggable_on/dirty" 46 | 47 | require "acts_as_taggable_on/tagger" 48 | require "acts_as_taggable_on/tag" 49 | require "acts_as_taggable_on/tag_list" 50 | require "acts_as_taggable_on/tags_helper" 51 | require "acts_as_taggable_on/tagging" 52 | 53 | $LOAD_PATH.shift 54 | 55 | 56 | if defined?(ActiveRecord::Base) 57 | ActiveRecord::Base.extend ActsAsTaggableOn::Compatibility 58 | ActiveRecord::Base.extend ActsAsTaggableOn::Taggable 59 | ActiveRecord::Base.send :include, ActsAsTaggableOn::Tagger 60 | end 61 | 62 | if defined?(ActionView::Base) 63 | ActionView::Base.send :include, ActsAsTaggableOn::TagsHelper 64 | end 65 | 66 | -------------------------------------------------------------------------------- /spec/bm.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'action_view' 3 | require File.expand_path('../../lib/acts-as-taggable-on', __FILE__) 4 | 5 | if defined?(ActiveRecord::Acts::TaggableOn) 6 | ActiveRecord::Base.send :include, ActiveRecord::Acts::TaggableOn 7 | ActiveRecord::Base.send :include, ActiveRecord::Acts::Tagger 8 | ActionView::Base.send :include, TagsHelper if defined?(ActionView::Base) 9 | end 10 | 11 | TEST_DATABASE_FILE = File.join(File.dirname(__FILE__), '..', 'test.sqlite3') 12 | File.unlink(TEST_DATABASE_FILE) if File.exist?(TEST_DATABASE_FILE) 13 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => TEST_DATABASE_FILE 14 | 15 | ActiveRecord::Base.silence do 16 | ActiveRecord::Migration.verbose = false 17 | ActiveRecord::Schema.define :version => 0 do 18 | create_table "taggings", :force => true do |t| 19 | t.integer "tag_id", :limit => 11 20 | t.integer "taggable_id", :limit => 11 21 | t.string "taggable_type" 22 | t.string "context" 23 | t.datetime "created_at" 24 | t.integer "tagger_id", :limit => 11 25 | t.string "tagger_type" 26 | end 27 | 28 | add_index "taggings", ["tag_id"], :name => "index_taggings_on_tag_id" 29 | add_index "taggings", ["taggable_id", "taggable_type", "context"], :name => "index_taggings_on_taggable_id_and_taggable_type_and_context" 30 | 31 | create_table "tags", :force => true do |t| 32 | t.string "name" 33 | end 34 | 35 | create_table :taggable_models, :force => true do |t| 36 | t.column :name, :string 37 | t.column :type, :string 38 | t.column :cached_tag_list, :string 39 | end 40 | end 41 | 42 | class TaggableModel < ActiveRecord::Base 43 | acts_as_taggable 44 | acts_as_taggable_on :languages 45 | acts_as_taggable_on :skills 46 | acts_as_taggable_on :needs, :offerings 47 | end 48 | end 49 | 50 | puts Benchmark.measure { 51 | 1000.times { TaggableModel.create :tag_list => "awesome, epic, neat" } 52 | } -------------------------------------------------------------------------------- /spec/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define :version => 0 do 2 | create_table "taggings", :force => true do |t| 3 | t.integer "tag_id", :limit => 11 4 | t.integer "taggable_id", :limit => 11 5 | t.string "taggable_type" 6 | t.string "context" 7 | t.datetime "created_at" 8 | t.integer "tagger_id", :limit => 11 9 | t.string "tagger_type" 10 | end 11 | 12 | add_index "taggings", ["tag_id"], :name => "index_taggings_on_tag_id" 13 | add_index "taggings", ["taggable_id", "taggable_type", "context"], :name => "index_taggings_on_taggable_id_and_taggable_type_and_context" 14 | 15 | create_table "tags", :force => true do |t| 16 | t.string "name" 17 | end 18 | 19 | create_table :taggable_models, :force => true do |t| 20 | t.column :name, :string 21 | t.column :type, :string 22 | end 23 | 24 | create_table :non_standard_id_taggable_models, :primary_key => "an_id", :force => true do |t| 25 | t.column :name, :string 26 | t.column :type, :string 27 | end 28 | 29 | create_table :untaggable_models, :force => true do |t| 30 | t.column :taggable_model_id, :integer 31 | t.column :name, :string 32 | end 33 | 34 | create_table :cached_models, :force => true do |t| 35 | t.column :name, :string 36 | t.column :type, :string 37 | t.column :cached_tag_list, :string 38 | end 39 | 40 | create_table :other_cached_models, :force => true do |t| 41 | t.column :name, :string 42 | t.column :type, :string 43 | t.column :cached_language_list, :string 44 | t.column :cached_status_list, :string 45 | t.column :cached_glass_list, :string 46 | end 47 | 48 | create_table :users, :force => true do |t| 49 | t.column :name, :string 50 | end 51 | 52 | create_table :other_taggable_models, :force => true do |t| 53 | t.column :name, :string 54 | t.column :type, :string 55 | end 56 | 57 | create_table :ordered_taggable_models, :force => true do |t| 58 | t.column :name, :string 59 | t.column :type, :string 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/tag_list.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/delegation' 2 | 3 | module ActsAsTaggableOn 4 | class TagList < Array 5 | attr_accessor :owner 6 | 7 | def initialize(*args) 8 | add(*args) 9 | end 10 | 11 | ## 12 | # Returns a new TagList using the given tag string. 13 | # 14 | # Example: 15 | # tag_list = TagList.from("One , Two, Three") 16 | # tag_list # ["One", "Two", "Three"] 17 | def self.from(string) 18 | 19 | end 20 | 21 | ## 22 | # Add tags to the tag_list. Duplicate or blank tags will be ignored. 23 | # Use the :parse option to add an unparsed tag string. 24 | # 25 | # Example: 26 | # tag_list.add("Fun", "Happy") 27 | # tag_list.add("Fun, Happy", :parse => true) 28 | def add(*names) 29 | extract_and_apply_options!(names) 30 | concat(names) 31 | clean! 32 | self 33 | end 34 | 35 | ## 36 | # Remove specific tags from the tag_list. 37 | # Use the :parse option to add an unparsed tag string. 38 | # 39 | # Example: 40 | # tag_list.remove("Sad", "Lonely") 41 | # tag_list.remove("Sad, Lonely", :parse => true) 42 | def remove(*names) 43 | extract_and_apply_options!(names) 44 | delete_if { |name| names.include?(name) } 45 | self 46 | end 47 | 48 | ## 49 | # Transform the tag_list into a tag string suitable for editing in a form. 50 | # The tags are joined with TagList.delimiter and quoted if necessary. 51 | # 52 | # Example: 53 | # tag_list = TagList.new("Round", "Square,Cube") 54 | # tag_list.to_s # 'Round, "Square,Cube"' 55 | def to_s 56 | tags = frozen? ? self.dup : self 57 | tags.send(:clean!) 58 | 59 | tags.map do |name| 60 | d = ActsAsTaggableOn.delimiter 61 | d = Regexp.new d.join("|") if d.kind_of? Array 62 | name.index(d) ? "\"#{name}\"" : name 63 | end.join(ActsAsTaggableOn.glue) 64 | end 65 | 66 | private 67 | 68 | # Remove whitespace, duplicates, and blanks. 69 | def clean! 70 | reject!(&:blank?) 71 | map!(&:strip) 72 | map!{ |tag| tag.mb_chars.downcase.to_s } if ActsAsTaggableOn.force_lowercase 73 | map!(&:parameterize) if ActsAsTaggableOn.force_parameterize 74 | 75 | uniq! 76 | end 77 | 78 | def extract_and_apply_options!(args) 79 | options = args.last.is_a?(Hash) ? args.pop : {} 80 | options.assert_valid_keys :parse 81 | 82 | if options[:parse] 83 | args.map! { |a| self.class.from(a) } 84 | end 85 | 86 | args.flatten! 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/tagger.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | module Tagger 3 | def self.included(base) 4 | base.extend ClassMethods 5 | end 6 | 7 | module ClassMethods 8 | ## 9 | # Make a model a tagger. This allows an instance of a model to claim ownership 10 | # of tags. 11 | # 12 | # Example: 13 | # class User < ActiveRecord::Base 14 | # acts_as_tagger 15 | # end 16 | def acts_as_tagger(opts={}) 17 | class_eval do 18 | has_many_with_compatibility :owned_taggings, 19 | opts.merge( 20 | :as => :tagger, 21 | :dependent => :destroy, 22 | :class_name => "ActsAsTaggableOn::Tagging" 23 | ) 24 | 25 | has_many_with_compatibility :owned_tags, 26 | :through => :owned_taggings, 27 | :source => :tag, 28 | :class_name => "ActsAsTaggableOn::Tag", 29 | :uniq => true 30 | end 31 | 32 | include ActsAsTaggableOn::Tagger::InstanceMethods 33 | extend ActsAsTaggableOn::Tagger::SingletonMethods 34 | end 35 | 36 | def is_tagger? 37 | false 38 | end 39 | end 40 | 41 | module InstanceMethods 42 | ## 43 | # Tag a taggable model with tags that are owned by the tagger. 44 | # 45 | # @param taggable The object that will be tagged 46 | # @param [Hash] options An hash with options. Available options are: 47 | # * :with - The tags that you want to 48 | # * :on - The context on which you want to tag 49 | # 50 | # Example: 51 | # @user.tag(@photo, :with => "paris, normandy", :on => :locations) 52 | def tag(taggable, opts={}) 53 | opts.reverse_merge!(:force => true) 54 | skip_save = opts.delete(:skip_save) 55 | return false unless taggable.respond_to?(:is_taggable?) && taggable.is_taggable? 56 | 57 | raise "You need to specify a tag context using :on" unless opts.has_key?(:on) 58 | raise "You need to specify some tags using :with" unless opts.has_key?(:with) 59 | raise "No context :#{opts[:on]} defined in #{taggable.class.to_s}" unless (opts[:force] || taggable.tag_types.include?(opts[:on])) 60 | 61 | taggable.set_owner_tag_list_on(self, opts[:on].to_s, opts[:with]) 62 | taggable.save unless skip_save 63 | end 64 | 65 | def is_tagger? 66 | self.class.is_tagger? 67 | end 68 | end 69 | 70 | module SingletonMethods 71 | def is_tagger? 72 | true 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/caching_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Acts As Taggable On" do 4 | 5 | before(:each) do 6 | clean_database! 7 | end 8 | 9 | describe 'Caching' do 10 | before(:each) do 11 | @taggable = CachedModel.new(:name => "Bob Jones") 12 | @another_taggable = OtherCachedModel.new(:name => "John Smith") 13 | end 14 | 15 | it "should add saving of tag lists and cached tag lists to the instance" do 16 | @taggable.should respond_to(:save_cached_tag_list) 17 | @another_taggable.should respond_to(:save_cached_tag_list) 18 | 19 | @taggable.should respond_to(:save_tags) 20 | end 21 | 22 | it "should add cached tag lists to the instance if cached column is not present" do 23 | TaggableModel.new(:name => "Art Kram").should_not respond_to(:save_cached_tag_list) 24 | end 25 | 26 | it "should generate a cached column checker for each tag type" do 27 | CachedModel.should respond_to(:caching_tag_list?) 28 | OtherCachedModel.should respond_to(:caching_language_list?) 29 | end 30 | 31 | it 'should not have cached tags' do 32 | @taggable.cached_tag_list.should be_blank 33 | @another_taggable.cached_language_list.should be_blank 34 | end 35 | 36 | it 'should cache tags' do 37 | @taggable.update_attributes(:tag_list => 'awesome, epic') 38 | @taggable.cached_tag_list.should == 'awesome, epic' 39 | 40 | @another_taggable.update_attributes(:language_list => 'ruby, .net') 41 | @another_taggable.cached_language_list.should == 'ruby, .net' 42 | end 43 | 44 | it 'should keep the cache' do 45 | @taggable.update_attributes(:tag_list => 'awesome, epic') 46 | @taggable = CachedModel.find(@taggable) 47 | @taggable.save! 48 | @taggable.cached_tag_list.should == 'awesome, epic' 49 | end 50 | 51 | it 'should update the cache' do 52 | @taggable.update_attributes(:tag_list => 'awesome, epic') 53 | @taggable.update_attributes(:tag_list => 'awesome') 54 | @taggable.cached_tag_list.should == 'awesome' 55 | end 56 | 57 | it 'should remove the cache' do 58 | @taggable.update_attributes(:tag_list => 'awesome, epic') 59 | @taggable.update_attributes(:tag_list => '') 60 | @taggable.cached_tag_list.should be_blank 61 | end 62 | 63 | it 'should have a tag list' do 64 | @taggable.update_attributes(:tag_list => 'awesome, epic') 65 | @taggable = CachedModel.find(@taggable.id) 66 | @taggable.tag_list.sort.should == %w(awesome epic).sort 67 | end 68 | 69 | it 'should keep the tag list' do 70 | @taggable.update_attributes(:tag_list => 'awesome, epic') 71 | @taggable = CachedModel.find(@taggable.id) 72 | @taggable.save! 73 | @taggable.tag_list.sort.should == %w(awesome epic).sort 74 | end 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/tag.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | class Tag < ::ActiveRecord::Base 3 | include ActsAsTaggableOn::Utils 4 | 5 | attr_accessible :name if defined?(ActiveModel::MassAssignmentSecurity) 6 | 7 | ### ASSOCIATIONS: 8 | 9 | has_many :taggings, :dependent => :destroy, :class_name => 'ActsAsTaggableOn::Tagging' 10 | 11 | ### VALIDATIONS: 12 | 13 | validates_presence_of :name 14 | validates_uniqueness_of :name, :if => :validates_name_uniqueness? 15 | validates_length_of :name, :maximum => 255 16 | 17 | # monkey patch this method if don't need name uniqueness validation 18 | def validates_name_uniqueness? 19 | true 20 | end 21 | 22 | ### SCOPES: 23 | 24 | def self.named(name) 25 | if ActsAsTaggableOn.strict_case_match 26 | where(["name = #{binary}?", name]) 27 | else 28 | where(["lower(name) = ?", name.downcase]) 29 | end 30 | end 31 | 32 | def self.named_any(list) 33 | if ActsAsTaggableOn.strict_case_match 34 | where(list.map { |tag| sanitize_sql(["name = #{binary}?", tag.to_s.mb_chars]) }.join(" OR ")) 35 | else 36 | where(list.map { |tag| sanitize_sql(["lower(name) = ?", tag.to_s.mb_chars.downcase]) }.join(" OR ")) 37 | end 38 | end 39 | 40 | def self.named_like(name) 41 | where(["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(name)}%"]) 42 | end 43 | 44 | def self.named_like_any(list) 45 | where(list.map { |tag| sanitize_sql(["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(tag.to_s)}%"]) }.join(" OR ")) 46 | end 47 | 48 | ### CLASS METHODS: 49 | 50 | def self.find_or_create_with_like_by_name(name) 51 | if (ActsAsTaggableOn.strict_case_match) 52 | self.find_or_create_all_with_like_by_name([name]).first 53 | else 54 | named_like(name).first || create(:name => name) 55 | end 56 | end 57 | 58 | def self.find_or_create_all_with_like_by_name(*list) 59 | list = [list].flatten 60 | 61 | return [] if list.empty? 62 | 63 | existing_tags = Tag.named_any(list) 64 | 65 | list.map do |tag_name| 66 | comparable_tag_name = comparable_name(tag_name) 67 | existing_tag = existing_tags.find { |tag| comparable_name(tag.name) == comparable_tag_name } 68 | 69 | existing_tag || Tag.create(:name => tag_name) 70 | end 71 | end 72 | 73 | ### INSTANCE METHODS: 74 | 75 | def ==(object) 76 | super || (object.is_a?(Tag) && name == object.name) 77 | end 78 | 79 | def to_s 80 | name 81 | end 82 | 83 | def count 84 | read_attribute(:count).to_i 85 | end 86 | 87 | class << self 88 | private 89 | 90 | def comparable_name(str) 91 | str.mb_chars.downcase.to_s 92 | end 93 | 94 | def binary 95 | /mysql/ === ActiveRecord::Base.connection_config[:adapter] ? "BINARY " : nil 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << "." unless $LOAD_PATH.include?(".") 2 | require 'logger' 3 | 4 | begin 5 | require "rubygems" 6 | require "bundler" 7 | 8 | if Gem::Version.new(Bundler::VERSION) <= Gem::Version.new("0.9.5") 9 | raise RuntimeError, "Your bundler version is too old." + 10 | "Run `gem install bundler` to upgrade." 11 | end 12 | 13 | # Set up load paths for all bundled gems 14 | Bundler.setup 15 | rescue Bundler::GemNotFound 16 | raise RuntimeError, "Bundler couldn't find some gems." + 17 | "Did you run \`bundlee install\`?" 18 | end 19 | 20 | Bundler.require 21 | require File.expand_path('../../lib/acts-as-taggable-on', __FILE__) 22 | require 'ammeter/init' 23 | 24 | unless [].respond_to?(:freq) 25 | class Array 26 | def freq 27 | k=Hash.new(0) 28 | each {|e| k[e]+=1} 29 | k 30 | end 31 | end 32 | end 33 | 34 | # set adapter to use, default is sqlite3 35 | # to use an alternative adapter run => rake spec DB='postgresql' 36 | db_name = ENV['DB'] || 'sqlite3' 37 | database_yml = File.expand_path('../database.yml', __FILE__) 38 | 39 | if File.exists?(database_yml) 40 | active_record_configuration = YAML.load_file(database_yml) 41 | 42 | ActiveRecord::Base.configurations = active_record_configuration 43 | config = ActiveRecord::Base.configurations[db_name] 44 | 45 | begin 46 | ActiveRecord::Base.establish_connection(db_name) 47 | ActiveRecord::Base.connection 48 | rescue 49 | case db_name 50 | when /mysql/ 51 | ActiveRecord::Base.establish_connection(config.merge('database' => nil)) 52 | ActiveRecord::Base.connection.create_database(config['database'], {:charset => 'utf8', :collation => 'utf8_unicode_ci'}) 53 | when 'postgresql' 54 | ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public')) 55 | ActiveRecord::Base.connection.create_database(config['database'], config.merge('encoding' => 'utf8')) 56 | end 57 | 58 | ActiveRecord::Base.establish_connection(config) 59 | end 60 | 61 | logger = ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), "debug.log")) 62 | ActiveRecord::Base.default_timezone = :utc 63 | 64 | begin 65 | old_logger_level, logger.level = logger.level, ::Logger::ERROR 66 | ActiveRecord::Migration.verbose = false 67 | 68 | load(File.dirname(__FILE__) + '/schema.rb') 69 | load(File.dirname(__FILE__) + '/models.rb') 70 | ensure 71 | logger.level = old_logger_level 72 | end 73 | 74 | else 75 | raise "Please create #{database_yml} first to configure your database. Take a look at: #{database_yml}.sample" 76 | end 77 | 78 | def clean_database! 79 | models = [ActsAsTaggableOn::Tag, ActsAsTaggableOn::Tagging, TaggableModel, OtherTaggableModel, InheritingTaggableModel, 80 | AlteredInheritingTaggableModel, User, UntaggableModel, OrderedTaggableModel] 81 | models.each do |model| 82 | ActiveRecord::Base.connection.execute "DELETE FROM #{model.table_name}" 83 | end 84 | end 85 | 86 | clean_database! 87 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/taggable.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn 2 | module Taggable 3 | def taggable? 4 | false 5 | end 6 | 7 | ## 8 | # This is an alias for calling acts_as_taggable_on :tags. 9 | # 10 | # Example: 11 | # class Book < ActiveRecord::Base 12 | # acts_as_taggable 13 | # end 14 | def acts_as_taggable 15 | acts_as_taggable_on :tags 16 | end 17 | 18 | ## 19 | # This is an alias for calling acts_as_ordered_taggable_on :tags. 20 | # 21 | # Example: 22 | # class Book < ActiveRecord::Base 23 | # acts_as_ordered_taggable 24 | # end 25 | def acts_as_ordered_taggable 26 | acts_as_ordered_taggable_on :tags 27 | end 28 | 29 | ## 30 | # Make a model taggable on specified contexts. 31 | # 32 | # @param [Array] tag_types An array of taggable contexts 33 | # 34 | # Example: 35 | # class User < ActiveRecord::Base 36 | # acts_as_taggable_on :languages, :skills 37 | # end 38 | def acts_as_taggable_on(*tag_types) 39 | taggable_on(false, tag_types) 40 | end 41 | 42 | 43 | ## 44 | # Make a model taggable on specified contexts 45 | # and preserves the order in which tags are created 46 | # 47 | # @param [Array] tag_types An array of taggable contexts 48 | # 49 | # Example: 50 | # class User < ActiveRecord::Base 51 | # acts_as_ordered_taggable_on :languages, :skills 52 | # end 53 | def acts_as_ordered_taggable_on(*tag_types) 54 | taggable_on(true, tag_types) 55 | end 56 | 57 | private 58 | 59 | # Make a model taggable on specified contexts 60 | # and optionally preserves the order in which tags are created 61 | # 62 | # Seperate methods used above for backwards compatibility 63 | # so that the original acts_as_taggable_on method is unaffected 64 | # as it's not possible to add another arguement to the method 65 | # without the tag_types being enclosed in square brackets 66 | # 67 | # NB: method overridden in core module in order to create tag type 68 | # associations and methods after this logic has executed 69 | # 70 | def taggable_on(preserve_tag_order, *tag_types) 71 | tag_types = tag_types.to_a.flatten.compact.map(&:to_sym) 72 | 73 | if taggable? 74 | self.tag_types = (self.tag_types + tag_types).uniq 75 | self.preserve_tag_order = preserve_tag_order 76 | else 77 | class_attribute :tag_types 78 | self.tag_types = tag_types 79 | class_attribute :preserve_tag_order 80 | self.preserve_tag_order = preserve_tag_order 81 | 82 | class_eval do 83 | has_many :taggings, :as => :taggable, :dependent => :destroy, :class_name => "ActsAsTaggableOn::Tagging" 84 | has_many :base_tags, :through => :taggings, :source => :tag, :class_name => "ActsAsTaggableOn::Tag" 85 | 86 | def self.taggable? 87 | true 88 | end 89 | 90 | include ActsAsTaggableOn::Utils 91 | end 92 | end 93 | 94 | # each of these add context-specific methods and must be 95 | # called on each call of taggable_on 96 | include ActsAsTaggableOn::Taggable::Core 97 | include ActsAsTaggableOn::Taggable::Collection 98 | include ActsAsTaggableOn::Taggable::Cache 99 | include ActsAsTaggableOn::Taggable::Ownership 100 | include ActsAsTaggableOn::Taggable::Related 101 | include ActsAsTaggableOn::Taggable::Dirty 102 | end 103 | 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/related.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Related 3 | def self.included(base) 4 | base.send :include, ActsAsTaggableOn::Taggable::Related::InstanceMethods 5 | base.extend ActsAsTaggableOn::Taggable::Related::ClassMethods 6 | base.initialize_acts_as_taggable_on_related 7 | end 8 | 9 | module ClassMethods 10 | def initialize_acts_as_taggable_on_related 11 | tag_types.map(&:to_s).each do |tag_type| 12 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 13 | def find_related_#{tag_type}(options = {}) 14 | related_tags_for('#{tag_type}', self.class, options) 15 | end 16 | alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type} 17 | 18 | def find_related_#{tag_type}_for(klass, options = {}) 19 | related_tags_for('#{tag_type}', klass, options) 20 | end 21 | RUBY 22 | end 23 | 24 | unless tag_types.empty? 25 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 26 | def find_matching_contexts(search_context, result_context, options = {}) 27 | matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options) 28 | end 29 | 30 | def find_matching_contexts_for(klass, search_context, result_context, options = {}) 31 | matching_contexts_for(search_context.to_s, result_context.to_s, klass, options) 32 | end 33 | RUBY 34 | end 35 | end 36 | 37 | def acts_as_taggable_on(*args) 38 | super(*args) 39 | initialize_acts_as_taggable_on_related 40 | end 41 | end 42 | 43 | module InstanceMethods 44 | def matching_contexts_for(search_context, result_context, klass, options = {}) 45 | tags_to_find = tags_on(search_context).collect { |t| t.name } 46 | 47 | klass.select("#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count") \ 48 | .from("#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}") \ 49 | .where(["#{exclude_self(klass, id)} #{klass.table_name}.#{klass.primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.base_class.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?) AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_to_find, result_context]) \ 50 | .group(group_columns(klass)) \ 51 | .order("count DESC") 52 | end 53 | 54 | def related_tags_for(context, klass, options = {}) 55 | tags_to_ignore = Array.wrap(options.delete(:ignore)).map(&:to_s) || [] 56 | tags_to_find = tags_on(context).collect { |t| t.name }.reject { |t| tags_to_ignore.include? t } 57 | 58 | klass.select("#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count") \ 59 | .from("#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}") \ 60 | .where(["#{exclude_self(klass, id)} #{klass.table_name}.#{klass.primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.base_class.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?)", tags_to_find]) \ 61 | .group(group_columns(klass)) \ 62 | .order("count DESC") 63 | end 64 | 65 | private 66 | 67 | def exclude_self(klass, id) 68 | if [self.class.base_class, self.class].include? klass 69 | "#{klass.table_name}.#{klass.primary_key} != #{id} AND" 70 | else 71 | nil 72 | end 73 | end 74 | 75 | def group_columns(klass) 76 | if ActsAsTaggableOn::Tag.using_postgresql? 77 | grouped_column_names_for(klass) 78 | else 79 | "#{klass.table_name}.#{klass.primary_key}" 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/acts_as_tagger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "acts_as_tagger" do 4 | before(:each) do 5 | clean_database! 6 | end 7 | 8 | describe "Tagger Method Generation" do 9 | before(:each) do 10 | @tagger = User.new 11 | end 12 | 13 | it "should add #is_tagger? query method to the class-side" do 14 | User.should respond_to(:is_tagger?) 15 | end 16 | 17 | it "should return true from the class-side #is_tagger?" do 18 | User.is_tagger?.should be_true 19 | end 20 | 21 | it "should return false from the base #is_tagger?" do 22 | ActiveRecord::Base.is_tagger?.should be_false 23 | end 24 | 25 | it "should add #is_tagger? query method to the singleton" do 26 | @tagger.should respond_to(:is_tagger?) 27 | end 28 | 29 | it "should add #tag method on the instance-side" do 30 | @tagger.should respond_to(:tag) 31 | end 32 | 33 | it "should generate an association for #owned_taggings and #owned_tags" do 34 | @tagger.should respond_to(:owned_taggings, :owned_tags) 35 | end 36 | end 37 | 38 | describe "#tag" do 39 | context 'when called with a non-existent tag context' do 40 | before(:each) do 41 | @tagger = User.new 42 | @taggable = TaggableModel.new(:name=>"Richard Prior") 43 | end 44 | 45 | it "should by default not throw an exception " do 46 | @taggable.tag_list_on(:foo).should be_empty 47 | lambda { 48 | @tagger.tag(@taggable, :with=>'this, and, that', :on=>:foo) 49 | }.should_not raise_error 50 | end 51 | 52 | it 'should by default create the tag context on-the-fly' do 53 | @taggable.tag_list_on(:here_ond_now).should be_empty 54 | @tagger.tag(@taggable, :with=>'that', :on => :here_ond_now) 55 | @taggable.tag_list_on(:here_ond_now).should_not include('that') 56 | @taggable.all_tags_list_on(:here_ond_now).should include('that') 57 | end 58 | 59 | it "should show all the tag list when both public and owned tags exist" do 60 | @taggable.tag_list = 'ruby, python' 61 | @tagger.tag(@taggable, :with => 'java, lisp', :on => :tags) 62 | @taggable.all_tags_on(:tags).map(&:name).sort.should == %w(ruby python java lisp).sort 63 | end 64 | 65 | it "should not add owned tags to the common list" do 66 | @taggable.tag_list = 'ruby, python' 67 | @tagger.tag(@taggable, :with => 'java, lisp', :on => :tags) 68 | @taggable.tag_list.should == %w(ruby python) 69 | @tagger.tag(@taggable, :with => '', :on => :tags) 70 | @taggable.tag_list.should == %w(ruby python) 71 | end 72 | 73 | it "should throw an exception when the default is over-ridden" do 74 | @taggable.tag_list_on(:foo_boo).should be_empty 75 | lambda { 76 | @tagger.tag(@taggable, :with=>'this, and, that', :on=>:foo_boo, :force=>false) 77 | }.should raise_error 78 | end 79 | 80 | it "should not create the tag context on-the-fly when the default is over-ridden" do 81 | @taggable.tag_list_on(:foo_boo).should be_empty 82 | @tagger.tag(@taggable, :with=>'this, and, that', :on=>:foo_boo, :force=>false) rescue 83 | @taggable.tag_list_on(:foo_boo).should be_empty 84 | end 85 | end 86 | 87 | describe "when called by multiple tagger's" do 88 | before(:each) do 89 | @user_x = User.create(:name => "User X") 90 | @user_y = User.create(:name => "User Y") 91 | @taggable = TaggableModel.create(:name => 'acts_as_taggable_on', :tag_list => 'plugin') 92 | 93 | @user_x.tag(@taggable, :with => 'ruby, rails', :on => :tags) 94 | @user_y.tag(@taggable, :with => 'ruby, plugin', :on => :tags) 95 | 96 | @user_y.tag(@taggable, :with => '', :on => :tags) 97 | @user_y.tag(@taggable, :with => '', :on => :tags) 98 | end 99 | 100 | it "should delete owned tags" do 101 | @user_y.owned_tags.should == [] 102 | end 103 | 104 | it "should not delete other taggers tags" do 105 | @user_x.owned_tags.should have(2).items 106 | end 107 | 108 | it "should not delete original tags" do 109 | @taggable.all_tags_list_on(:tags).should include('plugin') 110 | end 111 | end 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/tag_list_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe ActsAsTaggableOn::TagList do 5 | let(:tag_list) { ActsAsTaggableOn::TagList.new("awesome","radical") } 6 | 7 | it { should be_kind_of Array } 8 | 9 | it "#from should return empty array if empty array is passed" do 10 | ActsAsTaggableOn::TagList.from([]).should be_empty 11 | end 12 | 13 | describe "#add" do 14 | it "should be able to be add a new tag word" do 15 | tag_list.add("cool") 16 | tag_list.include?("cool").should be_true 17 | end 18 | 19 | it "should be able to add delimited lists of words" do 20 | tag_list.add("cool, wicked", :parse => true) 21 | tag_list.should include("cool", "wicked") 22 | end 23 | 24 | it "should be able to add delimited list of words with quoted delimiters" do 25 | tag_list.add("'cool, wicked', \"really cool, really wicked\"", :parse => true) 26 | tag_list.should include("cool, wicked", "really cool, really wicked") 27 | end 28 | 29 | it "should be able to handle other uses of quotation marks correctly" do 30 | tag_list.add("john's cool car, mary's wicked toy", :parse => true) 31 | tag_list.should include("john's cool car", "mary's wicked toy") 32 | end 33 | 34 | it "should be able to add an array of words" do 35 | tag_list.add(["cool", "wicked"], :parse => true) 36 | tag_list.should include("cool", "wicked") 37 | end 38 | 39 | it "should quote escape tags with commas in them" do 40 | tag_list.add("cool","rad,bodacious") 41 | tag_list.to_s.should == "awesome, radical, cool, \"rad,bodacious\"" 42 | end 43 | 44 | end 45 | 46 | describe "#remove" do 47 | it "should be able to remove words" do 48 | tag_list.remove("awesome") 49 | tag_list.should_not include("awesome") 50 | end 51 | 52 | it "should be able to remove delimited lists of words" do 53 | tag_list.remove("awesome, radical", :parse => true) 54 | tag_list.should be_empty 55 | end 56 | 57 | it "should be able to remove an array of words" do 58 | tag_list.remove(["awesome", "radical"], :parse => true) 59 | tag_list.should be_empty 60 | end 61 | end 62 | 63 | describe "#to_s" do 64 | it "should give a delimited list of words when converted to string" do 65 | tag_list.to_s.should == "awesome, radical" 66 | end 67 | 68 | it "should be able to call to_s on a frozen tag list" do 69 | tag_list.freeze 70 | lambda { tag_list.add("cool","rad,bodacious") }.should raise_error 71 | lambda { tag_list.to_s }.should_not raise_error 72 | end 73 | end 74 | 75 | describe "cleaning" do 76 | it "should parameterize if force_parameterize is set to true" do 77 | ActsAsTaggableOn.force_parameterize = true 78 | tag_list = ActsAsTaggableOn::TagList.new("awesome()","radical)(cc") 79 | 80 | tag_list.to_s.should == "awesome, radical-cc" 81 | ActsAsTaggableOn.force_parameterize = false 82 | end 83 | 84 | it "should lowercase if force_lowercase is set to true" do 85 | ActsAsTaggableOn.force_lowercase = true 86 | 87 | tag_list = ActsAsTaggableOn::TagList.new("aweSomE","RaDicaL","Entrée") 88 | tag_list.to_s.should == "awesome, radical, entrée" 89 | 90 | ActsAsTaggableOn.force_lowercase = false 91 | end 92 | 93 | end 94 | 95 | describe "Multiple Delimiter" do 96 | before do 97 | @old_delimiter = ActsAsTaggableOn.delimiter 98 | end 99 | 100 | after do 101 | ActsAsTaggableOn.delimiter = @old_delimiter 102 | end 103 | 104 | it "should separate tags by delimiters" do 105 | ActsAsTaggableOn.delimiter = [',', ' ', '\|'] 106 | tag_list = ActsAsTaggableOn::TagList.from "cool, data|I have" 107 | tag_list.to_s.should == 'cool, data, I, have' 108 | end 109 | 110 | it "should escape quote" do 111 | ActsAsTaggableOn.delimiter = [',', ' ', '\|'] 112 | tag_list = ActsAsTaggableOn::TagList.from "'I have'|cool, data" 113 | tag_list.to_s.should == '"I have", cool, data' 114 | 115 | tag_list = ActsAsTaggableOn::TagList.from '"I, have"|cool, data' 116 | tag_list.to_s.should == '"I, have", cool, data' 117 | end 118 | 119 | it "should work for utf8 delimiter and long delimiter" do 120 | ActsAsTaggableOn.delimiter = [',', '的', '可能是'] 121 | tag_list = ActsAsTaggableOn::TagList.from "我的东西可能是不见了,还好有备份" 122 | tag_list.to_s.should == "我, 东西, 不见了, 还好有备份" 123 | end 124 | end 125 | 126 | end 127 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/related_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Acts As Taggable On" do 4 | before(:each) do 5 | clean_database! 6 | end 7 | 8 | describe "Related Objects" do 9 | it "should find related objects based on tag names on context" do 10 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 11 | taggable2 = TaggableModel.create!(:name => "Taggable 2") 12 | taggable3 = TaggableModel.create!(:name => "Taggable 3") 13 | 14 | taggable1.tag_list = "one, two" 15 | taggable1.save 16 | 17 | taggable2.tag_list = "three, four" 18 | taggable2.save 19 | 20 | taggable3.tag_list = "one, four" 21 | taggable3.save 22 | 23 | taggable1.find_related_tags.should include(taggable3) 24 | taggable1.find_related_tags.should_not include(taggable2) 25 | end 26 | 27 | it "finds related tags for ordered taggable on" do 28 | taggable1 = OrderedTaggableModel.create!(:name => "Taggable 1") 29 | taggable2 = OrderedTaggableModel.create!(:name => "Taggable 2") 30 | taggable3 = OrderedTaggableModel.create!(:name => "Taggable 3") 31 | 32 | taggable1.colour_list = "one, two" 33 | taggable1.save 34 | 35 | taggable2.colour_list = "three, four" 36 | taggable2.save 37 | 38 | taggable3.colour_list = "one, four" 39 | taggable3.save 40 | 41 | taggable1.find_related_colours.should include(taggable3) 42 | taggable1.find_related_colours.should_not include(taggable2) 43 | end 44 | 45 | it "should find related objects based on tag names on context - non standard id" do 46 | taggable1 = NonStandardIdTaggableModel.create!(:name => "Taggable 1") 47 | taggable2 = NonStandardIdTaggableModel.create!(:name => "Taggable 2") 48 | taggable3 = NonStandardIdTaggableModel.create!(:name => "Taggable 3") 49 | 50 | taggable1.tag_list = "one, two" 51 | taggable1.save 52 | 53 | taggable2.tag_list = "three, four" 54 | taggable2.save 55 | 56 | taggable3.tag_list = "one, four" 57 | taggable3.save 58 | 59 | taggable1.find_related_tags.should include(taggable3) 60 | taggable1.find_related_tags.should_not include(taggable2) 61 | end 62 | 63 | it "should find other related objects based on tag names on context" do 64 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 65 | taggable2 = OtherTaggableModel.create!(:name => "Taggable 2") 66 | taggable3 = OtherTaggableModel.create!(:name => "Taggable 3") 67 | 68 | taggable1.tag_list = "one, two" 69 | taggable1.save 70 | 71 | taggable2.tag_list = "three, four" 72 | taggable2.save 73 | 74 | taggable3.tag_list = "one, four" 75 | taggable3.save 76 | 77 | taggable1.find_related_tags_for(OtherTaggableModel).should include(taggable3) 78 | taggable1.find_related_tags_for(OtherTaggableModel).should_not include(taggable2) 79 | end 80 | 81 | it "should not include the object itself in the list of related objects" do 82 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 83 | taggable2 = TaggableModel.create!(:name => "Taggable 2") 84 | 85 | taggable1.tag_list = "one" 86 | taggable1.save 87 | 88 | taggable2.tag_list = "one, two" 89 | taggable2.save 90 | 91 | taggable1.find_related_tags.should include(taggable2) 92 | taggable1.find_related_tags.should_not include(taggable1) 93 | end 94 | 95 | it "should not include the object itself in the list of related objects - non standard id" do 96 | taggable1 = NonStandardIdTaggableModel.create!(:name => "Taggable 1") 97 | taggable2 = NonStandardIdTaggableModel.create!(:name => "Taggable 2") 98 | 99 | taggable1.tag_list = "one" 100 | taggable1.save 101 | 102 | taggable2.tag_list = "one, two" 103 | taggable2.save 104 | 105 | taggable1.find_related_tags.should include(taggable2) 106 | taggable1.find_related_tags.should_not include(taggable1) 107 | end 108 | 109 | context "Ignored Tags" do 110 | let(:taggable1) { TaggableModel.create!(:name => "Taggable 1") } 111 | let(:taggable2) { TaggableModel.create!(:name => "Taggable 2") } 112 | let(:taggable3) { TaggableModel.create!(:name => "Taggable 3") } 113 | before(:each) do 114 | taggable1.tag_list = "one, two, four" 115 | taggable1.save 116 | 117 | taggable2.tag_list = "two, three" 118 | taggable2.save 119 | 120 | taggable3.tag_list = "one, three" 121 | taggable3.save 122 | end 123 | it "should not include ignored tags in related search" do 124 | taggable1.find_related_tags(:ignore => 'two').should_not include(taggable2) 125 | taggable1.find_related_tags(:ignore => 'two').should include(taggable3) 126 | end 127 | 128 | it "should accept array of ignored tags" do 129 | taggable4 = TaggableModel.create!(:name => "Taggable 4") 130 | taggable4.tag_list = "four" 131 | taggable4.save 132 | 133 | taggable1.find_related_tags(:ignore => ['two', 'four']).should_not include(taggable2) 134 | taggable1.find_related_tags(:ignore => ['two', 'four']).should_not include(taggable4) 135 | end 136 | 137 | it "should accept symbols as ignored tags" do 138 | taggable1.find_related_tags(:ignore => :two).should_not include(taggable2) 139 | end 140 | end 141 | 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/tagger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Tagger" do 4 | before(:each) do 5 | clean_database! 6 | @user = User.create 7 | @taggable = TaggableModel.create(:name => "Bob Jones") 8 | end 9 | 10 | it "should have taggings" do 11 | @user.tag(@taggable, :with=>'ruby,scheme', :on=>:tags) 12 | @user.owned_taggings.size == 2 13 | end 14 | 15 | it "should have tags" do 16 | @user.tag(@taggable, :with=>'ruby,scheme', :on=>:tags) 17 | @user.owned_tags.size == 2 18 | end 19 | 20 | it "should scope objects returned by tagged_with by owners" do 21 | @taggable2 = TaggableModel.create(:name => "Jim Jones") 22 | @taggable3 = TaggableModel.create(:name => "Jane Doe") 23 | 24 | @user2 = User.new 25 | @user.tag(@taggable, :with => 'ruby, scheme', :on => :tags) 26 | @user2.tag(@taggable2, :with => 'ruby, scheme', :on => :tags) 27 | @user2.tag(@taggable3, :with => 'ruby, scheme', :on => :tags) 28 | 29 | TaggableModel.tagged_with(%w(ruby scheme), :owned_by => @user).count.should == 1 30 | TaggableModel.tagged_with(%w(ruby scheme), :owned_by => @user2).count.should == 2 31 | end 32 | 33 | it "only returns objects tagged by owned_by when any is true" do 34 | @user2 = User.new 35 | @taggable2 = TaggableModel.create(:name => "Jim Jones") 36 | @taggable3 = TaggableModel.create(:name => "Jane Doe") 37 | 38 | @user.tag(@taggable, :with => 'ruby', :on => :tags) 39 | @user.tag(@taggable2, :with => 'java', :on => :tags) 40 | @user2.tag(@taggable3, :with => 'ruby', :on => :tags) 41 | 42 | tags = TaggableModel.tagged_with(%w(ruby java), :owned_by => @user, :any => true) 43 | tags.should match_array [@taggable, @taggable2] 44 | end 45 | 46 | it "only returns objects tagged by owned_by when exclude is true" do 47 | @user2 = User.new 48 | @taggable2 = TaggableModel.create(:name => "Jim Jones") 49 | @taggable3 = TaggableModel.create(:name => "Jane Doe") 50 | 51 | @user.tag(@taggable, :with => 'ruby', :on => :tags) 52 | @user.tag(@taggable2, :with => 'java', :on => :tags) 53 | @user2.tag(@taggable3, :with => 'java', :on => :tags) 54 | 55 | tags = TaggableModel.tagged_with(%w(ruby), :owned_by => @user, :exclude => true) 56 | tags.should match_array [@taggable2] 57 | end 58 | 59 | it "should not overlap tags from different taggers" do 60 | @user2 = User.new 61 | lambda{ 62 | @user.tag(@taggable, :with => 'ruby, scheme', :on => :tags) 63 | @user2.tag(@taggable, :with => 'java, python, lisp, ruby', :on => :tags) 64 | }.should change(ActsAsTaggableOn::Tagging, :count).by(6) 65 | 66 | [@user, @user2, @taggable].each(&:reload) 67 | 68 | @user.owned_tags.map(&:name).sort.should == %w(ruby scheme).sort 69 | @user2.owned_tags.map(&:name).sort.should == %w(java python lisp ruby).sort 70 | 71 | @taggable.tags_from(@user).sort.should == %w(ruby scheme).sort 72 | @taggable.tags_from(@user2).sort.should == %w(java lisp python ruby).sort 73 | 74 | @taggable.all_tags_list.sort.should == %w(ruby scheme java python lisp).sort 75 | @taggable.all_tags_on(:tags).size.should == 5 76 | end 77 | 78 | it "should not lose tags from different taggers" do 79 | @user2 = User.create 80 | @user2.tag(@taggable, :with => 'java, python, lisp, ruby', :on => :tags) 81 | @user.tag(@taggable, :with => 'ruby, scheme', :on => :tags) 82 | 83 | lambda { 84 | @user2.tag(@taggable, :with => 'java, python, lisp', :on => :tags) 85 | }.should change(ActsAsTaggableOn::Tagging, :count).by(-1) 86 | 87 | [@user, @user2, @taggable].each(&:reload) 88 | 89 | @taggable.tags_from(@user).sort.should == %w(ruby scheme).sort 90 | @taggable.tags_from(@user2).sort.should == %w(java python lisp).sort 91 | 92 | @taggable.all_tags_list.sort.should == %w(ruby scheme java python lisp).sort 93 | @taggable.all_tags_on(:tags).length.should == 5 94 | end 95 | 96 | it "should not lose tags" do 97 | @user2 = User.create 98 | 99 | @user.tag(@taggable, :with => 'awesome', :on => :tags) 100 | @user2.tag(@taggable, :with => 'awesome, epic', :on => :tags) 101 | 102 | lambda { 103 | @user2.tag(@taggable, :with => 'epic', :on => :tags) 104 | }.should change(ActsAsTaggableOn::Tagging, :count).by(-1) 105 | 106 | @taggable.reload 107 | @taggable.all_tags_list.should include('awesome') 108 | @taggable.all_tags_list.should include('epic') 109 | end 110 | 111 | it "should not lose tags" do 112 | @taggable.update_attributes(:tag_list => 'ruby') 113 | @user.tag(@taggable, :with => 'ruby, scheme', :on => :tags) 114 | 115 | [@taggable, @user].each(&:reload) 116 | @taggable.tag_list.should == %w(ruby) 117 | @taggable.all_tags_list.sort.should == %w(ruby scheme).sort 118 | 119 | lambda { 120 | @taggable.update_attributes(:tag_list => "") 121 | }.should change(ActsAsTaggableOn::Tagging, :count).by(-1) 122 | 123 | @taggable.tag_list.should == [] 124 | @taggable.all_tags_list.sort.should == %w(ruby scheme).sort 125 | end 126 | 127 | it "is tagger" do 128 | @user.is_tagger?.should(be_true) 129 | end 130 | 131 | it "should skip save if skip_save is passed as option" do 132 | lambda { 133 | @user.tag(@taggable, :with => 'epic', :on => :tags, :skip_save => true) 134 | }.should_not change(ActsAsTaggableOn::Tagging, :count) 135 | end 136 | 137 | end 138 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Ownership 3 | def self.included(base) 4 | base.send :include, ActsAsTaggableOn::Taggable::Ownership::InstanceMethods 5 | base.extend ActsAsTaggableOn::Taggable::Ownership::ClassMethods 6 | 7 | base.class_eval do 8 | after_save :save_owned_tags 9 | end 10 | 11 | base.initialize_acts_as_taggable_on_ownership 12 | end 13 | 14 | module ClassMethods 15 | def acts_as_taggable_on(*args) 16 | initialize_acts_as_taggable_on_ownership 17 | super(*args) 18 | end 19 | 20 | def initialize_acts_as_taggable_on_ownership 21 | tag_types.map(&:to_s).each do |tag_type| 22 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 23 | def #{tag_type}_from(owner) 24 | owner_tag_list_on(owner, '#{tag_type}') 25 | end 26 | RUBY 27 | end 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def owner_tags_on(owner, context) 33 | if owner.nil? 34 | scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ?), context.to_s]) 35 | else 36 | scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND 37 | #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = ? AND 38 | #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = ?), context.to_s, owner.id, owner.class.base_class.to_s]) 39 | end 40 | 41 | # when preserving tag order, return tags in created order 42 | # if we added the order to the association this would always apply 43 | if self.class.preserve_tag_order? 44 | scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") 45 | else 46 | scope 47 | end 48 | end 49 | 50 | def cached_owned_tag_list_on(context) 51 | variable_name = "@owned_#{context}_list" 52 | cache = (instance_variable_defined?(variable_name) && instance_variable_get(variable_name)) || instance_variable_set(variable_name, {}) 53 | end 54 | 55 | def owner_tag_list_on(owner, context) 56 | add_custom_context(context) 57 | 58 | cache = cached_owned_tag_list_on(context) 59 | 60 | cache[owner] ||= ActsAsTaggableOn::TagList.new(*owner_tags_on(owner, context).map(&:name)) 61 | end 62 | 63 | def set_owner_tag_list_on(owner, context, new_list) 64 | add_custom_context(context) 65 | 66 | cache = cached_owned_tag_list_on(context) 67 | 68 | cache[owner] = ActsAsTaggableOn::TagList.from(new_list) 69 | end 70 | 71 | def reload(*args) 72 | self.class.tag_types.each do |context| 73 | instance_variable_set("@owned_#{context}_list", nil) 74 | end 75 | 76 | super(*args) 77 | end 78 | 79 | def save_owned_tags 80 | tagging_contexts.each do |context| 81 | cached_owned_tag_list_on(context).each do |owner, tag_list| 82 | 83 | # Find existing tags or create non-existing tags: 84 | tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list.uniq) 85 | 86 | # Tag objects for owned tags 87 | owned_tags = owner_tags_on(owner, context) 88 | 89 | # Tag maintenance based on whether preserving the created order of tags 90 | if self.class.preserve_tag_order? 91 | old_tags, new_tags = owned_tags - tags, tags - owned_tags 92 | 93 | shared_tags = owned_tags & tags 94 | 95 | if shared_tags.any? && tags[0...shared_tags.size] != shared_tags 96 | index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] } 97 | 98 | # Update arrays of tag objects 99 | old_tags |= owned_tags.from(index) 100 | new_tags |= owned_tags.from(index) & shared_tags 101 | 102 | # Order the array of tag objects to match the tag list 103 | new_tags = tags.map { |t| new_tags.find { |n| n.name.downcase == t.name.downcase } }.compact 104 | end 105 | else 106 | # Delete discarded tags and create new tags 107 | old_tags = owned_tags - tags 108 | new_tags = tags - owned_tags 109 | end 110 | 111 | # Find all taggings that belong to the taggable (self), are owned by the owner, 112 | # have the correct context, and are removed from the list. 113 | if old_tags.present? 114 | old_taggings = ActsAsTaggableOn::Tagging.where(:taggable_id => id, :taggable_type => self.class.base_class.to_s, 115 | :tagger_type => owner.class.base_class.to_s, :tagger_id => owner.id, 116 | :tag_id => old_tags, :context => context) 117 | end 118 | 119 | # Destroy old taggings: 120 | if old_taggings.present? 121 | ActsAsTaggableOn::Tagging.destroy_all(:id => old_taggings.map(&:id)) 122 | end 123 | 124 | # Create new taggings: 125 | new_tags.each do |tag| 126 | taggings.create!(:tag_id => tag.id, :context => context.to_s, :tagger => owner, :taggable => self) 127 | end 128 | end 129 | end 130 | 131 | true 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/tag_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe ActsAsTaggableOn::Tag do 5 | before(:each) do 6 | clean_database! 7 | @tag = ActsAsTaggableOn::Tag.new 8 | @user = TaggableModel.create(:name => "Pablo") 9 | end 10 | 11 | describe "named like any" do 12 | before(:each) do 13 | ActsAsTaggableOn::Tag.create(:name => "Awesome") 14 | ActsAsTaggableOn::Tag.create(:name => "awesome") 15 | ActsAsTaggableOn::Tag.create(:name => "epic") 16 | end 17 | 18 | it "should find both tags" do 19 | ActsAsTaggableOn::Tag.named_like_any(["awesome", "epic"]).should have(3).items 20 | end 21 | end 22 | 23 | describe "find or create by name" do 24 | before(:each) do 25 | @tag.name = "awesome" 26 | @tag.save 27 | end 28 | 29 | it "should find by name" do 30 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("awesome").should == @tag 31 | end 32 | 33 | it "should find by name case insensitive" do 34 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("AWESOME").should == @tag 35 | end 36 | 37 | it "should create by name" do 38 | lambda { 39 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("epic") 40 | }.should change(ActsAsTaggableOn::Tag, :count).by(1) 41 | end 42 | end 43 | 44 | unless ActsAsTaggableOn::Tag.using_sqlite? 45 | describe "find or create by unicode name" do 46 | before(:each) do 47 | @tag.name = "привет" 48 | @tag.save 49 | end 50 | 51 | it "should find by name" do 52 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("привет").should == @tag 53 | end 54 | 55 | it "should find by name case insensitive" do 56 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("ПРИВЕТ").should == @tag 57 | end 58 | end 59 | end 60 | 61 | describe "find or create all by any name" do 62 | before(:each) do 63 | @tag.name = "awesome" 64 | @tag.save 65 | end 66 | 67 | it "should find by name" do 68 | ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name("awesome").should == [@tag] 69 | end 70 | 71 | it "should find by name case insensitive" do 72 | ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name("AWESOME").should == [@tag] 73 | end 74 | 75 | it "should create by name" do 76 | lambda { 77 | ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name("epic") 78 | }.should change(ActsAsTaggableOn::Tag, :count).by(1) 79 | end 80 | 81 | it "should find or create by name" do 82 | lambda { 83 | ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name("awesome", "epic").map(&:name).should == ["awesome", "epic"] 84 | }.should change(ActsAsTaggableOn::Tag, :count).by(1) 85 | end 86 | 87 | it "should return an empty array if no tags are specified" do 88 | ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name([]).should == [] 89 | end 90 | end 91 | 92 | it "should require a name" do 93 | @tag.valid? 94 | 95 | @tag.errors[:name].should == ["can't be blank"] 96 | 97 | @tag.name = "something" 98 | @tag.valid? 99 | 100 | @tag.errors[:name].should == [] 101 | end 102 | 103 | it "should limit the name length to 255 or less characters" do 104 | @tag.name = "fgkgnkkgjymkypbuozmwwghblmzpqfsgjasflblywhgkwndnkzeifalfcpeaeqychjuuowlacmuidnnrkprgpcpybarbkrmziqihcrxirlokhnzfvmtzixgvhlxzncyywficpraxfnjptxxhkqmvicbcdcynkjvziefqzyndxkjmsjlvyvbwraklbalykyxoliqdlreeykuphdtmzfdwpphmrqvwvqffojkqhlzvinqajsxbszyvrqqyzusxranr" 105 | @tag.valid? 106 | @tag.errors[:name].should == ["is too long (maximum is 255 characters)"] 107 | 108 | @tag.name = "fgkgnkkgjymkypbuozmwwghblmzpqfsgjasflblywhgkwndnkzeifalfcpeaeqychjuuowlacmuidnnrkprgpcpybarbkrmziqihcrxirlokhnzfvmtzixgvhlxzncyywficpraxfnjptxxhkqmvicbcdcynkjvziefqzyndxkjmsjlvyvbwraklbalykyxoliqdlreeykuphdtmzfdwpphmrqvwvqffojkqhlzvinqajsxbszyvrqqyzusxran" 109 | @tag.valid? 110 | @tag.errors[:name].should == [] 111 | end 112 | 113 | it "should equal a tag with the same name" do 114 | @tag.name = "awesome" 115 | new_tag = ActsAsTaggableOn::Tag.new(:name => "awesome") 116 | new_tag.should == @tag 117 | end 118 | 119 | it "should return its name when to_s is called" do 120 | @tag.name = "cool" 121 | @tag.to_s.should == "cool" 122 | end 123 | 124 | it "have named_scope named(something)" do 125 | @tag.name = "cool" 126 | @tag.save! 127 | ActsAsTaggableOn::Tag.named('cool').should include(@tag) 128 | end 129 | 130 | it "have named_scope named_like(something)" do 131 | @tag.name = "cool" 132 | @tag.save! 133 | @another_tag = ActsAsTaggableOn::Tag.create!(:name => "coolip") 134 | ActsAsTaggableOn::Tag.named_like('cool').should include(@tag, @another_tag) 135 | end 136 | 137 | describe "escape wildcard symbols in like requests" do 138 | before(:each) do 139 | @tag.name = "cool" 140 | @tag.save 141 | @another_tag = ActsAsTaggableOn::Tag.create!(:name => "coo%") 142 | @another_tag2 = ActsAsTaggableOn::Tag.create!(:name => "coolish") 143 | end 144 | 145 | it "return escaped result when '%' char present in tag" do 146 | ActsAsTaggableOn::Tag.named_like('coo%').should_not include(@tag) 147 | ActsAsTaggableOn::Tag.named_like('coo%').should include(@another_tag) 148 | end 149 | 150 | end 151 | 152 | describe "when using strict_case_match" do 153 | before do 154 | ActsAsTaggableOn.strict_case_match = true 155 | @tag.name = "awesome" 156 | @tag.save! 157 | end 158 | 159 | after do 160 | ActsAsTaggableOn.strict_case_match = false 161 | end 162 | 163 | it "should find by name" do 164 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("awesome").should == @tag 165 | end 166 | 167 | it "should find by name case sensitively" do 168 | expect { 169 | ActsAsTaggableOn::Tag.find_or_create_with_like_by_name("AWESOME") 170 | }.to change(ActsAsTaggableOn::Tag, :count) 171 | 172 | ActsAsTaggableOn::Tag.last.name.should == "AWESOME" 173 | end 174 | 175 | it "should have a named_scope named(something) that matches exactly" do 176 | uppercase_tag = ActsAsTaggableOn::Tag.create(:name => "Cool") 177 | @tag.name = "cool" 178 | @tag.save! 179 | 180 | ActsAsTaggableOn::Tag.named('cool').should include(@tag) 181 | ActsAsTaggableOn::Tag.named('cool').should_not include(uppercase_tag) 182 | end 183 | end 184 | 185 | describe "name uniqeness validation" do 186 | let(:duplicate_tag) { ActsAsTaggableOn::Tag.new(:name => 'ror') } 187 | 188 | before { ActsAsTaggableOn::Tag.create(:name => 'ror') } 189 | 190 | context "when don't need unique names" do 191 | it "should not run uniqueness validation" do 192 | duplicate_tag.stub(:validates_name_uniqueness?).and_return(false) 193 | duplicate_tag.save 194 | duplicate_tag.should be_persisted 195 | end 196 | end 197 | 198 | context "when do need unique names" do 199 | it "should run uniqueness validation" do 200 | duplicate_tag.should_not be_valid 201 | end 202 | 203 | it "add error to name" do 204 | duplicate_tag.save 205 | 206 | duplicate_tag.should have(1).errors 207 | duplicate_tag.errors.messages[:name].should include('has already been taken') 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/single_table_inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Single Table Inheritance" do 4 | 5 | before(:each) do 6 | clean_database! 7 | end 8 | 9 | let(:taggable) { TaggableModel.new(:name => "taggable model") } 10 | 11 | let(:inheriting_model) { InheritingTaggableModel.new(:name => "Inheriting Taggable Model") } 12 | let(:altered_inheriting) { AlteredInheritingTaggableModel.new(:name => "Altered Inheriting Model") } 13 | 14 | 1.upto(4) do |n| 15 | let(:"inheriting_#{n}") { InheritingTaggableModel.new(:name => "Inheriting Model #{n}") } 16 | end 17 | 18 | let(:student) { Student.create! } 19 | 20 | describe "tag contexts" do 21 | it "should pass on to STI-inherited models" do 22 | inheriting_model.should respond_to(:tag_list, :skill_list, :language_list) 23 | altered_inheriting.should respond_to(:tag_list, :skill_list, :language_list) 24 | end 25 | 26 | it "should pass on to altered STI models" do 27 | altered_inheriting.should respond_to(:part_list) 28 | end 29 | end 30 | 31 | context "matching contexts" do 32 | 33 | before do 34 | inheriting_1.offering_list = "one, two" 35 | inheriting_1.need_list = "one, two" 36 | inheriting_1.save! 37 | 38 | inheriting_2.need_list = "one, two" 39 | inheriting_2.save! 40 | 41 | inheriting_3.offering_list = "one, two" 42 | inheriting_3.save! 43 | 44 | inheriting_4.tag_list = "one, two, three, four" 45 | inheriting_4.save! 46 | 47 | taggable.need_list = "one, two" 48 | taggable.save! 49 | end 50 | 51 | it "should find objects with tags of matching contexts" do 52 | inheriting_1.find_matching_contexts(:offerings, :needs).should include(inheriting_2) 53 | inheriting_1.find_matching_contexts(:offerings, :needs).should_not include(inheriting_3) 54 | inheriting_1.find_matching_contexts(:offerings, :needs).should_not include(inheriting_4) 55 | inheriting_1.find_matching_contexts(:offerings, :needs).should_not include(taggable) 56 | 57 | inheriting_1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should include(inheriting_2) 58 | inheriting_1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should_not include(inheriting_3) 59 | inheriting_1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should_not include(inheriting_4) 60 | inheriting_1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should include(taggable) 61 | end 62 | 63 | it "should not include the object itself in the list of related objects with tags of matching contexts" do 64 | inheriting_1.find_matching_contexts(:offerings, :needs).should_not include(inheriting_1) 65 | inheriting_1.find_matching_contexts_for(InheritingTaggableModel, :offerings, :needs).should_not include(inheriting_1) 66 | inheriting_1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should_not include(inheriting_1) 67 | end 68 | end 69 | 70 | context "find related tags" do 71 | before do 72 | inheriting_1.tag_list = "one, two" 73 | inheriting_1.save 74 | 75 | inheriting_2.tag_list = "three, four" 76 | inheriting_2.save 77 | 78 | inheriting_3.tag_list = "one, four" 79 | inheriting_3.save 80 | 81 | taggable.tag_list = "one, two, three, four" 82 | taggable.save 83 | end 84 | 85 | it "should find related objects based on tag names on context" do 86 | inheriting_1.find_related_tags.should include(inheriting_3) 87 | inheriting_1.find_related_tags.should_not include(inheriting_2) 88 | inheriting_1.find_related_tags.should_not include(taggable) 89 | 90 | inheriting_1.find_related_tags_for(TaggableModel).should include(inheriting_3) 91 | inheriting_1.find_related_tags_for(TaggableModel).should_not include(inheriting_2) 92 | inheriting_1.find_related_tags_for(TaggableModel).should include(taggable) 93 | end 94 | 95 | it "should not include the object itself in the list of related objects" do 96 | inheriting_1.find_related_tags.should_not include(inheriting_1) 97 | inheriting_1.find_related_tags_for(InheritingTaggableModel).should_not include(inheriting_1) 98 | inheriting_1.find_related_tags_for(TaggableModel).should_not include(inheriting_1) 99 | end 100 | end 101 | 102 | describe "tag list" do 103 | before do 104 | @inherited_same = InheritingTaggableModel.new(:name => "inherited same") 105 | @inherited_different = AlteredInheritingTaggableModel.new(:name => "inherited different") 106 | end 107 | 108 | it "should be able to save tags for inherited models" do 109 | inheriting_model.tag_list = "bob, kelso" 110 | inheriting_model.save 111 | InheritingTaggableModel.tagged_with("bob").first.should == inheriting_model 112 | end 113 | 114 | it "should find STI tagged models on the superclass" do 115 | inheriting_model.tag_list = "bob, kelso" 116 | inheriting_model.save 117 | TaggableModel.tagged_with("bob").first.should == inheriting_model 118 | end 119 | 120 | it "should be able to add on contexts only to some subclasses" do 121 | altered_inheriting.part_list = "fork, spoon" 122 | altered_inheriting.save 123 | InheritingTaggableModel.tagged_with("fork", :on => :parts).should be_empty 124 | AlteredInheritingTaggableModel.tagged_with("fork", :on => :parts).first.should == altered_inheriting 125 | end 126 | 127 | it "should have different tag_counts_on for inherited models" do 128 | inheriting_model.tag_list = "bob, kelso" 129 | inheriting_model.save! 130 | altered_inheriting.tag_list = "fork, spoon" 131 | altered_inheriting.save! 132 | 133 | InheritingTaggableModel.tag_counts_on(:tags, :order => 'tags.id').map(&:name).should == %w(bob kelso) 134 | AlteredInheritingTaggableModel.tag_counts_on(:tags, :order => 'tags.id').map(&:name).should == %w(fork spoon) 135 | TaggableModel.tag_counts_on(:tags, :order => 'tags.id').map(&:name).should == %w(bob kelso fork spoon) 136 | end 137 | 138 | it "should have different tags_on for inherited models" do 139 | inheriting_model.tag_list = "bob, kelso" 140 | inheriting_model.save! 141 | altered_inheriting.tag_list = "fork, spoon" 142 | altered_inheriting.save! 143 | 144 | InheritingTaggableModel.tags_on(:tags, :order => 'tags.id').map(&:name).should == %w(bob kelso) 145 | AlteredInheritingTaggableModel.tags_on(:tags, :order => 'tags.id').map(&:name).should == %w(fork spoon) 146 | TaggableModel.tags_on(:tags, :order => 'tags.id').map(&:name).should == %w(bob kelso fork spoon) 147 | end 148 | 149 | it 'should store same tag without validation conflict' do 150 | taggable.tag_list = 'one' 151 | taggable.save! 152 | 153 | inheriting_model.tag_list = 'one' 154 | inheriting_model.save! 155 | 156 | inheriting_model.update_attributes! :name => 'foo' 157 | end 158 | end 159 | 160 | describe "ownership" do 161 | it "should have taggings" do 162 | student.tag(taggable, :with=>'ruby,scheme', :on=>:tags) 163 | student.owned_taggings.should have(2).tags 164 | end 165 | 166 | it "should have tags" do 167 | student.tag(taggable, :with=>'ruby,scheme', :on=>:tags) 168 | student.owned_tags.should have(2).tags 169 | end 170 | 171 | it "should return tags for the inheriting tagger" do 172 | student.tag(taggable, :with => 'ruby, scheme', :on => :tags) 173 | taggable.tags_from(student).should match_array(%w(ruby scheme)) 174 | end 175 | 176 | it "returns owner tags on the tagger" do 177 | student.tag(taggable, :with => 'ruby, scheme', :on => :tags) 178 | taggable.owner_tags_on(student, :tags).should have(2).tags 179 | end 180 | 181 | it "should scope objects returned by tagged_with by owners" do 182 | student.tag(taggable, :with => 'ruby, scheme', :on => :tags) 183 | TaggableModel.tagged_with(%w(ruby scheme), :owned_by => student).should have(1).tag 184 | end 185 | end 186 | 187 | end 188 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Collection 3 | def self.included(base) 4 | base.send :include, ActsAsTaggableOn::Taggable::Collection::InstanceMethods 5 | base.extend ActsAsTaggableOn::Taggable::Collection::ClassMethods 6 | base.initialize_acts_as_taggable_on_collection 7 | end 8 | 9 | module ClassMethods 10 | def initialize_acts_as_taggable_on_collection 11 | tag_types.map(&:to_s).each do |tag_type| 12 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 13 | def self.#{tag_type.singularize}_counts(options={}) 14 | tag_counts_on('#{tag_type}', options) 15 | end 16 | 17 | def #{tag_type.singularize}_counts(options = {}) 18 | tag_counts_on('#{tag_type}', options) 19 | end 20 | 21 | def top_#{tag_type}(limit = 10) 22 | tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i) 23 | end 24 | 25 | def self.top_#{tag_type}(limit = 10) 26 | tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i) 27 | end 28 | RUBY 29 | end 30 | end 31 | 32 | def acts_as_taggable_on(*args) 33 | super(*args) 34 | initialize_acts_as_taggable_on_collection 35 | end 36 | 37 | def tag_counts_on(context, options = {}) 38 | all_tag_counts(options.merge({:on => context.to_s})) 39 | end 40 | 41 | def tags_on(context, options = {}) 42 | all_tags(options.merge({:on => context.to_s})) 43 | end 44 | 45 | ## 46 | # Calculate the tag names. 47 | # To be used when you don't need tag counts and want to avoid the taggable joins. 48 | # 49 | # @param [Hash] options Options: 50 | # * :start_at - Restrict the tags to those created after a certain time 51 | # * :end_at - Restrict the tags to those created before a certain time 52 | # * :conditions - A piece of SQL conditions to add to the query. Note we don't join the taggable objects for performance reasons. 53 | # * :limit - The maximum number of tags to return 54 | # * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc' 55 | # * :on - Scope the find to only include a certain context 56 | def all_tags(options = {}) 57 | options.assert_valid_keys :start_at, :end_at, :conditions, :order, :limit, :on 58 | 59 | ## Generate conditions: 60 | options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions] 61 | 62 | start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] 63 | end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] 64 | 65 | taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name]) 66 | taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on] 67 | 68 | tagging_conditions = [ 69 | taggable_conditions, 70 | start_at_conditions, 71 | end_at_conditions 72 | ].compact.reverse 73 | 74 | tag_conditions = [ 75 | options[:conditions] 76 | ].compact.reverse 77 | 78 | ## Generate scope: 79 | tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id") 80 | tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*").order(options[:order]).limit(options[:limit]) 81 | 82 | # Joins and conditions 83 | tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) } 84 | tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) } 85 | 86 | group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id" 87 | 88 | # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore: 89 | scoped_select = "#{table_name}.#{primary_key}" 90 | tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})").group(group_columns) 91 | 92 | tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id") 93 | tag_scope 94 | end 95 | 96 | ## 97 | # Calculate the tag counts for all tags. 98 | # 99 | # @param [Hash] options Options: 100 | # * :start_at - Restrict the tags to those created after a certain time 101 | # * :end_at - Restrict the tags to those created before a certain time 102 | # * :conditions - A piece of SQL conditions to add to the query 103 | # * :limit - The maximum number of tags to return 104 | # * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc' 105 | # * :at_least - Exclude tags with a frequency less than the given value 106 | # * :at_most - Exclude tags with a frequency greater than the given value 107 | # * :on - Scope the find to only include a certain context 108 | def all_tag_counts(options = {}) 109 | options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id 110 | 111 | scope = {} 112 | 113 | ## Generate conditions: 114 | options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions] 115 | 116 | start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] 117 | end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] 118 | 119 | taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name]) 120 | taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = ?", options[:id]]) if options[:id] 121 | taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on] 122 | 123 | tagging_conditions = [ 124 | taggable_conditions, 125 | scope[:conditions], 126 | start_at_conditions, 127 | end_at_conditions 128 | ].compact.reverse 129 | 130 | tag_conditions = [ 131 | options[:conditions] 132 | ].compact.reverse 133 | 134 | ## Generate joins: 135 | taggable_join = "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id" 136 | taggable_join << " AND #{table_name}.#{inheritance_column} = '#{name}'" unless descends_from_active_record? # Current model is STI descendant, so add type checking to the join condition 137 | 138 | tagging_joins = [ 139 | taggable_join, 140 | scope[:joins] 141 | ].compact 142 | 143 | tag_joins = [ 144 | ].compact 145 | 146 | ## Generate scope: 147 | tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id, COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) AS tags_count") 148 | tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*, #{ActsAsTaggableOn::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit]) 149 | 150 | # Joins and conditions 151 | tagging_joins.each { |join| tagging_scope = tagging_scope.joins(join) } 152 | tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) } 153 | 154 | tag_joins.each { |join| tag_scope = tag_scope.joins(join) } 155 | tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) } 156 | 157 | # GROUP BY and HAVING clauses: 158 | at_least = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) >= ?", options.delete(:at_least)]) if options[:at_least] 159 | at_most = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) <= ?", options.delete(:at_most)]) if options[:at_most] 160 | having = ["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) > 0", at_least, at_most].compact.join(' AND ') 161 | 162 | group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id" 163 | 164 | unless options[:id] 165 | # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore: 166 | scoped_select = "#{table_name}.#{primary_key}" 167 | tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})") 168 | end 169 | 170 | tagging_scope = tagging_scope.group(group_columns).having(having) 171 | 172 | tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id") 173 | tag_scope 174 | end 175 | end 176 | 177 | module InstanceMethods 178 | def tag_counts_on(context, options={}) 179 | self.class.tag_counts_on(context, options.merge(:id => id)) 180 | end 181 | end 182 | end 183 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActsAsTaggableOn 2 | [![Build Status](https://secure.travis-ci.org/mbleigh/acts-as-taggable-on.png)](http://travis-ci.org/mbleigh/acts-as-taggable-on) 3 | 4 | This plugin was originally based on Acts as Taggable on Steroids by Jonathan Viney. 5 | It has evolved substantially since that point, but all credit goes to him for the 6 | initial tagging functionality that so many people have used. 7 | 8 | For instance, in a social network, a user might have tags that are called skills, 9 | interests, sports, and more. There is no real way to differentiate between tags and 10 | so an implementation of this type is not possible with acts as taggable on steroids. 11 | 12 | Enter Acts as Taggable On. Rather than tying functionality to a specific keyword 13 | (namely `tags`), acts as taggable on allows you to specify an arbitrary number of 14 | tag "contexts" that can be used locally or in combination in the same way steroids 15 | was used. 16 | 17 | ## Compatibility 18 | 19 | Versions 2.x are compatible with Ruby 1.8.7+ and Rails 3. 20 | 21 | Versions 2.4.1 and up are compatible with Rails 4 too (thanks to arabonradar and cwoodcox). 22 | 23 | Versions 3.x (currently unreleased) are compatible with Ruby 1.9.3+ and Rails 3 and 4. 24 | 25 | For an up-to-date roadmap, see https://github.com/mbleigh/acts-as-taggable-on/issues/milestones 26 | 27 | ## Installation 28 | 29 | To use it, add it to your Gemfile: 30 | 31 | ```ruby 32 | gem 'acts-as-taggable-on' 33 | ``` 34 | 35 | and bundle: 36 | 37 | ```ruby 38 | bundle 39 | ``` 40 | 41 | #### Post Installation 42 | 43 | ```shell 44 | rails generate acts_as_taggable_on:migration 45 | rake db:migrate 46 | ``` 47 | 48 | ## Testing 49 | 50 | Acts As Taggable On uses RSpec for its test coverage. Inside the gem 51 | directory, you can run the specs with: 52 | 53 | ```shell 54 | bundle 55 | rake spec 56 | ``` 57 | 58 | If you want, add a `.ruby-version` file in the project root (and use rbenv or RVM) to work on a specific version of Ruby. 59 | 60 | ## Usage 61 | 62 | ```ruby 63 | class User < ActiveRecord::Base 64 | # Alias for acts_as_taggable_on :tags 65 | acts_as_taggable 66 | acts_as_taggable_on :skills, :interests 67 | end 68 | 69 | @user = User.new(:name => "Bobby") 70 | @user.tag_list = "awesome, slick, hefty" # this should be familiar 71 | @user.skill_list = "joking, clowning, boxing" # but you can do it for any context! 72 | 73 | @user.tags # => [,,] 74 | @user.skills # => [,,] 75 | @user.skill_list # => ["joking","clowning","boxing"] as TagList 76 | 77 | @user.tag_list.remove("awesome") # remove a single tag 78 | @user.tag_list.remove("awesome, slick") # works with arrays too 79 | @user.tag_list.add("awesomer") # add a single tag. alias for << 80 | @user.tag_list.add("awesomer, slicker") # also works with arrays 81 | 82 | User.skill_counts # => [,...] 83 | ``` 84 | 85 | To preserve the order in which tags are created use `acts_as_ordered_taggable`: 86 | 87 | ```ruby 88 | class User < ActiveRecord::Base 89 | # Alias for acts_as_ordered_taggable_on :tags 90 | acts_as_ordered_taggable 91 | acts_as_ordered_taggable_on :skills, :interests 92 | end 93 | 94 | @user = User.new(:name => "Bobby") 95 | @user.tag_list = "east, south" 96 | @user.save 97 | 98 | @user.tag_list = "north, east, south, west" 99 | @user.save 100 | 101 | @user.reload 102 | @user.tag_list # => ["north", "east", "south", "west"] 103 | ``` 104 | 105 | ### Finding Tagged Objects 106 | 107 | Acts As Taggable On uses scopes to create an association for tags. 108 | This way you can mix and match to filter down your results. 109 | 110 | ```ruby 111 | class User < ActiveRecord::Base 112 | acts_as_taggable_on :tags, :skills 113 | scope :by_join_date, order("created_at DESC") 114 | end 115 | 116 | User.tagged_with("awesome").by_join_date 117 | User.tagged_with("awesome").by_join_date.paginate(:page => params[:page], :per_page => 20) 118 | 119 | # Find a user with matching all tags, not just one 120 | User.tagged_with(["awesome", "cool"], :match_all => true) 121 | 122 | # Find a user with any of the tags: 123 | User.tagged_with(["awesome", "cool"], :any => true) 124 | 125 | # Find a user that not tags with awesome or cool: 126 | User.tagged_with(["awesome", "cool"], :exclude => true) 127 | 128 | # Find a user with any of tags based on context: 129 | User.tagged_with(['awesome, cool'], :on => :tags, :any => true).tagged_with(['smart', 'shy'], :on => :skills, :any => true) 130 | ``` 131 | 132 | You can also use `:wild => true` option along with `:any` or `:exclude` option. It will looking for `%awesome%` and `%cool%` in sql. 133 | 134 | __Tip:__ `User.tagged_with([])` or '' will return `[]`, but not all records. 135 | 136 | ### Relationships 137 | 138 | You can find objects of the same type based on similar tags on certain contexts. 139 | Also, objects will be returned in descending order based on the total number of 140 | matched tags. 141 | 142 | ```ruby 143 | @bobby = User.find_by_name("Bobby") 144 | @bobby.skill_list # => ["jogging", "diving"] 145 | 146 | @frankie = User.find_by_name("Frankie") 147 | @frankie.skill_list # => ["hacking"] 148 | 149 | @tom = User.find_by_name("Tom") 150 | @tom.skill_list # => ["hacking", "jogging", "diving"] 151 | 152 | @tom.find_related_skills # => [,] 153 | @bobby.find_related_skills # => [] 154 | @frankie.find_related_skills # => [] 155 | ``` 156 | 157 | ### Dynamic Tag Contexts 158 | 159 | In addition to the generated tag contexts in the definition, it is also possible 160 | to allow for dynamic tag contexts (this could be user generated tag contexts!) 161 | 162 | ```ruby 163 | @user = User.new(:name => "Bobby") 164 | @user.set_tag_list_on(:customs, "same, as, tag, list") 165 | @user.tag_list_on(:customs) # => ["same","as","tag","list"] 166 | @user.save 167 | @user.tags_on(:customs) # => [,...] 168 | @user.tag_counts_on(:customs) 169 | User.tagged_with("same", :on => :customs) # => [@user] 170 | ``` 171 | 172 | ### Tag Ownership 173 | 174 | Tags can have owners: 175 | 176 | ```ruby 177 | class User < ActiveRecord::Base 178 | acts_as_tagger 179 | end 180 | 181 | class Photo < ActiveRecord::Base 182 | acts_as_taggable_on :locations 183 | end 184 | 185 | @some_user.tag(@some_photo, :with => "paris, normandy", :on => :locations) 186 | @some_user.owned_taggings 187 | @some_user.owned_tags 188 | Photo.tagged_with("paris", :on => :locations, :owned_by => @some_user) 189 | @some_photo.locations_from(@some_user) # => ["paris", "normandy"] 190 | @some_photo.owner_tags_on(@some_user, :locations) # => [#...] 191 | @some_photo.owner_tags_on(nil, :locations) # => Ownerships equivalent to saying @some_photo.locations 192 | @some_user.tag(@some_photo, :with => "paris, normandy", :on => :locations, :skip_save => true) #won't save @some_photo object 193 | ``` 194 | 195 | ### Dirty objects 196 | 197 | ```ruby 198 | @bobby = User.find_by_name("Bobby") 199 | @bobby.skill_list # => ["jogging", "diving"] 200 | 201 | @bobby.skill_list_changed? #=> false 202 | @bobby.changes #=> {} 203 | 204 | @bobby.skill_list = "swimming" 205 | @bobby.changes.should == {"skill_list"=>["jogging, diving", ["swimming"]]} 206 | @bobby.skill_list_changed? #=> true 207 | 208 | @bobby.skill_list_change.should == ["jogging, diving", ["swimming"]] 209 | ``` 210 | 211 | ### Tag cloud calculations 212 | 213 | To construct tag clouds, the frequency of each tag needs to be calculated. 214 | Because we specified `acts_as_taggable_on` on the `User` class, we can 215 | get a calculation of all the tag counts by using `User.tag_counts_on(:customs)`. But what if we wanted a tag count for 216 | an single user's posts? To achieve this we call tag_counts on the association: 217 | 218 | ```ruby 219 | User.find(:first).posts.tag_counts_on(:tags) 220 | ``` 221 | 222 | A helper is included to assist with generating tag clouds. 223 | 224 | Here is an example that generates a tag cloud. 225 | 226 | Helper: 227 | 228 | ```ruby 229 | module PostsHelper 230 | include ActsAsTaggableOn::TagsHelper 231 | end 232 | ``` 233 | 234 | Controller: 235 | 236 | ```ruby 237 | class PostController < ApplicationController 238 | def tag_cloud 239 | @tags = Post.tag_counts_on(:tags) 240 | end 241 | end 242 | ``` 243 | 244 | View: 245 | 246 | ```erb 247 | <% tag_cloud(@tags, %w(css1 css2 css3 css4)) do |tag, css_class| %> 248 | <%= link_to tag.name, { :action => :tag, :id => tag.name }, :class => css_class %> 249 | <% end %> 250 | ``` 251 | 252 | CSS: 253 | 254 | ```css 255 | .css1 { font-size: 1.0em; } 256 | .css2 { font-size: 1.2em; } 257 | .css3 { font-size: 1.4em; } 258 | .css4 { font-size: 1.6em; } 259 | ``` 260 | 261 | ## Configuration 262 | 263 | If you would like to remove unused tag objects after removing taggings, add: 264 | 265 | ```ruby 266 | ActsAsTaggableOn.remove_unused_tags = true 267 | ``` 268 | 269 | If you want force tags to be saved downcased: 270 | 271 | ```ruby 272 | ActsAsTaggableOn.force_lowercase = true 273 | ``` 274 | 275 | If you want tags to be saved parametrized (you can redefine to_param as well): 276 | 277 | ```ruby 278 | ActsAsTaggableOn.force_parameterize = true 279 | ``` 280 | 281 | If you would like tags to be case-sensitive and not use LIKE queries for creation: 282 | 283 | ```ruby 284 | ActsAsTaggableOn.strict_case_match = true 285 | ``` 286 | 287 | If you want to change the default delimiter (it defaults to ','). You can also pass in an array of delimiters such as ([',', '|']): 288 | 289 | ```ruby 290 | ActsAsTaggableOn.delimiter = ',' 291 | ``` 292 | 293 | ## Contributors 294 | 295 | We have a long list of valued contributors. [Check them all](https://github.com/mbleigh/acts-as-taggable-on/contributors) 296 | 297 | ## Maintainer 298 | 299 | * [Joost Baaij](https://github.com/tilsammans) 300 | 301 | ## License 302 | 303 | See [LICENSE](https://github.com/mbleigh/acts-as-taggable-on/blob/master/LICENSE.md) 304 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Acts As Taggable On" do 4 | before(:each) do 5 | clean_database! 6 | end 7 | 8 | it "should provide a class method 'taggable?' that is false for untaggable models" do 9 | UntaggableModel.should_not be_taggable 10 | end 11 | 12 | describe "Taggable Method Generation To Preserve Order" do 13 | before(:each) do 14 | clean_database! 15 | TaggableModel.tag_types = [] 16 | TaggableModel.preserve_tag_order = false 17 | TaggableModel.acts_as_ordered_taggable_on(:ordered_tags) 18 | @taggable = TaggableModel.new(:name => "Bob Jones") 19 | end 20 | 21 | it "should respond 'true' to preserve_tag_order?" do 22 | @taggable.class.preserve_tag_order?.should be_true 23 | end 24 | end 25 | 26 | describe "Taggable Method Generation" do 27 | before(:each) do 28 | clean_database! 29 | TaggableModel.tag_types = [] 30 | TaggableModel.acts_as_taggable_on(:tags, :languages, :skills, :needs, :offerings) 31 | @taggable = TaggableModel.new(:name => "Bob Jones") 32 | end 33 | 34 | it "should respond 'true' to taggable?" do 35 | @taggable.class.should be_taggable 36 | end 37 | 38 | it "should create a class attribute for tag types" do 39 | @taggable.class.should respond_to(:tag_types) 40 | end 41 | 42 | it "should create an instance attribute for tag types" do 43 | @taggable.should respond_to(:tag_types) 44 | end 45 | 46 | it "should have all tag types" do 47 | @taggable.tag_types.should == [:tags, :languages, :skills, :needs, :offerings] 48 | end 49 | 50 | it "should create a class attribute for preserve tag order" do 51 | @taggable.class.should respond_to(:preserve_tag_order?) 52 | end 53 | 54 | it "should create an instance attribute for preserve tag order" do 55 | @taggable.should respond_to(:preserve_tag_order?) 56 | end 57 | 58 | it "should respond 'false' to preserve_tag_order?" do 59 | @taggable.class.preserve_tag_order?.should be_false 60 | end 61 | 62 | it "should generate an association for each tag type" do 63 | @taggable.should respond_to(:tags, :skills, :languages) 64 | end 65 | 66 | it "should add tagged_with and tag_counts to singleton" do 67 | TaggableModel.should respond_to(:tagged_with, :tag_counts) 68 | end 69 | 70 | it "should generate a tag_list accessor/setter for each tag type" do 71 | @taggable.should respond_to(:tag_list, :skill_list, :language_list) 72 | @taggable.should respond_to(:tag_list=, :skill_list=, :language_list=) 73 | end 74 | 75 | it "should generate a tag_list accessor, that includes owned tags, for each tag type" do 76 | @taggable.should respond_to(:all_tags_list, :all_skills_list, :all_languages_list) 77 | end 78 | end 79 | 80 | describe "Reloading" do 81 | it "should save a model instantiated by Model.find" do 82 | taggable = TaggableModel.create!(:name => "Taggable") 83 | found_taggable = TaggableModel.find(taggable.id) 84 | found_taggable.save 85 | end 86 | end 87 | 88 | describe "Matching Contexts" do 89 | it "should find objects with tags of matching contexts" do 90 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 91 | taggable2 = TaggableModel.create!(:name => "Taggable 2") 92 | taggable3 = TaggableModel.create!(:name => "Taggable 3") 93 | 94 | taggable1.offering_list = "one, two" 95 | taggable1.save! 96 | 97 | taggable2.need_list = "one, two" 98 | taggable2.save! 99 | 100 | taggable3.offering_list = "one, two" 101 | taggable3.save! 102 | 103 | taggable1.find_matching_contexts(:offerings, :needs).should include(taggable2) 104 | taggable1.find_matching_contexts(:offerings, :needs).should_not include(taggable3) 105 | end 106 | 107 | it "should find other related objects with tags of matching contexts" do 108 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 109 | taggable2 = OtherTaggableModel.create!(:name => "Taggable 2") 110 | taggable3 = OtherTaggableModel.create!(:name => "Taggable 3") 111 | 112 | taggable1.offering_list = "one, two" 113 | taggable1.save 114 | 115 | taggable2.need_list = "one, two" 116 | taggable2.save 117 | 118 | taggable3.offering_list = "one, two" 119 | taggable3.save 120 | 121 | taggable1.find_matching_contexts_for(OtherTaggableModel, :offerings, :needs).should include(taggable2) 122 | taggable1.find_matching_contexts_for(OtherTaggableModel, :offerings, :needs).should_not include(taggable3) 123 | end 124 | 125 | it "should not include the object itself in the list of related objects with tags of matching contexts" do 126 | taggable1 = TaggableModel.create!(:name => "Taggable 1") 127 | taggable2 = TaggableModel.create!(:name => "Taggable 2") 128 | 129 | taggable1.offering_list = "one, two" 130 | taggable1.need_list = "one, two" 131 | taggable1.save 132 | 133 | taggable2.need_list = "one, two" 134 | taggable2.save 135 | 136 | taggable1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should include(taggable2) 137 | taggable1.find_matching_contexts_for(TaggableModel, :offerings, :needs).should_not include(taggable1) 138 | end 139 | 140 | end 141 | 142 | describe 'Tagging Contexts' do 143 | it 'should eliminate duplicate tagging contexts ' do 144 | TaggableModel.acts_as_taggable_on(:skills, :skills) 145 | TaggableModel.tag_types.freq[:skills].should_not == 3 146 | end 147 | 148 | it "should not contain embedded/nested arrays" do 149 | TaggableModel.acts_as_taggable_on([:array], [:array]) 150 | TaggableModel.tag_types.freq[[:array]].should == 0 151 | end 152 | 153 | it "should _flatten_ the content of arrays" do 154 | TaggableModel.acts_as_taggable_on([:array], [:array]) 155 | TaggableModel.tag_types.freq[:array].should == 1 156 | end 157 | 158 | it "should not raise an error when passed nil" do 159 | lambda { 160 | TaggableModel.acts_as_taggable_on() 161 | }.should_not raise_error 162 | end 163 | 164 | it "should not raise an error when passed [nil]" do 165 | lambda { 166 | TaggableModel.acts_as_taggable_on([nil]) 167 | }.should_not raise_error 168 | end 169 | end 170 | 171 | context 'when tagging context ends in an "s" when singular (ex. "status", "glass", etc.)' do 172 | describe 'caching' do 173 | before { @taggable = OtherCachedModel.new(:name => "John Smith") } 174 | subject { @taggable } 175 | 176 | it { should respond_to(:save_cached_tag_list) } 177 | its(:cached_language_list) { should be_blank } 178 | its(:cached_status_list) { should be_blank } 179 | its(:cached_glass_list) { should be_blank } 180 | 181 | context 'language taggings cache after update' do 182 | before { @taggable.update_attributes(:language_list => 'ruby, .net') } 183 | subject { @taggable } 184 | 185 | its(:language_list) { should == ['ruby', '.net']} 186 | its(:cached_language_list) { should == 'ruby, .net' } # passes 187 | its(:instance_variables) { should include((RUBY_VERSION < '1.9' ? '@language_list' : :@language_list)) } 188 | end 189 | 190 | context 'status taggings cache after update' do 191 | before { @taggable.update_attributes(:status_list => 'happy, married') } 192 | subject { @taggable } 193 | 194 | its(:status_list) { should == ['happy', 'married'] } 195 | its(:cached_status_list) { should == 'happy, married' } # fails 196 | its(:cached_status_list) { should_not == '' } # fails, is blank 197 | its(:instance_variables) { should include((RUBY_VERSION < '1.9' ? '@status_list' : :@status_list)) } 198 | its(:instance_variables) { should_not include((RUBY_VERSION < '1.9' ? '@statu_list' : :@statu_list)) } # fails, note: one "s" 199 | 200 | end 201 | 202 | context 'glass taggings cache after update' do 203 | before do 204 | @taggable.update_attributes(:glass_list => 'rectangle, aviator') 205 | end 206 | 207 | subject { @taggable } 208 | its(:glass_list) { should == ['rectangle', 'aviator'] } 209 | its(:cached_glass_list) { should == 'rectangle, aviator' } # fails 210 | its(:cached_glass_list) { should_not == '' } # fails, is blank 211 | if RUBY_VERSION < '1.9' 212 | its(:instance_variables) { should include('@glass_list') } 213 | its(:instance_variables) { should_not include('@glas_list') } # fails, note: one "s" 214 | else 215 | its(:instance_variables) { should include(:@glass_list) } 216 | its(:instance_variables) { should_not include(:@glas_list) } # fails, note: one "s" 217 | end 218 | 219 | end 220 | end 221 | end 222 | 223 | describe "taggings" do 224 | before(:each) do 225 | @taggable = TaggableModel.new(:name => "Art Kram") 226 | end 227 | 228 | it 'should return [] taggings' do 229 | @taggable.taggings.should == [] 230 | end 231 | end 232 | 233 | describe "@@remove_unused_tags" do 234 | before do 235 | @taggable = TaggableModel.create(:name => "Bob Jones") 236 | @tag = ActsAsTaggableOn::Tag.create(:name => "awesome") 237 | 238 | @tagging = ActsAsTaggableOn::Tagging.create(:taggable => @taggable, :tag => @tag, :context => 'tags') 239 | end 240 | 241 | context "if set to true" do 242 | before do 243 | ActsAsTaggableOn.remove_unused_tags = true 244 | end 245 | 246 | it "should remove unused tags after removing taggings" do 247 | @tagging.destroy 248 | ActsAsTaggableOn::Tag.find_by_name("awesome").should be_nil 249 | end 250 | end 251 | 252 | context "if set to false" do 253 | before do 254 | ActsAsTaggableOn.remove_unused_tags = false 255 | end 256 | 257 | it "should not remove unused tags after removing taggings" do 258 | @tagging.destroy 259 | ActsAsTaggableOn::Tag.find_by_name("awesome").should == @tag 260 | end 261 | end 262 | end 263 | 264 | end 265 | -------------------------------------------------------------------------------- /lib/acts_as_taggable_on/acts_as_taggable_on/core.rb: -------------------------------------------------------------------------------- 1 | module ActsAsTaggableOn::Taggable 2 | module Core 3 | def self.included(base) 4 | base.send :include, ActsAsTaggableOn::Taggable::Core::InstanceMethods 5 | base.extend ActsAsTaggableOn::Taggable::Core::ClassMethods 6 | 7 | base.class_eval do 8 | attr_writer :custom_contexts 9 | after_save :save_tags 10 | end 11 | 12 | base.initialize_acts_as_taggable_on_core 13 | end 14 | 15 | module ClassMethods 16 | 17 | def initialize_acts_as_taggable_on_core 18 | include taggable_mixin 19 | tag_types.map(&:to_s).each do |tags_type| 20 | tag_type = tags_type.to_s.singularize 21 | context_taggings = "#{tag_type}_taggings".to_sym 22 | context_tags = tags_type.to_sym 23 | taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : []) 24 | 25 | class_eval do 26 | # when preserving tag order, include order option so that for a 'tags' context 27 | # the associations tag_taggings & tags are always returned in created order 28 | has_many_with_compatibility context_taggings, :as => :taggable, 29 | :dependent => :destroy, 30 | :class_name => "ActsAsTaggableOn::Tagging", 31 | :order => taggings_order, 32 | :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.context = (?)", tags_type], 33 | :include => :tag 34 | 35 | has_many_with_compatibility context_tags, :through => context_taggings, 36 | :source => :tag, 37 | :class_name => "ActsAsTaggableOn::Tag", 38 | :order => taggings_order 39 | 40 | end 41 | 42 | taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1 43 | def #{tag_type}_list 44 | tag_list_on('#{tags_type}') 45 | end 46 | 47 | def #{tag_type}_list=(new_tags) 48 | set_tag_list_on('#{tags_type}', new_tags) 49 | end 50 | 51 | def all_#{tags_type}_list 52 | all_tags_list_on('#{tags_type}') 53 | end 54 | RUBY 55 | end 56 | end 57 | 58 | def taggable_on(preserve_tag_order, *tag_types) 59 | super(preserve_tag_order, *tag_types) 60 | initialize_acts_as_taggable_on_core 61 | end 62 | 63 | # all column names are necessary for PostgreSQL group clause 64 | def grouped_column_names_for(object) 65 | object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ") 66 | end 67 | 68 | ## 69 | # Return a scope of objects that are tagged with the specified tags. 70 | # 71 | # @param tags The tags that we want to query for 72 | # @param [Hash] options A hash of options to alter you query: 73 | # * :exclude - if set to true, return objects that are *NOT* tagged with the specified tags 74 | # * :any - if set to true, return objects that are tagged with *ANY* of the specified tags 75 | # * :match_all - if set to true, return objects that are *ONLY* tagged with the specified tags 76 | # * :owned_by - return objects that are *ONLY* owned by the owner 77 | # 78 | # Example: 79 | # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool 80 | # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool 81 | # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool 82 | # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool 83 | # User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo' 84 | def tagged_with(tags, options = {}) 85 | tag_list = ActsAsTaggableOn::TagList.from(tags) 86 | empty_result = where("1 = 0") 87 | 88 | return empty_result if tag_list.empty? 89 | 90 | joins = [] 91 | conditions = [] 92 | having = [] 93 | select_clause = [] 94 | 95 | context = options.delete(:on) 96 | owned_by = options.delete(:owned_by) 97 | alias_base_name = undecorated_table_name.gsub('.','_') 98 | quote = ActsAsTaggableOn::Tag.using_postgresql? ? '"' : '' 99 | 100 | if options.delete(:exclude) 101 | if options.delete(:wild) 102 | tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ? ESCAPE '!'", "%#{escape_like(t)}%"]) }.join(" OR ") 103 | else 104 | tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ") 105 | end 106 | 107 | conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})" 108 | 109 | if owned_by 110 | joins << "JOIN #{ActsAsTaggableOn::Tagging.table_name}" + 111 | " ON #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + 112 | " AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}" + 113 | " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = #{owned_by.id}" + 114 | " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = #{quote_value(owned_by.class.base_class.to_s)}" 115 | end 116 | 117 | elsif options.delete(:any) 118 | # get tags, drop out if nothing returned (we need at least one) 119 | tags = if options.delete(:wild) 120 | ActsAsTaggableOn::Tag.named_like_any(tag_list) 121 | else 122 | ActsAsTaggableOn::Tag.named_any(tag_list) 123 | end 124 | 125 | return empty_result unless tags.length > 0 126 | 127 | # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123 128 | # avoid ambiguous column name 129 | taggings_context = context ? "_#{context}" : '' 130 | 131 | taggings_alias = adjust_taggings_alias( 132 | "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}" 133 | ) 134 | 135 | tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" + 136 | " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + 137 | " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" 138 | tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context 139 | 140 | # don't need to sanitize sql, map all ids and join with OR logic 141 | conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{t.id}" }.join(" OR ") 142 | select_clause = "DISTINCT #{table_name}.*" unless context and tag_types.one? 143 | 144 | if owned_by 145 | tagging_join << " AND " + 146 | sanitize_sql([ 147 | "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?", 148 | owned_by.id, 149 | owned_by.class.base_class.to_s 150 | ]) 151 | end 152 | 153 | joins << tagging_join 154 | else 155 | tags = ActsAsTaggableOn::Tag.named_any(tag_list) 156 | 157 | return empty_result unless tags.length == tag_list.length 158 | 159 | tags.each do |tag| 160 | taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}") 161 | tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" + 162 | " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + 163 | " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" + 164 | " AND #{taggings_alias}.tag_id = #{tag.id}" 165 | 166 | tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context 167 | 168 | if owned_by 169 | tagging_join << " AND " + 170 | sanitize_sql([ 171 | "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?", 172 | owned_by.id, 173 | owned_by.class.base_class.to_s 174 | ]) 175 | end 176 | 177 | joins << tagging_join 178 | end 179 | end 180 | 181 | taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group" 182 | 183 | if options.delete(:match_all) 184 | joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" + 185 | " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + 186 | " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" 187 | 188 | 189 | group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}" 190 | group = group_columns 191 | having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}" 192 | end 193 | 194 | select(select_clause) \ 195 | .joins(joins.join(" ")) \ 196 | .where(conditions.join(" AND ")) \ 197 | .group(group) \ 198 | .having(having) \ 199 | .order(options[:order]) \ 200 | .readonly(false) 201 | end 202 | 203 | def is_taggable? 204 | true 205 | end 206 | 207 | def adjust_taggings_alias(taggings_alias) 208 | if taggings_alias.size > 75 209 | taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias) 210 | end 211 | taggings_alias 212 | end 213 | 214 | def taggable_mixin 215 | @taggable_mixin ||= Module.new 216 | end 217 | end 218 | 219 | module InstanceMethods 220 | # all column names are necessary for PostgreSQL group clause 221 | def grouped_column_names_for(object) 222 | self.class.grouped_column_names_for(object) 223 | end 224 | 225 | def custom_contexts 226 | @custom_contexts ||= [] 227 | end 228 | 229 | def is_taggable? 230 | self.class.is_taggable? 231 | end 232 | 233 | def add_custom_context(value) 234 | custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s) 235 | end 236 | 237 | def cached_tag_list_on(context) 238 | self["cached_#{context.to_s.singularize}_list"] 239 | end 240 | 241 | def tag_list_cache_set_on(context) 242 | variable_name = "@#{context.to_s.singularize}_list" 243 | instance_variable_defined?(variable_name) && !instance_variable_get(variable_name).nil? 244 | end 245 | 246 | def tag_list_cache_on(context) 247 | variable_name = "@#{context.to_s.singularize}_list" 248 | if instance_variable_get(variable_name) 249 | instance_variable_get(variable_name) 250 | elsif cached_tag_list_on(context) && self.class.caching_tag_list_on?(context) 251 | instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(cached_tag_list_on(context))) 252 | else 253 | instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name))) 254 | end 255 | end 256 | 257 | def tag_list_on(context) 258 | add_custom_context(context) 259 | tag_list_cache_on(context) 260 | end 261 | 262 | def all_tags_list_on(context) 263 | variable_name = "@all_#{context.to_s.singularize}_list" 264 | return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name) 265 | 266 | instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(all_tags_on(context).map(&:name)).freeze) 267 | end 268 | 269 | ## 270 | # Returns all tags of a given context 271 | def all_tags_on(context) 272 | tag_table_name = ActsAsTaggableOn::Tag.table_name 273 | tagging_table_name = ActsAsTaggableOn::Tagging.table_name 274 | 275 | opts = ["#{tagging_table_name}.context = ?", context.to_s] 276 | scope = base_tags.where(opts) 277 | 278 | if ActsAsTaggableOn::Tag.using_postgresql? 279 | group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag) 280 | scope.order("max(#{tagging_table_name}.created_at)").group(group_columns) 281 | else 282 | scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}") 283 | end.to_a 284 | end 285 | 286 | ## 287 | # Returns all tags that are not owned of a given context 288 | def tags_on(context) 289 | scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s]) 290 | # when preserving tag order, return tags in created order 291 | # if we added the order to the association this would always apply 292 | scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order? 293 | scope 294 | end 295 | 296 | def set_tag_list_on(context, new_list) 297 | add_custom_context(context) 298 | 299 | variable_name = "@#{context.to_s.singularize}_list" 300 | process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s) 301 | 302 | instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list)) 303 | end 304 | 305 | def tagging_contexts 306 | custom_contexts + self.class.tag_types.map(&:to_s) 307 | end 308 | 309 | def process_dirty_object(context,new_list) 310 | value = new_list.is_a?(Array) ? new_list.join(', ') : new_list 311 | attrib = "#{context.to_s.singularize}_list" 312 | 313 | if changed_attributes.include?(attrib) 314 | # The attribute already has an unsaved change. 315 | old = changed_attributes[attrib] 316 | changed_attributes.delete(attrib) if (old.to_s == value.to_s) 317 | else 318 | old = tag_list_on(context).to_s 319 | changed_attributes[attrib] = old if (old.to_s != value.to_s) 320 | end 321 | end 322 | 323 | def reload(*args) 324 | self.class.tag_types.each do |context| 325 | instance_variable_set("@#{context.to_s.singularize}_list", nil) 326 | instance_variable_set("@all_#{context.to_s.singularize}_list", nil) 327 | end 328 | 329 | super(*args) 330 | end 331 | 332 | def save_tags 333 | tagging_contexts.each do |context| 334 | next unless tag_list_cache_set_on(context) 335 | # List of currently assigned tag names 336 | tag_list = tag_list_cache_on(context).uniq 337 | 338 | # Find existing tags or create non-existing tags: 339 | tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list) 340 | 341 | # Tag objects for currently assigned tags 342 | current_tags = tags_on(context) 343 | 344 | # Tag maintenance based on whether preserving the created order of tags 345 | if self.class.preserve_tag_order? 346 | old_tags, new_tags = current_tags - tags, tags - current_tags 347 | 348 | shared_tags = current_tags & tags 349 | 350 | if shared_tags.any? && tags[0...shared_tags.size] != shared_tags 351 | index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] } 352 | 353 | # Update arrays of tag objects 354 | old_tags |= current_tags[index...current_tags.size] 355 | new_tags |= current_tags[index...current_tags.size] & shared_tags 356 | 357 | # Order the array of tag objects to match the tag list 358 | new_tags = tags.map do |t| 359 | new_tags.find { |n| n.name.downcase == t.name.downcase } 360 | end.compact 361 | end 362 | else 363 | # Delete discarded tags and create new tags 364 | old_tags = current_tags - tags 365 | new_tags = tags - current_tags 366 | end 367 | 368 | # Find taggings to remove: 369 | if old_tags.present? 370 | old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil, :context => context.to_s, :tag_id => old_tags) 371 | end 372 | 373 | # Destroy old taggings: 374 | if old_taggings.present? 375 | ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id) 376 | end 377 | 378 | # Create new taggings: 379 | new_tags.each do |tag| 380 | taggings.create!(:tag_id => tag.id, :context => context.to_s, :taggable => self) 381 | end 382 | end 383 | 384 | true 385 | end 386 | end 387 | end 388 | end 389 | -------------------------------------------------------------------------------- /spec/acts_as_taggable_on/taggable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Taggable To Preserve Order" do 4 | before(:each) do 5 | clean_database! 6 | @taggable = OrderedTaggableModel.new(:name => "Bob Jones") 7 | end 8 | 9 | it "should have tag types" do 10 | [:tags, :colours].each do |type| 11 | OrderedTaggableModel.tag_types.should include type 12 | end 13 | 14 | @taggable.tag_types.should == OrderedTaggableModel.tag_types 15 | end 16 | 17 | it "should have tag associations" do 18 | [:tags, :colours].each do |type| 19 | @taggable.respond_to?(type).should be_true 20 | @taggable.respond_to?("#{type.to_s.singularize}_taggings").should be_true 21 | end 22 | end 23 | 24 | # it "should have tag associations ordered by id" do 25 | # [:tags, :colours].each do |type| 26 | # OrderedTaggableModel.reflect_on_association(type).options[:order].should include('id') 27 | # OrderedTaggableModel.reflect_on_association("#{type.to_s.singularize}_taggings".to_sym).options[:order].should include('id') 28 | # end 29 | # end 30 | 31 | it "should have tag methods" do 32 | [:tags, :colours].each do |type| 33 | @taggable.respond_to?("#{type.to_s.singularize}_list").should be_true 34 | @taggable.respond_to?("#{type.to_s.singularize}_list=").should be_true 35 | @taggable.respond_to?("all_#{type.to_s}_list").should be_true 36 | end 37 | end 38 | 39 | it "should return tag list in the order the tags were created" do 40 | # create 41 | @taggable.tag_list = "rails, ruby, css" 42 | @taggable.instance_variable_get("@tag_list").instance_of?(ActsAsTaggableOn::TagList).should be_true 43 | 44 | lambda { 45 | @taggable.save 46 | }.should change(ActsAsTaggableOn::Tag, :count).by(3) 47 | 48 | @taggable.reload 49 | @taggable.tag_list.should == %w(rails ruby css) 50 | 51 | # update 52 | @taggable.tag_list = "pow, ruby, rails" 53 | @taggable.save 54 | 55 | @taggable.reload 56 | @taggable.tag_list.should == %w(pow ruby rails) 57 | 58 | # update with no change 59 | @taggable.tag_list = "pow, ruby, rails" 60 | @taggable.save 61 | 62 | @taggable.reload 63 | @taggable.tag_list.should == %w(pow ruby rails) 64 | 65 | # update to clear tags 66 | @taggable.tag_list = "" 67 | @taggable.save 68 | 69 | @taggable.reload 70 | @taggable.tag_list.should == [] 71 | end 72 | 73 | it "should return tag objects in the order the tags were created" do 74 | # create 75 | @taggable.tag_list = "pow, ruby, rails" 76 | @taggable.instance_variable_get("@tag_list").instance_of?(ActsAsTaggableOn::TagList).should be_true 77 | 78 | lambda { 79 | @taggable.save 80 | }.should change(ActsAsTaggableOn::Tag, :count).by(3) 81 | 82 | @taggable.reload 83 | @taggable.tags.map{|t| t.name}.should == %w(pow ruby rails) 84 | 85 | # update 86 | @taggable.tag_list = "rails, ruby, css, pow" 87 | @taggable.save 88 | 89 | @taggable.reload 90 | @taggable.tags.map{|t| t.name}.should == %w(rails ruby css pow) 91 | end 92 | 93 | it "should return tag objects in tagging id order" do 94 | # create 95 | @taggable.tag_list = "pow, ruby, rails" 96 | @taggable.save 97 | 98 | @taggable.reload 99 | ids = @taggable.tags.map{|t| t.taggings.first.id} 100 | ids.should == ids.sort 101 | 102 | # update 103 | @taggable.tag_list = "rails, ruby, css, pow" 104 | @taggable.save 105 | 106 | @taggable.reload 107 | ids = @taggable.tags.map{|t| t.taggings.first.id} 108 | ids.should == ids.sort 109 | end 110 | end 111 | 112 | describe "Taggable" do 113 | before(:each) do 114 | clean_database! 115 | @taggable = TaggableModel.new(:name => "Bob Jones") 116 | @taggables = [@taggable, TaggableModel.new(:name => "John Doe")] 117 | end 118 | 119 | it "should have tag types" do 120 | [:tags, :languages, :skills, :needs, :offerings].each do |type| 121 | TaggableModel.tag_types.should include type 122 | end 123 | 124 | @taggable.tag_types.should == TaggableModel.tag_types 125 | end 126 | 127 | it "should have tag_counts_on" do 128 | TaggableModel.tag_counts_on(:tags).should be_empty 129 | 130 | @taggable.tag_list = ["awesome", "epic"] 131 | @taggable.save 132 | 133 | TaggableModel.tag_counts_on(:tags).length.should == 2 134 | @taggable.tag_counts_on(:tags).length.should == 2 135 | end 136 | 137 | it "should have tags_on" do 138 | TaggableModel.tags_on(:tags).should be_empty 139 | 140 | @taggable.tag_list = ["awesome", "epic"] 141 | @taggable.save 142 | 143 | TaggableModel.tags_on(:tags).length.should == 2 144 | @taggable.tags_on(:tags).length.should == 2 145 | end 146 | 147 | it "should return [] right after create" do 148 | blank_taggable = TaggableModel.new(:name => "Bob Jones") 149 | blank_taggable.tag_list.should == [] 150 | end 151 | 152 | it "should be able to create tags" do 153 | @taggable.skill_list = "ruby, rails, css" 154 | @taggable.instance_variable_get("@skill_list").instance_of?(ActsAsTaggableOn::TagList).should be_true 155 | 156 | lambda { 157 | @taggable.save 158 | }.should change(ActsAsTaggableOn::Tag, :count).by(3) 159 | 160 | @taggable.reload 161 | @taggable.skill_list.sort.should == %w(ruby rails css).sort 162 | end 163 | 164 | it "should be able to create tags through the tag list directly" do 165 | @taggable.tag_list_on(:test).add("hello") 166 | @taggable.tag_list_cache_on(:test).should_not be_empty 167 | @taggable.tag_list_on(:test).should == ["hello"] 168 | 169 | @taggable.save 170 | @taggable.save_tags 171 | 172 | @taggable.reload 173 | @taggable.tag_list_on(:test).should == ["hello"] 174 | end 175 | 176 | it "should differentiate between contexts" do 177 | @taggable.skill_list = "ruby, rails, css" 178 | @taggable.tag_list = "ruby, bob, charlie" 179 | @taggable.save 180 | @taggable.reload 181 | @taggable.skill_list.should include("ruby") 182 | @taggable.skill_list.should_not include("bob") 183 | end 184 | 185 | it "should be able to remove tags through list alone" do 186 | @taggable.skill_list = "ruby, rails, css" 187 | @taggable.save 188 | @taggable.reload 189 | @taggable.should have(3).skills 190 | @taggable.skill_list = "ruby, rails" 191 | @taggable.save 192 | @taggable.reload 193 | @taggable.should have(2).skills 194 | end 195 | 196 | it "should be able to select taggables by subset of tags using ActiveRelation methods" do 197 | @taggables[0].tag_list = "bob" 198 | @taggables[1].tag_list = "charlie" 199 | @taggables[0].skill_list = "ruby" 200 | @taggables[1].skill_list = "css" 201 | @taggables.each{|taggable| taggable.save} 202 | 203 | @found_taggables_by_tag = TaggableModel.joins(:tags).where(:tags => {:name => ["bob"]}) 204 | @found_taggables_by_skill = TaggableModel.joins(:skills).where(:tags => {:name => ["ruby"]}) 205 | 206 | @found_taggables_by_tag.should include @taggables[0] 207 | @found_taggables_by_tag.should_not include @taggables[1] 208 | @found_taggables_by_skill.should include @taggables[0] 209 | @found_taggables_by_skill.should_not include @taggables[1] 210 | end 211 | 212 | it "should be able to find by tag" do 213 | @taggable.skill_list = "ruby, rails, css" 214 | @taggable.save 215 | 216 | TaggableModel.tagged_with("ruby").first.should == @taggable 217 | end 218 | 219 | it "should be able to find by tag with context" do 220 | @taggable.skill_list = "ruby, rails, css" 221 | @taggable.tag_list = "bob, charlie" 222 | @taggable.save 223 | 224 | TaggableModel.tagged_with("ruby").first.should == @taggable 225 | TaggableModel.tagged_with("ruby, css").first.should == @taggable 226 | TaggableModel.tagged_with("bob", :on => :skills).first.should_not == @taggable 227 | TaggableModel.tagged_with("bob", :on => :tags).first.should == @taggable 228 | end 229 | 230 | it "should not care about case" do 231 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby") 232 | frank = TaggableModel.create(:name => "Frank", :tag_list => "Ruby") 233 | 234 | ActsAsTaggableOn::Tag.all.size.should == 1 235 | TaggableModel.tagged_with("ruby").to_a.should == TaggableModel.tagged_with("Ruby").to_a 236 | end 237 | 238 | it "should be able to get tag counts on model as a whole" do 239 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 240 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 241 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 242 | TaggableModel.tag_counts.should_not be_empty 243 | TaggableModel.skill_counts.should_not be_empty 244 | end 245 | 246 | it "should be able to get all tag counts on model as whole" do 247 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 248 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 249 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 250 | 251 | TaggableModel.all_tag_counts.should_not be_empty 252 | TaggableModel.all_tag_counts(:order => 'tags.id').first.count.should == 3 # ruby 253 | end 254 | 255 | it "should be able to get all tags on model as whole" do 256 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 257 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 258 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 259 | 260 | TaggableModel.all_tags.should_not be_empty 261 | TaggableModel.all_tags(:order => 'tags.id').first.name.should == "ruby" 262 | end 263 | 264 | it "should be able to use named scopes to chain tag finds by any tags by context" do 265 | bob = TaggableModel.create(:name => "Bob", :need_list => "rails", :offering_list => "c++") 266 | frank = TaggableModel.create(:name => "Frank", :need_list => "css", :offering_list => "css") 267 | steve = TaggableModel.create(:name => 'Steve', :need_list => "c++", :offering_list => "java") 268 | 269 | # Let's only find those who need rails or css and are offering c++ or java 270 | TaggableModel.tagged_with(['rails, css'], :on => :needs, :any => true).tagged_with(['c++', 'java'], :on => :offerings, :any => true).to_a.should == [bob] 271 | end 272 | 273 | it "should not return read-only records" do 274 | TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 275 | TaggableModel.tagged_with("ruby").first.should_not be_readonly 276 | end 277 | 278 | it "should be able to get scoped tag counts" do 279 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 280 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 281 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 282 | 283 | TaggableModel.tagged_with("ruby").tag_counts(:order => 'tags.id').first.count.should == 2 # ruby 284 | TaggableModel.tagged_with("ruby").skill_counts.first.count.should == 1 # ruby 285 | end 286 | 287 | it "should be able to get all scoped tag counts" do 288 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 289 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 290 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 291 | 292 | TaggableModel.tagged_with("ruby").all_tag_counts(:order => 'tags.id').first.count.should == 3 # ruby 293 | end 294 | 295 | it "should be able to get all scoped tags" do 296 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 297 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 298 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby") 299 | 300 | TaggableModel.tagged_with("ruby").all_tags(:order => 'tags.id').first.name.should == "ruby" 301 | end 302 | 303 | it 'should only return tag counts for the available scope' do 304 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 305 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 306 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby, java") 307 | 308 | TaggableModel.tagged_with('rails').all_tag_counts.should have(3).items 309 | TaggableModel.tagged_with('rails').all_tag_counts.any? { |tag| tag.name == 'java' }.should be_false 310 | 311 | # Test specific join syntaxes: 312 | frank.untaggable_models.create! 313 | TaggableModel.tagged_with('rails').joins(:untaggable_models).all_tag_counts.should have(2).items 314 | TaggableModel.tagged_with('rails').joins(:untaggable_models => :taggable_model).all_tag_counts.should have(2).items 315 | TaggableModel.tagged_with('rails').joins([:untaggable_models]).all_tag_counts.should have(2).items 316 | end 317 | 318 | it 'should only return tags for the available scope' do 319 | bob = TaggableModel.create(:name => "Bob", :tag_list => "ruby, rails, css") 320 | frank = TaggableModel.create(:name => "Frank", :tag_list => "ruby, rails") 321 | charlie = TaggableModel.create(:name => "Charlie", :skill_list => "ruby, java") 322 | 323 | TaggableModel.tagged_with('rails').all_tags.should have(3).items 324 | TaggableModel.tagged_with('rails').all_tags.any? { |tag| tag.name == 'java' }.should be_false 325 | 326 | # Test specific join syntaxes: 327 | frank.untaggable_models.create! 328 | TaggableModel.tagged_with('rails').joins(:untaggable_models).all_tags.should have(2).items 329 | TaggableModel.tagged_with('rails').joins({ :untaggable_models => :taggable_model }).all_tags.should have(2).items 330 | TaggableModel.tagged_with('rails').joins([:untaggable_models]).all_tags.should have(2).items 331 | end 332 | 333 | it "should be able to set a custom tag context list" do 334 | bob = TaggableModel.create(:name => "Bob") 335 | bob.set_tag_list_on(:rotors, "spinning, jumping") 336 | bob.tag_list_on(:rotors).should == ["spinning","jumping"] 337 | bob.save 338 | bob.reload 339 | bob.tags_on(:rotors).should_not be_empty 340 | end 341 | 342 | it "should be able to find tagged" do 343 | bob = TaggableModel.create(:name => "Bob", :tag_list => "fitter, happier, more productive", :skill_list => "ruby, rails, css") 344 | frank = TaggableModel.create(:name => "Frank", :tag_list => "weaker, depressed, inefficient", :skill_list => "ruby, rails, css") 345 | steve = TaggableModel.create(:name => 'Steve', :tag_list => 'fitter, happier, more productive', :skill_list => 'c++, java, ruby') 346 | 347 | TaggableModel.tagged_with("ruby", :order => 'taggable_models.name').to_a.should == [bob, frank, steve] 348 | TaggableModel.tagged_with("ruby, rails", :order => 'taggable_models.name').to_a.should == [bob, frank] 349 | TaggableModel.tagged_with(["ruby", "rails"], :order => 'taggable_models.name').to_a.should == [bob, frank] 350 | end 351 | 352 | it "should be able to find tagged with quotation marks" do 353 | bob = TaggableModel.create(:name => "Bob", :tag_list => "fitter, happier, more productive, 'I love the ,comma,'") 354 | TaggableModel.tagged_with("'I love the ,comma,'").should include(bob) 355 | end 356 | 357 | it "should be able to find tagged with invalid tags" do 358 | bob = TaggableModel.create(:name => "Bob", :tag_list => "fitter, happier, more productive") 359 | TaggableModel.tagged_with("sad, happier").should_not include(bob) 360 | end 361 | 362 | it "should be able to find tagged with any tag" do 363 | bob = TaggableModel.create(:name => "Bob", :tag_list => "fitter, happier, more productive", :skill_list => "ruby, rails, css") 364 | frank = TaggableModel.create(:name => "Frank", :tag_list => "weaker, depressed, inefficient", :skill_list => "ruby, rails, css") 365 | steve = TaggableModel.create(:name => 'Steve', :tag_list => 'fitter, happier, more productive', :skill_list => 'c++, java, ruby') 366 | 367 | TaggableModel.tagged_with(["ruby", "java"], :order => 'taggable_models.name', :any => true).to_a.should == [bob, frank, steve] 368 | TaggableModel.tagged_with(["c++", "fitter"], :order => 'taggable_models.name', :any => true).to_a.should == [bob, steve] 369 | TaggableModel.tagged_with(["depressed", "css"], :order => 'taggable_models.name', :any => true).to_a.should == [bob, frank] 370 | end 371 | 372 | context "wild: true" do 373 | it "should use params as wildcards" do 374 | bob = TaggableModel.create(:name => "Bob", :tag_list => "bob, tricia") 375 | frank = TaggableModel.create(:name => "Frank", :tag_list => "bobby, jim") 376 | steve = TaggableModel.create(:name => "Steve", :tag_list => "john, patricia") 377 | jim = TaggableModel.create(:name => "Jim", :tag_list => "jim, steve") 378 | 379 | 380 | TaggableModel.tagged_with(["bob", "tricia"], :wild => true, :any => true).to_a.sort_by{|o| o.id}.should == [bob, frank, steve] 381 | TaggableModel.tagged_with(["bob", "tricia"], :wild => true, :exclude => true).to_a.should == [jim] 382 | end 383 | end 384 | 385 | it "should be able to find tagged on a custom tag context" do 386 | bob = TaggableModel.create(:name => "Bob") 387 | bob.set_tag_list_on(:rotors, "spinning, jumping") 388 | bob.tag_list_on(:rotors).should == ["spinning","jumping"] 389 | bob.save 390 | 391 | TaggableModel.tagged_with("spinning", :on => :rotors).to_a.should == [bob] 392 | end 393 | 394 | it "should be able to use named scopes to chain tag finds" do 395 | bob = TaggableModel.create(:name => "Bob", :tag_list => "fitter, happier, more productive", :skill_list => "ruby, rails, css") 396 | frank = TaggableModel.create(:name => "Frank", :tag_list => "weaker, depressed, inefficient", :skill_list => "ruby, rails, css") 397 | steve = TaggableModel.create(:name => 'Steve', :tag_list => 'fitter, happier, more productive', :skill_list => 'c++, java, python') 398 | 399 | # Let's only find those productive Rails developers 400 | TaggableModel.tagged_with('rails', :on => :skills, :order => 'taggable_models.name').to_a.should == [bob, frank] 401 | TaggableModel.tagged_with('happier', :on => :tags, :order => 'taggable_models.name').to_a.should == [bob, steve] 402 | TaggableModel.tagged_with('rails', :on => :skills).tagged_with('happier', :on => :tags).to_a.should == [bob] 403 | TaggableModel.tagged_with('rails').tagged_with('happier', :on => :tags).to_a.should == [bob] 404 | end 405 | 406 | it "should be able to find tagged with only the matching tags" do 407 | bob = TaggableModel.create(:name => "Bob", :tag_list => "lazy, happier") 408 | frank = TaggableModel.create(:name => "Frank", :tag_list => "fitter, happier, inefficient") 409 | steve = TaggableModel.create(:name => 'Steve', :tag_list => "fitter, happier") 410 | 411 | TaggableModel.tagged_with("fitter, happier", :match_all => true).to_a.should == [steve] 412 | end 413 | 414 | it "should be able to find tagged with some excluded tags" do 415 | bob = TaggableModel.create(:name => "Bob", :tag_list => "happier, lazy") 416 | frank = TaggableModel.create(:name => "Frank", :tag_list => "happier") 417 | steve = TaggableModel.create(:name => 'Steve', :tag_list => "happier") 418 | 419 | TaggableModel.tagged_with("lazy", :exclude => true).to_a.should == [frank, steve] 420 | end 421 | 422 | it "should return an empty scope for empty tags" do 423 | TaggableModel.tagged_with('').should == [] 424 | TaggableModel.tagged_with(' ').should == [] 425 | TaggableModel.tagged_with(nil).should == [] 426 | TaggableModel.tagged_with([]).should == [] 427 | end 428 | 429 | it "should not create duplicate taggings" do 430 | bob = TaggableModel.create(:name => "Bob") 431 | lambda { 432 | bob.tag_list << "happier" 433 | bob.tag_list << "happier" 434 | bob.save 435 | }.should change(ActsAsTaggableOn::Tagging, :count).by(1) 436 | end 437 | 438 | describe "Associations" do 439 | before(:each) do 440 | @taggable = TaggableModel.create(:tag_list => "awesome, epic") 441 | end 442 | 443 | it "should not remove tags when creating associated objects" do 444 | @taggable.untaggable_models.create! 445 | @taggable.reload 446 | @taggable.tag_list.should have(2).items 447 | end 448 | end 449 | 450 | describe "grouped_column_names_for method" do 451 | it "should return all column names joined for Tag GROUP clause" do 452 | @taggable.grouped_column_names_for(ActsAsTaggableOn::Tag).should == "tags.id, tags.name" 453 | end 454 | 455 | it "should return all column names joined for TaggableModel GROUP clause" do 456 | @taggable.grouped_column_names_for(TaggableModel).should == "taggable_models.id, taggable_models.name, taggable_models.type" 457 | end 458 | 459 | it "should return all column names joined for NonStandardIdTaggableModel GROUP clause" do 460 | @taggable.grouped_column_names_for(TaggableModel).should == "taggable_models.#{TaggableModel.primary_key}, taggable_models.name, taggable_models.type" 461 | end 462 | end 463 | 464 | describe "NonStandardIdTaggable" do 465 | before(:each) do 466 | clean_database! 467 | @taggable = NonStandardIdTaggableModel.new(:name => "Bob Jones") 468 | @taggables = [@taggable, NonStandardIdTaggableModel.new(:name => "John Doe")] 469 | end 470 | 471 | it "should have tag types" do 472 | [:tags, :languages, :skills, :needs, :offerings].each do |type| 473 | NonStandardIdTaggableModel.tag_types.should include type 474 | end 475 | 476 | @taggable.tag_types.should == NonStandardIdTaggableModel.tag_types 477 | end 478 | 479 | it "should have tag_counts_on" do 480 | NonStandardIdTaggableModel.tag_counts_on(:tags).should be_empty 481 | 482 | @taggable.tag_list = ["awesome", "epic"] 483 | @taggable.save 484 | 485 | NonStandardIdTaggableModel.tag_counts_on(:tags).length.should == 2 486 | @taggable.tag_counts_on(:tags).length.should == 2 487 | end 488 | 489 | it "should have tags_on" do 490 | NonStandardIdTaggableModel.tags_on(:tags).should be_empty 491 | 492 | @taggable.tag_list = ["awesome", "epic"] 493 | @taggable.save 494 | 495 | NonStandardIdTaggableModel.tags_on(:tags).length.should == 2 496 | @taggable.tags_on(:tags).length.should == 2 497 | end 498 | 499 | it "should be able to create tags" do 500 | @taggable.skill_list = "ruby, rails, css" 501 | @taggable.instance_variable_get("@skill_list").instance_of?(ActsAsTaggableOn::TagList).should be_true 502 | 503 | lambda { 504 | @taggable.save 505 | }.should change(ActsAsTaggableOn::Tag, :count).by(3) 506 | 507 | @taggable.reload 508 | @taggable.skill_list.sort.should == %w(ruby rails css).sort 509 | end 510 | 511 | it "should be able to create tags through the tag list directly" do 512 | @taggable.tag_list_on(:test).add("hello") 513 | @taggable.tag_list_cache_on(:test).should_not be_empty 514 | @taggable.tag_list_on(:test).should == ["hello"] 515 | 516 | @taggable.save 517 | @taggable.save_tags 518 | 519 | @taggable.reload 520 | @taggable.tag_list_on(:test).should == ["hello"] 521 | end 522 | end 523 | 524 | describe "Dirty Objects" do 525 | context "with un-contexted tags" do 526 | before(:each) do 527 | @taggable = TaggableModel.create(:tag_list => "awesome, epic") 528 | end 529 | 530 | context "when tag_list changed" do 531 | before(:each) do 532 | @taggable.changes.should == {} 533 | @taggable.tag_list = 'one' 534 | end 535 | 536 | it 'should show changes of dirty object' do 537 | @taggable.changes.should == {"tag_list"=>["awesome, epic", ["one"]]} 538 | end 539 | 540 | it 'flags tag_list as changed' do 541 | @taggable.tag_list_changed?.should be_true 542 | end 543 | 544 | it 'preserves original value' do 545 | @taggable.tag_list_was.should == "awesome, epic" 546 | end 547 | 548 | it 'shows what the change was' do 549 | @taggable.tag_list_change.should == ["awesome, epic", ["one"]] 550 | end 551 | end 552 | 553 | context 'when tag_list is the same' do 554 | before(:each) do 555 | @taggable.tag_list = "awesome, epic" 556 | end 557 | 558 | it 'is not flagged as changed' do 559 | @taggable.tag_list_changed?.should be_false 560 | end 561 | 562 | it 'does not show any changes to the taggable item' do 563 | @taggable.changes.should == {} 564 | end 565 | end 566 | end 567 | 568 | context "with context tags" do 569 | before(:each) do 570 | @taggable = TaggableModel.create(:language_list => "awesome, epic") 571 | end 572 | 573 | context "when language_list changed" do 574 | before(:each) do 575 | @taggable.changes.should == {} 576 | @taggable.language_list = 'one' 577 | end 578 | 579 | it 'should show changes of dirty object' do 580 | @taggable.changes.should == {"language_list"=>["awesome, epic", ["one"]]} 581 | end 582 | 583 | it 'flags language_list as changed' do 584 | @taggable.language_list_changed?.should be_true 585 | end 586 | 587 | it 'preserves original value' do 588 | @taggable.language_list_was.should == "awesome, epic" 589 | end 590 | 591 | it 'shows what the change was' do 592 | @taggable.language_list_change.should == ["awesome, epic", ["one"]] 593 | end 594 | 595 | it 'shows what the changes were' do 596 | @taggable.language_list_changes.should == ["awesome, epic", ["one"]] 597 | end 598 | end 599 | 600 | context 'when language_list is the same' do 601 | before(:each) do 602 | @taggable.language_list = "awesome, epic" 603 | end 604 | 605 | it 'is not flagged as changed' do 606 | @taggable.language_list_changed?.should be_false 607 | end 608 | 609 | it 'does not show any changes to the taggable item' do 610 | @taggable.changes.should == {} 611 | end 612 | end 613 | end 614 | end 615 | 616 | describe "Autogenerated methods" do 617 | it "should be overridable" do 618 | TaggableModel.create(:tag_list=>'woo').tag_list_submethod_called.should be_true 619 | end 620 | end 621 | end 622 | 623 | 624 | --------------------------------------------------------------------------------