boffin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,171 @@
1
+ module Boffin
2
+ # A collection of utility methods that are used throughout the library
3
+ module Utils
4
+
5
+ # The number of seconds in an hour
6
+ SECONDS_IN_HOUR = 3600
7
+
8
+ # Number of seconds in a day
9
+ SECONDS_IN_DAY = 24 * SECONDS_IN_HOUR
10
+
11
+ # Number of seconds in a month
12
+ SECONDS_IN_MONTH = 30 * SECONDS_IN_DAY
13
+
14
+ # Number of seconds for a single value of each unit
15
+ SECONDS_IN_UNIT = {
16
+ hours: SECONDS_IN_HOUR,
17
+ days: SECONDS_IN_DAY,
18
+ months: SECONDS_IN_MONTH
19
+ }
20
+
21
+ module_function
22
+
23
+ # @param [#to_s] thing
24
+ # A Module, Class, String, or anything in which the underscored value of
25
+ # `#to_s` is desirable.
26
+ # @return [String]
27
+ # The underscored version of `#to_s` on thing
28
+ # @note
29
+ # Originally pulled from ActiveSupport::Inflector
30
+ def underscore(thing)
31
+ thing.to_s.dup.tap do |word|
32
+ word.gsub!(/::/, '_')
33
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
34
+ word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
35
+ word.tr!('-', '_')
36
+ word.downcase!
37
+ end
38
+ end
39
+
40
+ # @param [Object] obj any Ruby object
41
+ # @return [true, false]
42
+ # `true` if the provided object is blank, examples of blank objects are:
43
+ # `[]`, `{}`, `nil`, `false`, `''`.
44
+ def blank?(obj)
45
+ obj.respond_to?(:empty?) ? obj.empty? : !obj
46
+ end
47
+
48
+ # Pulls time interval information from a hash of options.
49
+ # @example
50
+ # extract_time_unit(this: 'is ignored', days: 6, so_is: 'this')
51
+ # #=> [:days, 6]
52
+ # @param [Hash] hsh
53
+ # Any Hash that contains amoungst its keys one of `:hours`, `:days`, or
54
+ # `:months`.
55
+ # @return [Array]
56
+ # A two-element array containing the unit-type (`:hours`, `:days`, or
57
+ # `:months`) and the value.
58
+ def extract_time_unit(hsh)
59
+ case
60
+ when hsh.key?(:hours) then [:hours, hsh[:hours]]
61
+ when hsh.key?(:days) then [:days, hsh[:days]]
62
+ when hsh.key?(:months) then [:months, hsh[:months]]
63
+ else
64
+ raise ArgumentError, 'no time unit exists in the hash provided'
65
+ end
66
+ end
67
+
68
+ # @example
69
+ # time_ago(Time.local(2011, 1, 3), days: 2)
70
+ # # => 2011-01-01 00:00:00
71
+ # @param [Time] time
72
+ # The initial time that the offset will be calculated from
73
+ # @param [Hash] unit
74
+ # (see {#extract_time_unit})
75
+ # @return [Time]
76
+ # The time in the past offset by the specified amount
77
+ def time_ago(time, unit)
78
+ unit, unit_value = *extract_time_unit(unit)
79
+ time - (unit_value * SECONDS_IN_UNIT[unit])
80
+ end
81
+
82
+ # @param [Time] upto
83
+ # The base time of which to calculate the range from
84
+ # @param [Hash] unit
85
+ # (see {#extract_time_unit})
86
+ # @return [Array<Time>]
87
+ # An array of times in the calculated range
88
+ # @example
89
+ # time_ago_range(Time.local(2011, 1, 5), days: 3)
90
+ # # => [2011-01-03 00:00:00, 2011-01-04 00:00:00, 2011-01-05 00:00:00]
91
+ def time_ago_range(upto, unit)
92
+ unit, size = *extract_time_unit(unit)
93
+ ago = time_ago(upto, unit => (size - 1))
94
+ max, count, times = upto.to_i, ago.to_i, []
95
+ begin
96
+ times << Time.at(count)
97
+ end while (count += SECONDS_IN_UNIT[unit]) <= max
98
+ times
99
+ end
100
+
101
+ # Generates a set member based off the first object in the provided array
102
+ # that is not `nil`. If the array is empty or only contains `nil` elements
103
+ # then {Boffin::NIL_SESSION_MEMBER} is returned.
104
+ # @param [Array] aspects
105
+ # An array of which the first non-nil element is passed to
106
+ # {#object_as_session_identifier}
107
+ # @return [String]
108
+ def uniquenesses_as_session_identifier(aspects)
109
+ if (obj = aspects.flatten.reject { |u| blank?(u) }.first)
110
+ object_as_session_identifier(obj)
111
+ else
112
+ NIL_SESSION_MEMBER
113
+ end
114
+ end
115
+
116
+ # @param [String, Symbol, Object] obj
117
+ # @return [String]
118
+ # Returns a string that can be used as a namespace in Redis keys
119
+ def object_as_namespace(obj)
120
+ case obj
121
+ when String, Symbol
122
+ obj.to_s
123
+ else
124
+ underscore(obj)
125
+ end
126
+ end
127
+
128
+ # @param [#as_member, #id, #to_s] obj
129
+ # @param [Hash] opts
130
+ # @option opts [true, false] :namespace
131
+ # If `true` the generated value will be prefixed with a namespace
132
+ # @option opts [true, false] :encode
133
+ # If `true` and object fails to respond to `#as_member` or `#id`, the
134
+ # generated value will be Base64 encoded.
135
+ # @return [String]
136
+ def object_as_identifier(obj, opts = {})
137
+ if obj.respond_to?(:as_member) || obj.respond_to?(:id)
138
+ ''.tap do |s|
139
+ s << "#{underscore(obj.class)}:" if opts[:namespace]
140
+ s << (obj.respond_to?(:as_member) ? obj.as_member : obj.id).to_s
141
+ end
142
+ else
143
+ opts[:encode] ? Base64.strict_encode64(obj.to_s) : obj.to_s
144
+ end
145
+ end
146
+
147
+ # @return [String]
148
+ # @param [#as_member, #id, #to_s] obj
149
+ # @return [String] A string that can be used as a member in
150
+ # {Keyspace#hits_time_window}.
151
+ # @see #object_as_identifier
152
+ def object_as_member(obj)
153
+ object_as_identifier(obj)
154
+ end
155
+
156
+ # @param [#as_member, #id, #to_s] obj
157
+ # @return [String] A string that can be used as a member in {Keyspace#hits}.
158
+ # @see #object_as_identifier
159
+ def object_as_session_identifier(obj)
160
+ object_as_identifier(obj, namespace: true)
161
+ end
162
+
163
+ # @param [#as_member, #id, #to_s] obj
164
+ # @return [String] A string that can be used as part of a Redis key
165
+ # @see #object_as_identifier
166
+ def object_as_key(obj)
167
+ object_as_identifier(obj, encode: true)
168
+ end
169
+
170
+ end
171
+ end
@@ -0,0 +1,4 @@
1
+ module Boffin
2
+ # Version of this Boffin release
3
+ VERSION = '0.1.0'
4
+ end
@@ -0,0 +1,65 @@
1
+ require 'spec_helper'
2
+
3
+ describe Boffin::Config do
4
+ describe '#namespace' do
5
+ specify { subject.namespace.should == 'boffin' }
6
+
7
+ it 'includes the Rails environment when available' do
8
+ ENV['RAILS_ENV'] = 'production'
9
+ Boffin::Config.new.namespace.should == 'boffin:production'
10
+ ENV['RAILS_ENV'] = nil
11
+ end
12
+
13
+ it 'includes the Rack environment when available' do
14
+ ENV['RACK_ENV'] = 'staging'
15
+ Boffin::Config.new.namespace.should == 'boffin:staging'
16
+ ENV['RACK_ENV'] = nil
17
+ end
18
+ end
19
+
20
+ describe '#new' do
21
+ it 'returns a default config instance with no arguments' do
22
+ Boffin::Config.new.should be_a Boffin::Config
23
+ end
24
+
25
+ it 'can be sent a block' do
26
+ conf = Boffin::Config.new { |c| c.namespace = 'hihi' }
27
+ conf.namespace.should == 'hihi'
28
+ end
29
+
30
+ it 'can be sent a hash' do
31
+ conf = Boffin::Config.new(namespace: 'hello')
32
+ conf.namespace.should == 'hello'
33
+ end
34
+ end
35
+
36
+ describe '#merge' do
37
+ it 'copies the existing instance' do
38
+ newconf = subject.merge(namespace: 'carsten')
39
+ newconf.namespace.should == 'carsten'
40
+ subject.namespace.should == 'boffin'
41
+ end
42
+ end
43
+
44
+ describe '#redis' do
45
+ it 'calls Redis.connect by default' do
46
+ Boffin::Config.new.redis.should be_a Redis
47
+ end
48
+ end
49
+
50
+ describe '#hours_window_secs' do
51
+ specify { subject.hours_window_secs.should == 3 * 24 * 3600 } # 3 days
52
+ end
53
+
54
+ describe '#days_window_secs' do
55
+ specify { subject.days_window_secs.should == 3 * 30 * 24 * 3600 } # 3 months
56
+ end
57
+
58
+ describe '#months_window_secs' do
59
+ specify { subject.months_window_secs.should == 3 * 12 * 30 * 24 * 3600 } # 3 years
60
+ end
61
+
62
+ describe '#cache_expire_secs' do
63
+ specify { subject.cache_expire_secs.should == 900 } # 15 minutes
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ require 'spec_helper'
2
+
3
+ describe Boffin::Hit, '::new' do
4
+ before do
5
+ SpecHelper.flush_keyspace!
6
+ @tracker = Boffin::Tracker.new(MockDitty)
7
+ @ditty = MockDitty.new
8
+ @user = MockUser.new
9
+ Timecop.travel(Time.local(2011, 1, 1))
10
+ end
11
+
12
+ after do
13
+ Timecop.return
14
+ end
15
+
16
+ it 'stores hit data under the appropriate keys' do
17
+ Boffin::Hit.new(@tracker, :tests, @ditty, [nil, @user])
18
+ [:hours, :days, :months].each do |interval|
19
+ @tracker.top(:tests, interval => 1, counts: true).
20
+ should == [['1', 1]]
21
+ @tracker.top(:tests, interval => 1, counts: true, unique: true).
22
+ should == [['1', 1]]
23
+ end
24
+ @tracker.hit_count(:tests, @ditty).should == 1
25
+ @tracker.uhit_count(:tests, @ditty).should == 1
26
+ end
27
+
28
+ it 'does not store data under unique keys if the hit is not unique' do
29
+ Boffin::Hit.new(@tracker, :tests, @ditty, [nil, @user])
30
+ Boffin::Hit.new(@tracker, :tests, @ditty, [nil, @user])
31
+ [:hours, :days, :months].each do |interval|
32
+ @tracker.top(:tests, interval => 1, counts: true).
33
+ should == [['1', 2]]
34
+ @tracker.top(:tests, interval => 1, counts: true, unique: true).
35
+ should == [['1', 1]]
36
+ end
37
+ @tracker.hit_count_for_session_id(:tests, @ditty, @user).should == 2
38
+ @tracker.hit_count(:tests, @ditty).should == 2
39
+ @tracker.uhit_count(:tests, @ditty).should == 1
40
+ end
41
+
42
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ describe Boffin::Keyspace do
4
+ before :all do
5
+ @tracker = Boffin::Tracker.new(MockDitty)
6
+ @tracker.config.namespace = 'b'
7
+ @ks = @tracker.keyspace
8
+ @uks = @tracker.keyspace(true)
9
+ end
10
+
11
+ describe '#root' do
12
+ specify { @ks.root.should == 'b:mock_ditty' }
13
+ specify { @uks.root.should == 'b:mock_ditty:uniq' }
14
+ end
15
+
16
+ describe '#hits' do
17
+ specify do
18
+ @ks.hits([:views, :likes]).should == 'b:mock_ditty:views_likes:hits'
19
+ end
20
+
21
+ specify do
22
+ @ks.hits(:views).should == 'b:mock_ditty:views:hits'
23
+ end
24
+
25
+ specify do
26
+ @ks.hits(:views, MockDitty.new).should == 'b:mock_ditty.1:views:hits'
27
+ end
28
+ end
29
+
30
+ describe '#hit_count' do
31
+ specify do
32
+ @ks.hit_count(:views, MockDitty.new).should == 'b:mock_ditty.1:views:hit_count'
33
+ end
34
+ end
35
+
36
+ describe '#hits_union' do
37
+ specify do
38
+ @ks.hits_union(:views, :days, 5).
39
+ should == 'b:mock_ditty:views:hits:current.days_5'
40
+ end
41
+ end
42
+
43
+ describe '#hits_union_multi' do
44
+ specify do
45
+ @ks.hits_union_multi({ views: 1, likes: 3 }, :days, 5).
46
+ should == 'b:mock_ditty:views_1_likes_3:hits:current.days_5'
47
+ end
48
+ end
49
+
50
+ describe '#hits_window' do
51
+ specify do
52
+ @ks.hits_window(:views, '*').should == 'b:mock_ditty:views:hits.*'
53
+ end
54
+ end
55
+
56
+ describe '#hits_time_window' do
57
+ before do
58
+ @time = Time.local(2011, 1, 1, 23)
59
+ end
60
+
61
+ Boffin::INTERVAL_TYPES.each do |format|
62
+ describe "given #{format} based window" do
63
+ specify do
64
+ strf = Boffin::INTERVAL_FORMATS[format]
65
+ @ks.hits_time_window(:views, format, @time).
66
+ should == "b:mock_ditty:views:hits.#{@time.strftime(strf)}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ describe '#hit_time_windows' do
73
+ before do
74
+ @time = Time.local(2011, 1, 1, 23)
75
+ end
76
+
77
+ it 'generates keys each interval in the range' do
78
+ @ks.hit_time_windows(:views, :days, 3, @time).should == [
79
+ 'b:mock_ditty:views:hits.2010-12-30',
80
+ 'b:mock_ditty:views:hits.2010-12-31',
81
+ 'b:mock_ditty:views:hits.2011-01-01'
82
+ ]
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,38 @@
1
+ require 'spec_helper'
2
+
3
+ describe Boffin::Trackable do
4
+ before :all do
5
+ SpecHelper.flush_keyspace!
6
+ @mock = MockTrackableInjected.new(1)
7
+ @mock.hit(:views, ['sess.1'])
8
+ @mock.hit(:views, ['sess.1'])
9
+ end
10
+
11
+ it 'can be included' do
12
+ MockTrackableIncluded.boffin.hit_types.should == [:views, :likes]
13
+ end
14
+
15
+ it 'can be injected' do
16
+ MockTrackableInjected.boffin.hit_types.should == [:views, :likes]
17
+ end
18
+
19
+ it 'provides ::boffin as an accessor to the Tracker instance' do
20
+ MockTrackableInjected.boffin.should be_a Boffin::Tracker
21
+ end
22
+
23
+ it 'delegates ::top_ids to the Tracker instance' do
24
+ MockTrackableInjected.top_ids(:views, days: 1).should == ['1']
25
+ end
26
+
27
+ it 'delegates #hit_count to the Tracker instance' do
28
+ @mock.hit_count(:views).should == 2
29
+ end
30
+
31
+ it 'delegates #uhit_count to the Tracker instance' do
32
+ @mock.uhit_count(:views).should == 1
33
+ end
34
+
35
+ it 'delegates #hit_count_for_session_id to the Tracker instance' do
36
+ @mock.hit_count_for_session_id(:views, 'sess.1').should == 2
37
+ end
38
+ end
@@ -0,0 +1,162 @@
1
+ require 'spec_helper'
2
+
3
+ describe Boffin::Tracker do
4
+ before :all do
5
+ SpecHelper.flush_keyspace!
6
+ @tracker = Boffin::Tracker.new(MockDitty, [:views, :likes, :shares])
7
+ @instance1 = MockDitty.new(100)
8
+ @instance2 = MockDitty.new(200)
9
+ @instance3 = MockDitty.new(300)
10
+ @instance4 = MockDitty.new(400)
11
+ @user1 = MockUser.new(1)
12
+ @user2 = MockUser.new(2)
13
+ @date = Date.today
14
+
15
+ Timecop.freeze(@date - 2) do
16
+ @tracker.hit(:views, @instance3)
17
+ @tracker.hit(:likes, @instance3, [@user1])
18
+ @tracker.hit(:views, @instance3, ['sess.1'])
19
+ @tracker.hit(:views, @instance3, ['sess.2'])
20
+ @tracker.hit(:views, @instance3, [@user2])
21
+ @tracker.hit(:likes, @instance1, [nil, nil])
22
+ @tracker.hit(:views, @instance3, ['sess.4'])
23
+ @tracker.hit(:views, @instance3, [@user1])
24
+ @tracker.hit(:views, @instance2, [@user2])
25
+ @tracker.hit(:views, @instance3, [@user2])
26
+ @tracker.hit(:likes, @instance3)
27
+ @tracker.hit(:views, @instance3, ['sess.1'])
28
+ @tracker.hit(:views, @instance3)
29
+ @tracker.hit(:likes, @instance3, [@user1])
30
+ @tracker.hit(:views, @instance3, ['sess.1'])
31
+ end
32
+
33
+ Timecop.freeze(@date - 1) do
34
+ @tracker.hit(:views, @instance1)
35
+ @tracker.hit(:likes, @instance2, [@user1])
36
+ @tracker.hit(:views, @instance2, ['sess.4'])
37
+ @tracker.hit(:views, @instance2, [nil, @user1])
38
+ @tracker.hit(:views, @instance2, ['sess.3'])
39
+ @tracker.hit(:views, @instance1, ['sess.3'])
40
+ @tracker.hit(:views, @instance1, [@user1])
41
+ @tracker.hit(:views, @instance2, ['sess.2'])
42
+ @tracker.hit(:views, @instance1, [@user1])
43
+ @tracker.hit(:views, @instance1, [@user2])
44
+ end
45
+
46
+ @tracker.hit(:views, @instance3, ['sess.2'])
47
+ @tracker.hit(:views, @instance2, [@user2])
48
+ @tracker.hit(:likes, @instance2)
49
+ @tracker.hit(:views, @instance2, [@user1])
50
+ @tracker.hit(:views, @instance1, ['sess.4'])
51
+ @tracker.hit(:views, @instance3, ['sess.3'])
52
+ @tracker.hit(:views, @instance1, [@user1])
53
+ @tracker.hit(:views, @instance1, [@user2])
54
+ end
55
+
56
+ describe '#hit' do
57
+ it 'throws an error if the hit type is not in the list' do
58
+ -> { @tracker.hit(:view, @instance1) }.
59
+ should raise_error Boffin::UndefinedHitTypeError
60
+ end
61
+ end
62
+
63
+ describe '#hit_count' do
64
+ it 'throws an error if the hit type is not in the list' do
65
+ -> { @tracker.hit_count(:view, @instance1) }.
66
+ should raise_error Boffin::UndefinedHitTypeError
67
+ end
68
+
69
+ it 'returns the raw hit count for the instance' do
70
+ @tracker.hit_count(:views, @instance1).should == 8
71
+ end
72
+
73
+ it 'returns 0 for an instance that was never hit' do
74
+ @tracker.hit_count(:views, 'neverhit').should == 0
75
+ end
76
+ end
77
+
78
+ describe '#uhit_count' do
79
+ it 'throws an error if the hit type is not in the list' do
80
+ -> { @tracker.uhit_count(:view, @instance1) }.
81
+ should raise_error Boffin::UndefinedHitTypeError
82
+ end
83
+
84
+ it 'returns the unique hit count for the instance' do
85
+ @tracker.uhit_count(:views, @instance1).should == 5
86
+ end
87
+
88
+ it 'returns 0 for an instance that was never hit' do
89
+ @tracker.hit_count(:likes, @instance4).should == 0
90
+ end
91
+ end
92
+
93
+ describe '#hit_count_for_session_id' do
94
+ it 'throws an error if the hit type is not in the list' do
95
+ -> { @tracker.hit_count_for_session_id(:view, @instance1, 'sess.1') }.
96
+ should raise_error Boffin::UndefinedHitTypeError
97
+ end
98
+
99
+ it 'returns the number of times the instance was hit by the session id' do
100
+ @tracker.hit_count_for_session_id(:views, @instance3, 'sess.1').should == 3
101
+ end
102
+
103
+ it 'returns a count of 0 if the session id never hit the instance' do
104
+ @tracker.hit_count_for_session_id(:views, @instance1, 'nohit').should == 0
105
+ end
106
+ end
107
+
108
+ describe '#top' do
109
+ it 'throws an error if passed hit type is invalid' do
110
+ -> { @tracker.top(:view, days: 3) }.
111
+ should raise_error Boffin::UndefinedHitTypeError
112
+ end
113
+
114
+ it 'throws an error if passed weights with hit type that is invalid' do
115
+ -> { @tracker.top({ view: 1 }, days: 3) }.
116
+ should raise_error Boffin::UndefinedHitTypeError
117
+ end
118
+
119
+ it 'returns ids ordered by hit counts of weighted totals' do
120
+ ids = @tracker.top({ views: 1, likes: 2 }, days: 3)
121
+ ids.should == ['300', '200', '100']
122
+ end
123
+
124
+ it 'returns ids ordered by total counts of a specific hit type' do
125
+ ids = @tracker.top(:views, days: 3)
126
+ ids.should == ['300', '100', '200']
127
+ end
128
+
129
+ it 'returns ids in ascending order when passed { order: "asc" } as an option' do
130
+ ids = @tracker.top(:views, days: 3, order: 'asc')
131
+ ids.should == ['200', '100', '300']
132
+ end
133
+
134
+ it 'returns ids and counts when passed { counts: true } as an option' do
135
+ ids = @tracker.top(:views, days: 3, counts: true)
136
+ ids.should == [
137
+ ['300', 12],
138
+ ['100', 8],
139
+ ['200', 7]
140
+ ]
141
+ end
142
+
143
+ it 'returns ids based on unique hit data when passed { unique: true } as an option' do
144
+ ids = @tracker.top(:views, days: 3, counts: true, unique: true)
145
+ ids.should == [
146
+ ['300', 7],
147
+ ['200', 5],
148
+ ['100', 5]
149
+ ]
150
+ end
151
+ end
152
+
153
+ describe '#keyspace' do
154
+ it 'returns a keyspace' do
155
+ @tracker.keyspace.unique_namespace?.should be_false
156
+ end
157
+
158
+ it 'returns a unique keyspace when passed true' do
159
+ @tracker.keyspace(true).unique_namespace?.should be_true
160
+ end
161
+ end
162
+ end