boffin 0.1.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.
@@ -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