lib/sohm.rb in sohm-0.0.1 vs lib/sohm.rb in sohm-0.9.0

- old
+ new

@@ -31,11 +31,11 @@ # UniqueIndexViolation: # # Raised when trying to save an object with a `unique` index for # which the value already exists. # - # Solution: rescue `Ohm::UniqueIndexViolation` during save, but + # Solution: rescue `Sohm::UniqueIndexViolation` during save, but # also, do some validations even before attempting to save. # class Error < StandardError; end class MissingID < Error; end class IndexNotFound < Error; end @@ -51,18 +51,18 @@ # Employed as a solution to avoid `NameError` problems when trying # to load models referring to other models not yet loaded. # # Example: # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # reference :user, User # NameError undefined constant User. # end # # # Instead of relying on some clever `const_missing` hack, we can # # simply use a symbol or a string. # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # reference :user, :User # reference :post, "Post" # end # def self.const(context, name) @@ -74,40 +74,42 @@ end def self.dict(arr) Hash[*arr] end - - def self.sort(redis, key, options) - args = [] - - args.concat(["BY", options[:by]]) if options[:by] - args.concat(["GET", options[:get]]) if options[:get] - args.concat(["LIMIT"] + options[:limit]) if options[:limit] - args.concat(options[:order].split(" ")) if options[:order] - args.concat(["STORE", options[:store]]) if options[:store] - - redis.call("SORT", key, *args) - end end # Use this if you want to do quick ad hoc redis commands against the # defined Ohm connection. # # Examples: # # Ohm.redis.call("SET", "foo", "bar") # Ohm.redis.call("FLUSH") # + @redis = Redic.new def self.redis - @redis ||= Redic.new + @redis end def self.redis=(redis) @redis = redis end + # If you are using a Redis pool to override @redis above, chances are + # you won't need a mutex(since your opertions will run on different + # redis instances), so you can use this to override the default mutex + # for better performance + @mutex = Mutex.new + def self.mutex=(mutex) + @mutex = mutex + end + + def self.mutex + @mutex + end + # By default, EVALSHA is used def self.enable_evalsha defined?(@enable_evalsha) ? @enable_evalsha : true end @@ -135,11 +137,10 @@ def empty? size == 0 end - # TODO: fix this later # Wraps the whole pipelining functionality. def fetch(ids) data = nil model.synchronize do @@ -152,11 +153,11 @@ return [] if data.nil? [].tap do |result| data.each_with_index do |atts, idx| - unless attrs.empty? + unless atts.empty? result << model.new(Utils.dict(atts).update(:id => ids[idx])) end end end end @@ -193,14 +194,14 @@ # Returns an array of elements from the list using LRANGE. # #range receives 2 integers, start and stop # # Example: # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # end # - # class Post < Ohm::Model + # class Post < Sohm::Model # list :comments, :Comment # end # # c1 = Comment.create # c2 = Comment.create @@ -244,14 +245,14 @@ # Note: If your list contains the model multiple times, this method # will delete all instances of that model in one go. # # Example: # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # end # - # class Post < Ohm::Model + # class Post < Sohm::Model # list :comments, :Comment # end # # p = Post.create # c = Comment.create @@ -270,14 +271,14 @@ redis.call("LREM", key, 0, model.id) end # Returns an array with all the ID's of the list. # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # end # - # class Post < Ohm::Model + # class Post < Sohm::Model # list :comments, :Comment # end # # post = Post.create # post.comments.push(Comment.create) @@ -303,61 +304,10 @@ # Defines most of the methods used by `Set` and `MultiSet`. class BasicSet include Collection - # Allows you to sort by any attribute in the hash, this doesn't include - # the +id+. If you want to sort by ID, use #sort. - # - # class User < Ohm::Model - # attribute :name - # end - # - # User.all.sort_by(:name, :order => "ALPHA") - # User.all.sort_by(:name, :order => "ALPHA DESC") - # User.all.sort_by(:name, :order => "ALPHA DESC", :limit => [0, 10]) - # - # Note: This is slower compared to just doing `sort`, specifically - # because Redis has to read each individual hash in order to sort - # them. - # - def sort_by(att, options = {}) - sort(options.merge(:by => to_key(att))) - end - - # Allows you to sort your models using their IDs. This is much - # faster than `sort_by`. If you simply want to get records in - # ascending or descending order, then this is the best method to - # do that. - # - # Example: - # - # class User < Ohm::Model - # attribute :name - # end - # - # User.create(:name => "John") - # User.create(:name => "Jane") - # - # User.all.sort.map(&:id) == ["1", "2"] - # # => true - # - # User.all.sort(:order => "ASC").map(&:id) == ["1", "2"] - # # => true - # - # User.all.sort(:order => "DESC").map(&:id) == ["2", "1"] - # # => true - # - def sort(options = {}) - if options.has_key?(:get) - options[:get] = to_key(options[:get]) - return execute { |key| Utils.sort(redis, key, options) } - end - - fetch(execute { |key| Utils.sort(redis, key, options) }) - end - # Check if a model is included in this set. # # Example: # # u = User.create @@ -375,38 +325,32 @@ # Returns the total size of the set using SCARD. def size execute { |key| redis.call("SCARD", key) } end - # Syntactic sugar for `sort_by` or `sort` when you only need the - # first element. + # SMEMBERS then choosing the first will take too much memory in case data + # grow big enough, which will be slow in this case. + # Providing +sample+ only gives a hint that we won't preserve any order + # for this, there will be 2 cases: # - # Example: + # 1. Anyone in the set will do, this is the original use case of +sample* + # 2. For some reasons(maybe due to filters), we only have 1 element left + # in this set, using +sample+ will do the trick # - # User.all.first == - # User.all.sort(:limit => [0, 1]).first - # - # User.all.first(:by => :name, "ALPHA") == - # User.all.sort_by(:name, :order => "ALPHA", :limit => [0, 1]).first - # - def first(options = {}) - opts = options.dup - opts.merge!(:limit => [0, 1]) - - if opts[:by] - sort_by(opts.delete(:by), opts).first - else - sort(opts).first - end + # For all the other cases, we won't be able to fetch a single element + # without fetching all elements first(in other words, doing this + # efficiently) + def sample + model[execute { |key| redis.call("SRANDMEMBER", key) }] end # Returns an array with all the ID's of the set. # - # class Post < Ohm::Model + # class Post < Sohm::Model # end # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # index :name # # set :posts, :Post # end @@ -440,14 +384,14 @@ # Returns +true+ if +id+ is included in the set. Otherwise, returns +false+. # # Example: # - # class Post < Ohm::Model + # class Post < Sohm::Model # end # - # class User < Ohm::Model + # class User < Sohm::Model # set :posts, :Post # end # # user = User.create # post = Post.create @@ -457,19 +401,10 @@ # user.posts.exists?(post.id) # => true # def exists?(id) execute { |key| redis.call("SISMEMBER", key, id) == 1 } end - - private - def to_key(att) - if model.counters.include?(att) - namespace["*:counters->%s" % att] - else - namespace["*->%s" % att] - end - end end class Set < BasicSet attr :key attr :namespace @@ -581,17 +516,17 @@ # a `Set` because it has to `SINTERSTORE` all the keys prior to # retrieving the members, size, etc. # # Example: # - # User.all.kind_of?(Ohm::Set) + # User.all.kind_of?(Sohm::Set) # # => true # - # User.find(:name => "John").kind_of?(Ohm::Set) + # User.find(:name => "John").kind_of?(Sohm::Set) # # => true # - # User.find(:name => "John", :age => 30).kind_of?(Ohm::MultiSet) + # User.find(:name => "John", :age => 30).kind_of?(Sohm::MultiSet) # # => true # class MultiSet < BasicSet attr :namespace attr :model @@ -702,11 +637,11 @@ # it, here is a semi-realtime explanation of the details involved # when creating a User instance. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # index :name # # attribute :email # unique :email @@ -738,11 +673,11 @@ # # Store the HASH # HMSET User:1 name John email foo@bar.com # # Next we increment points: # - # HINCR User:1:counters points 1 + # HINCR User:1:_counters points 1 # # And then we add a Post to the `posts` set. # (For brevity, let's assume the Post created has an ID of 1). # # SADD User:1:posts 1 @@ -755,22 +690,22 @@ def self.redis defined?(@redis) ? @redis : Sohm.redis end def self.mutex - @@mutex ||= Mutex.new + Sohm.mutex end def self.synchronize(&block) mutex.synchronize(&block) end # Returns the namespace for all the keys generated using this model. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # end # # User.key == "User" # User.key.kind_of?(String) # # => true @@ -780,11 +715,11 @@ # # To find out more about Nido, see: # http://github.com/soveran/nido # def self.key - @key ||= Nido.new(self.name) + Nido.new(self.name) end # Retrieve a record by ID. # # Example: @@ -805,11 +740,11 @@ # ids.map(&User) # # Note: The use of this should be a last resort for your actual # application runtime, or for simply debugging in your console. If # you care about performance, you should pipeline your reads. For - # more information checkout the implementation of Ohm::List#fetch. + # more information checkout the implementation of Sohm::List#fetch. # def self.to_proc lambda { |id| self[id] } end @@ -820,11 +755,11 @@ # Find values in indexed fields. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :email # # attribute :name # index :name # @@ -858,13 +793,13 @@ # def self.find(dict) keys = filters(dict) if keys.size == 1 - Ohm::Set.new(keys.first, key, self) + Sohm::Set.new(keys.first, key, self) else - Ohm::MultiSet.new(key, self, Command.new(:sinterstore, *keys)) + Sohm::MultiSet.new(key, self, Command.new(:sinterstore, *keys)) end end # Retrieve a set of models given an array of IDs. # @@ -880,79 +815,79 @@ # use it in `find` statements. def self.index(attribute) indices << attribute unless indices.include?(attribute) end - # Declare an Ohm::Set with the given name. + # Declare an Sohm::Set with the given name. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # set :posts, :Post # end # # u = User.create # u.posts.empty? # # => true # # Note: You can't use the set until you save the model. If you try - # to do it, you'll receive an Ohm::MissingID error. + # to do it, you'll receive an Sohm::MissingID error. # def self.set(name, model) track(name) define_method name do model = Utils.const(self.class, model) - Ohm::MutableSet.new(key[name], model.key, model) + Sohm::MutableSet.new(key[name], model.key, model) end end - # Declare an Ohm::List with the given name. + # Declare an Sohm::List with the given name. # # Example: # - # class Comment < Ohm::Model + # class Comment < Sohm::Model # end # - # class Post < Ohm::Model + # class Post < Sohm::Model # list :comments, :Comment # end # # p = Post.create # p.comments.push(Comment.create) # p.comments.unshift(Comment.create) # p.comments.size == 2 # # => true # # Note: You can't use the list until you save the model. If you try - # to do it, you'll receive an Ohm::MissingID error. + # to do it, you'll receive an Sohm::MissingID error. # def self.list(name, model) track(name) define_method name do model = Utils.const(self.class, model) - Ohm::List.new(key[name], model.key, model) + Sohm::List.new(key[name], model.key, model) end end # A macro for defining a method which basically does a find. # # Example: - # class Post < Ohm::Model + # class Post < Sohm::Model # reference :user, :User # end # - # class User < Ohm::Model + # class User < Sohm::Model # collection :posts, :Post # end # # # is the same as # - # class User < Ohm::Model + # class User < Sohm::Model # def posts # Post.find(:user_id => self.id) # end # end # @@ -966,31 +901,29 @@ # A macro for defining an attribute, an index, and an accessor # for a given model. # # Example: # - # class Post < Ohm::Model + # class Post < Sohm::Model # reference :user, :User # end # # # It's the same as: # - # class Post < Ohm::Model + # class Post < Sohm::Model # attribute :user_id # index :user_id # # def user - # @_memo[:user] ||= User[user_id] + # User[user_id] # end # # def user=(user) # self.user_id = user.id - # @_memo[:user] = user # end # # def user_id=(user_id) - # @_memo.delete(:user_id) # self.user_id = user_id # end # end # def self.reference(name, model) @@ -1004,32 +937,28 @@ define_method(reader) do @attributes[reader] end define_method(writer) do |value| - @_memo.delete(name) @attributes[reader] = value end define_method(:"#{name}=") do |value| - @_memo.delete(name) send(writer, value ? value.id : nil) end define_method(name) do - @_memo[name] ||= begin - model = Utils.const(self.class, model) - model[send(reader)] - end + model = Utils.const(self.class, model) + model[send(reader)] end end # The bread and butter macro of all models. Basically declares # persisted attributes. All attributes are stored on the Redis # hash. # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # end # # user = User.new(name: "John") # user.name @@ -1040,11 +969,11 @@ # # => "Jane" # # A +lambda+ can be passed as a second parameter to add # typecasting support to the attribute. # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :age, ->(x) { x.to_i } # end # # user = User.new(age: 100) # @@ -1087,14 +1016,19 @@ end serial_attributes << name unless serial_attributes.include?(name) if cast define_method(name) do + # NOTE: This is a temporary solution, since we might use + # composite objects (such as arrays), which won't always + # do a reset + @serial_attributes_changed = true cast[@serial_attributes[name]] end else define_method(name) do + @serial_attributes_changed = true @serial_attributes[name] end end define_method(:"#{name}=") do |value| @@ -1109,30 +1043,30 @@ # `decr` methods, which interact directly with Redis. Their value # can't be assigned as with regular attributes. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # counter :points # end # # u = User.create # u.incr :points # # u.points # # => 1 # # Note: You can't use counters until you save the model. If you - # try to do it, you'll receive an Ohm::MissingID error. + # try to do it, you'll receive an Sohm::MissingID error. # def self.counter(name) counters << name unless counters.include?(name) define_method(name) do return 0 if new? - redis.call("HGET", key[:counters], name).to_i + redis.call("HGET", key[:_counters], name).to_i end end # Keep track of `key[name]` and remove when deleting the object. def self.track(name) @@ -1140,15 +1074,15 @@ end # Create a new model, notice that under Sohm's circumstances, # this is no longer a syntactic sugar for Model.new(atts).save def self.create(atts = {}) - new(atts).save(create: true) + new(atts).save end # Returns the namespace for the keys generated using this model. - # Check `Ohm::Model.key` documentation for more details. + # Check `Sohm::Model.key` documentation for more details. def key model.key[id] end # Initialize a model using a dictionary of attributes. @@ -1158,21 +1092,20 @@ # u = User.new(:name => "John") # def initialize(atts = {}) @attributes = {} @serial_attributes = {} - @_memo = {} @serial_attributes_changed = false update_attributes(atts) end # Access the ID used to store this model. The ID is used together # with the name of the class in order to form the Redis key. # # Example: # - # class User < Ohm::Model; end + # class User < Sohm::Model; end # # u = User.create # u.id # # => 1 # @@ -1199,56 +1132,20 @@ end # Preload all the attributes of this model from Redis. Used # internally by `Model::[]`. def load! - update_attributes(Utils.dict(redis.call("HGETALL", key))) unless new? + update_attributes(Utils.dict(redis.call("HGETALL", key))) if id @serial_attributes_changed = false return self end - # Read an attribute remotely from Redis. Useful if you want to get - # the most recent value of the attribute and not rely on locally - # cached value. - # - # Example: - # - # User.create(:name => "A") - # - # Session 1 | Session 2 - # --------------|------------------------ - # u = User[1] | u = User[1] - # u.name = "B" | - # u.save | - # | u.name == "A" - # | u.get(:name) == "B" - # - def get(att) - @attributes[att] = redis.call("HGET", key, att) - end - - # Update an attribute value atomically. The best usecase for this - # is when you simply want to update one value. - # - # Note: This method is dangerous because it doesn't update indices - # and uniques. Use it wisely. The safe equivalent is `update`. - # - def set(att, val) - if val.to_s.empty? - redis.call("HDEL", key, att) - else - redis.call("HSET", key, att, val) - end - - @attributes[att] = val - end - # Returns +true+ if the model is not persisted. Otherwise, returns +false+. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # end # # u = User.new(:name => "John") # u.new? @@ -1256,16 +1153,16 @@ # # u.save # u.new? # # => false def new? - !model.exists?(id) + !(defined?(@id) && model.exists?(id)) end # Increment a counter atomically. Internally uses HINCRBY. def incr(att, count = 1) - redis.call("HINCRBY", key[:counters], att, count) + redis.call("HINCRBY", key[:_counters], att, count) end # Decrement a counter atomically. Internally uses HINCRBY. def decr(att, count = 1) incr(att, -count) @@ -1292,11 +1189,11 @@ # and the values of the attributes as values. It doesn't # include the ID of the model. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # end # # u = User.create(:name => "John") # u.attributes @@ -1314,21 +1211,21 @@ # whitelist public attributes, as opposed to exporting each # (possibly sensitive) attribute. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # end # # u = User.create(:name => "John") # u.to_hash # # => { :id => "1" } # # In order to add additional attributes, you can override `to_hash`: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # # def to_hash # super.merge(:name => name) # end @@ -1349,22 +1246,22 @@ # Persist the model attributes and update indices and unique # indices. The `counter`s and `set`s are not touched during save. # # Example: # - # class User < Ohm::Model + # class User < Sohm::Model # attribute :name # end # # u = User.new(:name => "John").save # u.kind_of?(User) # # => true # def save if serial_attributes_changed response = script(LUA_SAVE, 1, key, - serial_attributes.to_msgpack, + sanitize_attributes(serial_attributes).to_msgpack, cas_token) if response.is_a?(RuntimeError) if response.message =~ /cas_error/ raise CasViolation @@ -1375,32 +1272,36 @@ @cas_token = response @serial_attributes_changed = false end - redis.call("HSET", key, "_ndata", attributes.to_msgpack) + redis.call("HSET", key, "_ndata", + sanitize_attributes(attributes).to_msgpack) refresh_indices return self end # Delete the model, including all the following keys: # # - <Model>:<id> - # - <Model>:<id>:counters + # - <Model>:<id>:_counters # - <Model>:<id>:<set name> # # If the model has uniques or indices, they're also cleaned up. # def delete memo_key = key["_indices"] - commands = [["DEL", key], ["DEL", memo_key]] + commands = [["DEL", key], ["DEL", memo_key], ["DEL", key["_counters"]]] index_list = redis.call("SMEMBERS", memo_key) index_list.each do |index_key| commands << ["SREM", index_key, id] end + model.tracked.each do |tracked_key| + commands << ["DEL", key[tracked_key]] + end model.synchronize do commands.each do |command| redis.queue(*command) end @@ -1457,28 +1358,37 @@ match(/^(?:.*::)*(.*)$/)[1]. gsub(/([a-z\d])([A-Z])/, '\1_\2'). downcase.to_sym end + # Workaround to JRuby's concurrency problem + def self.inherited(subclass) + subclass.instance_variable_set(:@indices, []) + subclass.instance_variable_set(:@counters, []) + subclass.instance_variable_set(:@tracked, []) + subclass.instance_variable_set(:@attributes, []) + subclass.instance_variable_set(:@serial_attributes, []) + end + def self.indices - @indices ||= [] + @indices end def self.counters - @counters ||= [] + @counters end def self.tracked - @tracked ||= [] + @tracked end def self.attributes - @attributes ||= [] + @attributes end def self.serial_attributes - @serial_attributes ||= [] + @serial_attributes end def self.filters(dict) unless dict.kind_of?(Hash) raise ArgumentError, @@ -1491,13 +1401,13 @@ def self.to_indices(att, val) raise IndexNotFound unless indices.include?(att) if val.kind_of?(Enumerable) - val.map { |v| key[:indices][att][v] } + val.map { |v| key[:_indices][att][v] } else - [key[:indices][att][val]] + [key[:_indices][att][val]] end end def fetch_indices indices = {} @@ -1509,11 +1419,11 @@ def refresh_indices memo_key = key["_indices"] # Add new indices first commands = fetch_indices.each_pair.map do |field, vals| vals.map do |val| - index_key = key["_indices"][field][val] + index_key = model.key["_indices"][field][val] [["SADD", memo_key, index_key], ["SADD", index_key, id]] end end.flatten(2) # TODO: Think about switching to a redis pool later @@ -1527,11 +1437,11 @@ # Remove old indices # TODO: we can do this asynchronously, or maybe in a background queue index_set = ::Set.new(redis.call("SMEMBERS", memo_key)) valid_list = model[id].send(:fetch_indices).each_pair.map do |field, vals| vals.map do |val| - key["_indices"][field][val] + model.key["_indices"][field][val] end end.flatten(1) valid_set = ::Set.new(valid_list) diff_set = index_set - valid_set diff_list = diff_set.to_a @@ -1561,9 +1471,13 @@ if cas_token = attrs.delete("_cas") attrs["cas_token"] = cas_token end attrs + end + + def sanitize_attributes(attributes) + attributes.select { |key, val| val } end def model self.class end