sohm 0.0.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/.gems +4 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +312 -0
- data/LICENSE +19 -0
- data/README.md +10 -0
- data/benchmarks/common.rb +33 -0
- data/benchmarks/create.rb +21 -0
- data/benchmarks/delete.rb +13 -0
- data/examples/activity-feed.rb +162 -0
- data/examples/chaining.rb +162 -0
- data/examples/json-hash.rb +75 -0
- data/examples/one-to-many.rb +124 -0
- data/examples/philosophy.rb +137 -0
- data/examples/redis-logging.txt +179 -0
- data/examples/slug.rb +149 -0
- data/examples/tagging.rb +237 -0
- data/lib/sample.rb +14 -0
- data/lib/sohm/command.rb +51 -0
- data/lib/sohm/json.rb +17 -0
- data/lib/sohm/lua/delete.lua +72 -0
- data/lib/sohm/lua/save.lua +13 -0
- data/lib/sohm.rb +1576 -0
- data/makefile +4 -0
- data/sohm.gemspec +18 -0
- data/test/association.rb +33 -0
- data/test/command.rb +55 -0
- data/test/connection.rb +16 -0
- data/test/core.rb +24 -0
- data/test/counters.rb +67 -0
- data/test/enumerable.rb +79 -0
- data/test/filtering.rb +185 -0
- data/test/hash_key.rb +31 -0
- data/test/helper.rb +23 -0
- data/test/indices.rb +133 -0
- data/test/json.rb +62 -0
- data/test/list.rb +83 -0
- data/test/model.rb +789 -0
- data/test/set.rb +37 -0
- data/test/thread_safety.rb +67 -0
- data/test/to_hash.rb +29 -0
- data/test/uniques.rb +98 -0
- metadata +142 -0
data/lib/sohm.rb
ADDED
@@ -0,0 +1,1576 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'digest/sha1'
|
4
|
+
require "msgpack"
|
5
|
+
require "nido"
|
6
|
+
require "redic"
|
7
|
+
require "securerandom"
|
8
|
+
require "set"
|
9
|
+
require_relative "sohm/command"
|
10
|
+
|
11
|
+
module Sohm
|
12
|
+
LUA_SAVE = File.read(File.expand_path("../sohm/lua/save.lua", __FILE__))
|
13
|
+
LUA_SAVE_DIGEST = Digest::SHA1.hexdigest LUA_SAVE
|
14
|
+
|
15
|
+
# All of the known errors in Ohm can be traced back to one of these
|
16
|
+
# exceptions.
|
17
|
+
#
|
18
|
+
# MissingID:
|
19
|
+
#
|
20
|
+
# Comment.new.id # => Error
|
21
|
+
# Comment.new.key # => Error
|
22
|
+
#
|
23
|
+
# Solution: you need to save your model first.
|
24
|
+
#
|
25
|
+
# IndexNotFound:
|
26
|
+
#
|
27
|
+
# Comment.find(:foo => "Bar") # => Error
|
28
|
+
#
|
29
|
+
# Solution: add an index with `Comment.index :foo`.
|
30
|
+
#
|
31
|
+
# UniqueIndexViolation:
|
32
|
+
#
|
33
|
+
# Raised when trying to save an object with a `unique` index for
|
34
|
+
# which the value already exists.
|
35
|
+
#
|
36
|
+
# Solution: rescue `Ohm::UniqueIndexViolation` during save, but
|
37
|
+
# also, do some validations even before attempting to save.
|
38
|
+
#
|
39
|
+
class Error < StandardError; end
|
40
|
+
class MissingID < Error; end
|
41
|
+
class IndexNotFound < Error; end
|
42
|
+
class CasViolation < Error; end
|
43
|
+
|
44
|
+
# Instead of monkey patching Kernel or trying to be clever, it's
|
45
|
+
# best to confine all the helper methods in a Utils module.
|
46
|
+
module Utils
|
47
|
+
|
48
|
+
# Used by: `attribute`, `counter`, `set`, `reference`,
|
49
|
+
# `collection`.
|
50
|
+
#
|
51
|
+
# Employed as a solution to avoid `NameError` problems when trying
|
52
|
+
# to load models referring to other models not yet loaded.
|
53
|
+
#
|
54
|
+
# Example:
|
55
|
+
#
|
56
|
+
# class Comment < Ohm::Model
|
57
|
+
# reference :user, User # NameError undefined constant User.
|
58
|
+
# end
|
59
|
+
#
|
60
|
+
# # Instead of relying on some clever `const_missing` hack, we can
|
61
|
+
# # simply use a symbol or a string.
|
62
|
+
#
|
63
|
+
# class Comment < Ohm::Model
|
64
|
+
# reference :user, :User
|
65
|
+
# reference :post, "Post"
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
def self.const(context, name)
|
69
|
+
case name
|
70
|
+
when Symbol, String
|
71
|
+
context.const_get(name)
|
72
|
+
else name
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.dict(arr)
|
77
|
+
Hash[*arr]
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.sort(redis, key, options)
|
81
|
+
args = []
|
82
|
+
|
83
|
+
args.concat(["BY", options[:by]]) if options[:by]
|
84
|
+
args.concat(["GET", options[:get]]) if options[:get]
|
85
|
+
args.concat(["LIMIT"] + options[:limit]) if options[:limit]
|
86
|
+
args.concat(options[:order].split(" ")) if options[:order]
|
87
|
+
args.concat(["STORE", options[:store]]) if options[:store]
|
88
|
+
|
89
|
+
redis.call("SORT", key, *args)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Use this if you want to do quick ad hoc redis commands against the
|
94
|
+
# defined Ohm connection.
|
95
|
+
#
|
96
|
+
# Examples:
|
97
|
+
#
|
98
|
+
# Ohm.redis.call("SET", "foo", "bar")
|
99
|
+
# Ohm.redis.call("FLUSH")
|
100
|
+
#
|
101
|
+
def self.redis
|
102
|
+
@redis ||= Redic.new
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.redis=(redis)
|
106
|
+
@redis = redis
|
107
|
+
end
|
108
|
+
|
109
|
+
# By default, EVALSHA is used
|
110
|
+
def self.enable_evalsha
|
111
|
+
defined?(@enable_evalsha) ? @enable_evalsha : true
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.enable_evalsha=(enabled)
|
115
|
+
@enable_evalsha = enabled
|
116
|
+
end
|
117
|
+
|
118
|
+
module Collection
|
119
|
+
include Enumerable
|
120
|
+
|
121
|
+
def each
|
122
|
+
if block_given?
|
123
|
+
ids.each_slice(1000) do |slice|
|
124
|
+
fetch(slice).each { |e| yield(e) }
|
125
|
+
end
|
126
|
+
else
|
127
|
+
to_enum
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Fetch the data from Redis in one go.
|
132
|
+
def to_a
|
133
|
+
fetch(ids)
|
134
|
+
end
|
135
|
+
|
136
|
+
def empty?
|
137
|
+
size == 0
|
138
|
+
end
|
139
|
+
|
140
|
+
# TODO: fix this later
|
141
|
+
# Wraps the whole pipelining functionality.
|
142
|
+
def fetch(ids)
|
143
|
+
data = nil
|
144
|
+
|
145
|
+
model.synchronize do
|
146
|
+
ids.each do |id|
|
147
|
+
redis.queue("HGETALL", namespace[id])
|
148
|
+
end
|
149
|
+
|
150
|
+
data = redis.commit
|
151
|
+
end
|
152
|
+
|
153
|
+
return [] if data.nil?
|
154
|
+
|
155
|
+
[].tap do |result|
|
156
|
+
data.each_with_index do |atts, idx|
|
157
|
+
unless attrs.empty?
|
158
|
+
result << model.new(Utils.dict(atts).update(:id => ids[idx]))
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
class List
|
166
|
+
include Collection
|
167
|
+
|
168
|
+
attr :key
|
169
|
+
attr :namespace
|
170
|
+
attr :model
|
171
|
+
|
172
|
+
def initialize(key, namespace, model)
|
173
|
+
@key = key
|
174
|
+
@namespace = namespace
|
175
|
+
@model = model
|
176
|
+
end
|
177
|
+
|
178
|
+
# Returns the total size of the list using LLEN.
|
179
|
+
def size
|
180
|
+
redis.call("LLEN", key)
|
181
|
+
end
|
182
|
+
|
183
|
+
# Returns the first element of the list using LINDEX.
|
184
|
+
def first
|
185
|
+
model[redis.call("LINDEX", key, 0)]
|
186
|
+
end
|
187
|
+
|
188
|
+
# Returns the last element of the list using LINDEX.
|
189
|
+
def last
|
190
|
+
model[redis.call("LINDEX", key, -1)]
|
191
|
+
end
|
192
|
+
|
193
|
+
# Returns an array of elements from the list using LRANGE.
|
194
|
+
# #range receives 2 integers, start and stop
|
195
|
+
#
|
196
|
+
# Example:
|
197
|
+
#
|
198
|
+
# class Comment < Ohm::Model
|
199
|
+
# end
|
200
|
+
#
|
201
|
+
# class Post < Ohm::Model
|
202
|
+
# list :comments, :Comment
|
203
|
+
# end
|
204
|
+
#
|
205
|
+
# c1 = Comment.create
|
206
|
+
# c2 = Comment.create
|
207
|
+
# c3 = Comment.create
|
208
|
+
#
|
209
|
+
# post = Post.create
|
210
|
+
#
|
211
|
+
# post.comments.push(c1)
|
212
|
+
# post.comments.push(c2)
|
213
|
+
# post.comments.push(c3)
|
214
|
+
#
|
215
|
+
# [c1, c2] == post.comments.range(0, 1)
|
216
|
+
# # => true
|
217
|
+
def range(start, stop)
|
218
|
+
fetch(redis.call("LRANGE", key, start, stop))
|
219
|
+
end
|
220
|
+
|
221
|
+
# Checks if the model is part of this List.
|
222
|
+
#
|
223
|
+
# An important thing to note is that this method loads all of the
|
224
|
+
# elements of the List since there is no command in Redis that
|
225
|
+
# allows you to actually check the list contents efficiently.
|
226
|
+
#
|
227
|
+
# You may want to avoid doing this if your list has say, 10K entries.
|
228
|
+
def include?(model)
|
229
|
+
ids.include?(model.id)
|
230
|
+
end
|
231
|
+
|
232
|
+
# Pushes the model to the _end_ of the list using RPUSH.
|
233
|
+
def push(model)
|
234
|
+
redis.call("RPUSH", key, model.id)
|
235
|
+
end
|
236
|
+
|
237
|
+
# Pushes the model to the _beginning_ of the list using LPUSH.
|
238
|
+
def unshift(model)
|
239
|
+
redis.call("LPUSH", key, model.id)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Delete a model from the list.
|
243
|
+
#
|
244
|
+
# Note: If your list contains the model multiple times, this method
|
245
|
+
# will delete all instances of that model in one go.
|
246
|
+
#
|
247
|
+
# Example:
|
248
|
+
#
|
249
|
+
# class Comment < Ohm::Model
|
250
|
+
# end
|
251
|
+
#
|
252
|
+
# class Post < Ohm::Model
|
253
|
+
# list :comments, :Comment
|
254
|
+
# end
|
255
|
+
#
|
256
|
+
# p = Post.create
|
257
|
+
# c = Comment.create
|
258
|
+
#
|
259
|
+
# p.comments.push(c)
|
260
|
+
# p.comments.push(c)
|
261
|
+
#
|
262
|
+
# p.comments.delete(c)
|
263
|
+
#
|
264
|
+
# p.comments.size == 0
|
265
|
+
# # => true
|
266
|
+
#
|
267
|
+
def delete(model)
|
268
|
+
# LREM key 0 <id> means remove all elements matching <id>
|
269
|
+
# @see http://redis.io/commands/lrem
|
270
|
+
redis.call("LREM", key, 0, model.id)
|
271
|
+
end
|
272
|
+
|
273
|
+
# Returns an array with all the ID's of the list.
|
274
|
+
#
|
275
|
+
# class Comment < Ohm::Model
|
276
|
+
# end
|
277
|
+
#
|
278
|
+
# class Post < Ohm::Model
|
279
|
+
# list :comments, :Comment
|
280
|
+
# end
|
281
|
+
#
|
282
|
+
# post = Post.create
|
283
|
+
# post.comments.push(Comment.create)
|
284
|
+
# post.comments.push(Comment.create)
|
285
|
+
# post.comments.push(Comment.create)
|
286
|
+
#
|
287
|
+
# post.comments.map(&:id)
|
288
|
+
# # => ["1", "2", "3"]
|
289
|
+
#
|
290
|
+
# post.comments.ids
|
291
|
+
# # => ["1", "2", "3"]
|
292
|
+
#
|
293
|
+
def ids
|
294
|
+
redis.call("LRANGE", key, 0, -1)
|
295
|
+
end
|
296
|
+
|
297
|
+
private
|
298
|
+
|
299
|
+
def redis
|
300
|
+
model.redis
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
# Defines most of the methods used by `Set` and `MultiSet`.
|
305
|
+
class BasicSet
|
306
|
+
include Collection
|
307
|
+
|
308
|
+
# Allows you to sort by any attribute in the hash, this doesn't include
|
309
|
+
# the +id+. If you want to sort by ID, use #sort.
|
310
|
+
#
|
311
|
+
# class User < Ohm::Model
|
312
|
+
# attribute :name
|
313
|
+
# end
|
314
|
+
#
|
315
|
+
# User.all.sort_by(:name, :order => "ALPHA")
|
316
|
+
# User.all.sort_by(:name, :order => "ALPHA DESC")
|
317
|
+
# User.all.sort_by(:name, :order => "ALPHA DESC", :limit => [0, 10])
|
318
|
+
#
|
319
|
+
# Note: This is slower compared to just doing `sort`, specifically
|
320
|
+
# because Redis has to read each individual hash in order to sort
|
321
|
+
# them.
|
322
|
+
#
|
323
|
+
def sort_by(att, options = {})
|
324
|
+
sort(options.merge(:by => to_key(att)))
|
325
|
+
end
|
326
|
+
|
327
|
+
# Allows you to sort your models using their IDs. This is much
|
328
|
+
# faster than `sort_by`. If you simply want to get records in
|
329
|
+
# ascending or descending order, then this is the best method to
|
330
|
+
# do that.
|
331
|
+
#
|
332
|
+
# Example:
|
333
|
+
#
|
334
|
+
# class User < Ohm::Model
|
335
|
+
# attribute :name
|
336
|
+
# end
|
337
|
+
#
|
338
|
+
# User.create(:name => "John")
|
339
|
+
# User.create(:name => "Jane")
|
340
|
+
#
|
341
|
+
# User.all.sort.map(&:id) == ["1", "2"]
|
342
|
+
# # => true
|
343
|
+
#
|
344
|
+
# User.all.sort(:order => "ASC").map(&:id) == ["1", "2"]
|
345
|
+
# # => true
|
346
|
+
#
|
347
|
+
# User.all.sort(:order => "DESC").map(&:id) == ["2", "1"]
|
348
|
+
# # => true
|
349
|
+
#
|
350
|
+
def sort(options = {})
|
351
|
+
if options.has_key?(:get)
|
352
|
+
options[:get] = to_key(options[:get])
|
353
|
+
return execute { |key| Utils.sort(redis, key, options) }
|
354
|
+
end
|
355
|
+
|
356
|
+
fetch(execute { |key| Utils.sort(redis, key, options) })
|
357
|
+
end
|
358
|
+
|
359
|
+
# Check if a model is included in this set.
|
360
|
+
#
|
361
|
+
# Example:
|
362
|
+
#
|
363
|
+
# u = User.create
|
364
|
+
#
|
365
|
+
# User.all.include?(u)
|
366
|
+
# # => true
|
367
|
+
#
|
368
|
+
# Note: Ohm simply checks that the model's ID is included in the
|
369
|
+
# set. It doesn't do any form of type checking.
|
370
|
+
#
|
371
|
+
def include?(model)
|
372
|
+
exists?(model.id)
|
373
|
+
end
|
374
|
+
|
375
|
+
# Returns the total size of the set using SCARD.
|
376
|
+
def size
|
377
|
+
execute { |key| redis.call("SCARD", key) }
|
378
|
+
end
|
379
|
+
|
380
|
+
# Syntactic sugar for `sort_by` or `sort` when you only need the
|
381
|
+
# first element.
|
382
|
+
#
|
383
|
+
# Example:
|
384
|
+
#
|
385
|
+
# User.all.first ==
|
386
|
+
# User.all.sort(:limit => [0, 1]).first
|
387
|
+
#
|
388
|
+
# User.all.first(:by => :name, "ALPHA") ==
|
389
|
+
# User.all.sort_by(:name, :order => "ALPHA", :limit => [0, 1]).first
|
390
|
+
#
|
391
|
+
def first(options = {})
|
392
|
+
opts = options.dup
|
393
|
+
opts.merge!(:limit => [0, 1])
|
394
|
+
|
395
|
+
if opts[:by]
|
396
|
+
sort_by(opts.delete(:by), opts).first
|
397
|
+
else
|
398
|
+
sort(opts).first
|
399
|
+
end
|
400
|
+
end
|
401
|
+
|
402
|
+
# Returns an array with all the ID's of the set.
|
403
|
+
#
|
404
|
+
# class Post < Ohm::Model
|
405
|
+
# end
|
406
|
+
#
|
407
|
+
# class User < Ohm::Model
|
408
|
+
# attribute :name
|
409
|
+
# index :name
|
410
|
+
#
|
411
|
+
# set :posts, :Post
|
412
|
+
# end
|
413
|
+
#
|
414
|
+
# User.create(name: "John")
|
415
|
+
# User.create(name: "Jane")
|
416
|
+
#
|
417
|
+
# User.all.ids
|
418
|
+
# # => ["1", "2"]
|
419
|
+
#
|
420
|
+
# User.find(name: "John").union(name: "Jane").ids
|
421
|
+
# # => ["1", "2"]
|
422
|
+
#
|
423
|
+
def ids
|
424
|
+
execute { |key| redis.call("SMEMBERS", key) }
|
425
|
+
end
|
426
|
+
|
427
|
+
# Retrieve a specific element using an ID from this set.
|
428
|
+
#
|
429
|
+
# Example:
|
430
|
+
#
|
431
|
+
# # Let's say we got the ID 1 from a request parameter.
|
432
|
+
# id = 1
|
433
|
+
#
|
434
|
+
# # Retrieve the post if it's included in the user's posts.
|
435
|
+
# post = user.posts[id]
|
436
|
+
#
|
437
|
+
def [](id)
|
438
|
+
model[id] if exists?(id)
|
439
|
+
end
|
440
|
+
|
441
|
+
# Returns +true+ if +id+ is included in the set. Otherwise, returns +false+.
|
442
|
+
#
|
443
|
+
# Example:
|
444
|
+
#
|
445
|
+
# class Post < Ohm::Model
|
446
|
+
# end
|
447
|
+
#
|
448
|
+
# class User < Ohm::Model
|
449
|
+
# set :posts, :Post
|
450
|
+
# end
|
451
|
+
#
|
452
|
+
# user = User.create
|
453
|
+
# post = Post.create
|
454
|
+
# user.posts.add(post)
|
455
|
+
#
|
456
|
+
# user.posts.exists?('nonexistent') # => false
|
457
|
+
# user.posts.exists?(post.id) # => true
|
458
|
+
#
|
459
|
+
def exists?(id)
|
460
|
+
execute { |key| redis.call("SISMEMBER", key, id) == 1 }
|
461
|
+
end
|
462
|
+
|
463
|
+
private
|
464
|
+
def to_key(att)
|
465
|
+
if model.counters.include?(att)
|
466
|
+
namespace["*:counters->%s" % att]
|
467
|
+
else
|
468
|
+
namespace["*->%s" % att]
|
469
|
+
end
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
class Set < BasicSet
|
474
|
+
attr :key
|
475
|
+
attr :namespace
|
476
|
+
attr :model
|
477
|
+
|
478
|
+
def initialize(key, namespace, model)
|
479
|
+
@key = key
|
480
|
+
@namespace = namespace
|
481
|
+
@model = model
|
482
|
+
end
|
483
|
+
|
484
|
+
# Chain new fiters on an existing set.
|
485
|
+
#
|
486
|
+
# Example:
|
487
|
+
#
|
488
|
+
# set = User.find(:name => "John")
|
489
|
+
# set.find(:age => 30)
|
490
|
+
#
|
491
|
+
def find(dict)
|
492
|
+
MultiSet.new(
|
493
|
+
namespace, model, Command[:sinterstore, key, *model.filters(dict)]
|
494
|
+
)
|
495
|
+
end
|
496
|
+
|
497
|
+
# Reduce the set using any number of filters.
|
498
|
+
#
|
499
|
+
# Example:
|
500
|
+
#
|
501
|
+
# set = User.find(:name => "John")
|
502
|
+
# set.except(:country => "US")
|
503
|
+
#
|
504
|
+
# # You can also do it in one line.
|
505
|
+
# User.find(:name => "John").except(:country => "US")
|
506
|
+
#
|
507
|
+
def except(dict)
|
508
|
+
MultiSet.new(namespace, model, key).except(dict)
|
509
|
+
end
|
510
|
+
|
511
|
+
# Perform an intersection between the existent set and
|
512
|
+
# the new set created by the union of the passed filters.
|
513
|
+
#
|
514
|
+
# Example:
|
515
|
+
#
|
516
|
+
# set = User.find(:status => "active")
|
517
|
+
# set.combine(:name => ["John", "Jane"])
|
518
|
+
#
|
519
|
+
# # The result will include all users with active status
|
520
|
+
# # and with names "John" or "Jane".
|
521
|
+
def combine(dict)
|
522
|
+
MultiSet.new(namespace, model, key).combine(dict)
|
523
|
+
end
|
524
|
+
|
525
|
+
# Do a union to the existing set using any number of filters.
|
526
|
+
#
|
527
|
+
# Example:
|
528
|
+
#
|
529
|
+
# set = User.find(:name => "John")
|
530
|
+
# set.union(:name => "Jane")
|
531
|
+
#
|
532
|
+
# # You can also do it in one line.
|
533
|
+
# User.find(:name => "John").union(:name => "Jane")
|
534
|
+
#
|
535
|
+
def union(dict)
|
536
|
+
MultiSet.new(namespace, model, key).union(dict)
|
537
|
+
end
|
538
|
+
|
539
|
+
private
|
540
|
+
def execute
|
541
|
+
yield key
|
542
|
+
end
|
543
|
+
|
544
|
+
def redis
|
545
|
+
model.redis
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
class MutableSet < Set
|
550
|
+
# Add a model directly to the set.
|
551
|
+
#
|
552
|
+
# Example:
|
553
|
+
#
|
554
|
+
# user = User.create
|
555
|
+
# post = Post.create
|
556
|
+
#
|
557
|
+
# user.posts.add(post)
|
558
|
+
#
|
559
|
+
def add(model)
|
560
|
+
redis.call("SADD", key, model.id)
|
561
|
+
end
|
562
|
+
|
563
|
+
alias_method :<<, :add
|
564
|
+
|
565
|
+
# Remove a model directly from the set.
|
566
|
+
#
|
567
|
+
# Example:
|
568
|
+
#
|
569
|
+
# user = User.create
|
570
|
+
# post = Post.create
|
571
|
+
#
|
572
|
+
# user.posts.delete(post)
|
573
|
+
#
|
574
|
+
def delete(model)
|
575
|
+
redis.call("SREM", key, model.id)
|
576
|
+
end
|
577
|
+
end
|
578
|
+
|
579
|
+
# Anytime you filter a set with more than one requirement, you
|
580
|
+
# internally use a `MultiSet`. `MultiSet` is a bit slower than just
|
581
|
+
# a `Set` because it has to `SINTERSTORE` all the keys prior to
|
582
|
+
# retrieving the members, size, etc.
|
583
|
+
#
|
584
|
+
# Example:
|
585
|
+
#
|
586
|
+
# User.all.kind_of?(Ohm::Set)
|
587
|
+
# # => true
|
588
|
+
#
|
589
|
+
# User.find(:name => "John").kind_of?(Ohm::Set)
|
590
|
+
# # => true
|
591
|
+
#
|
592
|
+
# User.find(:name => "John", :age => 30).kind_of?(Ohm::MultiSet)
|
593
|
+
# # => true
|
594
|
+
#
|
595
|
+
class MultiSet < BasicSet
|
596
|
+
attr :namespace
|
597
|
+
attr :model
|
598
|
+
attr :command
|
599
|
+
|
600
|
+
def initialize(namespace, model, command)
|
601
|
+
@namespace = namespace
|
602
|
+
@model = model
|
603
|
+
@command = command
|
604
|
+
end
|
605
|
+
|
606
|
+
# Chain new fiters on an existing set.
|
607
|
+
#
|
608
|
+
# Example:
|
609
|
+
#
|
610
|
+
# set = User.find(:name => "John", :age => 30)
|
611
|
+
# set.find(:status => 'pending')
|
612
|
+
#
|
613
|
+
def find(dict)
|
614
|
+
MultiSet.new(
|
615
|
+
namespace, model, Command[:sinterstore, command, intersected(dict)]
|
616
|
+
)
|
617
|
+
end
|
618
|
+
|
619
|
+
# Reduce the set using any number of filters.
|
620
|
+
#
|
621
|
+
# Example:
|
622
|
+
#
|
623
|
+
# set = User.find(:name => "John")
|
624
|
+
# set.except(:country => "US")
|
625
|
+
#
|
626
|
+
# # You can also do it in one line.
|
627
|
+
# User.find(:name => "John").except(:country => "US")
|
628
|
+
#
|
629
|
+
def except(dict)
|
630
|
+
MultiSet.new(
|
631
|
+
namespace, model, Command[:sdiffstore, command, unioned(dict)]
|
632
|
+
)
|
633
|
+
end
|
634
|
+
|
635
|
+
# Perform an intersection between the existent set and
|
636
|
+
# the new set created by the union of the passed filters.
|
637
|
+
#
|
638
|
+
# Example:
|
639
|
+
#
|
640
|
+
# set = User.find(:status => "active")
|
641
|
+
# set.combine(:name => ["John", "Jane"])
|
642
|
+
#
|
643
|
+
# # The result will include all users with active status
|
644
|
+
# # and with names "John" or "Jane".
|
645
|
+
def combine(dict)
|
646
|
+
MultiSet.new(
|
647
|
+
namespace, model, Command[:sinterstore, command, unioned(dict)]
|
648
|
+
)
|
649
|
+
end
|
650
|
+
|
651
|
+
# Do a union to the existing set using any number of filters.
|
652
|
+
#
|
653
|
+
# Example:
|
654
|
+
#
|
655
|
+
# set = User.find(:name => "John")
|
656
|
+
# set.union(:name => "Jane")
|
657
|
+
#
|
658
|
+
# # You can also do it in one line.
|
659
|
+
# User.find(:name => "John").union(:name => "Jane")
|
660
|
+
#
|
661
|
+
def union(dict)
|
662
|
+
MultiSet.new(
|
663
|
+
namespace, model, Command[:sunionstore, command, intersected(dict)]
|
664
|
+
)
|
665
|
+
end
|
666
|
+
|
667
|
+
private
|
668
|
+
def redis
|
669
|
+
model.redis
|
670
|
+
end
|
671
|
+
|
672
|
+
def intersected(dict)
|
673
|
+
Command[:sinterstore, *model.filters(dict)]
|
674
|
+
end
|
675
|
+
|
676
|
+
def unioned(dict)
|
677
|
+
Command[:sunionstore, *model.filters(dict)]
|
678
|
+
end
|
679
|
+
|
680
|
+
def execute
|
681
|
+
# namespace[:tmp] is where all the temp keys should be stored in.
|
682
|
+
# redis will be where all the commands are executed against.
|
683
|
+
response = command.call(namespace[:tmp], redis)
|
684
|
+
|
685
|
+
begin
|
686
|
+
|
687
|
+
# At this point, we have the final aggregated set, which we yield
|
688
|
+
# to the caller. the caller can do all the normal set operations,
|
689
|
+
# i.e. SCARD, SMEMBERS, etc.
|
690
|
+
yield response
|
691
|
+
|
692
|
+
ensure
|
693
|
+
|
694
|
+
# We have to make sure we clean up the temporary keys to avoid
|
695
|
+
# memory leaks and the unintended explosion of memory usage.
|
696
|
+
command.clean
|
697
|
+
end
|
698
|
+
end
|
699
|
+
end
|
700
|
+
|
701
|
+
# The base class for all your models. In order to better understand
|
702
|
+
# it, here is a semi-realtime explanation of the details involved
|
703
|
+
# when creating a User instance.
|
704
|
+
#
|
705
|
+
# Example:
|
706
|
+
#
|
707
|
+
# class User < Ohm::Model
|
708
|
+
# attribute :name
|
709
|
+
# index :name
|
710
|
+
#
|
711
|
+
# attribute :email
|
712
|
+
# unique :email
|
713
|
+
#
|
714
|
+
# counter :points
|
715
|
+
#
|
716
|
+
# set :posts, :Post
|
717
|
+
# end
|
718
|
+
#
|
719
|
+
# u = User.create(:name => "John", :email => "foo@bar.com")
|
720
|
+
# u.incr :points
|
721
|
+
# u.posts.add(Post.create)
|
722
|
+
#
|
723
|
+
# When you execute `User.create(...)`, you run the following Redis
|
724
|
+
# commands:
|
725
|
+
#
|
726
|
+
# # Generate an ID
|
727
|
+
# INCR User:id
|
728
|
+
#
|
729
|
+
# # Add the newly generated ID, (let's assume the ID is 1).
|
730
|
+
# SADD User:all 1
|
731
|
+
#
|
732
|
+
# # Store the unique index
|
733
|
+
# HSET User:uniques:email foo@bar.com 1
|
734
|
+
#
|
735
|
+
# # Store the name index
|
736
|
+
# SADD User:indices:name:John 1
|
737
|
+
#
|
738
|
+
# # Store the HASH
|
739
|
+
# HMSET User:1 name John email foo@bar.com
|
740
|
+
#
|
741
|
+
# Next we increment points:
|
742
|
+
#
|
743
|
+
# HINCR User:1:counters points 1
|
744
|
+
#
|
745
|
+
# And then we add a Post to the `posts` set.
|
746
|
+
# (For brevity, let's assume the Post created has an ID of 1).
|
747
|
+
#
|
748
|
+
# SADD User:1:posts 1
|
749
|
+
#
|
750
|
+
class Model
|
751
|
+
def self.redis=(redis)
|
752
|
+
@redis = redis
|
753
|
+
end
|
754
|
+
|
755
|
+
def self.redis
|
756
|
+
defined?(@redis) ? @redis : Sohm.redis
|
757
|
+
end
|
758
|
+
|
759
|
+
def self.mutex
|
760
|
+
@@mutex ||= Mutex.new
|
761
|
+
end
|
762
|
+
|
763
|
+
def self.synchronize(&block)
|
764
|
+
mutex.synchronize(&block)
|
765
|
+
end
|
766
|
+
|
767
|
+
# Returns the namespace for all the keys generated using this model.
|
768
|
+
#
|
769
|
+
# Example:
|
770
|
+
#
|
771
|
+
# class User < Ohm::Model
|
772
|
+
# end
|
773
|
+
#
|
774
|
+
# User.key == "User"
|
775
|
+
# User.key.kind_of?(String)
|
776
|
+
# # => true
|
777
|
+
#
|
778
|
+
# User.key.kind_of?(Nido)
|
779
|
+
# # => true
|
780
|
+
#
|
781
|
+
# To find out more about Nido, see:
|
782
|
+
# http://github.com/soveran/nido
|
783
|
+
#
|
784
|
+
def self.key
|
785
|
+
@key ||= Nido.new(self.name)
|
786
|
+
end
|
787
|
+
|
788
|
+
# Retrieve a record by ID.
|
789
|
+
#
|
790
|
+
# Example:
|
791
|
+
#
|
792
|
+
# u = User.create
|
793
|
+
# u == User[u.id]
|
794
|
+
# # => true
|
795
|
+
#
|
796
|
+
def self.[](id)
|
797
|
+
new(:id => id).load! if id && exists?(id)
|
798
|
+
end
|
799
|
+
|
800
|
+
# Retrieve a set of models given an array of IDs.
|
801
|
+
#
|
802
|
+
# Example:
|
803
|
+
#
|
804
|
+
# ids = [1, 2, 3]
|
805
|
+
# ids.map(&User)
|
806
|
+
#
|
807
|
+
# Note: The use of this should be a last resort for your actual
|
808
|
+
# application runtime, or for simply debugging in your console. If
|
809
|
+
# you care about performance, you should pipeline your reads. For
|
810
|
+
# more information checkout the implementation of Ohm::List#fetch.
|
811
|
+
#
|
812
|
+
def self.to_proc
|
813
|
+
lambda { |id| self[id] }
|
814
|
+
end
|
815
|
+
|
816
|
+
# Check if the ID exists within <Model>:all.
|
817
|
+
def self.exists?(id)
|
818
|
+
redis.call("EXISTS", key[id]) == 1
|
819
|
+
end
|
820
|
+
|
821
|
+
# Find values in indexed fields.
|
822
|
+
#
|
823
|
+
# Example:
|
824
|
+
#
|
825
|
+
# class User < Ohm::Model
|
826
|
+
# attribute :email
|
827
|
+
#
|
828
|
+
# attribute :name
|
829
|
+
# index :name
|
830
|
+
#
|
831
|
+
# attribute :status
|
832
|
+
# index :status
|
833
|
+
#
|
834
|
+
# index :provider
|
835
|
+
# index :tag
|
836
|
+
#
|
837
|
+
# def provider
|
838
|
+
# email[/@(.*?).com/, 1]
|
839
|
+
# end
|
840
|
+
#
|
841
|
+
# def tag
|
842
|
+
# ["ruby", "python"]
|
843
|
+
# end
|
844
|
+
# end
|
845
|
+
#
|
846
|
+
# u = User.create(name: "John", status: "pending", email: "foo@me.com")
|
847
|
+
# User.find(provider: "me", name: "John", status: "pending").include?(u)
|
848
|
+
# # => true
|
849
|
+
#
|
850
|
+
# User.find(:tag => "ruby").include?(u)
|
851
|
+
# # => true
|
852
|
+
#
|
853
|
+
# User.find(:tag => "python").include?(u)
|
854
|
+
# # => true
|
855
|
+
#
|
856
|
+
# User.find(:tag => ["ruby", "python"]).include?(u)
|
857
|
+
# # => true
|
858
|
+
#
|
859
|
+
def self.find(dict)
|
860
|
+
keys = filters(dict)
|
861
|
+
|
862
|
+
if keys.size == 1
|
863
|
+
Ohm::Set.new(keys.first, key, self)
|
864
|
+
else
|
865
|
+
Ohm::MultiSet.new(key, self, Command.new(:sinterstore, *keys))
|
866
|
+
end
|
867
|
+
end
|
868
|
+
|
869
|
+
# Retrieve a set of models given an array of IDs.
|
870
|
+
#
|
871
|
+
# Example:
|
872
|
+
#
|
873
|
+
# User.fetch([1, 2, 3])
|
874
|
+
#
|
875
|
+
def self.fetch(ids)
|
876
|
+
all.fetch(ids)
|
877
|
+
end
|
878
|
+
|
879
|
+
# Index any method on your model. Once you index a method, you can
|
880
|
+
# use it in `find` statements.
|
881
|
+
def self.index(attribute)
|
882
|
+
indices << attribute unless indices.include?(attribute)
|
883
|
+
end
|
884
|
+
|
885
|
+
# Declare an Ohm::Set with the given name.
|
886
|
+
#
|
887
|
+
# Example:
|
888
|
+
#
|
889
|
+
# class User < Ohm::Model
|
890
|
+
# set :posts, :Post
|
891
|
+
# end
|
892
|
+
#
|
893
|
+
# u = User.create
|
894
|
+
# u.posts.empty?
|
895
|
+
# # => true
|
896
|
+
#
|
897
|
+
# Note: You can't use the set until you save the model. If you try
|
898
|
+
# to do it, you'll receive an Ohm::MissingID error.
|
899
|
+
#
|
900
|
+
def self.set(name, model)
|
901
|
+
track(name)
|
902
|
+
|
903
|
+
define_method name do
|
904
|
+
model = Utils.const(self.class, model)
|
905
|
+
|
906
|
+
Ohm::MutableSet.new(key[name], model.key, model)
|
907
|
+
end
|
908
|
+
end
|
909
|
+
|
910
|
+
# Declare an Ohm::List with the given name.
|
911
|
+
#
|
912
|
+
# Example:
|
913
|
+
#
|
914
|
+
# class Comment < Ohm::Model
|
915
|
+
# end
|
916
|
+
#
|
917
|
+
# class Post < Ohm::Model
|
918
|
+
# list :comments, :Comment
|
919
|
+
# end
|
920
|
+
#
|
921
|
+
# p = Post.create
|
922
|
+
# p.comments.push(Comment.create)
|
923
|
+
# p.comments.unshift(Comment.create)
|
924
|
+
# p.comments.size == 2
|
925
|
+
# # => true
|
926
|
+
#
|
927
|
+
# Note: You can't use the list until you save the model. If you try
|
928
|
+
# to do it, you'll receive an Ohm::MissingID error.
|
929
|
+
#
|
930
|
+
def self.list(name, model)
|
931
|
+
track(name)
|
932
|
+
|
933
|
+
define_method name do
|
934
|
+
model = Utils.const(self.class, model)
|
935
|
+
|
936
|
+
Ohm::List.new(key[name], model.key, model)
|
937
|
+
end
|
938
|
+
end
|
939
|
+
|
940
|
+
# A macro for defining a method which basically does a find.
|
941
|
+
#
|
942
|
+
# Example:
|
943
|
+
# class Post < Ohm::Model
|
944
|
+
# reference :user, :User
|
945
|
+
# end
|
946
|
+
#
|
947
|
+
# class User < Ohm::Model
|
948
|
+
# collection :posts, :Post
|
949
|
+
# end
|
950
|
+
#
|
951
|
+
# # is the same as
|
952
|
+
#
|
953
|
+
# class User < Ohm::Model
|
954
|
+
# def posts
|
955
|
+
# Post.find(:user_id => self.id)
|
956
|
+
# end
|
957
|
+
# end
|
958
|
+
#
|
959
|
+
def self.collection(name, model, reference = to_reference)
|
960
|
+
define_method name do
|
961
|
+
model = Utils.const(self.class, model)
|
962
|
+
model.find(:"#{reference}_id" => id)
|
963
|
+
end
|
964
|
+
end
|
965
|
+
|
966
|
+
# A macro for defining an attribute, an index, and an accessor
|
967
|
+
# for a given model.
|
968
|
+
#
|
969
|
+
# Example:
|
970
|
+
#
|
971
|
+
# class Post < Ohm::Model
|
972
|
+
# reference :user, :User
|
973
|
+
# end
|
974
|
+
#
|
975
|
+
# # It's the same as:
|
976
|
+
#
|
977
|
+
# class Post < Ohm::Model
|
978
|
+
# attribute :user_id
|
979
|
+
# index :user_id
|
980
|
+
#
|
981
|
+
# def user
|
982
|
+
# @_memo[:user] ||= User[user_id]
|
983
|
+
# end
|
984
|
+
#
|
985
|
+
# def user=(user)
|
986
|
+
# self.user_id = user.id
|
987
|
+
# @_memo[:user] = user
|
988
|
+
# end
|
989
|
+
#
|
990
|
+
# def user_id=(user_id)
|
991
|
+
# @_memo.delete(:user_id)
|
992
|
+
# self.user_id = user_id
|
993
|
+
# end
|
994
|
+
# end
|
995
|
+
#
|
996
|
+
def self.reference(name, model)
|
997
|
+
reader = :"#{name}_id"
|
998
|
+
writer = :"#{name}_id="
|
999
|
+
|
1000
|
+
attributes << reader unless attributes.include?(reader)
|
1001
|
+
|
1002
|
+
index reader
|
1003
|
+
|
1004
|
+
define_method(reader) do
|
1005
|
+
@attributes[reader]
|
1006
|
+
end
|
1007
|
+
|
1008
|
+
define_method(writer) do |value|
|
1009
|
+
@_memo.delete(name)
|
1010
|
+
@attributes[reader] = value
|
1011
|
+
end
|
1012
|
+
|
1013
|
+
define_method(:"#{name}=") do |value|
|
1014
|
+
@_memo.delete(name)
|
1015
|
+
send(writer, value ? value.id : nil)
|
1016
|
+
end
|
1017
|
+
|
1018
|
+
define_method(name) do
|
1019
|
+
@_memo[name] ||= begin
|
1020
|
+
model = Utils.const(self.class, model)
|
1021
|
+
model[send(reader)]
|
1022
|
+
end
|
1023
|
+
end
|
1024
|
+
end
|
1025
|
+
|
1026
|
+
# The bread and butter macro of all models. Basically declares
|
1027
|
+
# persisted attributes. All attributes are stored on the Redis
|
1028
|
+
# hash.
|
1029
|
+
#
|
1030
|
+
# class User < Ohm::Model
|
1031
|
+
# attribute :name
|
1032
|
+
# end
|
1033
|
+
#
|
1034
|
+
# user = User.new(name: "John")
|
1035
|
+
# user.name
|
1036
|
+
# # => "John"
|
1037
|
+
#
|
1038
|
+
# user.name = "Jane"
|
1039
|
+
# user.name
|
1040
|
+
# # => "Jane"
|
1041
|
+
#
|
1042
|
+
# A +lambda+ can be passed as a second parameter to add
|
1043
|
+
# typecasting support to the attribute.
|
1044
|
+
#
|
1045
|
+
# class User < Ohm::Model
|
1046
|
+
# attribute :age, ->(x) { x.to_i }
|
1047
|
+
# end
|
1048
|
+
#
|
1049
|
+
# user = User.new(age: 100)
|
1050
|
+
#
|
1051
|
+
# user.age
|
1052
|
+
# # => 100
|
1053
|
+
#
|
1054
|
+
# user.age.kind_of?(Integer)
|
1055
|
+
# # => true
|
1056
|
+
#
|
1057
|
+
# Check http://rubydoc.info/github/cyx/ohm-contrib#Ohm__DataTypes
|
1058
|
+
# to see more examples about the typecasting feature.
|
1059
|
+
#
|
1060
|
+
def self.attribute(name, cast = nil)
|
1061
|
+
if serial_attributes.include?(name)
|
1062
|
+
raise ArgumentError,
|
1063
|
+
"#{name} is already used as a serial attribute."
|
1064
|
+
end
|
1065
|
+
attributes << name unless attributes.include?(name)
|
1066
|
+
|
1067
|
+
if cast
|
1068
|
+
define_method(name) do
|
1069
|
+
cast[@attributes[name]]
|
1070
|
+
end
|
1071
|
+
else
|
1072
|
+
define_method(name) do
|
1073
|
+
@attributes[name]
|
1074
|
+
end
|
1075
|
+
end
|
1076
|
+
|
1077
|
+
define_method(:"#{name}=") do |value|
|
1078
|
+
@attributes[name] = value
|
1079
|
+
end
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
# Attributes that require CAS property
|
1083
|
+
def self.serial_attribute(name, cast = nil)
|
1084
|
+
if attributes.include?(name)
|
1085
|
+
raise ArgumentError,
|
1086
|
+
"#{name} is already used as a normal attribute."
|
1087
|
+
end
|
1088
|
+
serial_attributes << name unless serial_attributes.include?(name)
|
1089
|
+
|
1090
|
+
if cast
|
1091
|
+
define_method(name) do
|
1092
|
+
cast[@serial_attributes[name]]
|
1093
|
+
end
|
1094
|
+
else
|
1095
|
+
define_method(name) do
|
1096
|
+
@serial_attributes[name]
|
1097
|
+
end
|
1098
|
+
end
|
1099
|
+
|
1100
|
+
define_method(:"#{name}=") do |value|
|
1101
|
+
@serial_attributes_changed = true
|
1102
|
+
@serial_attributes[name] = value
|
1103
|
+
end
|
1104
|
+
end
|
1105
|
+
|
1106
|
+
# Declare a counter. All the counters are internally stored in
|
1107
|
+
# a different Redis hash, independent from the one that stores
|
1108
|
+
# the model attributes. Counters are updated with the `incr` and
|
1109
|
+
# `decr` methods, which interact directly with Redis. Their value
|
1110
|
+
# can't be assigned as with regular attributes.
|
1111
|
+
#
|
1112
|
+
# Example:
|
1113
|
+
#
|
1114
|
+
# class User < Ohm::Model
|
1115
|
+
# counter :points
|
1116
|
+
# end
|
1117
|
+
#
|
1118
|
+
# u = User.create
|
1119
|
+
# u.incr :points
|
1120
|
+
#
|
1121
|
+
# u.points
|
1122
|
+
# # => 1
|
1123
|
+
#
|
1124
|
+
# Note: You can't use counters until you save the model. If you
|
1125
|
+
# try to do it, you'll receive an Ohm::MissingID error.
|
1126
|
+
#
|
1127
|
+
def self.counter(name)
|
1128
|
+
counters << name unless counters.include?(name)
|
1129
|
+
|
1130
|
+
define_method(name) do
|
1131
|
+
return 0 if new?
|
1132
|
+
|
1133
|
+
redis.call("HGET", key[:counters], name).to_i
|
1134
|
+
end
|
1135
|
+
end
|
1136
|
+
|
1137
|
+
# Keep track of `key[name]` and remove when deleting the object.
|
1138
|
+
def self.track(name)
|
1139
|
+
tracked << name unless tracked.include?(name)
|
1140
|
+
end
|
1141
|
+
|
1142
|
+
# Create a new model, notice that under Sohm's circumstances,
|
1143
|
+
# this is no longer a syntactic sugar for Model.new(atts).save
|
1144
|
+
def self.create(atts = {})
|
1145
|
+
new(atts).save(create: true)
|
1146
|
+
end
|
1147
|
+
|
1148
|
+
# Returns the namespace for the keys generated using this model.
|
1149
|
+
# Check `Ohm::Model.key` documentation for more details.
|
1150
|
+
def key
|
1151
|
+
model.key[id]
|
1152
|
+
end
|
1153
|
+
|
1154
|
+
# Initialize a model using a dictionary of attributes.
|
1155
|
+
#
|
1156
|
+
# Example:
|
1157
|
+
#
|
1158
|
+
# u = User.new(:name => "John")
|
1159
|
+
#
|
1160
|
+
def initialize(atts = {})
|
1161
|
+
@attributes = {}
|
1162
|
+
@serial_attributes = {}
|
1163
|
+
@_memo = {}
|
1164
|
+
@serial_attributes_changed = false
|
1165
|
+
update_attributes(atts)
|
1166
|
+
end
|
1167
|
+
|
1168
|
+
# Access the ID used to store this model. The ID is used together
|
1169
|
+
# with the name of the class in order to form the Redis key.
|
1170
|
+
#
|
1171
|
+
# Example:
|
1172
|
+
#
|
1173
|
+
# class User < Ohm::Model; end
|
1174
|
+
#
|
1175
|
+
# u = User.create
|
1176
|
+
# u.id
|
1177
|
+
# # => 1
|
1178
|
+
#
|
1179
|
+
# u.key
|
1180
|
+
# # => User:1
|
1181
|
+
#
|
1182
|
+
def id
|
1183
|
+
raise MissingID if not defined?(@id)
|
1184
|
+
@id
|
1185
|
+
end
|
1186
|
+
|
1187
|
+
attr_writer :id
|
1188
|
+
attr_accessor :cas_token
|
1189
|
+
|
1190
|
+
# Check for equality by doing the following assertions:
|
1191
|
+
#
|
1192
|
+
# 1. That the passed model is of the same type.
|
1193
|
+
# 2. That they represent the same Redis key.
|
1194
|
+
#
|
1195
|
+
def ==(other)
|
1196
|
+
other.kind_of?(model) && other.key == key
|
1197
|
+
rescue MissingID
|
1198
|
+
false
|
1199
|
+
end
|
1200
|
+
|
1201
|
+
# Preload all the attributes of this model from Redis. Used
|
1202
|
+
# internally by `Model::[]`.
|
1203
|
+
def load!
|
1204
|
+
update_attributes(Utils.dict(redis.call("HGETALL", key))) unless new?
|
1205
|
+
@serial_attributes_changed = false
|
1206
|
+
return self
|
1207
|
+
end
|
1208
|
+
|
1209
|
+
# Read an attribute remotely from Redis. Useful if you want to get
|
1210
|
+
# the most recent value of the attribute and not rely on locally
|
1211
|
+
# cached value.
|
1212
|
+
#
|
1213
|
+
# Example:
|
1214
|
+
#
|
1215
|
+
# User.create(:name => "A")
|
1216
|
+
#
|
1217
|
+
# Session 1 | Session 2
|
1218
|
+
# --------------|------------------------
|
1219
|
+
# u = User[1] | u = User[1]
|
1220
|
+
# u.name = "B" |
|
1221
|
+
# u.save |
|
1222
|
+
# | u.name == "A"
|
1223
|
+
# | u.get(:name) == "B"
|
1224
|
+
#
|
1225
|
+
def get(att)
|
1226
|
+
@attributes[att] = redis.call("HGET", key, att)
|
1227
|
+
end
|
1228
|
+
|
1229
|
+
# Update an attribute value atomically. The best usecase for this
|
1230
|
+
# is when you simply want to update one value.
|
1231
|
+
#
|
1232
|
+
# Note: This method is dangerous because it doesn't update indices
|
1233
|
+
# and uniques. Use it wisely. The safe equivalent is `update`.
|
1234
|
+
#
|
1235
|
+
def set(att, val)
|
1236
|
+
if val.to_s.empty?
|
1237
|
+
redis.call("HDEL", key, att)
|
1238
|
+
else
|
1239
|
+
redis.call("HSET", key, att, val)
|
1240
|
+
end
|
1241
|
+
|
1242
|
+
@attributes[att] = val
|
1243
|
+
end
|
1244
|
+
|
1245
|
+
# Returns +true+ if the model is not persisted. Otherwise, returns +false+.
|
1246
|
+
#
|
1247
|
+
# Example:
|
1248
|
+
#
|
1249
|
+
# class User < Ohm::Model
|
1250
|
+
# attribute :name
|
1251
|
+
# end
|
1252
|
+
#
|
1253
|
+
# u = User.new(:name => "John")
|
1254
|
+
# u.new?
|
1255
|
+
# # => true
|
1256
|
+
#
|
1257
|
+
# u.save
|
1258
|
+
# u.new?
|
1259
|
+
# # => false
|
1260
|
+
def new?
|
1261
|
+
!model.exists?(id)
|
1262
|
+
end
|
1263
|
+
|
1264
|
+
# Increment a counter atomically. Internally uses HINCRBY.
|
1265
|
+
def incr(att, count = 1)
|
1266
|
+
redis.call("HINCRBY", key[:counters], att, count)
|
1267
|
+
end
|
1268
|
+
|
1269
|
+
# Decrement a counter atomically. Internally uses HINCRBY.
|
1270
|
+
def decr(att, count = 1)
|
1271
|
+
incr(att, -count)
|
1272
|
+
end
|
1273
|
+
|
1274
|
+
# Return a value that allows the use of models as hash keys.
|
1275
|
+
#
|
1276
|
+
# Example:
|
1277
|
+
#
|
1278
|
+
# h = {}
|
1279
|
+
#
|
1280
|
+
# u = User.new
|
1281
|
+
#
|
1282
|
+
# h[:u] = u
|
1283
|
+
# h[:u] == u
|
1284
|
+
# # => true
|
1285
|
+
#
|
1286
|
+
def hash
|
1287
|
+
new? ? super : key.hash
|
1288
|
+
end
|
1289
|
+
alias :eql? :==
|
1290
|
+
|
1291
|
+
# Returns a hash of the attributes with their names as keys
|
1292
|
+
# and the values of the attributes as values. It doesn't
|
1293
|
+
# include the ID of the model.
|
1294
|
+
#
|
1295
|
+
# Example:
|
1296
|
+
#
|
1297
|
+
# class User < Ohm::Model
|
1298
|
+
# attribute :name
|
1299
|
+
# end
|
1300
|
+
#
|
1301
|
+
# u = User.create(:name => "John")
|
1302
|
+
# u.attributes
|
1303
|
+
# # => { :name => "John" }
|
1304
|
+
#
|
1305
|
+
def attributes
|
1306
|
+
@attributes
|
1307
|
+
end
|
1308
|
+
|
1309
|
+
def serial_attributes
|
1310
|
+
@serial_attributes
|
1311
|
+
end
|
1312
|
+
|
1313
|
+
# Export the ID of the model. The approach of Ohm is to
|
1314
|
+
# whitelist public attributes, as opposed to exporting each
|
1315
|
+
# (possibly sensitive) attribute.
|
1316
|
+
#
|
1317
|
+
# Example:
|
1318
|
+
#
|
1319
|
+
# class User < Ohm::Model
|
1320
|
+
# attribute :name
|
1321
|
+
# end
|
1322
|
+
#
|
1323
|
+
# u = User.create(:name => "John")
|
1324
|
+
# u.to_hash
|
1325
|
+
# # => { :id => "1" }
|
1326
|
+
#
|
1327
|
+
# In order to add additional attributes, you can override `to_hash`:
|
1328
|
+
#
|
1329
|
+
# class User < Ohm::Model
|
1330
|
+
# attribute :name
|
1331
|
+
#
|
1332
|
+
# def to_hash
|
1333
|
+
# super.merge(:name => name)
|
1334
|
+
# end
|
1335
|
+
# end
|
1336
|
+
#
|
1337
|
+
# u = User.create(:name => "John")
|
1338
|
+
# u.to_hash
|
1339
|
+
# # => { :id => "1", :name => "John" }
|
1340
|
+
#
|
1341
|
+
def to_hash
|
1342
|
+
attrs = {}
|
1343
|
+
attrs[:id] = id unless new?
|
1344
|
+
|
1345
|
+
return attrs
|
1346
|
+
end
|
1347
|
+
|
1348
|
+
|
1349
|
+
# Persist the model attributes and update indices and unique
|
1350
|
+
# indices. The `counter`s and `set`s are not touched during save.
|
1351
|
+
#
|
1352
|
+
# Example:
|
1353
|
+
#
|
1354
|
+
# class User < Ohm::Model
|
1355
|
+
# attribute :name
|
1356
|
+
# end
|
1357
|
+
#
|
1358
|
+
# u = User.new(:name => "John").save
|
1359
|
+
# u.kind_of?(User)
|
1360
|
+
# # => true
|
1361
|
+
#
|
1362
|
+
def save
|
1363
|
+
if serial_attributes_changed
|
1364
|
+
response = script(LUA_SAVE, 1, key,
|
1365
|
+
serial_attributes.to_msgpack,
|
1366
|
+
cas_token)
|
1367
|
+
|
1368
|
+
if response.is_a?(RuntimeError)
|
1369
|
+
if response.message =~ /cas_error/
|
1370
|
+
raise CasViolation
|
1371
|
+
else
|
1372
|
+
raise response
|
1373
|
+
end
|
1374
|
+
end
|
1375
|
+
|
1376
|
+
@cas_token = response
|
1377
|
+
@serial_attributes_changed = false
|
1378
|
+
end
|
1379
|
+
|
1380
|
+
redis.call("HSET", key, "_ndata", attributes.to_msgpack)
|
1381
|
+
|
1382
|
+
refresh_indices
|
1383
|
+
|
1384
|
+
return self
|
1385
|
+
end
|
1386
|
+
|
1387
|
+
# Delete the model, including all the following keys:
|
1388
|
+
#
|
1389
|
+
# - <Model>:<id>
|
1390
|
+
# - <Model>:<id>:counters
|
1391
|
+
# - <Model>:<id>:<set name>
|
1392
|
+
#
|
1393
|
+
# If the model has uniques or indices, they're also cleaned up.
|
1394
|
+
#
|
1395
|
+
def delete
|
1396
|
+
memo_key = key["_indices"]
|
1397
|
+
commands = [["DEL", key], ["DEL", memo_key]]
|
1398
|
+
index_list = redis.call("SMEMBERS", memo_key)
|
1399
|
+
index_list.each do |index_key|
|
1400
|
+
commands << ["SREM", index_key, id]
|
1401
|
+
end
|
1402
|
+
|
1403
|
+
model.synchronize do
|
1404
|
+
commands.each do |command|
|
1405
|
+
redis.queue(*command)
|
1406
|
+
end
|
1407
|
+
redis.commit
|
1408
|
+
end
|
1409
|
+
|
1410
|
+
return self
|
1411
|
+
end
|
1412
|
+
|
1413
|
+
# Run lua scripts and cache the sha in order to improve
|
1414
|
+
# successive calls.
|
1415
|
+
def script(file, *args)
|
1416
|
+
response = nil
|
1417
|
+
|
1418
|
+
if Sohm.enable_evalsha
|
1419
|
+
response = redis.call("EVALSHA", LUA_SAVE_DIGEST, *args)
|
1420
|
+
if response.is_a?(RuntimeError)
|
1421
|
+
if response.message =~ /NOSCRIPT/
|
1422
|
+
response = nil
|
1423
|
+
end
|
1424
|
+
end
|
1425
|
+
end
|
1426
|
+
|
1427
|
+
response ? response : redis.call("EVAL", LUA_SAVE, *args)
|
1428
|
+
end
|
1429
|
+
|
1430
|
+
# Update the model attributes and call save.
|
1431
|
+
#
|
1432
|
+
# Example:
|
1433
|
+
#
|
1434
|
+
# User[1].update(:name => "John")
|
1435
|
+
#
|
1436
|
+
# # It's the same as:
|
1437
|
+
#
|
1438
|
+
# u = User[1]
|
1439
|
+
# u.update_attributes(:name => "John")
|
1440
|
+
# u.save
|
1441
|
+
#
|
1442
|
+
def update(attributes)
|
1443
|
+
update_attributes(attributes)
|
1444
|
+
save
|
1445
|
+
end
|
1446
|
+
|
1447
|
+
# Write the dictionary of key-value pairs to the model.
|
1448
|
+
def update_attributes(atts)
|
1449
|
+
unpack_attrs(atts).each { |att, val| send(:"#{att}=", val) }
|
1450
|
+
end
|
1451
|
+
|
1452
|
+
protected
|
1453
|
+
attr_reader :serial_attributes_changed
|
1454
|
+
|
1455
|
+
def self.to_reference
|
1456
|
+
name.to_s.
|
1457
|
+
match(/^(?:.*::)*(.*)$/)[1].
|
1458
|
+
gsub(/([a-z\d])([A-Z])/, '\1_\2').
|
1459
|
+
downcase.to_sym
|
1460
|
+
end
|
1461
|
+
|
1462
|
+
def self.indices
|
1463
|
+
@indices ||= []
|
1464
|
+
end
|
1465
|
+
|
1466
|
+
def self.counters
|
1467
|
+
@counters ||= []
|
1468
|
+
end
|
1469
|
+
|
1470
|
+
def self.tracked
|
1471
|
+
@tracked ||= []
|
1472
|
+
end
|
1473
|
+
|
1474
|
+
def self.attributes
|
1475
|
+
@attributes ||= []
|
1476
|
+
end
|
1477
|
+
|
1478
|
+
def self.serial_attributes
|
1479
|
+
@serial_attributes ||= []
|
1480
|
+
end
|
1481
|
+
|
1482
|
+
def self.filters(dict)
|
1483
|
+
unless dict.kind_of?(Hash)
|
1484
|
+
raise ArgumentError,
|
1485
|
+
"You need to supply a hash with filters. " +
|
1486
|
+
"If you want to find by ID, use #{self}[id] instead."
|
1487
|
+
end
|
1488
|
+
|
1489
|
+
dict.map { |k, v| to_indices(k, v) }.flatten
|
1490
|
+
end
|
1491
|
+
|
1492
|
+
def self.to_indices(att, val)
|
1493
|
+
raise IndexNotFound unless indices.include?(att)
|
1494
|
+
|
1495
|
+
if val.kind_of?(Enumerable)
|
1496
|
+
val.map { |v| key[:indices][att][v] }
|
1497
|
+
else
|
1498
|
+
[key[:indices][att][val]]
|
1499
|
+
end
|
1500
|
+
end
|
1501
|
+
|
1502
|
+
def fetch_indices
|
1503
|
+
indices = {}
|
1504
|
+
model.indices.each { |field| indices[field] = Array(send(field)) }
|
1505
|
+
indices
|
1506
|
+
end
|
1507
|
+
|
1508
|
+
# This is performed asynchronously
|
1509
|
+
def refresh_indices
|
1510
|
+
memo_key = key["_indices"]
|
1511
|
+
# Add new indices first
|
1512
|
+
commands = fetch_indices.each_pair.map do |field, vals|
|
1513
|
+
vals.map do |val|
|
1514
|
+
index_key = key["_indices"][field][val]
|
1515
|
+
[["SADD", memo_key, index_key], ["SADD", index_key, id]]
|
1516
|
+
end
|
1517
|
+
end.flatten(2)
|
1518
|
+
|
1519
|
+
# TODO: Think about switching to a redis pool later
|
1520
|
+
model.synchronize do
|
1521
|
+
commands.each do |command|
|
1522
|
+
redis.queue(*command)
|
1523
|
+
end
|
1524
|
+
redis.commit
|
1525
|
+
end
|
1526
|
+
|
1527
|
+
# Remove old indices
|
1528
|
+
# TODO: we can do this asynchronously, or maybe in a background queue
|
1529
|
+
index_set = ::Set.new(redis.call("SMEMBERS", memo_key))
|
1530
|
+
valid_list = model[id].send(:fetch_indices).each_pair.map do |field, vals|
|
1531
|
+
vals.map do |val|
|
1532
|
+
key["_indices"][field][val]
|
1533
|
+
end
|
1534
|
+
end.flatten(1)
|
1535
|
+
valid_set = ::Set.new(valid_list)
|
1536
|
+
diff_set = index_set - valid_set
|
1537
|
+
diff_list = diff_set.to_a
|
1538
|
+
commands = diff_list.map do |key|
|
1539
|
+
["SREM", key, id]
|
1540
|
+
end + [["SREM", memo_key] + diff_list]
|
1541
|
+
|
1542
|
+
model.synchronize do
|
1543
|
+
commands.each do |command|
|
1544
|
+
redis.queue(*command)
|
1545
|
+
end
|
1546
|
+
redis.commit
|
1547
|
+
end
|
1548
|
+
end
|
1549
|
+
|
1550
|
+
# Unpack hash returned by redis, which contains _cas, _sdata, _ndata
|
1551
|
+
# columns
|
1552
|
+
def unpack_attrs(attrs)
|
1553
|
+
if ndata = attrs.delete("_ndata")
|
1554
|
+
attrs.merge!(MessagePack.unpack(ndata))
|
1555
|
+
end
|
1556
|
+
|
1557
|
+
if sdata = attrs.delete("_sdata")
|
1558
|
+
attrs.merge!(MessagePack.unpack(sdata))
|
1559
|
+
end
|
1560
|
+
|
1561
|
+
if cas_token = attrs.delete("_cas")
|
1562
|
+
attrs["cas_token"] = cas_token
|
1563
|
+
end
|
1564
|
+
|
1565
|
+
attrs
|
1566
|
+
end
|
1567
|
+
|
1568
|
+
def model
|
1569
|
+
self.class
|
1570
|
+
end
|
1571
|
+
|
1572
|
+
def redis
|
1573
|
+
model.redis
|
1574
|
+
end
|
1575
|
+
end
|
1576
|
+
end
|