acts_as_20ggable 1.0.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.
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)