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,146 @@
1
+ class Tag < ActiveRecord::Base
2
+ class HierarchyCycle < StandardError; end
3
+
4
+ SYMBOL = /[^#=\/]/.freeze
5
+
6
+ attr_accessor :mark
7
+ attr_accessible :mark, :name
8
+
9
+ has_many :taggings, :dependent => :destroy
10
+
11
+ validates_presence_of :name
12
+ validates_uniqueness_of :name
13
+ validates_format_of :name, :with => /\A#{SYMBOL}*\Z/
14
+ validates_format_of :name, :with => /\A\S\Z|\A\S.*\S\Z/
15
+
16
+ cattr_accessor :destroy_unused
17
+ self.destroy_unused = false
18
+
19
+ has_and_belongs_to_many :children, :class_name => 'Tag', :foreign_key => 'tag_id',
20
+ :association_foreign_key => 'child_id',
21
+ :join_table => 'tags_hierarchy'
22
+
23
+ has_and_belongs_to_many :parents, :class_name => 'Tag', :foreign_key => 'child_id',
24
+ :association_foreign_key => 'tag_id',
25
+ :join_table => 'tags_hierarchy'
26
+
27
+ has_and_belongs_to_many :transitive_children, :class_name => 'Tag', :foreign_key => 'tag_id',
28
+ :association_foreign_key => 'child_id',
29
+ :join_table => 'tags_transitive_hierarchy'
30
+
31
+ has_and_belongs_to_many :synonyms, :class_name => 'Tag', :foreign_key => 'tag_id',
32
+ :association_foreign_key => 'synonym_id',
33
+ :join_table => 'tags_synonyms'
34
+
35
+ scope :with_joined_hierarchy_and_synonyms, -> {
36
+ joins("LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_parent ON tags_hierarchy_parent.tag_id = #{Tag.table_name}.id "+
37
+ "LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_child ON tags_hierarchy_child.child_id = #{Tag.table_name}.id "+
38
+ "LEFT OUTER JOIN tags_synonyms AS tags_synonyms_left ON tags_synonyms_left.tag_id = #{Tag.table_name}.id " +
39
+ "LEFT OUTER JOIN tags_synonyms AS tags_synonyms_right ON tags_synonyms_right.synonym_id = #{Tag.table_name}.id ")
40
+ }
41
+
42
+ scope :with_joined_hierarchy, -> {
43
+ joins("LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_parent ON tags_hierarchy_parent.tag_id = #{Tag.table_name}.id "+
44
+ "LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_child ON tags_hierarchy_child.child_id = #{Tag.table_name}.id")
45
+ }
46
+
47
+
48
+ # Scopes for "With joined hierarchy"
49
+ scope :without_children, -> { where('tags_hierarchy_parent.child_id IS NULL') }
50
+ scope :without_parents, -> { where('tags_hierarchy_child.tag_id IS NULL') }
51
+ scope :with_parents, -> { where('tags_hierarchy_child.tag_id IS NOT NULL') }
52
+ scope :without_synonyms, -> { where('tags_synonyms_left.synonym_id IS NULL AND tags_synonyms_right.tag_id IS NULL') }
53
+
54
+
55
+ # LIKE is used for cross-database case-insensitivity
56
+ def self.find_or_create_with_like_by_name(name)
57
+ find_with_like_by_name(name) || create(:name => name)
58
+ end
59
+
60
+ def self.find_or_create_with_like_by_name!(name)
61
+ find_with_like_by_name(name) || create!(:name => name)
62
+ end
63
+
64
+ def self.find_with_like_by_name(name)
65
+ where("name LIKE ?", name).first
66
+ end
67
+
68
+ def ==(object)
69
+ super || (object.is_a?(Tag) && name == object.name)
70
+ end
71
+
72
+ def to_s
73
+ name
74
+ end
75
+
76
+ def to_param
77
+ name
78
+ end
79
+
80
+ def count
81
+ read_attribute(:count).to_i
82
+ end
83
+
84
+ def marked?
85
+ read_attribute(:mark).to_i == 1
86
+ end
87
+
88
+ def check_mark!(condition)
89
+ self.mark = taggings.find(:first, :conditions => condition) ? 1 : 0
90
+ end
91
+
92
+ class << self
93
+ # Calculate the tag counts for all tags.
94
+ # :start_at - Restrict the tags to those created after a certain time
95
+ # :end_at - Restrict the tags to those created before a certain time
96
+ # :conditions - A piece of SQL conditions to add to the query
97
+ # :limit - The maximum number of tags to return
98
+ # :order - A piece of SQL to order by. Eg 'count desc' or 'taggings.created_at desc'
99
+ # :at_least - Exclude tags with a frequency less than the given value
100
+ # :at_most - Exclude tags with a frequency greater than the given value
101
+ # :mark_condition - Set 'mark' attribute on tags conforms this condition
102
+ # primarliy used with per-user taggings like ['taggings.create_by_id = ?', user.id]
103
+
104
+ def counts(options = {})
105
+ find(:all, options_for_counts(options))
106
+ end
107
+
108
+ def options_for_counts(options = {})
109
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :joins, :mark_condition
110
+ options = options.dup
111
+
112
+ start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
113
+ end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
114
+
115
+ conditions = [
116
+ options.delete(:conditions),
117
+ start_at,
118
+ end_at
119
+ ].compact
120
+
121
+ conditions = conditions.any? ? conditions.join(' AND ') : nil
122
+
123
+ mark_condition = options.delete(:mark_condition)
124
+ mark_condition = sanitize_sql(mark_condition) if mark_condition
125
+ mark_select = "GROUP_CONCAT(DISTINCT IF((#{sanitize_sql(mark_condition)}), 1, NULL)) as mark" if mark_condition
126
+ base_select = "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count"
127
+
128
+ select = [ base_select, mark_select ].compact.join(', ')
129
+
130
+ joins = ["INNER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
131
+ joins << options.delete(:joins) if options[:joins]
132
+
133
+ at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
134
+ at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
135
+ having = [at_least, at_most].compact.join(' AND ')
136
+ group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0"
137
+ group_by << " AND #{having}" unless having.blank?
138
+
139
+ { :select => select,
140
+ :joins => joins.join(" "),
141
+ :conditions => conditions,
142
+ :group => group_by
143
+ }.update(options)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,3 @@
1
+ # Deprecated
2
+ module TagCountsExtension #:nodoc:
3
+ end
@@ -0,0 +1,201 @@
1
+ class TagHierarchyBuilder
2
+ class WrongSpecificationSyntax < StandardError; end
3
+
4
+ def self.rebuild_transitive_closure
5
+ Tag.transaction do
6
+ Tag.connection.execute('DELETE from tags_transitive_hierarchy')
7
+
8
+ # (0) — тесты!
9
+ # OPTIMIZE
10
+ tags = Tag.includes(:synonyms, :children, :transitive_children)
11
+ transitive_children = { }
12
+
13
+ tags.each do |tag|
14
+ transitive_children[tag] = []
15
+ tag.children.each do |tag_child|
16
+ transitive_children[tag] << tag_child
17
+ end
18
+ end
19
+
20
+ visited_tags = []
21
+ root = {} ; comp = [] ; stack = []
22
+ tags.each do |outer_tag|
23
+ next if visited_tags.include?(outer_tag)
24
+ reclambda do |this, tag|
25
+ visited_tags << tag
26
+ root[tag] = tag; stack.push(tag)
27
+ tag.children.each do |child|
28
+ this.call(child) unless visited_tags.include?(child)
29
+ if !comp.include?(child)
30
+ root[tag] = visited_tags.index(tag) < visited_tags.index(child) ? tag : child
31
+ end
32
+ transitive_children[tag] += transitive_children[child]
33
+ end
34
+ if root[tag] == tag
35
+ loop do
36
+ w = stack.pop
37
+ comp << w
38
+ transitive_children[w] = transitive_children[tag] # Pointer assignment!
39
+ break if w == tag
40
+ end
41
+ end
42
+ end.call(outer_tag)
43
+ end
44
+
45
+ tags.each do |tag|
46
+ tag.synonyms.each do |synonym|
47
+ transitive_children[tag] << synonym unless transitive_children[tag].include?(synonym)
48
+ transitive_children[synonym] << tag unless transitive_children[synonym].include?(tag)
49
+ end
50
+ end
51
+
52
+ tags.each do |tag|
53
+ tag.transitive_children = transitive_children[tag].uniq
54
+ end
55
+ end
56
+ end
57
+
58
+ def self.rebuild_hierarchy(specification)
59
+ # TODO save old hierarchy somewhere.
60
+
61
+ Tag.transaction do
62
+ Tag.connection.execute('DELETE from tags_hierarchy')
63
+ Tag.connection.execute('DELETE from tags_synonyms')
64
+
65
+ specification.each do |line|
66
+ next if line.blank?
67
+ next if line =~ /^\s*#.*/ # If line is a comment
68
+ next if line =~ /^\s*#{Tag::SYMBOL}+\s*$/ # If line is a single tag
69
+ begin
70
+ if line =~ /^\s*#{Tag::SYMBOL}+\s*(=\s*#{Tag::SYMBOL}+\s*)+$/
71
+ instantiate_synonyms(line)
72
+ next
73
+ end
74
+
75
+ if line =~ /^\s*#{Tag::SYMBOL}+\s*(\/\s*#{Tag::SYMBOL}+\s*)+$/
76
+ instantiate_hierarchy(line)
77
+ next
78
+ end
79
+
80
+ raise WrongSpecificationSyntax.new("Line #{line}")
81
+ rescue ActiveRecord::RecordInvalid => _
82
+ raise WrongSpecificationSyntax.new("Line #{line}")
83
+ end
84
+ end
85
+
86
+ hierarchy_acyclic? or raise Tag::HierarchyCycle
87
+ rebuild_transitive_closure
88
+ end
89
+ end
90
+
91
+ # Input should be validated
92
+ def self.instantiate_synonyms(line)
93
+ # (2) TODO validate synonyms repetition? Like Cat = Kitty and Kitty = Cat
94
+ # То есть ни один из «синонимов» не может быть в «стволе»
95
+ # (3) FIXME а ведь можно намутить цикл при помощи сочетания синонимов и иерархии.
96
+ # Ни один из синонимов не может участвовать в «иерархии»
97
+ # То есть нам нужна flat иерархия, flat ствол и flat синонимы
98
+ # Причём хранить с номерами строк и выдавать ошибки «в такой-то строке»
99
+ # (4) TODO Мы хотим гламурные сообщения о циклах. Для этого опять-таки нужно можно воспользоваться тем flatten.
100
+ # Итого тесты. Сообщения об ошибках:
101
+ # "Левый синтаксис в строке 5"
102
+ # "Синоним AAA из строки 15 участвует в иерархии в строках …"
103
+ # "Синоним BBB из строки 15 повторяется в строке …"
104
+ syns = line.split('=').map(&:strip)
105
+
106
+ b = Tag.find_or_create_with_like_by_name!(syns.shift)
107
+ syns.each do |syn|
108
+ b.synonyms << Tag.find_or_create_with_like_by_name!(syn)
109
+ end
110
+ end
111
+
112
+ def self.instantiate_hierarchy(line)
113
+ line = line.split('/').map(&:strip)
114
+
115
+ line.each_cons(2) do |(p, c)|
116
+ p = Tag.find_or_create_with_like_by_name!(p)
117
+ c = Tag.find_or_create_with_like_by_name!(c)
118
+
119
+ raise Tag::HierarchyCycle.new if c.parents.include?(p) || c == p
120
+
121
+ c.parents << p
122
+ end
123
+ end
124
+
125
+ def self.hierarchy_acyclic?
126
+ # OPTIMIZE
127
+ tags = Tag.includes(:children)
128
+
129
+ tags_status = tags.map { |x| [ x, :unvisited ] }.flatten
130
+ tags_status = Hash[*tags_status]
131
+
132
+ tags.each do |tag|
133
+ next if tags_status[tag] != :unvisited
134
+ (reclambda do |this, tag|
135
+ return false if tags_status[tag] == :processing
136
+ tags_status[tag] = :processing
137
+ tag.children.any? do |child|
138
+ this.call(child)
139
+ end
140
+ tags_status[tag] = :closed
141
+ end).call(tag)
142
+ end
143
+ return true
144
+ end
145
+
146
+ # ==== DUMPER ======
147
+
148
+ def self.dump_tags
149
+ ['# Categories'] +
150
+ dump_hierarchy.map { |chain| chain * ' / '} +
151
+ ['', '# Synonyms'] +
152
+ dump_synonyms.map { |chain| chain * ' = '} +
153
+ ['', '# Unlinked tags'] +
154
+ dump_orphans
155
+ end
156
+
157
+ def self.dump_hierarchy
158
+ tags_chains = Tag.with_joined_hierarchy.without_children.with_parents.find(:all).map { |x| [x] }
159
+
160
+ # OPTIMIZE
161
+ while chain = tags_chains.detect { |chain| !chain.first.parents.empty? }
162
+ tags_chains.delete(chain)
163
+ chain.first.parents.each do |parent|
164
+ raise Tag::HierarchyCycle.new if chain.include?(parent)
165
+ tags_chains << ([parent] + chain)
166
+ end
167
+ end
168
+
169
+ tags_chains.map { |chain| chain.map { |tag| tag.name } }.sort_by { |chain| chain * ' ' }
170
+ end
171
+
172
+ def self.dump_synonyms
173
+ # OPTIMIZE N+1
174
+ tags_with_synonyms = Tag.includes(:synonyms)
175
+
176
+ tags_with_synonyms.map { |t|
177
+ [ t.name, *t.synonyms.pluck(:name).sort ]
178
+ }.keep_if(&:second)
179
+ end
180
+
181
+ def self.dump_orphans
182
+ # OPTIMIZE
183
+ Tag.with_joined_hierarchy_and_synonyms.without_children.without_parents.without_synonyms.
184
+ pluck(:name)
185
+ end
186
+ end
187
+
188
+ # FIXME move somewhere, uhm
189
+ def reclambda
190
+ lambda do |f|
191
+ f.call(f)
192
+ end.call(lambda do |f|
193
+ lambda do |this|
194
+ lambda do |*args|
195
+ yield(this, *args)
196
+ end
197
+ end.call(lambda do |x|
198
+ f.call(f).call(x)
199
+ end)
200
+ end)
201
+ end
@@ -0,0 +1,108 @@
1
+ class TagList < Array
2
+ cattr_accessor :delimiter
3
+ self.delimiter = ','
4
+
5
+ def initialize(*args)
6
+ add(*args)
7
+ end
8
+
9
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
10
+ #
11
+ # tag_list.add("Fun", "Happy")
12
+ #
13
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
14
+ #
15
+ # tag_list.add("Fun, Happy", :parse => true)
16
+ def add(*names)
17
+ extract_and_apply_options!(names)
18
+ concat(names)
19
+ clean!
20
+ self
21
+ end
22
+
23
+ # Remove specific tags from the tag_list.
24
+ #
25
+ # tag_list.remove("Sad", "Lonely")
26
+ #
27
+ # Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
28
+ #
29
+ # tag_list.remove("Sad, Lonely", :parse => true)
30
+ def remove(*names)
31
+ extract_and_apply_options!(names)
32
+ delete_if { |name| names.include?(name) }
33
+ self
34
+ end
35
+
36
+ # Toggle the presence of the given tags.
37
+ # If a tag is already in the list it is removed, otherwise it is added.
38
+ def toggle(*names)
39
+ extract_and_apply_options!(names)
40
+
41
+ names.each do |name|
42
+ include?(name) ? delete(name) : push(name)
43
+ end
44
+
45
+ clean!
46
+ self
47
+ end
48
+
49
+ # Transform the tag_list into a tag string suitable for edting in a form.
50
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
51
+ #
52
+ # tag_list = TagList.new("Round", "Square,Cube")
53
+ # tag_list.to_s # 'Round, "Square,Cube"'
54
+ def to_s
55
+ clean!
56
+
57
+ map do |name|
58
+ name.include?(delimiter) ? "\"#{name}\"" : name
59
+ end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
60
+ end
61
+
62
+ private
63
+ # Remove whitespace, duplicates, and blanks.
64
+ def clean!
65
+ reject!(&:blank?)
66
+ map!(&:strip)
67
+ uniq!
68
+ end
69
+
70
+ def extract_and_apply_options!(args)
71
+ options = args.last.is_a?(Hash) ? args.pop : {}
72
+ options.assert_valid_keys :parse
73
+
74
+ if options[:parse]
75
+ args.map! { |a| self.class.from(a) }
76
+ end
77
+
78
+ args.flatten!
79
+ end
80
+
81
+ class << self
82
+ # Returns a new TagList using the given tag string.
83
+ #
84
+ # tag_list = TagList.from("One , Two, Three")
85
+ # tag_list # ["One", "Two", "Three"]
86
+ def from(source)
87
+ new.tap do |tag_list|
88
+
89
+ case source
90
+ when Array
91
+ tag_list.add(source)
92
+ else
93
+ string = source.to_s.dup
94
+
95
+ # Parse the quoted tags
96
+ [
97
+ /\s*#{delimiter}\s*(['"])(.*?)\1\s*/,
98
+ /^\s*(['"])(.*?)\1\s*#{delimiter}?/
99
+ ].each do |re|
100
+ string.gsub!(re) { tag_list << $2; "" }
101
+ end
102
+
103
+ tag_list.add(string.split(delimiter))
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end