trackoid_mongoid4 0.1.3
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.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.gitignore +24 -0
- data/.rspec +3 -0
- data/.travis.yml +14 -0
- data/Gemfile +11 -0
- data/LICENSE +20 -0
- data/README.md +318 -0
- data/Rakefile +7 -0
- data/config/mongoid.yml +6 -0
- data/lib/mongoid/tracking/aggregates.rb +162 -0
- data/lib/mongoid/tracking/core_ext/range.rb +53 -0
- data/lib/mongoid/tracking/core_ext/time.rb +52 -0
- data/lib/mongoid/tracking/core_ext.rb +3 -0
- data/lib/mongoid/tracking/errors.rb +40 -0
- data/lib/mongoid/tracking/reader_extender.rb +92 -0
- data/lib/mongoid/tracking/readers.rb +85 -0
- data/lib/mongoid/tracking/tracker.rb +243 -0
- data/lib/mongoid/tracking/tracker_aggregates.rb +42 -0
- data/lib/mongoid/tracking.rb +112 -0
- data/lib/trackoid_mongoid4/version.rb +5 -0
- data/lib/trackoid_mongoid4.rb +12 -0
- data/spec/aggregates_spec.rb +490 -0
- data/spec/embedded_spec.rb +96 -0
- data/spec/ext/range_spec.rb +114 -0
- data/spec/ext/time_spec.rb +142 -0
- data/spec/reader_extender_spec.rb +57 -0
- data/spec/readers_spec.rb +93 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/timezone_spec.rb +303 -0
- data/spec/trackoid_mongoid4_spec.rb +257 -0
- data/trackoid_mongoid4.gemspec +24 -0
- metadata +143 -0
@@ -0,0 +1,52 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
class Time
|
3
|
+
# Functions to construct the MongoDB field key for trackers
|
4
|
+
#
|
5
|
+
# to_i_timestamp returns the computed UTC timestamp regardless of the
|
6
|
+
# timezone.
|
7
|
+
#
|
8
|
+
# Examples:
|
9
|
+
# 2011-01-01 00:00:00 UTC ===> 14975
|
10
|
+
# 2011-01-01 23:59:59 UTC ===> 14975
|
11
|
+
# 2011-01-02 00:00:00 UTC ===> 14976
|
12
|
+
#
|
13
|
+
# to_i_hour returns the hour for the date, again regardless of TZ
|
14
|
+
#
|
15
|
+
# 2011-01-01 00:00:00 UTC ===> 0
|
16
|
+
# 2011-01-01 23:59:59 UTC ===> 23
|
17
|
+
#
|
18
|
+
ONEHOUR = 60 * 60
|
19
|
+
ONEDAY = 24 * ONEHOUR
|
20
|
+
|
21
|
+
def to_i_timestamp
|
22
|
+
#Adding a fix for case where the 'quo' is being used instead of Fixnum's '/' operator
|
23
|
+
(self.dup.utc.to_i / ONEDAY).to_i
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_key_timestamp
|
27
|
+
to_i_timestamp.to_s
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_i_hour
|
31
|
+
self.dup.utc.hour
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_key_hour
|
35
|
+
to_i_hour.to_s
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns an integer to use as MongoDB key
|
39
|
+
def to_key
|
40
|
+
"#{to_i_timestamp}.#{to_i_hour}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.from_key(ts, h)
|
44
|
+
Time.at(ts.to_i * ONEDAY + h.to_i * ONEHOUR)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns a range to be enumerated using hours for the whole day
|
48
|
+
def whole_day
|
49
|
+
midnight = utc? ? Time.utc(year, month, day) : Time.new(year, month, day, 0, 0, 0, utc_offset)
|
50
|
+
midnight...(midnight + ::Range::DAYS)
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc
|
3
|
+
module Tracking #:nodoc
|
4
|
+
module Errors #:nodoc
|
5
|
+
|
6
|
+
class ClassAlreadyDefined < RuntimeError
|
7
|
+
def initialize(klass)
|
8
|
+
@klass = klass
|
9
|
+
end
|
10
|
+
def message
|
11
|
+
"#{@klass} already defined, can't aggregate!"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class AggregationAlreadyDefined < RuntimeError
|
16
|
+
def initialize(klass, token)
|
17
|
+
@klass = klass
|
18
|
+
@token = token
|
19
|
+
end
|
20
|
+
def message
|
21
|
+
"Aggregation '#{@token}' already defined for model #{@klass}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class AggregationNameDeprecated < RuntimeError
|
26
|
+
def initialize(token)
|
27
|
+
@token = token
|
28
|
+
end
|
29
|
+
def message
|
30
|
+
"Ussing aggregation name '#{@klass}' is deprecated. Please select another name."
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class ModelNotSaved < RuntimeError; end
|
35
|
+
|
36
|
+
class NotMongoid < RuntimeError; end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc:
|
3
|
+
module Tracking
|
4
|
+
|
5
|
+
# ReaderExtender is used in cases where we need to return an integer
|
6
|
+
# (class Numeric) while extending their contents. It would allow to
|
7
|
+
# perform advanced calculations in some situations:
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
#
|
11
|
+
# a = visits.today # Would return a number, but "extended" so that
|
12
|
+
# # we can make a.hourly to get a detailed, hourly
|
13
|
+
# # array of the visits.
|
14
|
+
#
|
15
|
+
# b = visits.yesterday
|
16
|
+
# c = a + b # Here, in c, normally we would have a FixNum with
|
17
|
+
# # the sum of a plus b, but if we extend the sum
|
18
|
+
# # operation, we can additionaly sum the hourly
|
19
|
+
# # array and return a new ReaderExtender c.
|
20
|
+
#
|
21
|
+
class ReaderExtender
|
22
|
+
def initialize(number, hours)
|
23
|
+
@total = number
|
24
|
+
@hours = hours
|
25
|
+
end
|
26
|
+
|
27
|
+
def hourly
|
28
|
+
@hours
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_s
|
32
|
+
@total.to_s
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_f
|
36
|
+
@total.to_f
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_i
|
40
|
+
@total.to_i
|
41
|
+
end
|
42
|
+
|
43
|
+
def ==(other)
|
44
|
+
@total == other
|
45
|
+
end
|
46
|
+
|
47
|
+
def <=>(other)
|
48
|
+
@total <=> other
|
49
|
+
end
|
50
|
+
|
51
|
+
def <(other)
|
52
|
+
@total < other
|
53
|
+
end
|
54
|
+
def <=(other)
|
55
|
+
@total <= other
|
56
|
+
end
|
57
|
+
|
58
|
+
def >(other)
|
59
|
+
@total > other
|
60
|
+
end
|
61
|
+
|
62
|
+
def >(other)
|
63
|
+
@total >= other
|
64
|
+
end
|
65
|
+
|
66
|
+
def +(other)
|
67
|
+
return @total + other unless other.is_a?(ReaderExtender)
|
68
|
+
self.class.new(other + @total, @hours.zip(other.hourly).map!(&:sum))
|
69
|
+
end
|
70
|
+
|
71
|
+
def coerce(other)
|
72
|
+
[self.to_i, other]
|
73
|
+
end
|
74
|
+
|
75
|
+
def as_json(options = nil)
|
76
|
+
@total
|
77
|
+
end
|
78
|
+
|
79
|
+
# Solution proposed by Yehuda Katz in the following Stack Overflow:
|
80
|
+
# http://stackoverflow.com/questions/1095789/sub-classing-fixnum-in-ruby
|
81
|
+
#
|
82
|
+
# Basically we override our methods while proxying all missing methods
|
83
|
+
# to the underliying FixNum
|
84
|
+
#
|
85
|
+
def method_missing(name, *args, &blk)
|
86
|
+
ret = @total.send(name, *args, &blk)
|
87
|
+
ret.is_a?(Numeric) ? ReaderExtender.new(ret, @hours) : ret
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc:
|
3
|
+
module Tracking
|
4
|
+
# Reader methods (previously known as "accessors")
|
5
|
+
module Readers
|
6
|
+
|
7
|
+
|
8
|
+
|
9
|
+
# Access methods
|
10
|
+
def today
|
11
|
+
whole_data_for(Time.now)
|
12
|
+
end
|
13
|
+
|
14
|
+
def yesterday
|
15
|
+
whole_data_for(Time.now - 1.day)
|
16
|
+
end
|
17
|
+
|
18
|
+
def first_value
|
19
|
+
data_for(first_date)
|
20
|
+
end
|
21
|
+
|
22
|
+
def last_value
|
23
|
+
data_for(last_date)
|
24
|
+
end
|
25
|
+
|
26
|
+
def last_days(how_much = 7)
|
27
|
+
return [today] unless how_much > 0
|
28
|
+
now, hmd = Time.now, (how_much - 1)
|
29
|
+
on( now.ago(hmd.days)..now )
|
30
|
+
end
|
31
|
+
|
32
|
+
def on(date)
|
33
|
+
if date.is_a?(Range)
|
34
|
+
whole_data_for_range(date)
|
35
|
+
else
|
36
|
+
whole_data_for(date)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def all_values
|
41
|
+
on(first_date..last_date) if first_date
|
42
|
+
end
|
43
|
+
|
44
|
+
def all_values_total
|
45
|
+
return all_values.sum.to_i if all_values && !all_values.nil?
|
46
|
+
return 0
|
47
|
+
end
|
48
|
+
|
49
|
+
# Utility methods
|
50
|
+
def first_date
|
51
|
+
date_cleanup
|
52
|
+
return nil unless _ts = @data.keys.min
|
53
|
+
return nil unless _h = @data[_ts].keys.min
|
54
|
+
Time.from_key(_ts, _h)
|
55
|
+
end
|
56
|
+
|
57
|
+
def last_date
|
58
|
+
date_cleanup
|
59
|
+
return nil unless _ts = @data.keys.max
|
60
|
+
return nil unless _h = @data[_ts].keys.max
|
61
|
+
Time.from_key(_ts, _h)
|
62
|
+
end
|
63
|
+
|
64
|
+
# We need the cleanup method only for methods who rely on date indexes
|
65
|
+
# to be valid (well formed) like first/last_date. This is because
|
66
|
+
# Mongo update operations cleans up the last key, which in our case
|
67
|
+
# left the array in an inconsistent state.
|
68
|
+
#
|
69
|
+
# Example:
|
70
|
+
# Before update:
|
71
|
+
#
|
72
|
+
# { :visits_data => {"14803" => {"22" => 1} } }
|
73
|
+
#
|
74
|
+
# After updating with: {"$unset"=>{"visits_data.14803.22"=>1}
|
75
|
+
#
|
76
|
+
# { :visits_data => {"14803" => {} } }
|
77
|
+
#
|
78
|
+
# We can NOT retrieve the first date with visits_data.keys.min
|
79
|
+
#
|
80
|
+
def date_cleanup
|
81
|
+
@data.reject! {|k,v| v.count == 0}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,243 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc:
|
3
|
+
module Tracking
|
4
|
+
# This internal class handles all interaction for a track field.
|
5
|
+
class Tracker
|
6
|
+
|
7
|
+
|
8
|
+
include Readers
|
9
|
+
|
10
|
+
def initialize(owner, field, aggregate_data)
|
11
|
+
@owner, @for = owner, field
|
12
|
+
@for_data = @owner.internal_track_name(@for)
|
13
|
+
@data = @owner.read_attribute(@for_data)
|
14
|
+
|
15
|
+
if @data.nil?
|
16
|
+
@owner.write_attribute(@for_data, {})
|
17
|
+
@data = @owner.read_attribute(@for_data)
|
18
|
+
end
|
19
|
+
|
20
|
+
@aggregate_data = aggregate_data.first if aggregate_data.first
|
21
|
+
end
|
22
|
+
|
23
|
+
# Delegate all missing methods to the aggregate accessors. This enables
|
24
|
+
# us to call an aggregation token after the tracking field.
|
25
|
+
#
|
26
|
+
# Example:
|
27
|
+
#
|
28
|
+
# <tt>@object.visits.browsers ...</tt>
|
29
|
+
#
|
30
|
+
|
31
|
+
def method_missing(name, *args, &block)
|
32
|
+
super unless @owner.aggregate_fields.member?(name)
|
33
|
+
@owner.send("#{name}_with_track".to_sym, @for, *args, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Update methods
|
37
|
+
def add(how_much = 1, date = Time.now)
|
38
|
+
raise Errors::ModelNotSaved, "Can't update a new record. Save first!" if @owner.new_record?
|
39
|
+
return if how_much == 0
|
40
|
+
|
41
|
+
# Note that the following #update_data method updates our local data
|
42
|
+
# and the current value might differ from the actual value on the
|
43
|
+
# database. Basically, what we do is update our own copy as a cache
|
44
|
+
# but send the command to atomically update the database: we don't
|
45
|
+
# read the actual value in return so that we save round trip delays.
|
46
|
+
#
|
47
|
+
update_data(data_for(date) + how_much, date)
|
48
|
+
@owner.inc(store_key(date) => how_much.abs)
|
49
|
+
|
50
|
+
return unless @owner.aggregated?
|
51
|
+
|
52
|
+
@owner.aggregate_fields.each do |k, v|
|
53
|
+
next unless token = v.call(@aggregate_data)
|
54
|
+
fk = @owner.class.name.to_s.foreign_key.to_sym
|
55
|
+
selector = { fk => @owner.id, ns: k, key: token.to_s }
|
56
|
+
|
57
|
+
docs = @owner.aggregate_klass.collection.find(selector)
|
58
|
+
docs.upsert("$inc" => update_hash(how_much.abs, date))
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def inc(date = Time.now)
|
63
|
+
add(1, date)
|
64
|
+
end
|
65
|
+
|
66
|
+
def dec(date = Time.now)
|
67
|
+
add(-1, date)
|
68
|
+
end
|
69
|
+
|
70
|
+
def set(how_much, date = Time.now)
|
71
|
+
raise Errors::ModelNotSaved, "Can't update a new record" if @owner.new_record?
|
72
|
+
update_data(how_much, date)
|
73
|
+
#debugger
|
74
|
+
date = normalize_date(date)
|
75
|
+
if @owner.send(@for_data).empty?
|
76
|
+
@owner.set(@for_data => {date.to_key_timestamp => {date.to_key_hour => how_much}})
|
77
|
+
else
|
78
|
+
current_data = @owner.send(@for_data)
|
79
|
+
current_data.merge!({date.to_key_timestamp => {date.to_key_hour => how_much}})
|
80
|
+
@owner.set(@for_data => current_data)
|
81
|
+
end
|
82
|
+
return unless @owner.aggregated?
|
83
|
+
|
84
|
+
@owner.aggregate_fields.each do |(k,v)|
|
85
|
+
next unless token = v.call(@aggregate_data)
|
86
|
+
fk = @owner.class.name.to_s.foreign_key.to_sym
|
87
|
+
selector = { fk => @owner.id, ns: k, key: token.to_s }
|
88
|
+
|
89
|
+
docs = @owner.aggregate_klass.collection.find(selector)
|
90
|
+
docs.upsert("$set" => update_hash(how_much.abs, date))
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def reset(how_much, date = Time.now)
|
95
|
+
return erase(date) if how_much.nil?
|
96
|
+
|
97
|
+
# First, we use the default "set" for the tracking field
|
98
|
+
# This will also update one aggregate but... oh well...
|
99
|
+
set(how_much, date)
|
100
|
+
|
101
|
+
# Need to iterate over all aggregates and send an update or delete
|
102
|
+
# operations over all mongo records for this aggregate field
|
103
|
+
@owner.aggregate_fields.each do |(k,v)|
|
104
|
+
fk = @owner.class.name.to_s.foreign_key.to_sym
|
105
|
+
selector = { fk => @owner.id, ns: k }
|
106
|
+
docs = @owner.aggregate_klass.collection.find(selector)
|
107
|
+
docs.update_all("$set" => update_hash(how_much.abs, date))
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def erase(date = Time.now)
|
112
|
+
raise Errors::ModelNotSaved, "Can't update a new record" if @owner.new_record?
|
113
|
+
|
114
|
+
remove_data(date)
|
115
|
+
|
116
|
+
@owner.unset(store_key(date))
|
117
|
+
|
118
|
+
return unless @owner.aggregated?
|
119
|
+
|
120
|
+
# Need to iterate over all aggregates and send an update or delete
|
121
|
+
# operations over all mongo records
|
122
|
+
@owner.aggregate_fields.each do |(k,v)|
|
123
|
+
fk = @owner.class.name.to_s.foreign_key.to_sym
|
124
|
+
selector = { fk => @owner.id, ns: k }
|
125
|
+
|
126
|
+
docs = @owner.aggregate_klass.collection.find(selector)
|
127
|
+
docs.update_all("$unset" => update_hash(1, date))
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
def data_for(date)
|
133
|
+
unless date.nil?
|
134
|
+
date = normalize_date(date)
|
135
|
+
@data.try(:[], date.to_i_timestamp.to_s).try(:[], date.to_i_hour.to_s) || 0
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def whole_data_for(date)
|
140
|
+
unless date.nil?
|
141
|
+
date = normalize_date(date)
|
142
|
+
if date.utc?
|
143
|
+
d = expand_hash @data[date.to_key_timestamp]
|
144
|
+
ReaderExtender.new(d.sum, d)
|
145
|
+
else
|
146
|
+
r = date.whole_day
|
147
|
+
d1 = expand_hash @data[r.first.to_key_timestamp]
|
148
|
+
d2 = expand_hash @data[r.last.to_key_timestamp]
|
149
|
+
t = d1[r.first.to_i_hour, 24] + d2[0, r.first.to_i_hour]
|
150
|
+
ReaderExtender.new(t.sum, t)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def whole_data_for_range(date)
|
156
|
+
date = normalize_date(date)
|
157
|
+
if date.first.utc?
|
158
|
+
keys = date.map(&:to_key_timestamp)
|
159
|
+
keys.inject([]) do |r, e|
|
160
|
+
d = expand_hash(@data[e])
|
161
|
+
r << ReaderExtender.new(d.sum, d)
|
162
|
+
end
|
163
|
+
else
|
164
|
+
first = date.first.whole_day.first.to_key_timestamp
|
165
|
+
last = date.last.whole_day.last.to_key_timestamp
|
166
|
+
pivot = date.first.whole_day.first.to_i_hour
|
167
|
+
acc = expand_hash(@data[first.to_s])
|
168
|
+
|
169
|
+
data = []
|
170
|
+
first.succ.upto(last) do |n|
|
171
|
+
d = expand_hash(@data[n])
|
172
|
+
t = acc[pivot, 24] + d[0, pivot]
|
173
|
+
acc = d
|
174
|
+
data << ReaderExtender.new(t.sum, t)
|
175
|
+
end
|
176
|
+
data
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def expand_hash(h)
|
181
|
+
d = Array.new(24, 0)
|
182
|
+
h.inject(d) { |d, e| d[e.first.to_i] = e.last; d } if h
|
183
|
+
d
|
184
|
+
end
|
185
|
+
|
186
|
+
def update_data(value, date)
|
187
|
+
unless date.nil?
|
188
|
+
return remove_data(date) unless value
|
189
|
+
date = normalize_date(date)
|
190
|
+
dk, hk = date.to_i_timestamp.to_s, date.to_i_hour.to_s
|
191
|
+
unless ts = @data[dk]
|
192
|
+
ts = (@data[dk] = {})
|
193
|
+
end
|
194
|
+
ts[hk] = value
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def remove_data(date)
|
199
|
+
unless date.nil?
|
200
|
+
date = normalize_date(date)
|
201
|
+
dk, hk = date.to_i_timestamp.to_s, date.to_i_hour.to_s
|
202
|
+
if ts = @data[dk]
|
203
|
+
ts.delete(hk)
|
204
|
+
unless ts.count > 0
|
205
|
+
@data.delete(dk)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Returns a store key for passed date.
|
212
|
+
def store_key(date)
|
213
|
+
"#{@for_data}.#{normalize_date(date).to_key}"
|
214
|
+
end
|
215
|
+
|
216
|
+
|
217
|
+
def update_hash(num, date)
|
218
|
+
{ store_key(date) => num }
|
219
|
+
end
|
220
|
+
|
221
|
+
# Allow for dates to be different types.
|
222
|
+
def normalize_date(date)
|
223
|
+
case date
|
224
|
+
when String
|
225
|
+
Time.parse(date)
|
226
|
+
when Date
|
227
|
+
date.to_time
|
228
|
+
when Range
|
229
|
+
normalize_date(date.first)..normalize_date(date.last)
|
230
|
+
else
|
231
|
+
date
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
# WARNING: This is +only+ for debugging purposes (rspec y tal)
|
236
|
+
def _original_hash
|
237
|
+
@data
|
238
|
+
end
|
239
|
+
|
240
|
+
end
|
241
|
+
|
242
|
+
end
|
243
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc:
|
3
|
+
module Tracking
|
4
|
+
# This internal class handles all interaction of an aggregation token.
|
5
|
+
class TrackerAggregates
|
6
|
+
|
7
|
+
def initialize(owner, token, key_selector, track_field = nil)
|
8
|
+
@owner, @token = owner, token
|
9
|
+
@key = key_selector.first
|
10
|
+
@track_field = track_field
|
11
|
+
|
12
|
+
@accessor = @owner.class.send(:internal_accessor_name, @token)
|
13
|
+
@selector = { ns: @token }
|
14
|
+
@selector.merge!(key: @key) if @key
|
15
|
+
|
16
|
+
@criteria = @owner.send(@accessor).where(@selector)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Delegate all missing methods to the underlying Mongoid Criteria
|
20
|
+
def method_missing(name, *args, &block)
|
21
|
+
@criteria.send(name)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Define all readers here. Basically we are delegating to the Track
|
25
|
+
# object for every object in the criteria
|
26
|
+
Readers.instance_methods.each {|name|
|
27
|
+
define_method(name) do |*args|
|
28
|
+
return nil unless @track_field
|
29
|
+
if @key
|
30
|
+
res = @criteria.first
|
31
|
+
res.send(@track_field).send(name, *args) if res
|
32
|
+
else
|
33
|
+
@criteria.collect {|c|
|
34
|
+
[c.key, c.send(@track_field).send(name, *args)]
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
}
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module Mongoid #:nodoc:
|
3
|
+
module Tracking #:nodoc:
|
4
|
+
|
5
|
+
# Include this module to add analytics tracking into a +root level+ document.
|
6
|
+
# Use "track :field" to add a field named :field and an associated mongoid
|
7
|
+
# field named after :field
|
8
|
+
def self.included(base)
|
9
|
+
base.class_eval do
|
10
|
+
unless self.ancestors.include? Mongoid::Document
|
11
|
+
raise Errors::NotMongoid, "Must be included in a Mongoid::Document"
|
12
|
+
end
|
13
|
+
|
14
|
+
include Aggregates
|
15
|
+
extend ClassMethods
|
16
|
+
|
17
|
+
class_attribute :tracked_fields
|
18
|
+
self.tracked_fields = []
|
19
|
+
delegate :tracked_fields, :internal_track_name, to: "self.class"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def clicks_score
|
24
|
+
(click_percent / 10.0).round
|
25
|
+
end
|
26
|
+
|
27
|
+
def click_percent
|
28
|
+
return 0 if impressions_count.eql?(0) || clicks.eql?(0)
|
29
|
+
if !impressions_count.eql?(0)
|
30
|
+
(( clicks.to_f / impressions_count.to_f) * 100).round
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def avg_days
|
35
|
+
if publish_date < Date.today
|
36
|
+
if end_date > Date.today
|
37
|
+
days = (Date.today - publish_date).to_i
|
38
|
+
else
|
39
|
+
days = (end_date - publish_date).to_i
|
40
|
+
end
|
41
|
+
return days
|
42
|
+
else
|
43
|
+
return 0
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def avg_daily_clicks
|
48
|
+
return (visits.all_values_total/ avg_days).to_f.round(2) if !avg_days.eql?(0)
|
49
|
+
return 0.0
|
50
|
+
end
|
51
|
+
|
52
|
+
def avg_daily_views
|
53
|
+
return (impressions.all_values_total / avg_days).to_f if !avg_days.eql?(0)
|
54
|
+
return 0.0
|
55
|
+
end
|
56
|
+
|
57
|
+
def impressions_count
|
58
|
+
return impressions.all_values_total
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
module ClassMethods
|
63
|
+
# Adds analytics tracking for +name+. Adds a +'name'_data+ mongoid
|
64
|
+
# field as a Hash for tracking this information. Additionaly, hiddes
|
65
|
+
# the field, so that the user can not mangle with the original one.
|
66
|
+
# This is necessary so that Mongoid does not "dirty" the field
|
67
|
+
# potentially overwriting the original data.
|
68
|
+
def track(name)
|
69
|
+
set_tracking_field(name.to_sym)
|
70
|
+
create_tracking_accessors(name.to_sym)
|
71
|
+
create_tracked_fields(name)
|
72
|
+
update_aggregates(name.to_sym) if aggregated?
|
73
|
+
end
|
74
|
+
|
75
|
+
def create_tracked_fields(name)
|
76
|
+
field "#{name}_data".to_sym, type: Hash, default: {}
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
# Returns the internal representation of the tracked field name
|
81
|
+
def internal_track_name(name)
|
82
|
+
"#{name}_data".to_sym
|
83
|
+
end
|
84
|
+
|
85
|
+
# Configures the internal fields for tracking. Additionally also creates
|
86
|
+
# an index for the internal tracking field.
|
87
|
+
def set_tracking_field(name)
|
88
|
+
# DONT make an index for this field. MongoDB indexes have limited
|
89
|
+
# size and seems that this is not a good target for indexing.
|
90
|
+
# index internal_track_name(name)
|
91
|
+
tracked_fields << name
|
92
|
+
end
|
93
|
+
|
94
|
+
# Creates the tracking field accessor and also disables the original
|
95
|
+
# ones from Mongoid. Hidding here the original accessors for the
|
96
|
+
# Mongoid fields ensures they doesn't get dirty, so Mongoid does not
|
97
|
+
# overwrite old data.
|
98
|
+
def create_tracking_accessors(name)
|
99
|
+
define_method(name) do |*aggr|
|
100
|
+
Tracker.new(self, name, aggr)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Updates the aggregated class for it to include a new tracking field
|
105
|
+
def update_aggregates(name)
|
106
|
+
aggregate_klass.track name
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'trackoid_mongoid4/version'
|
4
|
+
|
5
|
+
require 'mongoid/tracking'
|
6
|
+
require 'mongoid/tracking/errors'
|
7
|
+
require 'mongoid/tracking/core_ext'
|
8
|
+
require 'mongoid/tracking/reader_extender'
|
9
|
+
require 'mongoid/tracking/readers'
|
10
|
+
require 'mongoid/tracking/tracker'
|
11
|
+
require 'mongoid/tracking/aggregates'
|
12
|
+
require 'mongoid/tracking/tracker_aggregates'
|