genki-dm-is-taggable 0.1

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,189 @@
1
+ module DataMapper
2
+ module Is
3
+ module Taggable
4
+
5
+ def is_tag(options=nil)
6
+ extend DataMapper::Is::Taggable::SharedClassMethods
7
+ include DataMapper::Is::Taggable::SharedInstanceMethods
8
+ extend DataMapper::Is::Taggable::TagClassMethods
9
+ include DataMapper::Is::Taggable::TagInstanceMethods
10
+ property :id, DataMapper::Types::Serial
11
+ property :name, String, :length => 255, :unique => true, :nullable => false
12
+ has n, :taggings
13
+ @tagger_classes ||= []
14
+ @taggable_classes ||= []
15
+ end
16
+
17
+ module TagClassMethods
18
+ attr_reader :tagger
19
+ attr_reader :tagger_classes
20
+ attr_reader :taggable_classes
21
+ #TODO: check if there is any concurrency problem here
22
+ # NOT thread safe right now!!
23
+ # need to make it safer!!
24
+ def as(tagger_or_tagger_class, &block)
25
+ self.tagger = tagger_or_tagger_class
26
+ raise Exception("A block must be provided!") unless block_given?
27
+ yield
28
+ self.tagger=nil
29
+ end
30
+
31
+ def tagged_count(options={})
32
+ tagger, taggable, tag_list, options = extract_options(options)
33
+ tagger_class, tagger_obj = extract_tagger_class_object(tagger)
34
+ taggable_class, taggable_obj = extract_taggable_class_object(taggable)
35
+
36
+ association = Tagging
37
+ association = association.by(tagger) if tagger
38
+ association = association.on(taggable) if taggable
39
+
40
+ query = {:unique => true, :fields => [:taggable_type]}
41
+ query.merge!(Tagging.tag.name => tag_list.to_a) unless tag_list.empty?
42
+ query.merge!(options)
43
+
44
+ association.aggregate(:taggable_id.count, query).inject(0){|count, i| count + i[1]}
45
+ end
46
+
47
+ def find_taggables(options)
48
+ tagger, taggable, tag_list, options = extract_options(options)
49
+ tagger_class, tagger_obj = extract_tagger_class_object(tagger)
50
+ taggable_class, taggable_obj = extract_taggable_class_object(taggable)
51
+
52
+ if taggable_class.nil?
53
+ rv = Tag.taggable_classes.map{|klass| find_taggables(options.merge(:with => tag_list, :on =>klass, :by => tagger) )}.flatten!
54
+ rv.uniq!
55
+ return rv
56
+ end
57
+
58
+ query = { taggable_class.tags.tag.name => tag_list.to_a,
59
+ Tagging.properties[:taggable_type] => taggable_class.to_s,
60
+ :unique => true
61
+ }
62
+ query.merge!(Tagging.properties[:tagger_type] => tagger_class) if tagger_class
63
+ query.merge!(Tagging.properties[:tagger_id] => tagger_obj.id) if tagger_obj
64
+
65
+ unless options[:match] == :any
66
+ conditions = "SELECT COUNT(DISTINCT(tag_id)) FROM taggings INNER JOIN tags ON taggings.tag_id = tags.id WHERE "
67
+ counter_conditions = [
68
+ "taggings.taggable_type = '#{taggable_class.to_s}'",
69
+ "taggings.taggable_id = #{taggable_class.storage_name}.id",
70
+ "tags.name IN ?"
71
+ ]
72
+ counter_conditions << "taggings.tagger_type = '#{tagger_class.to_s}'" if tagger_class
73
+ counter_conditions << "taggings.tagger_id = #{tagger_obj.id}" if tagger_obj
74
+ conditions = "(" << conditions << counter_conditions.join(" AND ") << ") = ?"
75
+ conditions = [conditions, tag_list, tag_list.size]
76
+ query.merge!(:conditions => conditions)
77
+ end
78
+ taggable_class.all(query)
79
+ end
80
+
81
+ def fetch(name)
82
+ first(:name => name) || create(:name => name)
83
+ end
84
+
85
+ def get(name)
86
+ first(:name => name)
87
+ end
88
+
89
+ private
90
+ def tagger=(tagger)
91
+ unless (tagger.respond_to?(:tagger?) && tagger.tagger?) || tagger.nil?
92
+ raise Exception.new("#{tagger} is not a tagger datamapper resource object!")
93
+ end
94
+ @tagger = tagger
95
+ end
96
+ end # ClassMethods
97
+
98
+ module TagInstanceMethods
99
+ def <=>(other)
100
+ self.name <=>(other.name)
101
+ end
102
+ def to_s
103
+ self.name
104
+ end
105
+
106
+ # Returns an array of related tags.
107
+ # Related tags are all the other tags that are found tagged with the provided tags.
108
+ def related
109
+ sql = "SELECT count(*) AS count, tag_id
110
+ FROM
111
+ (SELECT DISTINCT t2.tag_id AS tag_id, t2.tagger_id, t2.tagger_type, t2.taggable_id, t2.taggable_type
112
+ FROM taggings AS t1
113
+ INNER JOIN taggings AS t2
114
+ ON ( t2.taggable_id = t1.taggable_id AND t2.taggable_type = t1.taggable_type)
115
+ WHERE t1.tag_id = #{self.id} AND t2.tag_id != #{self.id})
116
+ AS a
117
+ GROUP BY tag_id
118
+ HAVING count(*) > 1
119
+ ORDER BY count(*) DESC;"
120
+
121
+ # get a hash with tag_id => tag count
122
+ tags = repository.adapter.query(sql).inject({}){|h, t| h[t[1].to_i] = t[0].to_i; h}
123
+
124
+ # get all the tag resources
125
+ tag_objects = Tag.all(:id => tags.keys)
126
+
127
+ # turn it into an array like this [[count, tag], [count, tag] ...]
128
+ # sorted by count in descending order
129
+ tag_objects.collect do |t|
130
+ [tags[t.id], t]
131
+ end.sort{|a, b| a[0] <=> b[0]}.reverse
132
+ end
133
+
134
+ def popular_by_tags
135
+ sql= "SELECT tagger_type, tagger_id, COUNT( taggable_id ) AS counter
136
+ FROM taggings
137
+ INNER JOIN tags ON taggings.tag_id = tags.id
138
+ WHERE tags.name = '#{self.name}'
139
+ GROUP BY tagger_type, tagger_id
140
+ ORDER BY counter DESC"
141
+ tagger_columns = repository.adapter.query(sql)
142
+ tagger_hash = {}
143
+ counter_hash = {}
144
+ # group all keys
145
+ tagger_columns.each do |t|
146
+ t = t.to_a
147
+ tagger_hash[t[0]] ||= []
148
+ tagger_hash[t[0]] << t[1]
149
+ count = t.pop
150
+ counter_hash[t] = count
151
+ end
152
+ taggers = []
153
+ # find all taggers
154
+ tagger_hash.each_pair do |key, value|
155
+ taggers = taggers + Extlib::Inflection.constantize(key).all(:id => value)
156
+ end
157
+ # sort the taggers by count
158
+ taggers.sort! do |a, b|
159
+ counter_hash[[a.class.to_s, a.id]] <=>counter_hash[[b.class.to_s, b.id]]
160
+ end.reverse!
161
+
162
+ taggers
163
+ end
164
+
165
+ def tagged_together_count
166
+ end
167
+
168
+ def tagged_count(conditions={})
169
+ taggable_class, taggable_obj = extract_taggable_class_object(conditions.delete(:on))
170
+ tagger_class, tagger_obj = extract_tagger_class_object(conditions.delete(:by))
171
+
172
+ association = if taggable_class && taggable_class.is_a?(Class) && taggable_class.taggable?
173
+ taggable_class.all.taggings
174
+ else
175
+ Tagging.all
176
+ end
177
+
178
+ association = association.all(:tagger_type => tagger_class.to_s) if tagger_class
179
+ association = association.all(:tagger_id => tagger_obj.id) if tagger_obj
180
+
181
+ query = {:unique => true, :tag_id => self.id, :fields => [:taggable_type]}
182
+ query.merge!(conditions)
183
+
184
+ association.aggregate(:taggable_id.count, query).inject(0){|count, i| count + i[1]}
185
+ end
186
+ end # InstanceMethods
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,114 @@
1
+ module DataMapper
2
+ module Is
3
+ module Taggable
4
+
5
+ def is_taggable(options)
6
+ extend DataMapper::Is::Taggable::SharedClassMethods
7
+ include DataMapper::Is::Taggable::SharedInstanceMethods
8
+ extend DataMapper::Is::Taggable::TaggableClassMethods
9
+ include DataMapper::Is::Taggable::TaggableInstanceMethods
10
+
11
+ @tagger_classes = options[:by]
12
+ Tag.instance_variable_set('@tagger_classes', (Tag.instance_variable_get('@tagger_classes') | @tagger_classes))
13
+
14
+ has n, :taggings, :class_name => "Tagging", :child_key => [:taggable_id], :taggable_type => self.to_s
15
+ # real ugly syntax... wait until dm make better conditional has n :through association better, than update code
16
+ has n, :tags, :through => :taggings, :class_name => "Tag",
17
+ :child_key => [:taggable_id],
18
+ Tagging.properties[:taggable_type] => self.to_s,
19
+ :unique => true
20
+
21
+ tagger_classes.each do |class_name|
22
+ has n, "taggings_by_#{class_name.snake_case.plural}".intern,
23
+ :class_name => "Tagging", :child_key => [:taggable_id],
24
+ :taggable_type => self.to_s, :tagger_type => class_name
25
+
26
+ has n, "tags_by_#{class_name.snake_case.plural}".intern, :through => :taggings,
27
+ :class_name => "Tag", :child_key => [:taggable_id],
28
+ Tagging.properties[:taggable_type] => self.to_s,
29
+ Tagging.properties[:tagger_type] => class_name,
30
+ :remote_name => "tag"
31
+
32
+ class_eval <<-TAGS
33
+ def count_tags_by_#{class_name.snake_case.plural}(conditions={})
34
+ conditions = {:unique => true}.merge(conditions)
35
+ tags_by_#{class_name.snake_case.plural}.count(:id, conditions)
36
+ end
37
+ TAGS
38
+ end
39
+
40
+ after :save, :tag_now_with_taglist
41
+ before :destroy, :destroy_all_taggings
42
+ end
43
+
44
+ module TaggableClassMethods
45
+ def tagger_classes
46
+ @tagger_classes.map{|klass| klass.to_s.singular.camel_case}
47
+ end
48
+
49
+ def tagger?;false;end
50
+ def taggable?;true;end
51
+ def taggable_class;self;end
52
+
53
+ def find(options)
54
+ tagger, taggable, tags, options = extract_options(options)
55
+ options.merge!(:on => self, :by =>tagger, :with => tags)
56
+ Tag.find_taggables(options)
57
+ end
58
+ end # ClassMethods
59
+
60
+ module TaggableInstanceMethods
61
+ def tagger?;false;end
62
+ def taggable?;true;end
63
+ def taggable_class;self.class;end
64
+ def taggable;self;end
65
+
66
+ def taglist
67
+ @taglist || TagList.new(self.tags.collect{|t| t.name}).to_s
68
+ end
69
+
70
+ def taglist=(ataglist)
71
+ # if the object is a new record, we will tag the object after save
72
+ # if the object isn't a new record, we tag the object now
73
+ @taglist = ataglist
74
+ tag_now_with_taglist unless new_record?
75
+ true
76
+ end
77
+
78
+ def can_tag_by?(tagger)
79
+ if tagger.is_a?(Class)
80
+ return self.class.tagger_classes.include?(tagger)
81
+ end
82
+ return self.class.tagger_classes.include?(tagger.class)
83
+ end
84
+
85
+ def tag(options)
86
+ tagger, taggable, tags = extract_options(options)
87
+ taggable = self
88
+ # TODO: verify tagger and taggable
89
+ self.class.create_taggings(tagger, taggable, tags)
90
+ end
91
+
92
+ def count_tags(conditions={})
93
+ conditions = {:unique => true}.merge(conditions)
94
+ tags.count(:id, conditions)
95
+ end
96
+
97
+ protected
98
+ def tag_now_with_taglist
99
+ return unless @taglist
100
+ # Destroy all the taggings
101
+ destroy_all_taggings unless self.new_record?
102
+
103
+ # Tag with the tag list
104
+ self.tag(:with => TagList.from(@taglist))
105
+ @taglist = nil
106
+ end
107
+
108
+ def destroy_all_taggings
109
+ self.taggings.destroy!
110
+ end
111
+ end # InstanceMethods
112
+ end # Taggable
113
+ end # Is
114
+ end # DataMapper
@@ -0,0 +1,80 @@
1
+ module DataMapper
2
+ module Is
3
+ module Taggable
4
+
5
+ def is_tagger(options)
6
+ extend DataMapper::Is::Taggable::SharedClassMethods
7
+ include DataMapper::Is::Taggable::SharedInstanceMethods
8
+ extend DataMapper::Is::Taggable::TaggerClassMethods
9
+ include DataMapper::Is::Taggable::TaggerInstanceMethods
10
+
11
+ @taggable_classes = options[:on]
12
+ Tag.instance_variable_set('@taggable_classes', (Tag.instance_variable_get('@taggable_classes') | @taggable_classes))
13
+
14
+ has n, :taggings, :class_name => "Tagging", :child_key => [:tagger_id], :tagger_type => self.to_s
15
+ # real ugly syntax... wait until dm make better conditional has n :through association better, than update code
16
+ has n, :tags, :through => :taggings, :class_name => "Tag", :child_key => [:tagger_id], Tagging.properties[:tagger_type] => self.to_s
17
+ end
18
+
19
+ module TaggerClassMethods
20
+ def taggable_classes
21
+ @taggable_classes.map{|klass| Extlib::Inflection.constantize(klass.to_s.singular.camel_case)}
22
+ end
23
+
24
+ def tagger?;true;end
25
+ def taggable?;false;end
26
+ def tagger_class;self;end
27
+
28
+ end # ClassMethods
29
+
30
+ module TaggerInstanceMethods
31
+ def tagger?;true;end
32
+ def taggable?;false;end
33
+ def tagger_class;self.class;end
34
+ def tagger;self;end
35
+
36
+ def can_tag_on?(taggable)
37
+ if taggable.is_a?(Class)
38
+ return self.class.taggable_classes.include?(taggable)
39
+ end
40
+ return self.class.taggable_classes.include?(taggable.class)
41
+ end
42
+
43
+ def find_taggables(options)
44
+ tagger, taggable, tag_list, options = extract_options(options)
45
+ tagger = self
46
+ Tag.find_taggables(options.merge(:with => tag_list, :on =>taggable, :by => tagger))
47
+ end
48
+
49
+ def tag(options)
50
+ tagger, taggable, tags = extract_options(options)
51
+ tagger = self
52
+ self.class.create_taggings(tagger, taggable, tags)
53
+ end
54
+
55
+ def all_similar_by_tags
56
+ # this magic query is basically looking at the taggings table,
57
+ # searching for taggings having a corresponding tagging from the current taggable object with the same tag_id
58
+ # grouping by tagger_id and counting how many are they
59
+ sql = "SELECT count(*), tagger_id
60
+ FROM (
61
+ SELECT DISTINCT t1.tag_id, t1.tagger_id AS tagger_id
62
+ FROM taggings AS t1
63
+ INNER JOIN taggings AS t2
64
+ ON ( t2.tag_id = t1.tag_id AND t2.tagger_type = '#{self.class.to_s}' AND t2.tagger_id = #{self.id})
65
+ WHERE t1.tagger_type = '#{self.class.to_s}' AND t1.tagger_id != #{self.id}
66
+ )
67
+ AS a
68
+ GROUP BY tagger_id;"
69
+
70
+
71
+ taggers = repository.adapter.query(sql)
72
+
73
+ # for each other taggers, we have the number of tags in common and the tagger object
74
+ # we order the result by similarity descending
75
+ taggers.collect{|tagger| [tagger[0], self.class.get(tagger[1])]}.sort {|x,y| y[0]<=>x[0]}
76
+ end
77
+ end # InstanceMethods
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ module DataMapper
2
+ module Is
3
+ module Taggable
4
+
5
+ def is_tagging(options=nil)
6
+ extend DataMapper::Is::Taggable::SharedClassMethods
7
+ include DataMapper::Is::Taggable::SharedInstanceMethods
8
+ extend DataMapper::Is::Taggable::TaggingClassMethods
9
+ include DataMapper::Is::Taggable::TaggingInstanceMethods
10
+
11
+ property :id, DataMapper::Types::Serial
12
+ property :tag_id, Integer, :index => :true, :nullable => false
13
+
14
+ property :tagger_id, Integer, :index => :tagger, :nullable => true
15
+ property :tagger_type, String, :index => :tagger, :nullable => true, :size => 255
16
+
17
+ property :taggable_id, Integer, :index => :taggable, :nullable => false
18
+ property :taggable_type, String, :index => :taggable, :nullable => false, :size => 255
19
+
20
+ belongs_to :tag
21
+ end
22
+
23
+ module TaggingClassMethods
24
+ end
25
+
26
+ module TaggingInstanceMethods
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ module DataMapper
2
+ module Is
3
+ module Taggable
4
+ VERSION = "0.1"
5
+ DEPENDENCY_VERSION = "0.9.6"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,4 @@
1
+ class Tag
2
+ include DataMapper::Resource
3
+ is :tag
4
+ end
@@ -0,0 +1,113 @@
1
+ # Backporting the ends_with? method from Rails' ActiveSupport
2
+ class String
3
+ def ends_with?(suffix)
4
+ suffix = suffix.to_s
5
+ self[-suffix.length, suffix.length] == suffix
6
+ end
7
+ end
8
+
9
+ class TagList < Array
10
+ cattr_accessor :delimiter
11
+ self.delimiter = ','
12
+
13
+ def initialize(*args)
14
+ add(*args)
15
+ end
16
+
17
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
18
+ #
19
+ # tag_list.add("Fun", "Happy")
20
+ #
21
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
22
+ #
23
+ # tag_list.add("Fun, Happy", :parse => true)
24
+ def add(*names)
25
+ extract_and_apply_options!(names)
26
+ concat(names)
27
+ clean!
28
+ self
29
+ end
30
+
31
+ # Remove specific tags from the tag_list.
32
+ #
33
+ # tag_list.remove("Sad", "Lonely")
34
+ #
35
+ # Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
36
+ #
37
+ # tag_list.remove("Sad, Lonely", :parse => true)
38
+ def remove(*names)
39
+ extract_and_apply_options!(names)
40
+ delete_if { |name| names.include?(name) }
41
+ self
42
+ end
43
+
44
+ # Toggle the presence of the given tags.
45
+ # If a tag is already in the list it is removed, otherwise it is added.
46
+ def toggle(*names)
47
+ extract_and_apply_options!(names)
48
+
49
+ names.each do |name|
50
+ include?(name) ? delete(name) : push(name)
51
+ end
52
+
53
+ clean!
54
+ self
55
+ end
56
+
57
+ # Transform the tag_list into a tag string suitable for edting in a form.
58
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
59
+ #
60
+ # tag_list = TagList.new("Round", "Square,Cube")
61
+ # tag_list.to_s # 'Round, "Square,Cube"'
62
+ def to_s
63
+ clean!
64
+
65
+ map do |name|
66
+ name.include?(delimiter) ? "\"#{name}\"" : name
67
+ end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
68
+ end
69
+
70
+ private
71
+ # Remove whitespace, duplicates, and blanks.
72
+ def clean!
73
+ reject!{|t| t.blank?}
74
+ map!{|t| t.strip}
75
+ uniq!
76
+ end
77
+
78
+ def extract_and_apply_options!(args)
79
+ options = args.last.is_a?(Hash) ? args.pop : {}
80
+
81
+ if options[:parse]
82
+ args.map! { |a| self.class.from(a) }
83
+ end
84
+
85
+ args.flatten!
86
+ end
87
+
88
+ class << self
89
+ # Returns a new TagList using the given tag string.
90
+ #
91
+ # tag_list = TagList.from("One , Two, Three")
92
+ # tag_list # ["One", "Two", "Three"]
93
+ def from(source)
94
+ tag_list = TagList.new
95
+ case source
96
+ when Array
97
+ source.each{|a| tag_list.add(TagList.from(a))}
98
+ else
99
+ string = source.to_s.dup
100
+ # Parse the quoted tags
101
+ [
102
+ /\s*#{delimiter}\s*(['"])(.*?)\1\s*/,
103
+ /^\s*(['"])(.*?)\1\s*#{delimiter}?/
104
+ ].each do |re|
105
+ string.gsub!(re) { tag_list << $2; "" }
106
+ end
107
+ tag_list.add(string.split(delimiter))
108
+ end
109
+
110
+ tag_list
111
+ end
112
+ end
113
+ end