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