redtastic 0.2.1 → 0.2.2

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,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