redisant 0.1.1

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,19 @@
1
+ require 'redis'
2
+
3
+ require_relative 'errors'
4
+ require_relative 'inflector'
5
+ require_relative 'attribute_builder'
6
+ require_relative 'relation_builder'
7
+ require_relative 'index_builder'
8
+ require_relative 'search_builder'
9
+ require_relative 'relations'
10
+ require_relative 'index'
11
+ require_relative 'search'
12
+ require_relative 'records'
13
+ require_relative 'query'
14
+ require_relative 'criteria'
15
+
16
+
17
+
18
+ # connect to redis db
19
+ $redis = Redis.new(host: '127.0.0.1', port: 6379)
@@ -0,0 +1,168 @@
1
+ class Query
2
+
3
+ def initialize criteria
4
+ @criteria = criteria
5
+ end
6
+
7
+ def run
8
+ raise "Count and random cannot be combined" if @criteria.count? && @criteria.random?
9
+
10
+ collect_keys
11
+ if @criteria.count?
12
+ return count
13
+ elsif @criteria.random?
14
+ random
15
+ else
16
+ fetch
17
+ end
18
+
19
+ if @criteria.criteria[:exists]
20
+ any?
21
+ elsif @criteria.ids?
22
+ flatten_single_items @ids
23
+ else
24
+ load_objects
25
+ flatten_single_items @objects
26
+ end
27
+ ensure
28
+ delete_tmp
29
+ end
30
+
31
+ private
32
+
33
+ def any?
34
+ @ids and @ids.any?
35
+ end
36
+
37
+ def collect_keys
38
+ @sub_keys = []
39
+ # start from a has_many relation?
40
+ @sub_keys << @criteria.ids_key if @criteria.get_relation || @criteria.get_conditions.empty?
41
+
42
+ # where conditions
43
+ name = @criteria.object_class.name.downcase
44
+ @criteria.get_conditions.each_pair do |k,v|
45
+ @sub_keys << "#{name}:search:#{k}:#{v}"
46
+ end
47
+
48
+ @final_key = @sub_keys.first if @sub_keys.size == 1
49
+ end
50
+
51
+ def count
52
+ if @sub_keys.size > 1
53
+ count_intersection
54
+ else
55
+ count_ids
56
+ end
57
+ end
58
+
59
+ def random
60
+ if @sub_keys.size > 1
61
+ @ids = [random_intersection]
62
+ else
63
+ @ids = [random_ids]
64
+ end
65
+ end
66
+
67
+ def fetch
68
+ if @sub_keys.size > 1
69
+ if @criteria.sort? || @criteria.limit? || @criteria.single?
70
+ store_intersection
71
+ @ids = sort_and_limit
72
+ else
73
+ @ids = fetch_intersection
74
+ end
75
+ else
76
+ @final_key = @sub_keys.first
77
+ @ids = sort_and_limit
78
+ end
79
+ end
80
+
81
+ def flatten_single_items a
82
+ if @criteria.single?
83
+ a.first
84
+ else
85
+ a
86
+ end
87
+ end
88
+
89
+ def fetch_intersection
90
+ got = $redis.sinter @sub_keys
91
+ got.map { |id| id.to_i } if got
92
+ end
93
+
94
+ def store_intersection
95
+ # combine search sets to temporary set that we can sort later
96
+ @final_key = "tmp:#{rand(36**16).to_s(36)}"
97
+ $redis.sinterstore @final_key, @sub_keys
98
+ @del = true
99
+ end
100
+
101
+ def delete_tmp
102
+ $redis.del @final_key if @del
103
+ @del = nil
104
+ end
105
+
106
+ def random_intersection
107
+ # random numbers in redis lua scripts must be seeded with an outside integer
108
+ lua = "
109
+ math.randomseed(tonumber(ARGV[1]))
110
+ local ids=redis.call('SINTER', unpack(KEYS))
111
+ return ids[ math.random(#ids) ]
112
+ "
113
+ $redis.eval(lua, @sub_keys, [rand(2**32)]).to_i
114
+ end
115
+
116
+ def count_intersection
117
+ lua = "return #redis.call('SINTER', unpack(KEYS));"
118
+ $redis.eval lua, @sub_keys
119
+ end
120
+
121
+ def count_set
122
+ $redis.scard @final_key
123
+ end
124
+
125
+ def sort_and_limit
126
+ criteria = @criteria.criteria
127
+
128
+ sort = criteria[:sort]
129
+ # use dup because string might be frozen, and we might need to modify it later
130
+ order = criteria[:order].to_s.dup || 'asc'
131
+
132
+ if criteria[:limit]
133
+ limit = [criteria[:offset] || 0, criteria[:limit]]
134
+ end
135
+
136
+ if sort
137
+ index = @criteria.object_class.find_index(sort)
138
+ type = index.type
139
+ by = "#{@criteria.object_class.name.downcase}:*:attributes->#{sort}"
140
+ by << ":float" if type == 'float'
141
+ if type == 'string'
142
+ order << ' alpha'
143
+ end
144
+ else
145
+ by = 'nosort' unless criteria[:limit]==1
146
+ end
147
+
148
+ ids = $redis.sort @final_key, limit: limit, by: by, order: order
149
+ ids.map! { |t| t.to_i } if ids
150
+ end
151
+
152
+ def count_ids
153
+ @result = $redis.scard @final_key
154
+ end
155
+
156
+ def random_ids
157
+ $redis.srandmember(@final_key).to_i
158
+ end
159
+
160
+ def random_set
161
+ $redis.srandmember(@final_key).to_i
162
+ end
163
+
164
+ def load_objects
165
+ @objects = @ids.map { |id| @criteria.object_class.load id }
166
+ end
167
+
168
+ end
@@ -0,0 +1,390 @@
1
+ require 'set'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ class Record
6
+ include AttributeBuilder
7
+ include RelationBuilder
8
+ include IndexBuilder
9
+ include SearchBuilder
10
+
11
+ attr_reader :id
12
+ attr_reader :attributes
13
+ attr_reader :dirty
14
+ attr_reader :errors
15
+
16
+ def initialize attributes=nil
17
+ raise Redisant::InvalidArgument.new('Wrong arguments') unless attributes==nil or attributes.is_a? Hash
18
+ @id = attributes.delete(:id) if attributes
19
+ @attributes = stringify_attributes(attributes) || {}
20
+ @prev_attributes = {}
21
+ @dirty = @attributes.keys
22
+ setup_relations if respond_to? :setup_relations
23
+ @id_saved = false
24
+ @errors = nil
25
+ end
26
+
27
+ def class_name
28
+ self.class.name.downcase
29
+ end
30
+
31
+ def self.load id
32
+ t = self.new id:id
33
+ t.load
34
+ t
35
+ end
36
+
37
+ # query
38
+ def self.find id
39
+ raise Redisant::InvalidArgument.new("Invalid argument") unless id
40
+ return nil unless exists? id
41
+ t = self.new id:id
42
+
43
+ t.load
44
+ t
45
+ end
46
+
47
+ def self.find! id
48
+ raise Redisant::InvalidArgument.new("Not found") unless exists? id
49
+ t = self.new id:id
50
+ t.load
51
+ t
52
+ end
53
+
54
+ def self.exists? id
55
+ $redis.sismember( id_key, id )
56
+ end
57
+
58
+ def self.all options={}
59
+ sort = options.delete :sort
60
+ if sort
61
+ index = self.find_index sort.to_s
62
+ raise Redisant::InvalidArgument.new("Cannot order by #{sort}") unless index
63
+ index.objects options
64
+ else
65
+ ids.map { |id| self.find id }
66
+ end
67
+ end
68
+
69
+ def self.any?
70
+ $redis.scard( id_key ) > 0
71
+ end
72
+
73
+ def self.first attributes={}
74
+ Criteria.new(self).first attributes
75
+ end
76
+
77
+ def self.last attributes={}
78
+ Criteria.new(self).last attributes
79
+ end
80
+
81
+ def self.random
82
+ Criteria.new(self).random
83
+ end
84
+
85
+ def self.where attributes
86
+ Criteria.new(self).where attributes
87
+ end
88
+
89
+ def self.count
90
+ Criteria.new(self).count
91
+ end
92
+
93
+ def self.sort options
94
+ Criteria.new(self).sort options
95
+ end
96
+
97
+ def self.order options
98
+ Criteria.new(self).order options
99
+ end
100
+
101
+ # dirty
102
+ def dirty?
103
+ @dirty.any?
104
+ end
105
+
106
+ def dirty keys
107
+ @dirty = @dirty | [keys].flatten
108
+ end
109
+
110
+ def clean
111
+ @dirty = []
112
+ end
113
+
114
+ # crud
115
+ def self.build attributes=nil
116
+ object = self.new attributes
117
+ object.save
118
+ object
119
+ end
120
+
121
+ def destroy
122
+ destroy_relations
123
+ destroy_attributes
124
+ remove_id
125
+ end
126
+
127
+ def new?
128
+ id == nil
129
+ end
130
+
131
+ def load
132
+ load_attributes
133
+ @id_saved = true
134
+ end
135
+
136
+ def save
137
+ return false unless validate
138
+ make_unique_id
139
+ add_id
140
+ save_attributes
141
+ true
142
+ end
143
+
144
+ def save!
145
+ raise Redisant::ValidationFailed.new('Validation failed') unless save
146
+ end
147
+
148
+ def validate
149
+ @errors = nil
150
+ self.class.attributes.each_pair do |key,options|
151
+ v = attribute(key)
152
+ if options[:required]
153
+ if v==nil
154
+ @errors ||= {}
155
+ @errors[key] = "is required"
156
+ end
157
+ end
158
+ if v && options[:unique]
159
+ conditions = {}
160
+ conditions[key] = v
161
+ if self.class.where(conditions).count > 0
162
+ @errors ||= {}
163
+ @errors[key] = "must be unique"
164
+ end
165
+ end
166
+ end
167
+ @errors == nil
168
+ end
169
+
170
+ def self.destroy_all
171
+ keys = ids.map {|id| "#{self.name.downcase}:#{id}:attributes" }
172
+ keys << id_key
173
+ keys << class_key('ids:counter')
174
+ $redis.del keys if keys.any?
175
+ end
176
+
177
+ # keys
178
+ def member_key str
179
+ raise Redisant::InvalidArgument.new('Cannot make key without id') unless @id
180
+ "#{class_name}:#{@id}:#{str}"
181
+ end
182
+
183
+ def self.class_key str
184
+ "#{self.name.downcase}:#{str}"
185
+ end
186
+
187
+ # single attribute
188
+ def attribute key
189
+ @attributes[key.to_s]
190
+ end
191
+
192
+ def set_attribute key, value
193
+ if value != @attributes[key.to_s]
194
+ @attributes[key.to_s] = value
195
+ dirty [key]
196
+ end
197
+ end
198
+
199
+ def update_attribute key, value
200
+ if value != @attributes[key.to_s]
201
+ @attributes[key.to_s] = value
202
+ $redis.hset member_key('attributes'), key, value
203
+ update_search
204
+ end
205
+ end
206
+
207
+ # multiple attributes
208
+ def attributes= attributes
209
+ raise Redisant::InvalidArgument.new("Invalid arguments") unless attributes.is_a? Hash
210
+ attributes.each_pair do |key,value|
211
+ if value != @attributes[key.to_s]
212
+ @attributes[key.to_s] = value
213
+ dirty key
214
+ end
215
+ end
216
+ end
217
+
218
+ def load_attributes keys=nil
219
+ if keys
220
+ keys = keys.map { |key| key.to_s }
221
+ values = $redis.hmget(member_key('attributes'), keys)
222
+ raw = keys.zip(values).to_h
223
+ else
224
+ raw = $redis.hgetall(member_key('attributes'))
225
+ end
226
+ decoded = decode_attributes(raw)
227
+ @attributes = restore_attribute_types decoded
228
+ keep_attributes
229
+ end
230
+
231
+ def save_attributes
232
+ if dirty?
233
+ synthesize_attributes
234
+ $redis.hmset member_key('attributes'), encode_attributes
235
+ clean
236
+ end
237
+ update_search
238
+ end
239
+
240
+ def update_attributes attributes
241
+ raise Redisant::InvalidArgument.new("Invalid arguments") unless attributes.is_a? Hash
242
+ @attributes.merge! stringify_attributes(attributes)
243
+ dirty attributes.keys
244
+ save_attributes
245
+ end
246
+
247
+ def destroy_attributes
248
+ $redis.del member_key('attributes')
249
+ @attributes = nil
250
+ update_search
251
+ end
252
+
253
+ def cleanup_attributes
254
+ # delete attribues in the hash that's not in our local attributes
255
+ keys = $redis.hkeys member_key('attributes')
256
+ delete = keys - @attributes.keys
257
+ $redis.hdel member_key('attributes'), delete
258
+ end
259
+
260
+ # ids
261
+ def self.id_key
262
+ class_key 'id'
263
+ end
264
+
265
+ def make_unique_id
266
+ return if @id
267
+ #use optimistic concurrency control:
268
+ #if id is taken, try again until we succeed
269
+ while true
270
+ id = $redis.incr(self.class.class_key('ids:counter')).to_i
271
+ unless self.class.exists? id
272
+ @id = id
273
+ return
274
+ end
275
+ end
276
+ end
277
+
278
+ def self.ids
279
+ Criteria.new(self).ids
280
+ end
281
+
282
+ def add_id
283
+ raise Redisant::InvalidArgument.new('Cannot add empty id') unless @id
284
+ return if @id_saved
285
+ $redis.sadd self.class.id_key, @id.to_i
286
+ @id_saved = true
287
+ end
288
+
289
+ def remove_id
290
+ raise Redisant::InvalidArgument.new('Cannot remove empty id') unless @id
291
+ $redis.srem self.class.id_key, @id
292
+ @id = nil
293
+ @id_saved = false
294
+ end
295
+
296
+
297
+ private
298
+
299
+ # redis can only sort by string or float
300
+ # to sort by eg. Time we store float version of required attributes
301
+ def synthesize_attributes
302
+ synthesized = {}
303
+ self.class.indexes.each_pair do |name,index|
304
+ if @dirty.include? name
305
+ if index.type=='float'
306
+ # for Time objects to_f return number of seconds since epoch
307
+ key = "#{name}:float"
308
+ value = @attributes[name].to_f
309
+ @attributes[key] = value
310
+ dirty key
311
+ end
312
+ end
313
+ end
314
+ end
315
+
316
+ def encode_attributes
317
+ encoded = {}
318
+ @dirty.each do |key|
319
+ k = key.to_s
320
+ encoded[k] = @attributes[k].to_json
321
+ end
322
+ encoded.flatten
323
+ end
324
+
325
+ def decode_attributes attributes
326
+ decoded = {}
327
+ attributes.each do |pair|
328
+ if pair[1]==nil
329
+ decoded[pair[0]] = nil
330
+ else
331
+ decoded[pair[0]] = JSON.parse pair[1], quirks_mode: true
332
+ end
333
+ end
334
+ @attributes = decoded
335
+ end
336
+
337
+ def restore_attribute_types attributes
338
+ restored = {}
339
+ attributes.each_pair do |k,v|
340
+ time_match = /(.+)_at$/.match k
341
+ if time_match
342
+ restored[k] = Time.parse v
343
+ end
344
+ end
345
+ attributes.merge! restored
346
+ end
347
+
348
+ def destroy_relations
349
+ relations.values.each {|relation| relation.destroy }
350
+ @relations = nil
351
+ end
352
+
353
+ def stringify_attributes attributes
354
+ attributes.collect {|k,v| [k.to_s,v] }.to_h if attributes
355
+ end
356
+
357
+ def keep_attributes
358
+ if @attributes
359
+ @prev_attributes = @attributes.dup
360
+ else
361
+ @prev_attributes = nil
362
+ end
363
+ end
364
+
365
+ # search
366
+ def update_search
367
+ prev_keys = @prev_attributes ? @prev_attributes.keys : []
368
+ cur_keys = @attributes? @attributes.keys : []
369
+ keys = prev_keys | cur_keys
370
+ keys = keys & self.class.searches.keys # only attributes with search activated
371
+ keys.each do |k|
372
+ prev = @prev_attributes? @prev_attributes[k] : nil
373
+ cur = @attributes ? @attributes[k] : nil
374
+ if prev != cur
375
+ search = self.class.find_search k.to_s
376
+ if search
377
+ if prev && cur
378
+ search.update self, prev, cur
379
+ elsif cur
380
+ search.add self, cur
381
+ elsif prev
382
+ search.remove self, prev
383
+ end
384
+ end
385
+ end
386
+ end
387
+ keep_attributes
388
+ end
389
+
390
+ end