redtastic 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,285 @@
1
+ module Redtastic
2
+ class Model
3
+ class << self
4
+ # Recording
5
+
6
+ def increment(params)
7
+ key_data = fill_keys_for_update(params)
8
+ if @_type == :unique
9
+ argv = []
10
+ argv << params[:unique_id]
11
+ Redtastic::ScriptManager.msadd(key_data[0], argv)
12
+ else
13
+ Redtastic::ScriptManager.hmincrby(key_data[0], key_data[1].unshift(1))
14
+ end
15
+ end
16
+
17
+ def decrement(params)
18
+ key_data = fill_keys_for_update(params)
19
+ if @_type == :unique
20
+ argv = []
21
+ argv << params[:unique_id]
22
+ Redtastic::ScriptManager.msrem(key_data[0], argv)
23
+ else
24
+ Redtastic::ScriptManager.hmincrby(key_data[0], key_data[1].unshift(-1))
25
+ end
26
+ end
27
+
28
+ # Retrieving
29
+
30
+ def find(params)
31
+ keys = []
32
+ argv = []
33
+
34
+ # Construct the key's timestamp from inputed date parameters
35
+ timestamp = ''
36
+ timestamp += "#{params[:year]}"
37
+ timestamp += "-#{zeros(params[:month])}" if params[:month].present?
38
+ timestamp += "-W#{params[:week]}" if params[:week].present?
39
+ timestamp += "-#{zeros(params[:day])}" if params[:day].present?
40
+ params.merge!(timestamp: timestamp)
41
+
42
+ # Handle multiple ids
43
+ ids = param_to_array(params[:id])
44
+
45
+ ids.each do |id|
46
+ params[:id] = id
47
+ keys << key(params)
48
+ argv << index(id)
49
+ end
50
+
51
+ if @_type == :unique
52
+ unique_argv = []
53
+ unique_argv << params[:unique_id]
54
+ result = Redtastic::ScriptManager.msismember(keys, unique_argv)
55
+ else
56
+ result = Redtastic::ScriptManager.hmfind(keys, argv)
57
+ end
58
+
59
+ # If only for a single id, just return the value rather than an array
60
+ if result.size == 1
61
+ result[0]
62
+ else
63
+ result
64
+ end
65
+ end
66
+
67
+ def aggregate(params)
68
+ key_data = fill_keys_and_dates(params)
69
+ keys = key_data[0]
70
+
71
+ # If interval is present, we return a hash including the total as well as a data point for each interval.
72
+ # Example: Visits.aggregate(start_date: 2014-01-05, end_date: 2013-01-06, id: 1, interval: :days)
73
+ # {
74
+ # visits: 2
75
+ # days: [
76
+ # {
77
+ # created_at: 2014-01-05,
78
+ # visits: 1
79
+ # },
80
+ # {
81
+ # created_at: 2014-01-06,
82
+ # visits: 1
83
+ # }
84
+ # ]
85
+ # }
86
+ if params[:interval].present? && @_resolution.present?
87
+ if @_type == :unique
88
+ argv = []
89
+ argv << key_data[1].shift # Only need the # of business ids (which is 1st element) from key_data[1]
90
+ argv << temp_key
91
+ if params[:attributes].present?
92
+ attributes = param_to_array(params[:attributes])
93
+ attributes.each do |attribute|
94
+ keys << attribute_key(attribute)
95
+ argv << 1
96
+ end
97
+ end
98
+ data_points = Redtastic::ScriptManager.union_data_points_for_keys(keys, argv)
99
+ else
100
+ data_points = Redtastic::ScriptManager.data_points_for_keys(keys, key_data[1])
101
+ end
102
+
103
+ result = HashWithIndifferentAccess.new
104
+ dates = key_data[2]
105
+ # The data_points_for_keys lua script returns an array of all the data points, with one exception:
106
+ # the value at index 0 is the total across all the data points, so we pop it off of the data points array.
107
+ result[model_name] = data_points.shift
108
+ result[params[:interval]] = []
109
+
110
+ data_points.each_with_index do |data_point, index|
111
+ point_hash = HashWithIndifferentAccess.new
112
+ point_hash[model_name] = data_point
113
+ point_hash[:date] = dates[index]
114
+ result[params[:interval]] << point_hash
115
+ end
116
+ result
117
+ else
118
+ # If interval is not present, we just return the total as an integer
119
+ if @_type == :unique
120
+ argv = []
121
+ argv << temp_key
122
+ if params[:attributes].present?
123
+ attributes = param_to_array(params[:attributes])
124
+ attributes.each do |attribute|
125
+ keys << attribute_key(attribute)
126
+ argv << 1
127
+ end
128
+ end
129
+ Redtastic::ScriptManager.msunion(keys, argv)
130
+ else
131
+ key_data[1].shift # Remove the number of ids from the argv array (don't need it in the sum method)
132
+ Redtastic::ScriptManager.sum(keys, key_data[1]).to_i
133
+ end
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def type(type_name)
140
+ types = [:counter, :unique, :mosaic]
141
+ fail "#{type_name} is not a valid type" unless types.include?(type_name)
142
+ @_type = type_name
143
+ end
144
+
145
+ def resolution(resolution_name)
146
+ resolutions = [:days, :weeks, :months, :years]
147
+ fail "#{resolution_name} is not a valid resolution" unless resolutions.include?(resolution_name)
148
+ @_resolution = resolution_name
149
+ end
150
+
151
+ def fill_keys_for_update(params)
152
+ keys = []
153
+ argv = []
154
+
155
+ # Handle multiple keys
156
+ ids = param_to_array(params[:id])
157
+
158
+ ids.each do |id|
159
+ params[:id] = id
160
+ if params[:timestamp].present?
161
+ # This is for an update, so we want to build a key for each resolution that is applicable to the model
162
+ scoped_resolutions.each do |resolution|
163
+ keys << key(params, resolution)
164
+ argv << index(id)
165
+ end
166
+ else
167
+ keys << key(params)
168
+ argv << index(id)
169
+ end
170
+ end
171
+ [keys, argv]
172
+ end
173
+
174
+ def fill_keys_and_dates(params)
175
+ keys = []
176
+ dates = []
177
+ argv = []
178
+ ids = param_to_array(params[:id])
179
+
180
+ argv << ids.size
181
+ start_date = Date.parse(params[:start_date]) if params[:start_date].is_a?(String)
182
+ end_date = Date.parse(params[:end_date]) if params[:end_date].is_a?(String)
183
+
184
+ if params[:interval].present?
185
+ interval = params[:interval]
186
+ else
187
+ interval = @_resolution
188
+ end
189
+
190
+ current_date = start_date
191
+ while current_date <= end_date
192
+ params[:timestamp] = current_date
193
+ dates << formatted_timestamp(current_date, interval)
194
+ ids.each do |id|
195
+ params[:id] = id
196
+ keys << key(params, interval)
197
+ argv << index(id)
198
+ end
199
+ current_date = current_date.advance(interval => +1)
200
+ end
201
+ [keys, argv, dates]
202
+ end
203
+
204
+ def key(params, interval = nil)
205
+ key = ''
206
+ key += "#{Redtastic::Connection.namespace}:" if Redtastic::Connection.namespace.present?
207
+ key += "#{model_name}"
208
+ if params[:timestamp].present?
209
+ timestamp = params[:timestamp]
210
+ timestamp = formatted_timestamp(params[:timestamp], interval) if interval.present?
211
+ key += ":#{timestamp}"
212
+ end
213
+ if @_type == :counter
214
+ key += ":#{bucket(params[:id])}"
215
+ else
216
+ key += ":#{params[:id]}" if params[:id].present?
217
+ end
218
+ key
219
+ end
220
+
221
+ def formatted_timestamp(timestamp, interval)
222
+ timestamp = Date.parse(timestamp) if timestamp.is_a?(String)
223
+ case interval
224
+ when :days
225
+ timestamp.strftime('%Y-%m-%d')
226
+ when :weeks
227
+ week_number = timestamp.cweek
228
+ result = timestamp.strftime('%Y')
229
+ result + "-W#{week_number}"
230
+ when :months
231
+ timestamp.strftime('%Y-%m')
232
+ when :years
233
+ timestamp.strftime('%Y')
234
+ end
235
+ end
236
+
237
+ def bucket(id)
238
+ @_type == :counter ? id / 1000 : id
239
+ end
240
+
241
+ def index(id)
242
+ id % 1000 if id.is_a?(Integer)
243
+ end
244
+
245
+ def zeros(number)
246
+ "0#{number}" if number < 10
247
+ end
248
+
249
+ def model_name
250
+ name.underscore
251
+ end
252
+
253
+ def scoped_resolutions
254
+ case @_resolution
255
+ when :days
256
+ [:days, :weeks, :months, :years]
257
+ when :weeks
258
+ [:weeks, :months, :years]
259
+ when :months
260
+ [:months, :years]
261
+ when :years
262
+ [:years]
263
+ else
264
+ []
265
+ end
266
+ end
267
+
268
+ def param_to_array(param)
269
+ result = []
270
+ param.is_a?(Array) ? result = param : result << param
271
+ end
272
+
273
+ def temp_key
274
+ seed = Array.new(8) { [*'a'..'z'].sample }.join
275
+ "temp:#{seed}"
276
+ end
277
+
278
+ def attribute_key(attribute)
279
+ key = ''
280
+ key += "#{Redtastic::Connection.namespace}:" if Redtastic::Connection.namespace.present?
281
+ key + attribute.to_s
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,32 @@
1
+ module Redtastic
2
+ class ScriptManager
3
+ class << self
4
+ def load_scripts(script_path)
5
+ @stored_methods = HashWithIndifferentAccess.new unless @stored_methods.is_a?(Hash)
6
+ Dir["#{script_path}/*.lua"].map do |file|
7
+ method = File.basename(file, '.*')
8
+ unless @stored_methods.key?(method)
9
+ @stored_methods[method] = Redtastic::Connection.redis.script(:load, `cat #{file}`)
10
+ end
11
+ end
12
+ end
13
+
14
+ def method_missing(method_name, *args)
15
+ if @stored_methods.is_a?(Hash) && @stored_methods.key?(method_name)
16
+ Redtastic::Connection.redis.evalsha(@stored_methods[method_name], *args)
17
+ else
18
+ fail("Could not find script: #{method_name}.lua")
19
+ end
20
+ end
21
+
22
+ def flush_scripts
23
+ @stored_methods = nil
24
+ Redtastic::Connection.redis.script(:flush)
25
+ end
26
+
27
+ def to_ary
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ -- Returns total combined sum accross all ids and keys, as well as the combined sum accross all ids for each
2
+ -- specified data point interval
3
+
4
+ local result = {}
5
+ local sum = 0
6
+ local number_of_ids = tonumber(ARGV[1])
7
+ local data_point_index = 2 -- Initialized to 2 since position 1 in result is reserved for the total sum
8
+ local count = 1 -- Used to track whether we should should move to the next data point
9
+
10
+ for index, key in ipairs(KEYS) do
11
+ -- Get the value associated with the KEY + INDEX pair
12
+ local value = tonumber(redis.call('HGET', key, ARGV[index+1]))
13
+
14
+ -- Initialize the total value for the data point if it hasn't been initialized already
15
+ if result[data_point_index] == nil then
16
+ result[data_point_index] = 0
17
+ end
18
+
19
+ if value then
20
+ sum = sum + value
21
+ result[data_point_index] = result[data_point_index] + value
22
+ end
23
+
24
+ -- Check if we've accounted for each id for the current data point
25
+ -- If true, then move to the next data point
26
+ if count == number_of_ids then
27
+ count = 1
28
+ data_point_index = data_point_index + 1
29
+ else
30
+ count = count + 1
31
+ end
32
+ end
33
+
34
+ -- Position 1 in the result array is reserved for the sum
35
+ result[1] = sum
36
+
37
+ return result
@@ -0,0 +1,14 @@
1
+ -- Returns an array of values for each corresponding key/index pair passed into KEYS & ARGV
2
+
3
+ local result = {}
4
+
5
+ for index, key in ipairs(KEYS) do
6
+ local value = tonumber(redis.call('HGET', key, ARGV[index]))
7
+ if value then
8
+ result[index] = value
9
+ else
10
+ result[index] = 0
11
+ end
12
+ end
13
+
14
+ return result
@@ -0,0 +1,9 @@
1
+ -- Increments each corresponding key/index pair passed into KEYS & ARGV
2
+ -- SPECIAL: the first index in ARGV needs to be set to the value to increment by
3
+
4
+ local incrby = ARGV[1]
5
+
6
+ for index, key in ipairs(KEYS) do
7
+ -- For each key / id increment by the passed in value
8
+ redis.call('HINCRBY', key, ARGV[index+1], incrby)
9
+ end
@@ -0,0 +1,3 @@
1
+ for index, key in ipairs(KEYS) do
2
+ redis.call('sadd', key, ARGV[1])
3
+ end
@@ -0,0 +1,7 @@
1
+ local result = {}
2
+
3
+ for index, key in ipairs(KEYS) do
4
+ result[index] = redis.call('sismember', key, ARGV[1])
5
+ end
6
+
7
+ return result
@@ -0,0 +1,3 @@
1
+ for index, key in ipairs(KEYS) do
2
+ redis.call('srem', key, ARGV[1])
3
+ end
@@ -0,0 +1,28 @@
1
+ -- SPECIAL: ARGV[1] -> tempkey
2
+ -- SPECIAL: All ARGV[n], where n > 1, signify an attribute
3
+ -- SPECIAL: last n keys, where n is number of attributes, is keys associated with attributes
4
+
5
+ local num_attributes = table.getn(ARGV) - 1
6
+ local attribute_keys = {}
7
+
8
+ -- Remove keys associated with attributes and add them to our own attributes_keys array
9
+ for i=1,num_attributes do
10
+ table.insert(attribute_keys, table.remove(KEYS))
11
+ end
12
+
13
+ -- union all of the keys
14
+ redis.call('sunionstore', ARGV[1], unpack(KEYS))
15
+
16
+ -- If attributes are present, we want to get the intersect of the result + any attributes and store in ARGV[1]
17
+ if num_attributes > 0 then
18
+ table.insert(attribute_keys, ARGV[1])
19
+ redis.call('sinterstore', ARGV[1], unpack(attribute_keys))
20
+ end
21
+
22
+ -- get the cardinality of the resulting set
23
+ local count = redis.call('scard', ARGV[1])
24
+
25
+ -- delete the temp key
26
+ redis.call('del', ARGV[1])
27
+
28
+ return count
@@ -0,0 +1,13 @@
1
+ -- Returns the total sum of the values of each key/index pair passed into KEYS & ARGV
2
+
3
+ local sum = 0
4
+
5
+ for index, key in ipairs(KEYS) do
6
+ -- For each key, read the value and add it to the total
7
+ local value = tonumber(redis.call('HGET', key, ARGV[index]))
8
+ if value then
9
+ sum = sum + value
10
+ end
11
+ end
12
+
13
+ return sum
@@ -0,0 +1,51 @@
1
+ -- SPECIAL: ARGV[1] -> number_of_ids
2
+ -- SPECIAL: ARGV[2] -> temp_key
3
+ -- SPECIAL: All ARGV[n], where n > 2, signify an attribute
4
+ -- SPECIAL: last n keys, where n is number of attributes, is keys associated with attributes
5
+
6
+ local result = {}
7
+ local number_of_ids = tonumber(ARGV[1])
8
+ local data_point_index = 2
9
+ local count = 1
10
+ local num_attributes = table.getn(ARGV) - 2
11
+ local attribute_keys = {}
12
+
13
+ -- Remove keys associated with attributes and add them to our own attributes_keys array
14
+ for i=1,num_attributes do
15
+ table.insert(attribute_keys, table.remove(KEYS))
16
+ end
17
+
18
+ -- union all of the keys
19
+ redis.call('sunionstore', ARGV[2], unpack(KEYS))
20
+
21
+ -- If attribute are present, we want to get the intersect of the result + any attributes and store in ARGV[2]
22
+ if num_attributes > 0 then
23
+ redis.call('sinterstore', ARGV[2], ARGV[2], unpack(attribute_keys))
24
+ end
25
+
26
+ -- get the cardinality of the resulting set
27
+ result[1] = redis.call('scard', ARGV[2])
28
+
29
+ -- This loop returns the union of all the keys (for any number of ids) for each data point interval
30
+ local index = 1
31
+ while KEYS[index] do
32
+ local keys = {}
33
+ for i=0,(number_of_ids-1) do
34
+ keys[i+1] = KEYS[index+i]
35
+ end
36
+ redis.call('sunionstore', ARGV[2], unpack(keys))
37
+
38
+ -- If attributes are present, we want to get the interset of this data points result + any attributes
39
+ if num_attributes > 0 then
40
+ redis.call('sinterstore', ARGV[2], ARGV[2], unpack(attribute_keys))
41
+ end
42
+
43
+ result[data_point_index] = redis.call('scard', ARGV[2])
44
+ data_point_index = data_point_index + 1
45
+ index = index + number_of_ids
46
+ end
47
+
48
+ -- delete the temp key
49
+ redis.call('del', ARGV[2])
50
+
51
+ return result
@@ -0,0 +1,3 @@
1
+ module Redtastic
2
+ VERSION = '0.2.2'
3
+ end
data/redtastic.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/redtastic/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.name = 'redtastic'
6
+ gem.version = Redtastic::VERSION
7
+ gem.date = '2013-01-15'
8
+ gem.authors = ['Joe DiVita']
9
+ gem.email = ['joediv31@gmail.com']
10
+ gem.description = %q{ A simple, Redis-backed interface for storing, retrieving, and aggregating analytics }
11
+ gem.summary = %q{ A simple, Redis-backed interface for storing, retrieving, and aggregating analytics }
12
+ gem.homepage = 'https://github.com/bellycard/redtastic'
13
+ gem.files = `git ls-files`.split($\)
14
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
15
+ gem.require_paths = ["lib"]
16
+
17
+ gem.add_dependency 'redis'
18
+ gem.add_dependency 'activesupport'
19
+
20
+ gem.add_development_dependency 'dotenv'
21
+ gem.add_development_dependency 'rspec'
22
+ gem.add_development_dependency 'pry'
23
+ gem.add_development_dependency 'git'
24
+ gem.add_development_dependency 'rubocop'
25
+ gem.add_development_dependency 'simplecov'
26
+ gem.add_development_dependency 'coveralls'
27
+ end
@@ -0,0 +1,42 @@
1
+ require 'spec_helper'
2
+
3
+ describe Redtastic::Connection do
4
+ before do
5
+ # Reset any connections
6
+ Redtastic::Connection.redis = nil
7
+ Redtastic::Connection.namespace = nil
8
+ end
9
+
10
+ describe '#establish_connection' do
11
+ before do
12
+ Redtastic::ScriptManager.stub(:load_scripts)
13
+ redis = Redis.new(host: 'foo', port: 9000)
14
+ Redtastic::Connection.establish_connection(redis, 'bar')
15
+ end
16
+
17
+ it 'properly sets the redis connection' do
18
+ expect(Redtastic::Connection.redis.client.host).to eq('foo')
19
+ expect(Redtastic::Connection.redis.client.port).to eq(9000)
20
+ end
21
+
22
+ it 'properly sets the namespace' do
23
+ expect(Redtastic::Connection.namespace).to eq('bar')
24
+ end
25
+ end
26
+
27
+ context 'when setting options one at a time' do
28
+ before do
29
+ Redtastic::Connection.redis = Redis.new(host: 'foo', port: 1111)
30
+ Redtastic::Connection.namespace = 'bar'
31
+ end
32
+
33
+ it 'properly sets the redis connection' do
34
+ expect(Redtastic::Connection.redis.client.host).to eq('foo')
35
+ expect(Redtastic::Connection.redis.client.port).to eq(1111)
36
+ end
37
+
38
+ it 'properly sets the namespace' do
39
+ expect(Redtastic::Connection.namespace).to eq('bar')
40
+ end
41
+ end
42
+ end