acts_as_20ggable 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +1 -0
  3. data/CHANGELOG +6 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +61 -0
  6. data/README +76 -0
  7. data/Rakefile +24 -0
  8. data/acts_as_20ggable.gemspec +26 -0
  9. data/generators/acts_as_20ggable_migration/acts_as_20ggable_migration_generator.rb +11 -0
  10. data/generators/acts_as_20ggable_migration/templates/migration.rb +45 -0
  11. data/lib/acts_as_20ggable.rb +7 -0
  12. data/lib/acts_as_taggable.rb +201 -0
  13. data/lib/tag.rb +146 -0
  14. data/lib/tag_counts_extension.rb +3 -0
  15. data/lib/tag_hierarchy_builder.rb +201 -0
  16. data/lib/tag_list.rb +108 -0
  17. data/lib/tagging.rb +10 -0
  18. data/lib/tags_helper.rb +13 -0
  19. data/test/fixtures/magazine.rb +3 -0
  20. data/test/fixtures/magazines.yml +7 -0
  21. data/test/fixtures/photo.rb +8 -0
  22. data/test/fixtures/photos.yml +24 -0
  23. data/test/fixtures/post.rb +7 -0
  24. data/test/fixtures/posts.yml +34 -0
  25. data/test/fixtures/schema.rb +73 -0
  26. data/test/fixtures/special_post.rb +2 -0
  27. data/test/fixtures/subscription.rb +4 -0
  28. data/test/fixtures/subscriptions.yml +3 -0
  29. data/test/fixtures/taggings.yml +162 -0
  30. data/test/fixtures/tags.yml +75 -0
  31. data/test/fixtures/tags_hierarchy.yml +31 -0
  32. data/test/fixtures/tags_synonyms.yml +16 -0
  33. data/test/fixtures/tags_transitive_hierarchy.yml +96 -0
  34. data/test/fixtures/user.rb +9 -0
  35. data/test/fixtures/users.yml +7 -0
  36. data/test/fixtures/video.rb +3 -0
  37. data/test/fixtures/videos.yml +9 -0
  38. data/test/lib/acts_as_taggable_test.rb +359 -0
  39. data/test/lib/tag_hierarchy_builder_test.rb +109 -0
  40. data/test/lib/tag_list_test.rb +120 -0
  41. data/test/lib/tag_test.rb +45 -0
  42. data/test/lib/tagging_test.rb +14 -0
  43. data/test/lib/tags_helper_test.rb +28 -0
  44. data/test/lib/tags_hierarchy_test.rb +12 -0
  45. data/test/support/activerecord_test_connector.rb +129 -0
  46. data/test/support/custom_asserts.rb +35 -0
  47. data/test/support/database.yml +10 -0
  48. data/test/test_helper.rb +20 -0
  49. metadata +183 -0
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ ZGFiNzE5NjQ1ZWI5YmJmODA3MWQ4OWIyMGVlZTNiNmI5YTY2MjQ1NA==
5
+ data.tar.gz: !binary |-
6
+ MmE1ZjYxYjczZGE0MGNlYzI5YzExZDU4MTE1MTg4MzQzMjRjOTNhZQ==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ Y2MyOGU3ZjE0ZjcyZDY5ODlmYTZlNWNkMDNkYWZmODcyNmJhMWUzNGY4NDQ3
10
+ MDIwYTYxYzkxZTM1ZDliYmE0YzQ2NGUzODEyNzViMDBmYTMxZjgwYTZmYzEy
11
+ ODMzODNhMzc1YzNmMGJlNDgxMTUyNjU3YThiY2RiMzgzMmJkODg=
12
+ data.tar.gz: !binary |-
13
+ OWU5Y2Y5NzA2OWM1NGJiMTU4ODQxYmZhZDlkNzAxMTRkMjAxZGZlNmVkNmVm
14
+ OWM0ZDc0NTlhMmJkYTJhNGI0MTBhNDQzNzc2MTgwMzA5YWM3ZTgxYjkzYjg5
15
+ ZjgzZGM3MDE0ODQwZDI1MzYzNTk4ODAyM2I0ZjQ2M2QyYzE1NTI=
@@ -0,0 +1 @@
1
+ test/debug.log
@@ -0,0 +1,6 @@
1
+ [02 September 2014]
2
+ + Gemify
3
+ + Rails3 compatibility
4
+
5
+ [17 September 2008]
6
+ Initial upload to github ;)
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
@@ -0,0 +1,61 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ acts_as_20ggable (1.0.0)
5
+ activerecord (~> 3.2.0)
6
+
7
+ GEM
8
+ remote: http://rubygems.org/
9
+ specs:
10
+ actionpack (3.2.19)
11
+ activemodel (= 3.2.19)
12
+ activesupport (= 3.2.19)
13
+ builder (~> 3.0.0)
14
+ erubis (~> 2.7.0)
15
+ journey (~> 1.0.4)
16
+ rack (~> 1.4.5)
17
+ rack-cache (~> 1.2)
18
+ rack-test (~> 0.6.1)
19
+ sprockets (~> 2.2.1)
20
+ activemodel (3.2.19)
21
+ activesupport (= 3.2.19)
22
+ builder (~> 3.0.0)
23
+ activerecord (3.2.19)
24
+ activemodel (= 3.2.19)
25
+ activesupport (= 3.2.19)
26
+ arel (~> 3.0.2)
27
+ tzinfo (~> 0.3.29)
28
+ activesupport (3.2.19)
29
+ i18n (~> 0.6, >= 0.6.4)
30
+ multi_json (~> 1.0)
31
+ arel (3.0.3)
32
+ builder (3.0.4)
33
+ erubis (2.7.0)
34
+ hike (1.2.3)
35
+ i18n (0.6.11)
36
+ journey (1.0.4)
37
+ multi_json (1.10.1)
38
+ rack (1.4.5)
39
+ rack-cache (1.2)
40
+ rack (>= 0.4)
41
+ rack-test (0.6.2)
42
+ rack (>= 1.0)
43
+ rake (10.3.2)
44
+ sprockets (2.2.2)
45
+ hike (~> 1.2)
46
+ multi_json (~> 1.0)
47
+ rack (~> 1.0)
48
+ tilt (~> 1.1, != 1.3.0)
49
+ sqlite3 (1.3.9)
50
+ tilt (1.4.1)
51
+ tzinfo (0.3.41)
52
+
53
+ PLATFORMS
54
+ ruby
55
+
56
+ DEPENDENCIES
57
+ actionpack (~> 3.2.0)
58
+ activesupport (~> 3.2.0)
59
+ acts_as_20ggable!
60
+ rake
61
+ sqlite3
data/README ADDED
@@ -0,0 +1,76 @@
1
+ = acts_as_20ggable
2
+
3
+ This gem implements categories ('Tags v2.0') engine inspired by Dmitriy Smirnov's
4
+ post http://spectator.ru/technology/web-building/tags2null .
5
+
6
+ It is heavily based on acts_as_taggable_on_steroids by Jonathan Viney (thanks!):
7
+ http://svn.viney.net.nz/things/rails/plugins/acts_as_taggable_on_steroids/
8
+
9
+ Also, it is under development and not production-ready yet. Look at FIXMEs, TODOs and OPTIMIZEs in code. Interface is subject to change, too.
10
+
11
+ == Instructions & usage
12
+
13
+ Almost everything concerned with original acts_as_taggable_on_steriods applies equally
14
+ to this plugin, so read original README first:
15
+
16
+ http://svn.viney.net.nz/things/rails/plugins/acts_as_taggable_on_steroids/README
17
+
18
+ === Attention
19
+
20
+ TagHierarchyBuilder.rebuild_hierarchy relies on DB transactions. So, on
21
+ non-transactional datastores everything can break suddenly.
22
+
23
+ === Playground configuration
24
+
25
+ Generate and apply migration:
26
+
27
+ ruby script/generate acts_as_20ggable_migration
28
+ rake db:migrate
29
+
30
+ Let's suppose we have photos and we want those photos to have tags:
31
+
32
+ class Photo < ActiveRecord::Base
33
+ acts_as_taggable
34
+ end
35
+
36
+ Also let's suppose we already have some photos with some tags in DB.
37
+
38
+ === Tags hierarchy editing
39
+
40
+ To dump tags hierarchy for editing, use
41
+
42
+ hierarchy = TagHierarchyBuilder.dump_tags # => ['# Categories',
43
+ '# Synonyms',
44
+ '# Unlinked tags',
45
+ 'Nature',
46
+ 'Horse',
47
+ 'Cat',
48
+ 'Kitty',
49
+ 'Animals']
50
+
51
+ Let user edit it as plain text, then to update hierarchy use
52
+
53
+ # hierarchy => ['# Categories',
54
+ 'Nature / Animals',
55
+ 'Animals / Horse',
56
+ 'Nature / Animals / Cat',
57
+ '# Synonyms',
58
+ 'Cat = Kitty']
59
+
60
+ TagHierarchyBuilder.rebuild_hierarchy(hierarchy)
61
+
62
+ Comments ("# …") in hierarchy specification are purely optional and inserted only
63
+ for user convenience.
64
+
65
+ TagHierarchyBuilder can throw TagHierarchyBuilder::WrongSpecificationSyntax or Tag::HierarchyCycle. Errors descriptions still not implemented, sorry.
66
+
67
+ === Finding tagged objects
68
+
69
+ find_tagged_with by default returns models with all subtags:
70
+
71
+ Photo.find_tagged_with('Animals') # => Everything tagged with Animals, Horse, Cat, Kitty
72
+ Photo.find_tagged_with('Animals', :exclude_subtags => true) # => Only tagged with Animals
73
+
74
+ == Other
75
+
76
+ Problems, comments, and suggestions all welcome. avanie@gmail.com
@@ -0,0 +1,24 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rdoc/task'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ desc 'Test the acts_as_taggable_on_steroids plugin.'
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << 'lib'
11
+ t.libs << 'test'
12
+ t.libs << 'test/support'
13
+ t.pattern = 'test/**/*_test.rb'
14
+ t.verbose = true
15
+ end
16
+
17
+ desc 'Generate documentation for the acts_as_taggable_on_steroids plugin.'
18
+ Rake::RDocTask.new(:rdoc) do |rdoc|
19
+ rdoc.rdoc_dir = 'rdoc'
20
+ rdoc.title = 'Acts As Taggable On Steroids'
21
+ rdoc.options << '--line-numbers' << '--inline-source'
22
+ rdoc.rdoc_files.include('README')
23
+ rdoc.rdoc_files.include('lib/**/*.rb')
24
+ end
@@ -0,0 +1,26 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "acts_as_20ggable"
5
+ s.version = "1.0.0"
6
+
7
+ s.authors = ["Dmitriy Timokhin", "Andrey Subbota"]
8
+ s.email = ["subbota@gmail.com"]
9
+ s.homepage = "https://github.com/numbata/acts_as_20ggable"
10
+ s.summary = "Implements categories ('Tags v2.0') engine inspired by Dmitriy Smirnov's"
11
+ s.description = "This gem is rails3.2 compatible version of Dmitriy Timokhin's plugin acts_as_20ggable: https://github.com/pager/acts_as_20ggable"
12
+ s.license = "MIT"
13
+ s.extra_rdoc_files = [
14
+ "CHANGELOG",
15
+ "README"
16
+ ]
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test}/*`.split("\n")
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency "activerecord", '~> 3.2', ">= 3.2.0"
22
+ s.add_development_dependency "rake", "~> 0"
23
+ s.add_development_dependency "actionpack", '~> 3.2', ">= 3.2.0"
24
+ s.add_development_dependency "activesupport", "~> 3.2", ">= 3.2.0"
25
+ s.add_development_dependency "sqlite3", "~> 0"
26
+ end
@@ -0,0 +1,11 @@
1
+ class ActsAs20ggableMigrationGenerator < Rails::Generator::Base
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template 'migration.rb', 'db/migrate'
5
+ end
6
+ end
7
+
8
+ def file_name
9
+ "acts_as_20ggable_migration"
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ class ActsAs20ggableMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :tags do |t|
4
+ t.column :name, :string
5
+ end
6
+
7
+ create_table :taggings do |t|
8
+ t.column :tag_id, :integer
9
+ t.column :taggable_id, :integer
10
+
11
+ # You should make sure that the column created is
12
+ # long enough to store the required class names.
13
+ t.column :taggable_type, :string
14
+
15
+ t.column :created_at, :datetime
16
+ end
17
+
18
+ create_table :tags_hierarchy, :id => false do |t|
19
+ t.column :tag_id, :integer
20
+ t.column :child_id, :integer
21
+ end
22
+
23
+ create_table :tags_transitive_hierarchy, :id => false do |t|
24
+ t.column :tag_id, :integer
25
+ t.column :child_id, :integer
26
+ end
27
+
28
+ create_table :tags_synonyms, :id => false do |t|
29
+ t.column :tag_id, :integer
30
+ t.column :synonym_id, :integer
31
+ end
32
+
33
+ add_index :taggings, :tag_id
34
+ add_index :taggings, [:taggable_id, :taggable_type]
35
+ add_index :tags, :name
36
+ end
37
+
38
+ def self.down
39
+ drop_table :taggings
40
+ drop_table :tags
41
+ drop_table :tags_hierarchy
42
+ drop_table :tags_transitive_hierarchy
43
+ drop_table :tags_synonyms
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ require 'acts_as_taggable'
2
+ require 'tag'
3
+ require 'tag_counts_extension'
4
+ require 'tag_hierarchy_builder'
5
+ require 'tag_list'
6
+ require 'tagging'
7
+ require 'tags_helper'
@@ -0,0 +1,201 @@
1
+ require 'active_record'
2
+ module ActiveRecord
3
+ module Acts
4
+ module Taggable
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ module ClassMethods
9
+ def acts_as_taggable
10
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
11
+ has_many :tags, :through => :taggings
12
+
13
+ before_save :save_cached_tag_list
14
+ after_save :save_tags
15
+
16
+ extend ActiveRecord::Acts::Taggable::SingletonMethods
17
+
18
+ alias_method_chain :reload, :tag_list
19
+ end
20
+
21
+ def cached_tag_list_column_name
22
+ :cached_tag_list
23
+ end
24
+
25
+ def set_cached_tag_list_column_name(value = nil, &block)
26
+ define_attr_method :cached_tag_list_column_name, value, &block
27
+ end
28
+ end
29
+
30
+ module SingletonMethods
31
+ # Returns an array of related tags.
32
+ # Related tags are all the other tags that are found on the models tagged with the provided tags.
33
+ #
34
+ # Pass either a tag, string, or an array of strings or tags.
35
+ #
36
+ # Options:
37
+ # :order - SQL Order how to order the tags. Defaults to "count DESC, tags.name".
38
+ def find_related_tags(tags, options = {})
39
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
40
+
41
+ related_models = find_tagged_with(tags)
42
+
43
+ return [] unless related_models.exists?
44
+
45
+ related_ids = related_models.select("distinct #{table_name}.id")
46
+
47
+ Tag.select("#{Tag.table_name}.*, COUNT(#{Tag.table_name}.id) AS count").
48
+ joins("JOIN #{Tagging.table_name} ON #{Tagging.table_name}.taggable_type = '#{base_class.name}'
49
+ AND #{Tagging.table_name}.taggable_id IN (#{related_ids.to_sql})
50
+ AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id").
51
+ order(options[:order] || "count DESC, #{Tag.table_name}.name").
52
+ group("#{Tag.table_name}.id, #{Tag.table_name}.name HAVING LOWER(#{Tag.table_name}.name) NOT IN (#{tags.map { |n| quote_value(n.downcase) }.join(",")})")
53
+ end
54
+
55
+ # Pass either a tag, string, or an array of strings or tags.
56
+ #
57
+ # Options:
58
+ # :exclude - Find models that are not tagged with the given tags
59
+ # :match_all - Find models that match all of the given tags, not just one
60
+ # :conditions - A piece of SQL conditions to add to the query
61
+ # :exclude_subtags - Find models that are tagged with only given tags, not their subtags
62
+ def find_tagged_with(tags, options = {})
63
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
64
+
65
+ options = options.dup
66
+ exclude_subtags = options.delete(:exclude_subtags)
67
+
68
+ return where("1=0") if tags.empty?
69
+
70
+ taggings_alias = "#{table_name}_taggings"
71
+ tags_alias = "#{table_name}_tags"
72
+
73
+ results = base_class.joins("INNER 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)} " +
74
+ "INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id")
75
+
76
+ if options.delete(:exclude)
77
+ tc = tags_condition(tags, Tag.table_name, !exclude_subtags)
78
+ results = results.where("#{table_name}.id NOT IN
79
+ (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name}
80
+ INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
81
+ WHERE #{tc} AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})")
82
+ else
83
+ if options.delete(:match_all)
84
+ tc = tags_condition(tags, Tag.table_name, !exclude_subtags)
85
+ results = results.where("
86
+ (SELECT COUNT(*) FROM #{Tagging.table_name}
87
+ INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
88
+ WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)} AND
89
+ taggable_id = #{table_name}.id AND
90
+ #{tc}) = #{tags.size}")
91
+ else
92
+ results = results.where(tags_condition(tags, tags_alias, !exclude_subtags))
93
+ end
94
+ end
95
+
96
+ results
97
+ end
98
+
99
+ # Calculate the tag counts for all tags.
100
+ #
101
+ # See Tag.counts for available options.
102
+ def tag_counts(options = {})
103
+ Tag.find(:all, find_options_for_tag_counts(options))
104
+ end
105
+
106
+ def find_options_for_tag_counts(options = {})
107
+ options = options.dup
108
+ scope = scoped
109
+
110
+ conditions = []
111
+ conditions << send(:sanitize_conditions, options.delete(:conditions)) if options[:conditions]
112
+ conditions << scope.where_values.reduce(:and).to_sql if scope.where_values.any?
113
+ conditions << "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}"
114
+ conditions << type_condition.to_sql unless descends_from_active_record?
115
+ conditions.compact!
116
+ conditions = conditions.join(" AND ")
117
+
118
+ joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"]
119
+ joins << options.delete(:joins) if options[:joins]
120
+ joins << scope.joins_values.map(&:to_sql).join if scope.joins_values.any?
121
+ joins = joins.join(" ")
122
+
123
+ options = { :conditions => conditions, :joins => joins }.update(options)
124
+
125
+ Tag.options_for_counts(options)
126
+ end
127
+
128
+ def caching_tag_list?
129
+ column_names.include?(cached_tag_list_column_name.to_s)
130
+ end
131
+
132
+ private
133
+ def tags_condition(tags, table_name = Tag.table_name, include_subtags = true)
134
+ # FIXME N+1
135
+ tags += tags.map do |tag_name|
136
+ tag = Tag.find_with_like_by_name(tag_name)
137
+ tag ? tag.transitive_children.find(:all).map(&:name) : []
138
+ end.flatten if include_subtags
139
+ condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ")
140
+ condition.blank? ? '(1=0)' : "(" + condition + ")"
141
+ end
142
+ end
143
+
144
+ included do
145
+ def tag_list
146
+ return @tag_list if @tag_list
147
+
148
+ if self.class.caching_tag_list? and !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
149
+ @tag_list = TagList.from(cached_value)
150
+ else
151
+ @tag_list = TagList.new(*tags.map(&:name))
152
+ end
153
+ end
154
+
155
+ def tag_list=(value)
156
+ @tag_list = TagList.from(value)
157
+ end
158
+
159
+ def save_cached_tag_list
160
+ if self.class.caching_tag_list?
161
+ self[self.class.cached_tag_list_column_name] = tag_list.to_s
162
+ end
163
+ end
164
+
165
+ def save_tags
166
+ return unless @tag_list
167
+
168
+ new_tag_names = @tag_list - tags.map(&:name)
169
+ old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
170
+
171
+ self.class.transaction do
172
+ if old_tags.any?
173
+ taggings.find(:all, :conditions => ["tag_id IN (?)", old_tags.map(&:id)]).each(&:destroy)
174
+ taggings.reset
175
+ end
176
+
177
+ new_tag_names.each do |new_tag_name|
178
+ tags << Tag.find_or_create_with_like_by_name(new_tag_name)
179
+ end
180
+ end
181
+
182
+ true
183
+ end
184
+
185
+ # Calculate the tag counts for the tags used by this model.
186
+ #
187
+ # The possible options are the same as the tag_counts class method, excluding :conditions.
188
+ def tag_counts(options = {})
189
+ self.class.tag_counts({ :conditions => self.class.send(:tags_condition, tag_list, Tag.table_name, false) }.reverse_merge!(options))
190
+ end
191
+
192
+ def reload_with_tag_list(*args) #:nodoc:
193
+ @tag_list = nil
194
+ reload_without_tag_list(*args)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)