scoped-tags 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Josh Kalderimis
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.
@@ -0,0 +1,4 @@
1
+ ---
2
+ :patch: 0
3
+ :major: 0
4
+ :minor: 3
@@ -0,0 +1,7 @@
1
+ class ScopedTagsGenerator < Rails::Generator::Base
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template 'migration.rb', 'db/migrate', :migration_file_name => "scoped_tags_migration"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ class ScopedTagsMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :tags do |t|
4
+ t.string :name
5
+ t.string :context
6
+ end
7
+
8
+ create_table :taggings do |t|
9
+ t.references :tag
10
+ t.references :taggable, :polymorphic => true
11
+ end
12
+
13
+ add_index "tags", ['context', 'name']
14
+ add_index "taggings", ['taggable_id', 'taggable_type']
15
+ end
16
+
17
+ def self.down
18
+ drop_table :taggings
19
+ drop_table :tags
20
+ end
21
+ end
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,13 @@
1
+ require 'scoped_tags/active_record_additions'
2
+ require 'scoped_tags/tag'
3
+ require 'scoped_tags/tagging'
4
+ require 'scoped_tags/tag_list_proxy'
5
+ require 'scoped_tags/tag_list_collection'
6
+
7
+ module ActiveRecord
8
+ Base.class_eval do
9
+ include ScopedTags::ActiveRecordAdditions
10
+ end
11
+ end
12
+
13
+ TagListCollection.delimiter = ','
@@ -0,0 +1,64 @@
1
+ module ScopedTags
2
+
3
+ module ActiveRecordAdditions
4
+
5
+ def self.included(base)
6
+ base.class_eval do
7
+ def self.scoped_tags(contexts, options = nil)
8
+ self.class.instance_eval{ attr_accessor :tag_contexts }
9
+
10
+ raise ScopedTagsError, 'context is required for scoped-tags setup' if contexts.blank?
11
+
12
+ self.tag_contexts = [contexts].flatten
13
+
14
+ has_many :taggings, :as => :taggable, :class_name => 'Tagging', :dependent => :delete_all
15
+ has_many :tags, :through => :taggings, :class_name => 'Tag', :readonly => true
16
+
17
+ self.tag_contexts.each do |context|
18
+ has_many context, :through => :taggings, :class_name => 'Tag',
19
+ :source => :tag,
20
+ :conditions => ['context = ?', context.to_s.downcase]
21
+
22
+ c = context.to_s.singularize
23
+ define_method("#{c}_list") { get_tag_list(context) }
24
+ define_method("#{c}_list=") { |new_list| set_tag_list(context, new_list) }
25
+ self.class.instance_eval do
26
+ define_method("tagged_with_#{context}") { |*args| find_tagged_with(args.first, context.to_s, args.extract_options!) }
27
+ end
28
+ end
29
+
30
+ self.send :extend, ClassMethods
31
+ self.send :include, InstanceMethods
32
+ end
33
+ end
34
+ end
35
+
36
+
37
+ module ClassMethods
38
+ def find_tagged_with(tag_names, context, options = {})
39
+ tag_names = tag_names.is_a?(Array) ? tag_names : tag_names.split(TagListCollection.delimiter)
40
+ tag_names = tag_names.collect(&:strip).reject(&:blank?)
41
+
42
+ required_options = { :include => [:taggings, :tags],
43
+ :conditions => ['tags.name IN (?) AND tags.context = ?', tag_names, context] }
44
+
45
+ self.all(options.merge(required_options))
46
+ end
47
+ end
48
+
49
+
50
+ module InstanceMethods
51
+ protected
52
+ def get_tag_list(context)
53
+ @tag_list_collections = { } if not @tag_list_collections
54
+ @tag_list_collections[context] ||= TagListCollection.new(self, context.to_s.downcase)
55
+ end
56
+
57
+ def set_tag_list(context, new_tags)
58
+ get_tag_list(context).replace(new_tags)
59
+ end
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,32 @@
1
+ class Tag < ActiveRecord::Base
2
+ has_many :taggings, :dependent => :delete_all
3
+
4
+ validates_presence_of :context
5
+ validates_uniqueness_of :name, :case_sensitive => false, :scope => :context
6
+
7
+ attr_accessible :name, :context
8
+
9
+ before_validation :trim_spaces, :lowercase_name
10
+
11
+
12
+ def self.find_or_new_by_name_and_context(name, context)
13
+ tag = self.find(:first, :conditions => ["name = ? and context = ?", name, context])
14
+ tag || Tag.new(:name => name, :context => context)
15
+ end
16
+
17
+ def ==(object)
18
+ super || (object.is_a?(Tag) && self.name == object.name && self.context == object.context)
19
+ end
20
+
21
+
22
+ private
23
+ def trim_spaces
24
+ self.name.try(:strip!).try(:squeeze!, ' ')
25
+ self.context.try(:strip!).try(:squeeze!, ' ')
26
+ end
27
+
28
+ def lowercase_name
29
+ self.name.try(:downcase!)
30
+ self.context.try(:downcase!)
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ class TagListCollection < TagListProxy
2
+
3
+ self.class.instance_eval do
4
+ attr_accessor :delimiter
5
+ end
6
+
7
+
8
+ def <<(tag_names)
9
+ tag_names = clean_tag_list(tag_names)
10
+ current_list = self.to_a
11
+ context_tags = self.proxy_owner.send(self.proxy_context)
12
+ tag_names.each do |new_tag|
13
+ unless current_list.include?(new_tag)
14
+ context_tags << Tag.find_or_new_by_name_and_context(new_tag, self.proxy_context.to_s)
15
+ end
16
+ end
17
+ self
18
+ end
19
+
20
+ alias_method :push, :<<
21
+ alias_method :concat, :<<
22
+
23
+
24
+ def delete(tag_names)
25
+ context_tags = self.proxy_owner.send(self.proxy_context)
26
+ to_delete = []
27
+ tag_names = clean_tag_list(tag_names)
28
+ context_tags.each do |tag|
29
+ to_delete << tag if tag_names.include?(tag.name)
30
+ end
31
+ context_tags.delete(to_delete)
32
+ to_delete.collect(&:name)
33
+ end
34
+
35
+ def delete_at(index)
36
+ context_tags = self.proxy_owner.send(self.proxy_context)
37
+ return nil if 0 > index or index > context_tags.size
38
+ tag_at_index = context_tags[index]
39
+ context_tags.delete(tag_at_index)
40
+ tag_at_index
41
+ end
42
+
43
+ def pop
44
+ self.delete_at(self.size - 1)
45
+ end
46
+
47
+ def replace(tag_names)
48
+ new_uniq_list = clean_tag_list(tag_names).uniq
49
+ new_tag_list = new_uniq_list.inject([]) do |list, tag_name|
50
+ list << Tag.find_or_new_by_name_and_context(tag_name, self.proxy_context)
51
+ list
52
+ end
53
+ context_tags = self.proxy_owner.send(self.proxy_context)
54
+ context_tags.replace(new_tag_list)
55
+ self
56
+ end
57
+
58
+
59
+ def to_s
60
+ self.join("#{TagListCollection.delimiter} ")
61
+ end
62
+
63
+
64
+ private
65
+
66
+ def find_target
67
+ association = self.proxy_owner.send proxy_context
68
+ association.collect(&:name)
69
+ end
70
+
71
+ def clean_tag_list(tags)
72
+ tags = tags.is_a?(Array) ? tags : tags.split(TagListCollection.delimiter)
73
+ tags.collect(&:strip).reject(&:blank?)
74
+ end
75
+
76
+ end
@@ -0,0 +1,98 @@
1
+ class TagListProxy
2
+
3
+ alias_method :proxy_respond_to?, :respond_to?
4
+ alias_method :proxy_extend, :extend
5
+ delegate :to_param, :to => :proxy_target
6
+ instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?$|^send$|proxy_|^object_id$)/ }
7
+
8
+ def initialize(owner, context)
9
+ @owner, @context = owner, context
10
+ @target = nil
11
+ end
12
+
13
+ # Returns the owner of the proxy.
14
+ def proxy_owner
15
+ @owner
16
+ end
17
+
18
+ # Returns the \context of the proxy, same as +context+.
19
+ def proxy_context
20
+ @context
21
+ end
22
+
23
+ # Returns the \target of the proxy, same as +target+.
24
+ def proxy_target
25
+ @target
26
+ end
27
+
28
+ # Does the proxy or its \target respond to +symbol+?
29
+ def respond_to?(*args)
30
+ proxy_respond_to?(*args) || (load_target && @target.respond_to?(*args))
31
+ end
32
+
33
+ # Forwards <tt>===</tt> explicitly to the \target because the instance method
34
+ # removal above doesn't catch it. Loads the \target if needed.
35
+ def ===(other)
36
+ reload
37
+ other === @target
38
+ end
39
+
40
+ # Reloads the \target and returns +self+ on success.
41
+ def reload
42
+ @target = nil
43
+ load_target
44
+ self unless @target.nil?
45
+ end
46
+
47
+ # Returns the target of this proxy, same as +proxy_target+.
48
+ def target
49
+ @target
50
+ end
51
+
52
+ # Sets the target of this proxy to <tt>\target</tt>, and the \loaded flag to +true+.
53
+ def target=(target)
54
+ @target = target
55
+ end
56
+
57
+ # Forwards the call to the target. Loads the \target if needed.
58
+ def inspect
59
+ reload
60
+ @target.inspect
61
+ end
62
+
63
+ def send(method, *args)
64
+ if proxy_respond_to?(method)
65
+ super
66
+ else
67
+ reload
68
+ @target.send(method, *args)
69
+ end
70
+ end
71
+
72
+
73
+ private
74
+ # Forwards any missing method call to the \target.
75
+ def method_missing(method, *args)
76
+ if reload
77
+ unless @target.respond_to?(method)
78
+ message = "undefined method `#{method.to_s}' for \"#{@target}\":#{@target.class.to_s}"
79
+ raise NoMethodError, message
80
+ end
81
+
82
+ if block_given?
83
+ @target.send(method, *args) { |*block_args| yield(*block_args) }
84
+ else
85
+ @target.send(method, *args)
86
+ end
87
+ end
88
+ end
89
+
90
+ def load_target
91
+ @target = find_target
92
+ @target
93
+ rescue ActiveRecord::RecordNotFound
94
+ reset
95
+ end
96
+
97
+
98
+ end
@@ -0,0 +1,8 @@
1
+ class Tagging < ActiveRecord::Base
2
+ belongs_to :tag
3
+ belongs_to :taggable, :polymorphic => true
4
+
5
+ validates_uniqueness_of :taggable_id, :scope => :tag_id
6
+
7
+ attr_accessible :taggable_type, :taggable_id, :tag_id
8
+ end
@@ -0,0 +1,93 @@
1
+ Scoped-Tags
2
+ ===========
3
+
4
+ When it comes to tagging dsls there are three main players, acts-as-taggable-on, acts-as-taggable-on-steriods and is-taggable.
5
+ Each seem to have one thing is common and thats using an after save callback to sync the tags list (usually an array) with
6
+ the tags association.
7
+
8
+ The key difference between scoped-tags and the other players on the field is that scoped-tags syncs
9
+ the array version with the association version upon each request.
10
+
11
+ The initial array tag list implementation was half Array half beast. The current version has been updated to use a proxy
12
+ based implementation for the array list, similar to the ActiveRecord ProxyAssociation which is used for the different
13
+ relationship associations. This is the heart of the code which syncs the array style listing with the association listing.
14
+
15
+ Scoped-tags will, in its next release, allow for tags to be strictly or silently scoped, with the default allowing
16
+ tag creation if the tag does not exist.
17
+
18
+
19
+ Key Features
20
+ ------------
21
+
22
+ - multiple tag contexts per model
23
+ - works with sphinx and thinking-sphinx
24
+ - add and remove tags using array like features
25
+ - association and array list stay in sync
26
+ - tags are available for use in the validations as they are always kept in sync
27
+
28
+
29
+
30
+ How can I use it?
31
+ -----------------
32
+
33
+ class Person
34
+ scoped_tags :genres
35
+ end
36
+
37
+ me = Person.new(:name => 'Josh')
38
+
39
+ me.interests << Tag.new(:name => 'scuba', :context => 'interests')
40
+ # it would be nice to leave the context out, but sadly not just yet
41
+ # and me.interests.build(:name => 'scuba') does not work at the moment either
42
+ # (has_many through issue)
43
+
44
+ me.interest_list => ['scuba']
45
+
46
+ me.interest_list << 'cycling'
47
+ # comma seperated strings are excepted, as well as arrays of strings
48
+
49
+ me.interests => [#<Tag id: nil, name: "scuba", context: 'interests'>,#<Tag id: nil, name: "cycling", context: 'interests'>]
50
+
51
+
52
+
53
+ Things to watch out for
54
+ -----------------------
55
+
56
+ ### _updating a scoped tagged model via a controller update_
57
+
58
+ be aware that if no tags are selected then the browser
59
+ does not pass through a blank array. This means if the model previously had tags saved to it and you remove the tags, the browser
60
+ will not pass though the changes because the select box is empty meaning the tags will not be removed.
61
+
62
+ An example of
63
+ this is the FCBKcomplete javascript plugin, it uses a select box behind the scenes to store the tags and pass
64
+ them through to the controller, but if none are selected then the browser passes nothing to the controller,
65
+ not even a blank array. To fix this, add the following method to the controller and add it as a before\_filter to the update
66
+ action.
67
+
68
+ Add to the controller:
69
+
70
+ before_filter :check_blank_tag_ids, :only => :update
71
+
72
+ def check_blank_tag_ids
73
+ params[:person][:interest_ids] = [] if params[:person] && params[:person][:interest_ids].blank?
74
+ end
75
+
76
+
77
+ ### _use a transaction when updating the scoped model_
78
+
79
+ otherwise the tags will save even if the model validations fail.
80
+ This is due to the standard behavior of has\_many :through relationships.
81
+
82
+ Example
83
+
84
+ Person.transaction { @person.update_attributes!(params[:person]) }
85
+
86
+
87
+
88
+
89
+ Future enhancements
90
+ -------------------
91
+
92
+ - add strict and silent scoping on setup (scoped_tags :interests, :strict => true), thus any tag added which is not already in the tags table for that context will be rejected
93
+ - get the instance.context.build method to work correctly
@@ -0,0 +1 @@
1
+ # Logfile created on Sat Oct 17 19:04:03 +0200 2009 by logger.rb/22285
@@ -0,0 +1,20 @@
1
+ ActiveRecord::Schema.define :version => 0 do
2
+
3
+ create_table "scoped_tagged_models", :force => true do |t|
4
+ t.string :name
5
+ end
6
+
7
+ create_table "tags", :force => true do |t|
8
+ t.string :name
9
+ t.string :context
10
+ end
11
+
12
+ create_table "taggings", :force => true do |t|
13
+ t.references :tag
14
+ t.references :taggable, :polymorphic => true
15
+ end
16
+
17
+ add_index "tags", ['context', 'name']
18
+ add_index "taggings", ['taggable_id', 'taggable_type']
19
+
20
+ end
@@ -0,0 +1,145 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+
4
+ describe "ScopedTaggedModel" do
5
+ context "attaching scoped-tags to a model" do
6
+ before(:each) do
7
+ @scoped_model = ScopedTaggedModel.new
8
+ end
9
+
10
+ it "should include the taggings method" do
11
+ @scoped_model.should respond_to(:taggings)
12
+ end
13
+
14
+ it "should include a tags method" do
15
+ @scoped_model.should respond_to(:tags)
16
+ end
17
+
18
+ it "should include the genres method" do
19
+ @scoped_model.should respond_to(:genres)
20
+ end
21
+
22
+ it "should at tag_contexts to the class" do
23
+ ScopedTaggedModel.should respond_to(:tag_contexts)
24
+ end
25
+
26
+ it "should create a getter and setter context tag list" do
27
+ @scoped_model.should respond_to(:genre_list, :genre_list=)
28
+ end
29
+ end
30
+
31
+ context "using scoped-tags in an every day situation" do
32
+ before(:each) do
33
+ @scoped_model = ScopedTaggedModel.new
34
+ @scoped_model.genres << Tag.new(:name => 'rock', :context => 'genres')
35
+ end
36
+
37
+ it "should return the genres association name list when genre_list is called" do
38
+ @scoped_model.genre_list.should include('rock')
39
+ end
40
+
41
+ it "should add a tag to the association when genre_list << is used" do
42
+ @scoped_model.genre_list << 'pop'
43
+ @scoped_model.genres.size.should == 2
44
+ @scoped_model.genre_list.should include('pop')
45
+ end
46
+
47
+ it "should change the entire list when using genre_list =" do
48
+ @scoped_model.genre_list = 'blues'
49
+ @scoped_model.genres.size.should == 1
50
+ @scoped_model.genre_list.should include('blues')
51
+ end
52
+
53
+ it "should change the entire list when using genre_list= with a comma seperated list of tags" do
54
+ @scoped_model.genre_list = 'blues, rock, country'
55
+ @scoped_model.genres.size.should == 3
56
+ @scoped_model.genre_list.should == ["rock", "blues", "country"]
57
+ end
58
+
59
+ it "should only add uniq tags to the association" do
60
+ @scoped_model.genre_list << 'blues'
61
+ @scoped_model.genres.size.should == 2
62
+ @scoped_model.genre_list.should include('blues', 'rock')
63
+ @scoped_model.genre_list << 'blues'
64
+ @scoped_model.genre_list << 'blues'
65
+ @scoped_model.genre_list << 'rock'
66
+ @scoped_model.genres.size.should == 2
67
+ @scoped_model.genre_list.should include('blues', 'rock')
68
+ end
69
+
70
+ it "should allow tags to be removed from the association and have the tag list act consistently" do
71
+ @scoped_model.genre_list << 'blues'
72
+ @scoped_model.genres.size.should == 2
73
+ @scoped_model.genres.delete(@scoped_model.genres.last)
74
+ @scoped_model.genres.size.should == 1
75
+ @scoped_model.genre_list.should include('rock')
76
+ end
77
+
78
+ it "#tagged_with_genres" do
79
+ tag = 'pop'
80
+
81
+ @scoped_model.genre_list << tag
82
+ @scoped_model.save!
83
+
84
+ ar_check = ScopedTaggedModel.all(:conditions => ['tags.name IN (?) AND tags.context = ?', tag, 'genres'], :include => [:taggings, :tags])
85
+ ar_check.size.should == 1
86
+
87
+ ScopedTaggedModel.methods.should include('tagged_with_genres')
88
+
89
+ st_check = ScopedTaggedModel.tagged_with_genres(tag)
90
+ st_check.size.should == 1
91
+ st_check.first.id.should == @scoped_model.id
92
+ end
93
+
94
+ it "#tagged_with_genres with options" do
95
+ tag = 'pop'
96
+
97
+ ScopedTaggedModel.delete_all
98
+ options_check1 = ScopedTaggedModel.new
99
+ options_check1.genre_list << tag
100
+ options_check1.save!
101
+
102
+ options_check2 = ScopedTaggedModel.new
103
+ options_check2.genre_list << tag
104
+ options_check2.save!
105
+
106
+ st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
107
+ st_check.size.should == 1
108
+ st_check.first.id.should == options_check1.id
109
+ end
110
+
111
+ it "#tagged_with_genres with options and multiple tags in an array" do
112
+ tag = ['pop', 'techno', 'dance']
113
+
114
+ ScopedTaggedModel.delete_all
115
+ options_check1 = ScopedTaggedModel.new
116
+ options_check1.genre_list << tag
117
+ options_check1.save!
118
+
119
+ options_check2 = ScopedTaggedModel.new
120
+ options_check2.genre_list << tag
121
+ options_check2.save!
122
+
123
+ st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
124
+ st_check.size.should == 1
125
+ st_check.first.id.should == options_check1.id
126
+ end
127
+
128
+ it "#tagged_with_genres with options and multiple tags in a string" do
129
+ tag = 'pop, techno, dance'
130
+
131
+ ScopedTaggedModel.delete_all
132
+ options_check1 = ScopedTaggedModel.new
133
+ options_check1.genre_list << tag
134
+ options_check1.save!
135
+
136
+ options_check2 = ScopedTaggedModel.new
137
+ options_check2.genre_list << tag
138
+ options_check2.save!
139
+
140
+ st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
141
+ st_check.size.should == 1
142
+ st_check.first.id.should == options_check1.id
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,63 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+
4
+ describe Tagging do
5
+ before(:all) { Tagging.create!(:tag_id => 1, :taggable_id => 1) }
6
+
7
+ it { should belong_to(:tag) }
8
+ it { should belong_to(:taggable) }
9
+ it { should validate_uniqueness_of(:taggable_id).scoped_to(:tag_id) }
10
+
11
+ it { should allow_mass_assignment_of(:taggable_id)
12
+ should allow_mass_assignment_of(:taggable_type)
13
+ should allow_mass_assignment_of(:tag_id) }
14
+
15
+ it { should_not allow_mass_assignment_of(:created_at)
16
+ should_not allow_mass_assignment_of(:updated_at) }
17
+
18
+ it { should have_db_index([:taggable_id, :taggable_type]) }
19
+ end
20
+
21
+
22
+ describe Tag do
23
+ before(:all) { Tag.create!(:name => 'rock', :context => 'genres') }
24
+
25
+ it { should have_many(:taggings).dependent(:delete_all) }
26
+
27
+ it { should validate_uniqueness_of(:name).scoped_to(:context).case_insensitive }
28
+ it { should validate_presence_of(:context) }
29
+
30
+ it { should allow_mass_assignment_of(:name)
31
+ should allow_mass_assignment_of(:context) }
32
+
33
+ it { should_not allow_mass_assignment_of(:created_at)
34
+ should_not allow_mass_assignment_of(:updated_at) }
35
+
36
+ it { should have_db_index([:context, :name]) }
37
+
38
+ it "should trim and squeeze spaces from name and context before validation" do
39
+ t = Tag.new(:name => ' rock and roll', :context => ' genres ')
40
+ t.valid?
41
+ t.name.should == 'rock and roll'
42
+ t.context.should == 'genres'
43
+ end
44
+
45
+ it "should lowercase the name and context before validation" do
46
+ t = Tag.new(:name => 'POP', :context => 'GENRES')
47
+ t.valid?
48
+ t.name.should == 'pop'
49
+ t.context.should == 'genres'
50
+ end
51
+
52
+ it "should recognize that it is equal to another tag with the same name and context" do
53
+ t = Tag.new(:name => 'pop', :context => 'genres')
54
+ s = Tag.new(:name => 'pop', :context => 'genres')
55
+ t.should == s
56
+ end
57
+
58
+ it "#find_or_new_by_name_ane_context" do
59
+ Tag.create!(:name => 'pop', :context => 'genres')
60
+ s = Tag.find_or_new_by_name_and_context('pop', 'genres')
61
+ s.new_record?.should be_false
62
+ end
63
+ end
@@ -0,0 +1,123 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+
4
+ describe TagListCollection do
5
+
6
+ before(:each) do
7
+ @sm = ScopedTaggedModel.new
8
+ @tlc = TagListCollection.new(@sm, :genres)
9
+ end
10
+
11
+ it "#delimiter" do
12
+ TagListCollection.delimiter.should == ','
13
+ TagListCollection.delimiter = '-'
14
+ TagListCollection.delimiter.should == '-'
15
+ TagListCollection.delimiter = ','
16
+ TagListCollection.delimiter.should == ','
17
+ end
18
+
19
+ it ".proxy_owner" do
20
+ @sm.genre_list.proxy_owner.should == @sm
21
+ end
22
+
23
+ it ".===" do
24
+ @sm.genre_list << "disco"
25
+ @sm.genre_list.should === ['disco']
26
+ end
27
+
28
+ it "should return an array with the class method is called (think its an array)" do
29
+ @tlc.class.should == Array
30
+ end
31
+
32
+ it "should return the tag names in the genre list" do
33
+ @sm.genres << Tag.new(:name => 'pop', :context => 'genres')
34
+ @sm.genres << Tag.new(:name => 'rock', :context => 'genres')
35
+ @sm.genre_list.should == ['pop', 'rock']
36
+ end
37
+
38
+ it "should add a tag to the tag list when the association << method is used" do
39
+ @sm.genre_list.should == []
40
+ @sm.genres << Tag.new(:name => 'rock', :context => 'genres')
41
+ @sm.genre_list.should == ['rock']
42
+ end
43
+
44
+ it "should be able to be add a new tag word using <<" do
45
+ @sm.genre_list << "disco"
46
+ @sm.genre_list.should include('disco')
47
+ end
48
+
49
+ it "should be able to be add a new tag word using push" do
50
+ @tlc.push('techno')
51
+ @tlc.should include('techno')
52
+ end
53
+
54
+ it "should be able to be add a new tag word using concat" do
55
+ @tlc.concat('dance')
56
+ @tlc.should include('dance')
57
+ end
58
+
59
+ it "should be able to add delimited lists of tags" do
60
+ @tlc << "techno, house"
61
+ @tlc.should include('techno')
62
+ @tlc.should include('house')
63
+ @tlc.size.should == 2
64
+ end
65
+
66
+ it "should be able to delete tags using delete" do
67
+ @tlc << 'rock'
68
+ @sm.genres.size.should == 1
69
+ @tlc.delete('rock')
70
+ @tlc.should_not include('rock')
71
+ @sm.genres.size.should == 0
72
+ end
73
+
74
+ it "should be able to delete tags using delete_at" do
75
+ @tlc << 'rock' << 'pop' << 'disco'
76
+ @sm.genres.size.should == 3
77
+ @sm.save!
78
+ @tlc.delete_at(0)
79
+ @tlc.should_not include('rock')
80
+ @sm.genres.size.should == 2
81
+ @sm.save!
82
+ @sm.reload
83
+ @sm.genres.size.should == 2
84
+ end
85
+
86
+ it "should be able to delete tags using pop" do
87
+ @tlc << 'rock' << 'pop' << 'disco'
88
+ @sm.genres.size.should == 3
89
+ @sm.genre_list.last.should == 'disco'
90
+ @tlc.pop
91
+ @tlc.should_not include('disco')
92
+ @sm.save!
93
+ @sm.reload
94
+ @sm.genres.size.should == 2
95
+ end
96
+
97
+ it "should give a delimited list of words when converted to string" do
98
+ @tlc << 'rock' << 'pop'
99
+ @tlc.to_s.should == "rock, pop"
100
+ end
101
+
102
+ it "should not add duplicate tags" do
103
+ @tlc << 'rock' << 'pop' << 'disco'
104
+ @sm.genres.size.should == 3
105
+ @tlc.should == ['rock', 'pop', 'disco']
106
+ @tlc << "rock"
107
+ @tlc.should == ['rock', 'pop', 'disco']
108
+ @sm.genres.size.should == 3
109
+ end
110
+
111
+ it ".replace" do
112
+ @tlc << 'rock' << 'pop' << 'disco'
113
+ @sm.genre_list.should == ['rock', 'pop', 'disco']
114
+ @sm.genre_list = ['funk', 'house']
115
+ @sm.genre_list.should == ['funk', 'house']
116
+ end
117
+
118
+ it ".to_param" do
119
+ @sm.genre_list = ['funk', 'house']
120
+ @sm.genre_list.to_param.should == 'funk/house'
121
+ end
122
+
123
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec'
2
+ require 'shoulda'
3
+
4
+ require 'active_support'
5
+ require 'active_record'
6
+
7
+ require 'scoped_tags'
8
+
9
+ Spec::Runner.configure do |config|
10
+ config.include(Shoulda::ActiveRecord::Matchers, :type => :model)
11
+ end
12
+
13
+ TEST_DATABASE_FILE = 'spec/test.sqlite3'
14
+
15
+ File.unlink(TEST_DATABASE_FILE) if File.exist?(TEST_DATABASE_FILE)
16
+ ActiveRecord::Base.establish_connection(
17
+ "adapter" => "sqlite3", "database" => TEST_DATABASE_FILE
18
+ )
19
+
20
+ load('schema.rb')
21
+
22
+ RAILS_DEFAULT_LOGGER = Logger.new("spec/debug.log")
23
+
24
+ class ScopedTaggedModel < ActiveRecord::Base
25
+ scoped_tags :genres
26
+ end
27
+
28
+ class UnScopedTaggedModel < ActiveRecord::Base
29
+ end
Binary file
@@ -0,0 +1 @@
1
+ # Uninstall hook code here
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scoped-tags
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Josh Kalderimis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-10-18 00:00:00 +02:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: activerecord
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 2.3.3
24
+ version:
25
+ description:
26
+ email: josh.kalderimis@gmail.com
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files: []
32
+
33
+ files:
34
+ - MIT-LICENSE
35
+ - VERSION.yml
36
+ - generators/scoped_tags_migration/scoped_tags_generator.rb
37
+ - generators/scoped_tags_migration/templates/migration.rb
38
+ - install.rb
39
+ - lib/scoped_tags.rb
40
+ - lib/scoped_tags/active_record_additions.rb
41
+ - lib/scoped_tags/tag.rb
42
+ - lib/scoped_tags/tag_list_collection.rb
43
+ - lib/scoped_tags/tag_list_proxy.rb
44
+ - lib/scoped_tags/tagging.rb
45
+ - readme.md
46
+ - uninstall.rb
47
+ has_rdoc: true
48
+ homepage: http://github.com/joshk/scoped-tags
49
+ licenses: []
50
+
51
+ post_install_message:
52
+ rdoc_options:
53
+ - --charset=UTF-8
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ version:
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.3.5
72
+ signing_key:
73
+ specification_version: 3
74
+ summary: Scoped tagging plugin for your rails models which keeps your associations in sync with your tag arrays
75
+ test_files:
76
+ - spec/debug.log
77
+ - spec/schema.rb
78
+ - spec/scoped_tags/scoped_tags_spec.rb
79
+ - spec/scoped_tags/tag_and_tagging_spec.rb
80
+ - spec/scoped_tags/tag_list_spec.rb
81
+ - spec/spec_helper.rb
82
+ - spec/test.sqlite3