redisant 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +1 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/lib/redisant/attribute_builder.rb +38 -0
- data/lib/redisant/connection.rb +5 -0
- data/lib/redisant/criteria.rb +180 -0
- data/lib/redisant/errors.rb +20 -0
- data/lib/redisant/index.rb +11 -0
- data/lib/redisant/index_builder.rb +21 -0
- data/lib/redisant/inflector.rb +13 -0
- data/lib/redisant/orm.rb +19 -0
- data/lib/redisant/query.rb +168 -0
- data/lib/redisant/records.rb +390 -0
- data/lib/redisant/relation_builder.rb +55 -0
- data/lib/redisant/relations.rb +201 -0
- data/lib/redisant/search.rb +29 -0
- data/lib/redisant/search_builder.rb +21 -0
- data/lib/redisant/set.rb +6 -0
- data/lib/redisant/version.rb +3 -0
- data/lib/redisant.rb +17 -0
- data/redisant.gemspec +27 -0
- metadata +145 -0
data/lib/redisant/orm.rb
ADDED
@@ -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
|