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