citrusbyte-is_taggable 0.85
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.
- data/CHANGELOG +30 -0
- data/MIT-LICENSE +21 -0
- data/README +243 -0
- data/generators/is_taggable_migration/is_taggable_migration_generator.rb +7 -0
- data/generators/is_taggable_migration/templates/migration.rb +24 -0
- data/init.rb +1 -0
- data/lib/is_taggable/is_taggable.rb +354 -0
- data/lib/is_taggable/is_tagger.rb +49 -0
- data/lib/is_taggable/tag_list.rb +107 -0
- data/lib/is_taggable/tagging.rb +31 -0
- data/lib/is_taggable/tags_helper.rb +11 -0
- data/lib/is_taggable.rb +6 -0
- data/rails/init.rb +6 -0
- data/spec/is_taggable/is_taggable_spec.rb +170 -0
- data/spec/is_taggable/tag_list_spec.rb +67 -0
- data/spec/is_taggable/taggable_spec.rb +136 -0
- data/spec/is_taggable/tagger_spec.rb +18 -0
- data/spec/is_taggable/tagging_spec.rb +42 -0
- data/spec/schema.rb +30 -0
- data/spec/spec_helper.rb +36 -0
- data/uninstall.rb +1 -0
- metadata +79 -0
data/CHANGELOG
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
is_taggable:
|
|
2
|
+
|
|
3
|
+
== 2008-12-11
|
|
4
|
+
|
|
5
|
+
* Added support for Rails 2.2.2 (new Multibyte::Chars handling for tag normalization)
|
|
6
|
+
|
|
7
|
+
== 2008-something-something
|
|
8
|
+
|
|
9
|
+
* Forked and totally changed
|
|
10
|
+
|
|
11
|
+
acts-as-taggable-on:
|
|
12
|
+
|
|
13
|
+
== 2008-07-17
|
|
14
|
+
|
|
15
|
+
* Can now use a named_scope to find tags!
|
|
16
|
+
|
|
17
|
+
== 2008-06-23
|
|
18
|
+
|
|
19
|
+
* Can now find related objects of another class (tristanzdunn)
|
|
20
|
+
* Removed extraneous down migration cruft (azabaj)
|
|
21
|
+
|
|
22
|
+
== 2008-06-09
|
|
23
|
+
|
|
24
|
+
* Added support for Single Table Inheritance
|
|
25
|
+
* Adding gemspec and rails/init.rb for gemified plugin
|
|
26
|
+
|
|
27
|
+
== 2007-12-12
|
|
28
|
+
|
|
29
|
+
* Added ability to use dynamic tag contexts
|
|
30
|
+
* Fixed missing migration generator
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Copyright (c) 2008 Citrusbyte, LLC (is_taggable)
|
|
2
|
+
Copyright (c) 2007 Michael Bleigh and Intridea Inc.
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
5
|
+
a copy of this software and associated documentation files (the
|
|
6
|
+
"Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
8
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
10
|
+
the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be
|
|
13
|
+
included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
19
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
20
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
21
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
is_taggable
|
|
2
|
+
===============================================================================
|
|
3
|
+
|
|
4
|
+
This plugin is almost entirely based on acts-as-taggable-on by Michael Bleigh +
|
|
5
|
+
contributors.
|
|
6
|
+
|
|
7
|
+
is_taggable supports the awesome contextual tagging of acts-as-taggable-on, but
|
|
8
|
+
with a couple substantial changes to the underlying architecture.
|
|
9
|
+
|
|
10
|
+
Architecture Differences
|
|
11
|
+
===============================================================================
|
|
12
|
+
|
|
13
|
+
We had a couple key issues with the underlying architecture in
|
|
14
|
+
acts-as-taggable-on which we felt deserved a forking into a new direction.
|
|
15
|
+
There are two main architectural differences between is_taggable and
|
|
16
|
+
acts-as-taggable-on:
|
|
17
|
+
|
|
18
|
+
1) is_taggable does *not* use a normalized data model -- there is one table
|
|
19
|
+
"taggings" and not two tables "taggings" and "tags". This means that tags are
|
|
20
|
+
duplicated in the taggings table. The reason behind this was that we could not
|
|
21
|
+
find a reason justifying the join that couldn't be easily overcome with unique
|
|
22
|
+
indexing and grouped selects.
|
|
23
|
+
|
|
24
|
+
2) (This is the big one) -- is_taggable skips validations and callbacks on the
|
|
25
|
+
Tagging model when saving. This totally breaks the normal AR behavior and the
|
|
26
|
+
behavior used by all other AR tagging plugins. The reason for this is that the
|
|
27
|
+
taggings are updated with a multi-insert -- which also means this plugin can
|
|
28
|
+
only be used on databases which support multi-insert (MySQL, Postgres, Oracle,
|
|
29
|
+
...). The reason we use a multi-insert is because we ran into massive problems
|
|
30
|
+
with large numbers of writes on taggings. Take this scenario:
|
|
31
|
+
|
|
32
|
+
I upload a photo and fill in my tags when I upload it. I want people to find it
|
|
33
|
+
so I tag it with about 20 different keywords. In order to save the tags on my
|
|
34
|
+
photo the original plugin would have to (at least):
|
|
35
|
+
|
|
36
|
+
2) do 20 SELECTs on the taggings table for validates_uniqueness_of
|
|
37
|
+
4) do some number (at most 20) of INSERTs on the tags table to save the tags
|
|
38
|
+
3) do 20 INSERTs on the taggings table to save the taggings
|
|
39
|
+
|
|
40
|
+
So best case 20 INSERTs, 20 SELECTs -- worst case 40 INSERTs, 20 SELECTs.
|
|
41
|
+
|
|
42
|
+
Now get a few users adding lots of tags on things concurrently and you can see
|
|
43
|
+
writes quickly becoming a problem, and as the number of tags I'm adding grows,
|
|
44
|
+
the problem gets worse. Individual INSERTs are fast, but once you have
|
|
45
|
+
concurrency you have lock waits and the the problem gets massively compounded.
|
|
46
|
+
|
|
47
|
+
By using a multi-insert, a non-normalized table, and a "manual" validation (do
|
|
48
|
+
all the validates_uniqueness_of checks at once) we can get it down to:
|
|
49
|
+
|
|
50
|
+
2) do 1 SELECT to check for duplicated taggings
|
|
51
|
+
3) do 1 multi-INSERT to INSERT all the taggings
|
|
52
|
+
|
|
53
|
+
Furthermore, no matter how many tags your inserting, it's always 1 SELECT and 1
|
|
54
|
+
INSERT.
|
|
55
|
+
|
|
56
|
+
Another thing to consider is the impact of updating tags. In the original
|
|
57
|
+
plugin this was done as a loop causing multiple DELETEs followed by multiple
|
|
58
|
+
INSERTs. We've taken this down to 1 DELETE followed by 1 multi-INSERT.
|
|
59
|
+
|
|
60
|
+
Compatibility
|
|
61
|
+
===============================================================================
|
|
62
|
+
|
|
63
|
+
is_taggable requires that your underlying database support multi-INSERT
|
|
64
|
+
statements (i.e. INSERT INTO taggings (tag) VALUES ('foo', 'bar', 'baz')). Most
|
|
65
|
+
"major" databases do -- including MySQL, PostgreSQL and Oracle.
|
|
66
|
+
|
|
67
|
+
It has only been tested with Rails 2.1+ and makes use of named_scope (introduced
|
|
68
|
+
in Rails 2.1).
|
|
69
|
+
|
|
70
|
+
Installation
|
|
71
|
+
===============================================================================
|
|
72
|
+
|
|
73
|
+
GemPlugin
|
|
74
|
+
-------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
Rails 2.1+ introduces gem dependencies, to use them add this line to
|
|
77
|
+
environment.rb:
|
|
78
|
+
|
|
79
|
+
config.gem "citrusbyte-is_taggable", :source => "http://gems.github.com", :lib => "is_taggable"
|
|
80
|
+
|
|
81
|
+
Then run "rake gems:install" to install the gem.
|
|
82
|
+
|
|
83
|
+
Plugin
|
|
84
|
+
-------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
script/plugin install git://github.com/citrusbyte/is_taggable.git
|
|
87
|
+
|
|
88
|
+
Gem
|
|
89
|
+
-------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
gem install citrusbyte-is_taggable --source http://gems.github.com
|
|
92
|
+
|
|
93
|
+
Post Installation
|
|
94
|
+
-------------------------------------------------------------------------------
|
|
95
|
+
1. script/generate is_taggable_migration
|
|
96
|
+
2. rake db/migrate
|
|
97
|
+
|
|
98
|
+
Testing
|
|
99
|
+
===============================================================================
|
|
100
|
+
|
|
101
|
+
is_taggable uses RSpec for its test coverage, if you're using RSpec type:
|
|
102
|
+
|
|
103
|
+
rake spec:plugins
|
|
104
|
+
|
|
105
|
+
Examples (all stolen from acts-as-taggable-on docs)
|
|
106
|
+
===============================================================================
|
|
107
|
+
|
|
108
|
+
class User < ActiveRecord::Base
|
|
109
|
+
is_taggable :tags, :skills, :interests
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@user = User.new(:name => "Bobby")
|
|
113
|
+
@user.tag_list = "awesome, slick, hefty" # this should be familiar
|
|
114
|
+
@user.skill_list = "joking, clowning, boxing" # but you can do it for any context!
|
|
115
|
+
@user.skill_list # => ["joking","clowning","boxing"] as TagList
|
|
116
|
+
@user.save
|
|
117
|
+
|
|
118
|
+
@user.tags # => [<Tag name:"awesome">,<Tag name:"slick">,<Tag name:"hefty">]
|
|
119
|
+
@user.skills # => [<Tag name:"joking">,<Tag name:"clowning">,<Tag name:"boxing">]
|
|
120
|
+
|
|
121
|
+
# The old way
|
|
122
|
+
User.find_tagged_with("awesome", :on => :tags) # => [@user]
|
|
123
|
+
User.find_tagged_with("awesome", :on => :skills) # => []
|
|
124
|
+
|
|
125
|
+
# The better way (utilizes named_scope)
|
|
126
|
+
User.tagged_with("awesome", :on => :tags) # => [@user]
|
|
127
|
+
User.tagged_with("awesome", :on => :skills) # => []
|
|
128
|
+
|
|
129
|
+
@frankie = User.create(:name => "Frankie", :skill_list => "joking, flying, eating")
|
|
130
|
+
User.skill_counts # => [<Tag name="joking" count=2>,<Tag name="clowning" count=1>...]
|
|
131
|
+
@frankie.skill_counts
|
|
132
|
+
|
|
133
|
+
Finding Tagged Objects
|
|
134
|
+
======================
|
|
135
|
+
|
|
136
|
+
is_taggable utilizes Rails 2.1's named_scope to create an association
|
|
137
|
+
for tags. This way you can mix and match to filter down your results, and it
|
|
138
|
+
also improves compatibility with the will_paginate gem:
|
|
139
|
+
|
|
140
|
+
class User < ActiveRecord::Base
|
|
141
|
+
is_taggable :tags
|
|
142
|
+
named_scope :by_join_date, :order => "created_at DESC"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
User.tagged_with("awesome").by_date
|
|
146
|
+
User.tagged_with("awesome").by_date.paginate(:page => params[:page], :per_page => 20)
|
|
147
|
+
|
|
148
|
+
Relationships
|
|
149
|
+
=============
|
|
150
|
+
|
|
151
|
+
You can find objects of the same type based on similar tags on certain contexts.
|
|
152
|
+
Also, objects will be returned in descending order based on the total number of
|
|
153
|
+
matched tags.
|
|
154
|
+
|
|
155
|
+
@bobby = User.find_by_name("Bobby")
|
|
156
|
+
@bobby.skill_list # => ["jogging", "diving"]
|
|
157
|
+
|
|
158
|
+
@frankie = User.find_by_name("Frankie")
|
|
159
|
+
@frankie.skill_list # => ["hacking"]
|
|
160
|
+
|
|
161
|
+
@tom = User.find_by_name("Tom")
|
|
162
|
+
@tom.skill_list # => ["hacking", "jogging", "diving"]
|
|
163
|
+
|
|
164
|
+
@tom.find_related_skills # => [<User name="Bobby">,<User name="Frankie">]
|
|
165
|
+
@bobby.find_related_skills # => [<User name="Tom">]
|
|
166
|
+
@frankie.find_related_skills # => [<User name="Tom">]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Dynamic Tag Contexts
|
|
170
|
+
====================
|
|
171
|
+
|
|
172
|
+
In addition to the generated tag contexts in the definition, it is also possible
|
|
173
|
+
to allow for dynamic tag contexts (this could be user generated tag contexts!)
|
|
174
|
+
|
|
175
|
+
@user = User.new(:name => "Bobby")
|
|
176
|
+
@user.set_tag_list_on(:customs, "same, as, tag, list")
|
|
177
|
+
@user.tag_list_on(:customs) # => ["same","as","tag","list"]
|
|
178
|
+
@user.save
|
|
179
|
+
@user.tags_on(:customs) # => [<Tag name='same'>,...]
|
|
180
|
+
@user.tag_counts_on(:customs)
|
|
181
|
+
User.find_tagged_with("same", :on => :customs) # => [@user]
|
|
182
|
+
|
|
183
|
+
Tag Ownership
|
|
184
|
+
=============
|
|
185
|
+
|
|
186
|
+
Tags can have owners:
|
|
187
|
+
|
|
188
|
+
class User < ActiveRecord::Base
|
|
189
|
+
is_tagger
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class Photo < ActiveRecord::Base
|
|
193
|
+
is_taggable :locations
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@some_user.tag(@some_photo, :with => "paris, normandy", :on => :locations)
|
|
197
|
+
@some_user.owned_taggings
|
|
198
|
+
@some_user.owned_tags
|
|
199
|
+
@some_photo.locations_from(@some_user)
|
|
200
|
+
|
|
201
|
+
Caveats
|
|
202
|
+
===============================================================================
|
|
203
|
+
|
|
204
|
+
1) Your underlying database *must* support multi-INSERT
|
|
205
|
+
2) You probably need Rails 2.1+, but seriously named_scope is so cool you need
|
|
206
|
+
it anyways.
|
|
207
|
+
3) You cannot use callbacks/validations on the Tagging model (you can still use
|
|
208
|
+
them like normal on your Taggables and Taggers...)
|
|
209
|
+
|
|
210
|
+
Contributors
|
|
211
|
+
===============================================================================
|
|
212
|
+
|
|
213
|
+
is_taggable:
|
|
214
|
+
* Ben Alavi & Michel Martens - Ruthless hackers of acts-as-taggable-on
|
|
215
|
+
|
|
216
|
+
acts-as-taggable-on:
|
|
217
|
+
* Michael Bleigh - Original Author
|
|
218
|
+
* Brendan Lim - Related Objects
|
|
219
|
+
* Pradeep Elankumaran - Taggers
|
|
220
|
+
* Sinclair Bain - Patch King
|
|
221
|
+
|
|
222
|
+
Patch Contributors
|
|
223
|
+
-------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
acts-as-taggable-on:
|
|
226
|
+
* tristanzdunn - Related objects of other classes
|
|
227
|
+
* azabaj - Fixed migrate down
|
|
228
|
+
* Peter Cooper - named_scope fix
|
|
229
|
+
* slainer68 - STI fix
|
|
230
|
+
* harrylove - migration instructions and fix-ups
|
|
231
|
+
* lawrencepit - cached tag work
|
|
232
|
+
|
|
233
|
+
Resources
|
|
234
|
+
===============================================================================
|
|
235
|
+
|
|
236
|
+
* GitHub - http://github.com/citrusbyte/is_taggable
|
|
237
|
+
* Lighthouse - http://citrusbyte.lighthouseapp.com/projects/
|
|
238
|
+
|
|
239
|
+
is_taggable:
|
|
240
|
+
Copyright (c) 2008 Citrusbyte, LLC, released under the MIT license
|
|
241
|
+
|
|
242
|
+
acts-as-taggable-on:
|
|
243
|
+
Copyright (c) 2007 Michael Bleigh (http://mbleigh.com/) and Intridea Inc. (http://intridea.com/), released under the MIT license
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class IsTaggableMigration < ActiveRecord::Migration
|
|
2
|
+
def self.up
|
|
3
|
+
create_table :taggings do |t|
|
|
4
|
+
t.column :tagger_type, :string
|
|
5
|
+
t.column :tagger_id, :integer
|
|
6
|
+
t.column :taggable_type, :string
|
|
7
|
+
t.column :taggable_id, :integer
|
|
8
|
+
|
|
9
|
+
t.column :tag, :string
|
|
10
|
+
t.column :normalized, :string
|
|
11
|
+
t.column :context, :string
|
|
12
|
+
|
|
13
|
+
t.column :created_at, :datetime
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :taggings, [:taggable_id, :taggable_type]
|
|
17
|
+
add_index :taggings, [:taggable_id, :taggable_type, :context]
|
|
18
|
+
add_index :taggings, [:taggable_id, :taggable_type, :context, :normalized], :uniq => true, :name => 'taggable_and_context_and_normalized'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.down
|
|
22
|
+
drop_table :taggings
|
|
23
|
+
end
|
|
24
|
+
end
|
data/init.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require File.dirname(__FILE__) + "/rails/init"
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
module ActiveRecord
|
|
2
|
+
module Is
|
|
3
|
+
module Taggable
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.extend(ClassMethods)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
def taggable?
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def is_taggable
|
|
14
|
+
is_taggable :tags
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def is_taggable(*args)
|
|
18
|
+
args.flatten! if args
|
|
19
|
+
args.compact! if args
|
|
20
|
+
for tag_type in args
|
|
21
|
+
tag_type = tag_type.to_s
|
|
22
|
+
self.class_eval do
|
|
23
|
+
has_many "#{tag_type.singularize}_taggings".to_sym, :as => :taggable, :dependent => :destroy, :conditions => ["context = ?",tag_type], :class_name => "Tagging"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
self.class_eval <<-RUBY
|
|
27
|
+
def self.taggable?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.caching_#{tag_type.singularize}_list?
|
|
32
|
+
caching_tag_list_on?("#{tag_type}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.#{tag_type.singularize}_counts(options={})
|
|
36
|
+
tag_counts_on('#{tag_type}',options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def #{tag_type.singularize}_list
|
|
40
|
+
tag_list_on('#{tag_type}').to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def #{tag_type}
|
|
44
|
+
tag_list_on('#{tag_type}')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def #{tag_type}=(tags)
|
|
48
|
+
set_tags_on('#{tag_type}', tags)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def #{tag_type.singularize}_list=(new_tags)
|
|
52
|
+
set_tag_list_on('#{tag_type}', new_tags)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def #{tag_type.singularize}_counts(options = {})
|
|
56
|
+
tag_counts_on('#{tag_type}',options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def #{tag_type}_from(owner)
|
|
60
|
+
tag_list_on('#{tag_type}', owner)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find_related_#{tag_type}(options = {})
|
|
64
|
+
related_tags_for('#{tag_type}', self.class, options)
|
|
65
|
+
end
|
|
66
|
+
alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type}
|
|
67
|
+
|
|
68
|
+
def find_related_#{tag_type}_for(klass, options = {})
|
|
69
|
+
related_tags_for('#{tag_type}', klass, options)
|
|
70
|
+
end
|
|
71
|
+
RUBY
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if respond_to?(:tag_types)
|
|
75
|
+
write_inheritable_attribute( :tag_types, (tag_types + args).uniq )
|
|
76
|
+
else
|
|
77
|
+
self.class_eval do
|
|
78
|
+
write_inheritable_attribute(:tag_types, args.uniq)
|
|
79
|
+
class_inheritable_reader :tag_types
|
|
80
|
+
|
|
81
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy
|
|
82
|
+
|
|
83
|
+
attr_writer :custom_contexts
|
|
84
|
+
|
|
85
|
+
before_save :save_cached_tag_list
|
|
86
|
+
after_save :save_tags
|
|
87
|
+
|
|
88
|
+
if respond_to?(:named_scope)
|
|
89
|
+
named_scope :tagged_with, lambda{ |tags, options|
|
|
90
|
+
find_options_for_find_tagged_with(tags, options)
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
include ActiveRecord::Is::Taggable::InstanceMethods
|
|
96
|
+
extend ActiveRecord::Is::Taggable::SingletonMethods
|
|
97
|
+
alias_method_chain :reload, :tag_list
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def is_taggable?
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
module SingletonMethods
|
|
107
|
+
# Pass either a tag string, or an array of strings or tags
|
|
108
|
+
#
|
|
109
|
+
# Options:
|
|
110
|
+
# :exclude - Find models that are not tagged with the given tags
|
|
111
|
+
# :match_all - Find models that match all of the given tags, not just one
|
|
112
|
+
# :conditions - A piece of SQL conditions to add to the query
|
|
113
|
+
# :on - scopes the find to a context
|
|
114
|
+
def find_tagged_with(*args)
|
|
115
|
+
options = find_options_for_find_tagged_with(*args)
|
|
116
|
+
options.blank? ? [] : find(:all,options)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def caching_tag_list_on?(context)
|
|
120
|
+
column_names.include?("cached_#{context.to_s.singularize}_list")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tag_counts_on(context, options = {})
|
|
124
|
+
Tagging.find(:all, find_options_for_tag_counts(options.merge({:on => context.to_s})))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_options_for_find_tagged_with(tags, options = {})
|
|
128
|
+
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
|
|
129
|
+
|
|
130
|
+
return {} if tags.empty?
|
|
131
|
+
|
|
132
|
+
conditions = []
|
|
133
|
+
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
|
|
134
|
+
unless (on = options.delete(:on)).nil?
|
|
135
|
+
conditions << sanitize_sql(["context = ?",on.to_s])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
taggings_alias = "#{table_name}_taggings"
|
|
139
|
+
|
|
140
|
+
if options.delete(:exclude)
|
|
141
|
+
conditions << sanitize_sql(["#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} WHERE (#{Tagging.table_name}.normalized IN(?)) AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})", tags.normalized])
|
|
142
|
+
else
|
|
143
|
+
conditions << sanitize_sql(["#{taggings_alias}.normalized IN(?)", tags.normalized])
|
|
144
|
+
group = "#{taggings_alias}.taggable_id HAVING COUNT(#{taggings_alias}.taggable_id) = #{taggings.size}" if options.delete(:match_all)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{ :select => "DISTINCT #{table_name}.*",
|
|
148
|
+
:joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}",
|
|
149
|
+
:conditions => conditions.join(" AND "),
|
|
150
|
+
:group => group
|
|
151
|
+
}.update(options)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Calculate the tag counts for all tags.
|
|
155
|
+
#
|
|
156
|
+
# Options:
|
|
157
|
+
# :start_at - Restrict the tags to those created after a certain time
|
|
158
|
+
# :end_at - Restrict the tags to those created before a certain time
|
|
159
|
+
# :conditions - A piece of SQL conditions to add to the query
|
|
160
|
+
# :limit - The maximum number of tags to return
|
|
161
|
+
# :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
|
|
162
|
+
# :at_least - Exclude tags with a frequency less than the given value
|
|
163
|
+
# :at_most - Exclude tags with a frequency greater than the given value
|
|
164
|
+
# :on - Scope the find to only include a certain context
|
|
165
|
+
def find_options_for_tag_counts(options = {})
|
|
166
|
+
options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on
|
|
167
|
+
|
|
168
|
+
scope = scope(:find)
|
|
169
|
+
start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
|
|
170
|
+
end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
|
171
|
+
|
|
172
|
+
type_and_context = "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}"
|
|
173
|
+
type_and_context << sanitize_sql(["AND #{Tagging.table_name}.context = ?", options.delete(:on).to_s]) unless options[:on].nil?
|
|
174
|
+
|
|
175
|
+
conditions = [
|
|
176
|
+
type_and_context,
|
|
177
|
+
start_at,
|
|
178
|
+
end_at
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
conditions = conditions.compact.join(' AND ')
|
|
182
|
+
conditions = merge_conditions(conditions, options.delete(:conditions)) if options[:conditions]
|
|
183
|
+
conditions = merge_conditions(conditions, scope[:conditions]) if scope
|
|
184
|
+
|
|
185
|
+
joins = ["LEFT OUTER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"]
|
|
186
|
+
joins << scope[:joins] if scope && scope[:joins]
|
|
187
|
+
|
|
188
|
+
at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
|
|
189
|
+
at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
|
|
190
|
+
having = [at_least, at_most].compact.join(' AND ')
|
|
191
|
+
|
|
192
|
+
# note that it makes sense here to group by both (and allow both to
|
|
193
|
+
# be selected) since we're enforcing that tags that normalize to the
|
|
194
|
+
# same thing can't exist. this means there will never be a case when
|
|
195
|
+
# one normalized tag has multiple non-normalized representations,
|
|
196
|
+
# meaning we still have a proper set when grouping by either column
|
|
197
|
+
group_by = "#{Tagging.table_name}.normalized, #{Tagging.table_name}.tag HAVING COUNT(*) > 0"
|
|
198
|
+
group_by << " AND #{having}" unless having.blank?
|
|
199
|
+
|
|
200
|
+
{ :select => "#{Tagging.table_name}.tag, COUNT(*) AS count",
|
|
201
|
+
:joins => joins.join(" "),
|
|
202
|
+
:conditions => conditions,
|
|
203
|
+
:group => group_by
|
|
204
|
+
}.update(options)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def is_taggable?
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
module InstanceMethods
|
|
213
|
+
|
|
214
|
+
def tag_types
|
|
215
|
+
self.class.tag_types
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def custom_contexts
|
|
219
|
+
@custom_contexts ||= []
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def is_taggable?
|
|
223
|
+
self.class.is_taggable?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def add_custom_context(value)
|
|
227
|
+
custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def tag_list_on(context, owner=nil)
|
|
231
|
+
var_name = context.to_s.singularize + "_list"
|
|
232
|
+
add_custom_context(context)
|
|
233
|
+
return instance_variable_get("@#{var_name}") unless instance_variable_get("@#{var_name}").nil?
|
|
234
|
+
|
|
235
|
+
if !owner && self.class.caching_tag_list_on?(context) and !(cached_value = cached_tag_list_on(context)).nil?
|
|
236
|
+
instance_variable_set("@#{var_name}", TagList.from(self["cached_#{var_name}"]))
|
|
237
|
+
else
|
|
238
|
+
instance_variable_set("@#{var_name}", TagList.new(*taggings_on(context, owner).map(&:tag)))
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def taggings_on(context, owner=nil)
|
|
243
|
+
if owner
|
|
244
|
+
opts = {:conditions => ["context = ? AND tagger_id = ? AND tagger_type = ?", context.to_s, owner.id, owner.class.to_s]}
|
|
245
|
+
else
|
|
246
|
+
opts = {:conditions => ["context = ?", context.to_s]}
|
|
247
|
+
end
|
|
248
|
+
taggings.find(:all, opts)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def cached_tag_list_on(context)
|
|
252
|
+
self["cached_#{context.to_s.singularize}_list"]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def set_tag_list_on(context,new_list, tagger=nil)
|
|
256
|
+
instance_variable_set("@#{context.to_s.singularize}_list", TagList.from_owner(tagger, new_list))
|
|
257
|
+
add_custom_context(context)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def set_tags_on(context, new_tags, tagger=nil)
|
|
261
|
+
instance_variable_set("@#{context.to_s.singularize}_list", TagList.new_from_owner(tagger, *new_tags))
|
|
262
|
+
add_custom_context(context)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def tag_counts_on(context,options={})
|
|
266
|
+
self.class.tag_counts_on(context,{:conditions => ["#{Tagging.table_name}.normalized IN (?)", tag_list_on(context).normalized]}.reverse_merge!(options))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def related_tags_for(context, klass, options = {})
|
|
270
|
+
search_conditions = related_search_options(context, klass, options)
|
|
271
|
+
|
|
272
|
+
klass.find(:all, search_conditions)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def related_search_options(context, klass, options = {})
|
|
276
|
+
tags_to_find = self.taggings_on(context).collect(&:normalized)
|
|
277
|
+
|
|
278
|
+
{ :select => "#{klass.table_name}.*, related_ids.count AS count",
|
|
279
|
+
:from => "#{klass.table_name}",
|
|
280
|
+
:joins => sanitize_sql(["INNER JOIN(
|
|
281
|
+
SELECT #{klass.table_name}.id, COUNT(#{Tagging.table_name}.id) AS count
|
|
282
|
+
FROM #{klass.table_name}, #{Tagging.table_name}
|
|
283
|
+
WHERE #{klass.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{klass.to_s}'
|
|
284
|
+
AND #{Tagging.table_name}.context = '#{context}' AND #{Tagging.table_name}.normalized IN (?)
|
|
285
|
+
GROUP BY #{klass.table_name}.id
|
|
286
|
+
) AS related_ids ON(#{klass.table_name}.id = related_ids.id)", tags_to_find]),
|
|
287
|
+
:order => "count DESC"
|
|
288
|
+
}.update(options)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def save_cached_tag_list
|
|
292
|
+
self.class.tag_types.map(&:to_s).each do |tag_type|
|
|
293
|
+
if self.class.send("caching_#{tag_type.singularize}_list?")
|
|
294
|
+
self["cached_#{tag_type.singularize}_list"] = send("#{tag_type.singularize}_list").to_s
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def save_tags
|
|
300
|
+
all_taggings = {}
|
|
301
|
+
self.taggings.find(:all, :order => 'context ASC').each do |tagging|
|
|
302
|
+
all_taggings[tagging.context] ||= []
|
|
303
|
+
all_taggings[tagging.context] << tagging
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
(custom_contexts + self.class.tag_types.map(&:to_s)).each do |tag_type|
|
|
307
|
+
next unless contextual_tag_list = instance_variable_get("@#{tag_type.singularize}_list")
|
|
308
|
+
normalized_tag_list = contextual_tag_list.normalized
|
|
309
|
+
owner = contextual_tag_list.owner
|
|
310
|
+
existing_taggings = all_taggings[tag_type.to_sym] || []
|
|
311
|
+
new_tag_names = normalized_tag_list - existing_taggings.map(&:normalized)
|
|
312
|
+
old_tags = existing_taggings.reject { |tagging| normalized_tag_list.include?(tagging.normalized) }
|
|
313
|
+
|
|
314
|
+
self.class.transaction do
|
|
315
|
+
self.taggings.delete(*old_tags) if old_tags.any?
|
|
316
|
+
if new_tag_names.any? # it's possible we're just removing existing tags
|
|
317
|
+
sql = "INSERT INTO taggings (tag, normalized, context, taggable_id, taggable_type, tagger_id, tagger_type, created_at) VALUES "
|
|
318
|
+
sql += new_tag_names.collect { |tag| tag_insert_value(tag, tag_type, self, owner) }.join(", ")
|
|
319
|
+
ActiveRecord::Base.connection.execute(sql)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
true
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def sanitize_sql(attrs)
|
|
328
|
+
ActiveRecord::Base.send(:sanitize_sql, attrs)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def tag_insert_value(tag, type, taggable, owner=nil)
|
|
332
|
+
sanitize_sql(["(?, ?, ?, ?, ?, ?, ?, ?)",
|
|
333
|
+
tag,
|
|
334
|
+
TagList.normalize(tag),
|
|
335
|
+
type,
|
|
336
|
+
taggable.id,
|
|
337
|
+
taggable.class.base_class.to_s, # base_class to support STI properly
|
|
338
|
+
owner ? owner.id : nil,
|
|
339
|
+
owner ? owner.class.to_s : nil,
|
|
340
|
+
Time.now.utc.to_s(:db)
|
|
341
|
+
])
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def reload_with_tag_list(*args)
|
|
345
|
+
self.class.tag_types.each do |tag_type|
|
|
346
|
+
self.instance_variable_set("@#{tag_type.to_s.singularize}_list", nil)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
reload_without_tag_list(*args)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|