lib/ohm.rb in ohm-0.1.5 vs lib/ohm.rb in ohm-1.0.0.alpha1

- old
+ new

@@ -1,2000 +1,1231 @@ # encoding: UTF-8 -require "base64" -require "redis" require "nest" +require "redis" +require "securerandom" +require "scrivener" +require "ohm/transaction" -require File.join(File.dirname(__FILE__), "ohm", "pattern") -require File.join(File.dirname(__FILE__), "ohm", "validations") -require File.join(File.dirname(__FILE__), "ohm", "compat-1.8.6") -require File.join(File.dirname(__FILE__), "ohm", "key") - module Ohm - # Provides access to the _Redis_ database. It is highly recommended that you - # use this sparingly, and only if you really know what you're doing. + # All of the known errors in Ohm can be traced back to one of these + # exceptions. # - # The better way to access the _Redis_ database and do raw _Redis_ - # commands would be one of the following: + # MissingID: # - # 1. Use {Ohm::Model.key} or {Ohm::Model#key}. So if the name of your - # model is *Post*, it would be *Post.key* or the protected method - # *#key* which should be used within your *Post* model. + # Comment.new.id # => Error + # Comment.new.key # => Error # - # 2. Use {Ohm::Model.db} or {Ohm::Model#db}. Although this is also - # accessible, it is much cleaner and terse to use {Ohm::Model.key}. + # Solution: you need to save your model first. # - # @example + # IndexNotFound: # - # class Post < Ohm::Model - # def comment_ids - # key[:comments].zrange(0, -1) - # end + # Comment.find(foo: "Bar") # => Error # - # def add_comment_id(id) - # key[:comments].zadd(Time.now.to_i, id) - # end + # Solution: add an index with `Comment.index :foo`. # - # def remove_comment_id(id) - # # Let's use the db style here just to demonstrate. - # db.zrem key[:comments], id - # end - # end + # UniqueIndexViolation: # - # Post.key[:latest].sadd(1) - # Post.key[:latest].smembers == ["1"] - # # => true + # Raised when trying to save an object with a `unique` index for + # which the value already exists. # - # Post.key[:latest] == "Post:latest" - # # => true + # Solution: rescue `Ohm::UniqueIndexViolation` during save, but + # also, do some validations even before attempting to save. # - # p = Post.create - # p.comment_ids == [] - # # => true - # - # p.add_comment_id(101) - # p.comment_ids == ["101"] - # # => true - # - # p.remove_comment_id(101) - # p.comment_ids == [] - # # => true - def self.redis - threaded[:redis] ||= connection(*options) + class Error < StandardError; end + class MissingID < Error; end + class IndexNotFound < Error; end + class UniqueIndexViolation < Error; end + + # Instead of monkey patching Kernel or trying to be clever, it's + # best to confine all the helper methods in a Utils module. + module Utils + + # Used by: `attribute`, `counter`, `set`, `reference`, + # `collection`. + # + # 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 + # reference :user, User # NameError undefined constant User. + # end + # + # Instead of relying on some clever `const_missing` hack, we can + # simply use a Symbol. + # + # class Comment < Ohm::Model + # reference :user, :User + # end + # + def self.const(context, name) + case name + when Symbol then context.const_get(name) + else name + end + end end - # Assign a new _Redis_ connection. Internally used by {Ohm.connect} - # to clear the cached _Redis_ instance. - # - # If you're looking to change the connection or reconnect with different - # parameters, try {Ohm.connect} or {Ohm::Model.connect}. - # @see connect - # @see Model.connect - # @param connection [Redis] an instance created using `Redis.new`. - def self.redis=(connection) - threaded[:redis] = connection + class Connection + attr_accessor :context + attr_accessor :options + + def initialize(context = :main, options = {}) + @context = context + @options = options + end + + def reset! + threaded[context] = nil + end + + def start(options = {}) + self.options = options + self.reset! + end + + def redis + threaded[context] ||= Redis.connect(options) + end + + def threaded + Thread.current[:ohm] ||= {} + end end - # @private Used internally by Ohm for thread safety. - def self.threaded - Thread.current[:ohm] ||= {} + def self.conn + @conn ||= Connection.new end - # Connect to a _Redis_ database. + # Stores the connection options for the Redis instance. # - # It is also worth mentioning that you can pass in a *URI* e.g. + # Examples: # - # Ohm.connect :url => "redis://127.0.0.1:6379/0" + # Ohm.connect(port: 6380, db: 1, host: "10.0.1.1") + # Ohm.connect(url: "redis://10.0.1.1:6380/1") # - # Note that the value *0* refers to the database number for the given - # _Redis_ instance. + # All of the options are simply passed on to `Redis.connect`. # - # Also you can use {Ohm.connect} without any arguments. The behavior will - # be as follows: + def self.connect(options = {}) + conn.start(options) + end + + # Use this if you want to do quick ad hoc redis commands against the + # defined Ohm connection. # - # # Connect to redis://127.0.0.1:6379/0 - # Ohm.connect + # Examples: # - # # Connect to redis://10.0.0.100:22222/5 - # ENV["REDIS_URL"] = "redis://10.0.0.100:22222/5" - # Ohm.connect + # Ohm.redis.keys("User:*") + # Ohm.redis.set("foo", "bar") # - # @param options [{Symbol => #to_s}] An options hash. - # @see file:README.html#connecting Ohm.connect options documentation. - # - # @example Connect to a database in port 6380. - # Ohm.connect(:port => 6380) - def self.connect(*options) - self.redis = nil - @options = options + def self.redis + conn.redis end - # @private Return a connection to Redis. - # - # This is a wrapper around Redis.connect(options) - def self.connection(*options) - Redis.connect(*options) - end - - # @private Stores the connection options for Ohm.redis. - def self.options - @options = [] unless defined? @options - @options - end - - # Clear the database. You typically use this only during testing, - # or when you seed your site. - # - # @see http://code.google.com/p/redis/wiki/FlushdbCommand FLUSHDB in the - # Redis Command Reference. + # Wrapper for Ohm.redis.flushdb. def self.flush redis.flushdb end - # The base class of all *Ohm* errors. Can be used as a catch all for - # Ohm related errors. - class Error < StandardError; end + # Defines most of the methods used by `Set` and `MultiSet`. + module Collection + include Enumerable - # This is the class that you need to extend in order to define your - # own models. - # - # Probably the most magic happening within {Ohm::Model} is the catching - # of {Ohm::Model.const_missing} exceptions to allow the use of constants - # even before they are defined. - # - # @example - # - # class Post < Ohm::Model - # reference :author, User # no User definition yet! - # end - # - # class User < Ohm::Model - # end - # - # @see Model.const_missing - class Model + # Fetch the data from Redis in one go. + def to_a + fetch(ids) + end - # Wraps a model name for lazy evaluation. - class Wrapper < BasicObject + def each + to_a.each { |e| yield e } + end - # Allows you to use a constant even before it is defined. This solves - # the issue of having to require inter-project dependencies in a very - # simple and "magic-free" manner. - # - # Example of how it was done before Wrapper existed: - # - # require "./app/models/user" - # require "./app/models/comment" - # - # class Post < Ohm::Model - # reference :author, User - # list :comments, Comment - # end - # - # Now, you can simply do the following: - # class Post < Ohm::Model - # reference :author, User - # list :comments, Comment - # end - # - # @example - # - # module Commenting - # def self.included(base) - # base.list :comments, Ohm::Model::Wrapper.new(:Comment) { - # Object.const_get(:Comment) - # } - # end - # end - # - # # In your classes: - # class Post < Ohm::Model - # include Commenting - # end - # - # class Comment < Ohm::Model - # end - # - # p = Post.create - # p.comments.empty? - # # => true - # - # p.comments.push(Comment.create) - # p.comments.size == 1 - # # => true - # - # @param [Symbol, String] name Canonical name of wrapped class. - # @param [#to_proc] block Closure for getting the name of the constant. - def initialize(name, &block) - @name = name - @caller = ::Kernel.caller[2] - @block = block + def empty? + size == 0 + end - class << self - def method_missing(method_id, *args) - ::Kernel.raise( - ::NoMethodError, - "You tried to call %s#%s, but %s is not defined on %s" % [ - @name, method_id, @name, @caller - ] - ) - end - end - end + # Allows you to sort by any field in your model. + # + # Example: + # + # 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: namespace["*->%s" % att])) + end - # Used as a convenience for wrapping an existing constant into a - # {Ohm::Model::Wrapper wrapper object}. - # - # This is used extensively within the library for points where a user - # defined class (e.g. _Post_, _User_, _Comment_) is expected. - # - # You can also use this if you need to do uncommon things, such as - # creating your own {Ohm::Model::Set Set}, {Ohm::Model::List List}, etc. - # - # (*NOTE:* Keep in mind that the following code is given only as an - # educational example, and is in no way prescribed as good design.) - # - # class User < Ohm::Model - # end - # - # User.create(:id => "1001") - # - # Ohm.redis.sadd("myset", 1001) - # - # key = Ohm::Key.new("myset", Ohm.redis) - # set = Ohm::Model::Set.new(key, Ohm::Model::Wrapper.wrap(User)) - # - # [User[1001]] == set.all.to_a - # # => true - # - # @see http://ohm.keyvalue.org/tutorials/chaining Chaining Ohm Sets - def self.wrap(object) - object.class == self ? object : new(object.inspect) { object } + # 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] = namespace["*->%s" % options[:get]] + return execute { |key| key.sort(options) } end - # Evaluates the passed block in {Ohm::Model::Wrapper#initialize}. - # - # @return [Class] The wrapped class. - def unwrap - @block.call - end + fetch(execute { |key| key.sort(options) }) + end - # Since {Ohm::Model::Wrapper} is a subclass of _BasicObject_ we have - # to manually declare this. - # - # @return [Wrapper] - def class - Wrapper - end + # Check if a model is included in this set. + # + # Example: + # + # u = User.create + # + # User.all.include?(u) + # # => true + # + # Note: Ohm simply checks that the model's ID is included in the + # set. It doesn't do any form of type checking. + # + def include?(model) + exists?(model.id) + end - # @return [String] A string describing this lazy object. - def inspect - "<Wrapper for #{@name} (in #{@caller})>" - end + # Returns the total size of the set using SCARD. + def size + execute { |key| key.scard } end - # Defines the base implementation for all enumerable types in Ohm, - # which includes {Ohm::Model::Set Sets}, {Ohm::Model::List Lists} and - # {Ohm::Model::Index Indices}. - class Collection - include Enumerable + # Syntactic sugar for `sort_by` or `sort` when you only need the + # first element. + # + # Example: + # + # 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]) - # An instance of {Ohm::Key}. - attr :key - - # A subclass of {Ohm::Model}. - attr :model - - # @param [Key] key A key which includes a _Redis_ connection. - # @param [Ohm::Model::Wrapper] model A wrapped subclass of {Ohm::Model}. - def initialize(key, model) - @key = key - @model = model.unwrap + if opts[:by] + sort_by(opts.delete(:by), opts).first + else + sort(opts).first end + end - # Adds an instance of {Ohm::Model} to this collection. - # - # @param [#id] model A model with an ID. - def add(model) - self << model - end + # Grab all the elements of this set using SMEMBERS. + def ids + execute { |key| key.smembers } + end - # Sort this collection using the ID by default, or an attribute defined - # in the elements of this collection. - # - # *NOTE:* It is worth mentioning that if you want to sort by a specific - # attribute instead of an ID, you would probably want to use - # {Ohm::Model::Collection#sort_by sort_by} instead. - # - # @example - # class Post < Ohm::Model - # attribute :title - # end - # - # p1 = Post.create(:title => "Alpha") - # p2 = Post.create(:title => "Beta") - # p3 = Post.create(:title => "Gamma") - # - # [p1, p2, p3] == Post.all.sort.to_a - # # => true - # - # [p3, p2, p1] == Post.all.sort(:order => "DESC").to_a - # # => true - # - # [p1, p2, p3] == Post.all.sort(:by => "Post:*->title", - # :order => "ASC ALPHA").to_a - # # => true - # - # [p3, p2, p1] == Post.all.sort(:by => "Post:*->title", - # :order => "DESC ALPHA").to_a - # # => true - # - # @see file:README.html#sorting Sorting in the README. - # @see http://code.google.com/p/redis/wiki/SortCommand SORT in the - # Redis Command Reference. - def sort(options = {}) - return [] unless key.exists + # Retrieve a specific element using an ID from this set. + # + # Example: + # + # # Let's say we got the ID 1 from a request parameter. + # id = 1 + # + # # Retrieve the post if it's included in the user's posts. + # post = user.posts[id] + # + def [](id) + model[id] if exists?(id) + end - opts = options.dup - opts[:start] ||= 0 - opts[:limit] = [opts[:start], opts[:limit]] if opts[:limit] + private + def exists?(id) + execute { |key| key.sismember(id) } + end - key.sort(opts).map(&model) + def fetch(ids) + arr = model.db.pipelined do + ids.each { |id| namespace[id].hgetall } end - # Sort the model instances by the given attribute. - # - # @example Sorting elements by name: - # - # User.create :name => "B" - # User.create :name => "A" - # - # user = User.all.sort_by(:name, :order => "ALPHA").first - # user.name == "A" - # # => true - # - # @see file:README.html#sorting Sorting in the README. - def sort_by(att, options = {}) - return [] unless key.exists + return [] if arr.nil? - opts = options.dup - opts.merge!(:by => model.key["*->#{att}"]) - - if opts[:get] - key.sort(opts.merge(:get => model.key["*->#{opts[:get]}"])) - else - sort(opts) - end + arr.map.with_index do |atts, idx| + model.new(atts.update(id: ids[idx])) end + end + end - # Delete this collection. - # - # @example - # - # class Post < Ohm::Model - # list :comments, Comment - # end - # - # class Comment < Ohm::Model - # end - # - # post = Post.create - # post.comments << Comment.create - # - # post.comments.size == 1 - # # => true - # - # post.comments.clear - # post.comments.size == 0 - # # => true - # @see http://code.google.com/p/redis/wiki/DelCommand DEL in the Redis - # Command Reference. - def clear - key.del - end + class Set < Struct.new(:key, :namespace, :model) + include Collection - # Simultaneously clear and add all models. This wraps all operations - # in a MULTI EXEC block to make the whole operation atomic. - # - # @example - # - # class Post < Ohm::Model - # list :comments, Comment - # end - # - # class Comment < Ohm::Model - # end - # - # post = Post.create - # post.comments << Comment.create(:id => 100) - # - # post.comments.map(&:id) == ["100"] - # # => true - # - # comments = (101..103).to_a.map { |i| Comment.create(:id => i) } - # - # post.comments.replace(comments) - # post.comments.map(&:id) == ["101", "102", "103"] - # # => true - # - # @see http://code.google.com/p/redis/wiki/MultiExecCommand MULTI EXEC - # in the Redis Command Reference. - def replace(models) - model.db.multi do - clear - models.each { |model| add(model) } - end - end + # Add a model directly to the set. + # + # Example: + # + # user = User.create + # post = Post.create + # + # user.posts.add(post) + # + def add(model) + key.sadd(model.id) + end - # @return [true, false] Whether or not this collection is empty. - def empty? - !key.exists - end + # Chain new fiters on an existing set. + # + # Example: + # + # set = User.find(name: "John") + # set.find(age: 30) + # + def find(dict) + keys = model.filters(dict) + keys.push(key) - # @return [Array] Array representation of this collection. - def to_a - all - end + MultiSet.new(keys, namespace, model) end - # Provides a Ruby-esque interface to a _Redis_ *SET*. The *SET* is assumed - # to be composed of ids which maps to {#model}. - class Set < Collection - # An implementation which relies on *SMEMBERS* and yields an instance - # of {#model}. - # - # @example - # - # class Author < Ohm::Model - # set :poems, Poem - # end - # - # class Poem < Ohm::Model - # end - # - # neruda = Author.create - # neruda.poems.add(Poem.create) - # - # neruda.poems.each do |poem| - # # do something with the poem - # end - # - # # if you look at the source, you'll quickly see that this can - # # easily be achieved by doing the following: - # - # neruda.poems.key.smembers.each do |id| - # poem = Poem[id] - # # do something with the poem - # end - # - # @see http://code.google.com/p/redis/wiki/SmembersCommand SMEMBERS - # in Redis Command Reference. - def each(&block) - key.smembers.each { |id| block.call(model.to_proc[id]) } - end + # Replace all the existing elements of a set with a different + # collection of models. This happens atomically in a MULTI-EXEC + # block. + # + # Example: + # + # user = User.create + # p1 = Post.create + # user.posts.add(p1) + # + # p2, p3 = Post.create, Post.create + # user.posts.replace([p2, p3]) + # + # user.posts.include?(p1) + # # => false + # + def replace(models) + ids = models.map { |model| model.id } - # Convenient way to scope access to a predefined set, useful for access - # control. - # - # @example - # - # class User < Ohm::Model - # set :photos, Photo - # end - # - # class Photo < Ohm::Model - # end - # - # @user = User.create - # @user.photos.add(Photo.create(:id => "101")) - # @user.photos.add(Photo.create(:id => "102")) - # - # Photo.create(:id => "500") - # - # @user.photos[101] == Photo[101] - # # => true - # - # @user.photos[500] == nil - # # => true - # - # @param [#to_s] id Any id existing within this set. - # @return [Ohm::Model, nil] The model if it exists. - def [](id) - model[id] if key.sismember(id) + key.redis.multi do + key.del + ids.each { |id| key.sadd(id) } end + end - # Adds a model to this set. - # - # @param [#id] model Typically an instance of an {Ohm::Model} subclass. - # - # @see http://code.google.com/p/redis/wiki/SaddCommand SADD in Redis - # Command Reference. - def <<(model) - key.sadd(model.id) - end - alias add << + private + def execute + yield key + end + end - # Thin Ruby interface wrapper for *SCARD*. - # - # @return [Fixnum] The total number of members for this set. - # @see http://code.google.com/p/redis/wiki/ScardCommand SCARD in Redis - # Command Reference. - def size - key.scard - end + # Anytime you filter a set with more than one requirement, you + # internally use a `MultiSet`. `MutiSet` is a bit slower than just + # a `Set` because it has to `SINTERSTORE` all the keys prior to + # retrieving the members, size, etc. + # + # Example: + # + # User.all.kind_of?(Ohm::Set) + # # => true + # + # User.find(name: "John").kind_of?(Ohm::Set) + # # => true + # + # User.find(name: "John", age: 30).kind_of?(Ohm::MultiSet) + # # => true + # + class MultiSet < Struct.new(:keys, :namespace, :model) + include Collection - # Thin Ruby interface wrapper for *SREM*. - # - # @param [#id] member a member of this set. - # @see http://code.google.com/p/redis/wiki/SremCommand SREM in Redis - # Command Reference. - def delete(member) - key.srem(member.id) - end + # Chain new fiters on an existing set. + # + # Example: + # + # set = User.find(name: "John", age: 30) + # set.find(status: 'pending') + # + def find(dict) + keys = model.filters(dict) + keys.push(*self.keys) - # Array representation of this set. - # - # @example - # - # class Author < Ohm::Model - # set :posts, Post - # end - # - # class Post < Ohm::Model - # end - # - # author = Author.create - # author.posts.add(Author.create(:id => "101")) - # author.posts.add(Author.create(:id => "102")) - # - # author.posts.all.is_a?(Array) - # # => true - # - # author.posts.all.include?(Author[101]) - # # => true - # - # author.posts.all.include?(Author[102]) - # # => true - # - # @return [Array<Ohm::Model>] All members of this set. - def all - key.smembers.map(&model) - end + MultiSet.new(keys, namespace, model) + end - # Allows you to find members of this set which fits the given criteria. - # - # @example - # - # class Post < Ohm::Model - # attribute :title - # attribute :tags - # - # index :title - # index :tag - # - # def tag - # tags.split(/\s+/) - # end - # end - # - # post = Post.create(:title => "Ohm", :tags => "ruby ohm redis") - # Post.all.is_a?(Ohm::Model::Set) - # # => true - # - # Post.all.find(:tag => "ruby").include?(post) - # # => true - # - # # Post.find is actually just a wrapper around Post.all.find - # Post.find(:tag => "ohm", :title => "Ohm").include?(post) - # # => true - # - # Post.find(:tag => ["ruby", "python"]).empty? - # # => true - # - # # Alternatively, you may choose to chain them later on. - # ruby = Post.find(:tag => "ruby") - # ruby.find(:title => "Ohm").include?(post) - # # => true - # - # @param [Hash] options A hash of key value pairs. - # @return [Ohm::Model::Set] A set satisfying the filter passed. - def find(options) - source = keys(options) - target = source.inject(key.volatile) { |chain, other| chain + other } - apply(:sinterstore, key, source, target) - end + private + def execute + key = namespace[:temp][SecureRandom.uuid] + key.sinterstore(*keys) - # Similar to find except that it negates the criteria. - # - # @example - # class Post < Ohm::Model - # attribute :title - # end - # - # ohm = Post.create(:title => "Ohm") - # ruby = Post.create(:title => "Ruby") - # - # Post.except(:title => "Ohm").include?(ruby) - # # => true - # - # Post.except(:title => "Ohm").size == 1 - # # => true - # - # @param [Hash] options A hash of key value pairs. - # @return [Ohm::Model::Set] A set satisfying the filter passed. - def except(options) - source = keys(options) - target = source.inject(key.volatile) { |chain, other| chain - other } - apply(:sdiffstore, key, source, target) + begin + yield key + ensure + key.del end + end + end - # Returns by default the lowest id value for this set. You may also - # pass in options similar to {#sort}. - # - # @example - # - # class Post < Ohm::Model - # attribute :title - # end - # - # p1 = Post.create(:id => "101", :title => "Alpha") - # p2 = Post.create(:id => "100", :title => "Beta") - # p3 = Post.create(:id => "99", :title => "Gamma") - # - # Post.all.is_a?(Ohm::Model::Set) - # # => true - # - # p3 == Post.all.first - # # => true - # - # p1 == Post.all.first(:order => "DESC") - # # => true - # - # p1 == Post.all.first(:by => :title, :order => "ASC ALPHA") - # # => true - # - # # just ALPHA also means ASC ALPHA, for brevity. - # p1 == Post.all.first(:by => :title, :order => "ALPHA") - # # => true - # - # p3 == Post.all.first(:by => :title, :order => "DESC ALPHA") - # # => true - # - # @param [Hash] options Sort options hash. - # @return [Ohm::Model, nil] an {Ohm::Model} instance or nil if this - # set is empty. - # - # @see file:README.html#sorting Sorting in the README. - def first(options = {}) - opts = options.dup - opts.merge!(:limit => 1) + # The base class for all your models. In order to better understand + # it, here is a semi-realtime explanation of the details involved + # when creating a User instance. + # + # Example: + # + # class User < Ohm::Model + # attribute :name + # index :name + # + # attribute :email + # unique :email + # + # counter :points + # + # set :posts, :Post + # end + # + # u = User.create(name: "John", email: "foo@bar.com") + # u.incr :points + # u.posts.add(Post.create) + # + # When you execute `User.create(...)`, you run the following Redis + # commands: + # + # # Generate an ID + # INCR User:id + # + # # Add the newly generated ID, (let's assume the ID is 1). + # SADD User:all 1 + # + # # Store the unique index + # HSET User:uniques:email foo@bar.com 1 + # + # # Store the name index + # SADD User:indices:name:John 1 + # + # # Store the HASH + # HMSET User:1 name John email foo@bar.com + # + # Next we increment points: + # + # 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 + # + class Model + include Scrivener::Validations - if opts[:by] - sort_by(opts.delete(:by), opts).first - else - sort(opts).first - end - end + def self.conn + @conn ||= Connection.new(name, Ohm.conn.options) + end - # Ruby-like interface wrapper around *SISMEMBER*. - # - # @param [#id] model Typically an {Ohm::Model} instance. - # - # @return [true, false] Whether or not the {Ohm::Model model} instance - # is a member of this set. - # - # @see http://code.google.com/p/redis/wiki/SismemberCommand SISMEMBER - # in Redis Command Reference. - def include?(model) - key.sismember(model.id) - end + def self.connect(options) + @key = nil + @lua = nil + conn.start(options) + end - def inspect - "#<Set (#{model}): #{key.smembers.inspect}>" - end + def self.db + conn.redis + end - protected - # @private - def apply(operation, key, source, target) - target.send(operation, key, *source) - Set.new(target, Wrapper.wrap(model)) - end + def self.lua + @lua ||= Lua.new(File.join(Dir.pwd, "lua"), db) + end - # @private - # - # Transform a hash of attribute/values into an array of keys. - def keys(hash) - [].tap do |keys| - hash.each do |key, values| - values = [values] unless values.kind_of?(Array) - values.each do |v| - keys << model.index_key_for(key, v) - end - end - end - end + # The namespace for all the keys generated using this model. + # + # Example: + # + # class User < Ohm::Model + # + # User.key == "User" + # User.key.kind_of?(String) + # # => true + # + # User.key.kind_of?(Nest) + # # => true + # + # To find out more about Nest, see: + # http://github.com/soveran/nest + # + def self.key + @key ||= Nest.new(self.name, db) end - class Index < Set - # This method is here primarily as an optimization. Let's say you have - # the following model: - # - # class Post < Ohm::Model - # attribute :title - # index :title - # end - # - # ruby = Post.create(:title => "ruby") - # redis = Post.create(:title => "redis") - # - # Post.key[:all].smembers == [ruby.id, redis.id] - # # => true - # - # Post.index_key_for(:title, "ruby").smembers == [ruby.id] - # # => true - # - # Post.index_key_for(:title, "redis").smembers == [redis.id] - # # => true - # - # If we want to search for example all `Posts` entitled "ruby" or - # "redis", then it doesn't make sense to do an INTERSECTION with - # `Post.key[:all]` since it would be redundant. - # - # The implementation of {Ohm::Model::Index#find} avoids this redundancy. - # - # @see Ohm::Model::Set#find find in Ohm::Model::Set. - # @see Ohm::Model.find find in Ohm::Model. - def find(options) - keys = keys(options) - return super(options) if keys.size > 1 - - Set.new(keys.first, Wrapper.wrap(model)) - end + # Retrieve a record by ID. + # + # Example: + # + # u = User.create + # u == User[u.id] + # # => true + # + def self.[](id) + new(id: id).load! if id && exists?(id) end - # Provides a Ruby-esque interface to a _Redis_ *LIST*. The *LIST* is - # assumed to be composed of ids which maps to {#model}. - class List < Collection - # An implementation which relies on *LRANGE* and yields an instance - # of {#model}. - # - # @example - # - # class Post < Ohm::Model - # list :comments, Comment - # end - # - # class Comment < Ohm::Model - # end - # - # post = Post.create - # post.comments.add(Comment.create) - # post.comments.add(Comment.create) - # - # post.comments.each do |comment| - # # do something with the comment - # end - # - # # reading the source reveals that this is achieved by doing: - # post.comments.key.lrange(0, -1).each do |id| - # comment = Comment[id] - # # do something with the comment - # end - # - # @see http://code.google.com/p/redis/wiki/LrangeCommand LRANGE - # in Redis Command Reference. - def each(&block) - key.lrange(0, -1).each { |id| block.call(model.to_proc[id]) } - end - - # Thin wrapper around *RPUSH*. - # - # @example - # - # class Post < Ohm::Model - # list :comments, Comment - # end - # - # class Comment < Ohm::Model - # end - # - # p = Post.create - # p.comments << Comment.create - # - # @param [#id] model Typically an {Ohm::Model} instance. - # - # @see http://code.google.com/p/redis/wiki/RpushCommand RPUSH - # in Redis Command Reference. - def <<(model) - key.rpush(model.id) - end - alias push << - - # Returns the element at index, or returns a subarray starting at - # `start` and continuing for `length` elements, or returns a subarray - # specified by `range`. Negative indices count backward from the end - # of the array (-1 is the last element). Returns nil if the index - # (or starting index) are out of range. - # - # @example - # class Post < Ohm::Model - # list :comments, Comment - # end - # - # class Comment < Ohm::Model - # end - # - # post = Post.create - # - # 10.times { post.comments << Comment.create } - # - # post.comments[0] == Comment[1] - # # => true - # - # post.comments[0, 4] == (1..5).map { |i| Comment[i] } - # # => true - # - # post.comments[0, 4] == post.comments[0..4] - # # => true - # - # post.comments.all == post.comments[0, -1] - # # => true - # - # @see http://code.google.com/p/redis/wiki/LrangeCommand LRANGE - # in Redis Command Reference. - def [](index, limit = nil) - case [index, limit] - when Pattern[Fixnum, Fixnum] then - key.lrange(index, limit).collect { |id| model.to_proc[id] } - when Pattern[Range, nil] then - key.lrange(index.first, index.last).collect { |id| model.to_proc[id] } - when Pattern[Fixnum, nil] then - model[key.lindex(index)] - end - end - - # Convience method for doing list[0], similar to Ruby's Array#first - # method. - # - # @return [Ohm::Model, nil] An {Ohm::Model} instance or nil if the list - # is empty. - def first - self[0] - end - - # Returns the model at the tail of this list, while simultaneously - # removing it from the list. - # - # @return [Ohm::Model, nil] an {Ohm::Model} instance or nil if the list - # is empty. - # - # @see http://code.google.com/p/redis/wiki/LpopCommand RPOP - # in Redis Command Reference. - def pop - model[key.rpop] - end - - # Returns the model at the head of this list, while simultaneously - # removing it from the list. - # - # @return [Ohm::Model, nil] An {Ohm::Model} instance or nil if the list - # is empty. - # - # @see http://code.google.com/p/redis/wiki/LpopCommand LPOP - # in Redis Command Reference. - def shift - model[key.lpop] - end - - # Prepends an {Ohm::Model} instance at the beginning of this list. - # - # @param [#id] model Typically an {Ohm::Model} instance. - # - # @see http://code.google.com/p/redis/wiki/RpushCommand LPUSH - # in Redis Command Reference. - def unshift(model) - key.lpush(model.id) - end - - # Returns an array representation of this list, with elements of the - # array being an instance of {#model}. - # - # @return [Array<Ohm::Model>] Instances of {Ohm::Model}. - def all - key.lrange(0, -1).map(&model) - end - - # Thin Ruby interface wrapper for *LLEN*. - # - # @return [Fixnum] The total number of elements for this list. - # - # @see http://code.google.com/p/redis/wiki/LlenCommand LLEN in Redis - # Command Reference. - def size - key.llen - end - - # Ruby-like interface wrapper around *LRANGE*. - # - # @param [#id] model Typically an {Ohm::Model} instance. - # - # @return [true, false] Whether or not the {Ohm::Model} instance is - # an element of this list. - # - # @see http://code.google.com/p/redis/wiki/LrangeCommand LRANGE - # in Redis Command Reference. - def include?(model) - key.lrange(0, -1).include?(model.id) - end - - def inspect - "#<List (#{model}): #{key.lrange(0, -1).inspect}>" - end + # Retrieve a set of models given an array of IDs. + # + # Example: + # + # ids = [1, 2, 3] + # 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::Set#fetch. + # + def self.to_proc + lambda { |id| self[id] } end - # All validations that need access to the _Redis_ database go here. - # As of this writing, {Ohm::Model::Validations#assert_unique} is the only - # assertion contained within this module. - module Validations - include Ohm::Validations - - # Validates that the attribute or array of attributes are unique. For - # this, an index of the same kind must exist. - # - # @overload assert_unique :name - # Validates that the name attribute is unique. - # @overload assert_unique [:street, :city] - # Validates that the :street and :city pair is unique. - def assert_unique(atts, error = [atts, :not_unique]) - indices = Array(atts).map { |att| index_key_for(att, send(att)) } - result = db.sinter(*indices) - - assert result.empty? || !new? && result.include?(id.to_s), error - end + # Check if the ID exists within <Model>:all. + def self.exists?(id) + key[:all].sismember(id) end - include Validations - - # Raised when you try and get the *id* of an {Ohm::Model} without an id. + # Find values in `unique` indices. # - # class Post < Ohm::Model - # list :comments, Comment - # end + # Example: # - # class Comment < Ohm::Model + # class User < Ohm::Model + # unique :email # end # - # ex = nil - # begin - # Post.new.id - # rescue Exception => e - # ex = e - # end - # - # ex.kind_of?(Ohm::Model::MissingID) + # u = User.create(email: "foo@bar.com") + # u == User.with(:email, "foo@bar.com") # # => true # - # This is also one of the most common errors you'll be faced with when - # you're new to {Ohm} coming from an ActiveRecord background, where you - # are used to just assigning associations even before the base model is - # persisted. + def self.with(att, val) + id = key[:uniques][att].hget(val) + id && self[id] + end + + # Find values in indexed fields. # - # # following from the example above: - # post = Post.new + # Example: # - # ex = nil - # begin - # post.comments << Comment.new - # rescue Exception => e - # ex = e - # end + # class User < Ohm::Model + # attribute :email # - # ex.kind_of?(Ohm::Model::MissingID) - # # => true + # attribute :name + # index :name # - # # Correct way: - # post = Post.new + # attribute :status + # index :status # - # if post.save - # post.comments << Comment.create - # end - class MissingID < Error - def message - "You tried to perform an operation that needs the model ID, " + - "but it's not present." - end - end - - # Raised when you try and do an {Ohm::Model::Set#find} operation and use - # a key which you did not define as an index. + # index :provider + # index :tag # - # class Post < Ohm::Model - # attribute :title + # def provider + # email[/@(.*?).com/, 1] + # end + # + # def tag + # ["ruby", "python"] + # end # end # - # post = Post.create(:title => "Ohm") + # u = User.create(name: "John", status: "pending", email: "foo@me.com") + # User.find(provider: "me", name: "John", status: "pending").include?(u) + # # => true # - # ex = nil - # begin - # Post.find(:title => "Ohm") - # rescue Exception => e - # ex = e - # end + # User.find(tag: "ruby").include?(u) + # # => true # - # ex.kind_of?(Ohm::Model::IndexNotFound) + # User.find(tag: "python").include?(u) # # => true # - # To correct this problem, simply define a _:title_ *index* in your class. + # User.find(tag: ["ruby", "python"]).include?(u) + # # => true # - # class Post < Ohm::Model - # attribute :title - # index :title - # end - class IndexNotFound < Error - def initialize(att) - @att = att - end + def self.find(dict) + keys = filters(dict) - def message - "Index #{@att.inspect} not found." + if keys.size == 1 + Ohm::Set.new(keys.first, key, self) + else + Ohm::MultiSet.new(keys, key, self) end end - @@attributes = Hash.new { |hash, key| hash[key] = [] } - @@collections = Hash.new { |hash, key| hash[key] = [] } - @@counters = Hash.new { |hash, key| hash[key] = [] } - @@indices = Hash.new { |hash, key| hash[key] = [] } - - def id - @id or raise MissingID + # Index any method on your model. Once you index a method, you can + # use it in `find` statements. + def self.index(attribute) + indices << attribute unless indices.include?(attribute) end - # Defines a string attribute for the model. This attribute will be - # persisted by _Redis_ as a string. Any value stored here will be - # retrieved in its string representation. + # Create a unique index for any method on your model. Once you add + # a unique index, you can use it in `with` statements. # - # If you're looking to have typecasting built in, you may want to look at - # Ohm::Typecast in Ohm::Contrib. + # Note: if there is a conflict while saving, an + # `Ohm::UniqueIndexViolation` violation is raised. # - # @param name [Symbol] Name of the attribute. - # @see http://cyx.github.com/ohm-contrib/doc/Ohm/Typecast.html - def self.attribute(name) - define_method(name) do - read_local(name) - end - - define_method(:"#{name}=") do |value| - write_local(name, value) - end - - attributes << name unless attributes.include?(name) + def self.unique(attribute) + uniques << attribute unless uniques.include?(attribute) end - # Defines a counter attribute for the model. This attribute can't be - # assigned, only incremented or decremented. It will be zero by default. + # Declare an Ohm::Set with the given name. # - # @param [Symbol] name Name of the counter. - def self.counter(name) - define_method(name) do - read_local(name).to_i - end - - counters << name unless counters.include?(name) - end - - # Defines a list attribute for the model. It can be accessed only after - # the model instance is created, or if you assign an :id during object - # construction. + # Example: # - # @example - # - # class Post < Ohm::Model - # list :comments, Comment + # class User < Ohm::Model + # set :posts, :Post # end # - # class Comment < Ohm::Model - # end + # u = User.create + # u.posts.empty? + # # => true # - # # WRONG!!! - # post = Post.new - # post.comments << Comment.create + # 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. # - # # Right :-) - # post = Post.create - # post.comments << Comment.create - # - # # Alternative way if you want to have custom ids. - # post = Post.new(:id => "my-id") - # post.comments << Comment.create - # post.create - # - # @param [Symbol] name Name of the list. - def self.list(name, model) - define_memoized_method(name) { List.new(key[name], Wrapper.wrap(model)) } - collections << name unless collections.include?(name) - end - - # Defines a set attribute for the model. It can be accessed only after - # the model instance is created. Sets are recommended when insertion and - # retreival order is irrelevant, and operations like union, join, and - # membership checks are important. - # - # @param [Symbol] name Name of the set. def self.set(name, model) - define_memoized_method(name) { Set.new(key[name], Wrapper.wrap(model)) } collections << name unless collections.include?(name) + + define_method name do + model = Utils.const(self.class, model) + + Ohm::Set.new(key[name], model.key, model) + end end - # Creates an index (a set) that will be used for finding instances. + # A macro for defining a method which basically does a find. # - # If you want to find a model instance by some attribute value, then an - # index for that attribute must exist. + # Example: + # class Post < Ohm::Model + # reference :user, :User + # end # - # @example - # # class User < Ohm::Model - # attribute :email - # index :email + # collection :posts, :Post # end # - # # Now this is possible: - # User.find :email => "ohm@example.com" + # # is the same as # - # @param [Symbol] name Name of the attribute to be indexed. - def self.index(att) - indices << att unless indices.include?(att) + # class User < Ohm::Model + # def posts + # Post.find(user_id: self.id) + # end + # end + # + def self.collection(name, model, reference = to_reference) + define_method name do + model = Utils.const(self.class, model) + model.find(:"#{reference}_id" => id) + end end - # Define a reference to another object. + # A macro for defining an attribute, an index, and an accessor + # for a given model. # - # @example + # Example: # - # class Comment < Ohm::Model - # attribute :content - # reference :post, Post + # class Post < Ohm::Model + # reference :user, :User # end # - # @post = Post.create :content => "Interesting stuff" + # # It's the same as: # - # @comment = Comment.create(:content => "Indeed!", :post => @post) + # class Post < Ohm::Model + # attribute :user_id + # index :user_id # - # @comment.post.content - # # => "Interesting stuff" + # def user + # @_memo[:user] ||= User[user_id] + # end # - # @comment.post = Post.create(:content => "Wonderful stuff") + # def user=(user) + # self.user_id = user.id + # @_memo[:user] = user + # end # - # @comment.post.content - # # => "Wonderful stuff" + # def user_id=(user_id) + # @_memo.delete(:user_id) + # self.user_id = user_id + # end + # end # - # @comment.post.update(:content => "Magnific stuff") - # - # @comment.post.content - # # => "Magnific stuff" - # - # @comment.post = nil - # - # @comment.post - # # => nil - # - # @see file:README.html#references References Explained. - # @see Ohm::Model.collection def self.reference(name, model) - model = Wrapper.wrap(model) - reader = :"#{name}_id" writer = :"#{name}_id=" - attributes << reader unless attributes.include?(reader) - index reader - define_memoized_method(name) do - model.unwrap[send(reader)] + 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(reader) do - read_local(reader) + define_method(name) do + @_memo[name] ||= begin + model = Utils.const(self.class, model) + model[send(reader)] + end end - - define_method(writer) do |value| - @_memo.delete(name) - write_local(reader, value) - end end - # Define a collection of objects which have a - # {Ohm::Model.reference reference} to this model. + # The bread and butter macro of all models. Basically declares + # persisted attributes. All attributes are stored on the Redis + # hash. # - # class Comment < Ohm::Model - # attribute :content - # reference :post, Post + # Example: + # class User < Ohm::Model + # attribute :name # end # - # class Post < Ohm::Model - # attribute :content - # collection :comments, Comment - # reference :author, Person - # end + # # It's the same as: # - # class Person < Ohm::Model - # attribute :name + # class User < Ohm::Model + # def name + # @attributes[:name] + # end # - # # When the name of the reference cannot be inferred, - # # you need to specify it in the third param. - # collection :posts, Post, :author + # def name=(name) + # @attributes[:name] = name + # end # end # - # @person = Person.create :name => "Albert" - # @post = Post.create :content => "Interesting stuff", - # :author => @person - # @comment = Comment.create :content => "Indeed!", :post => @post + def self.attribute(name, cast = nil) + if cast + define_method(name) do + cast[@attributes[name]] + end + else + define_method(name) do + @attributes[name] + end + end + + define_method(:"#{name}=") do |value| + @attributes[name] = value + end + end + + # Declare a counter. All the counters are internally stored in + # a different Redis hash, independent from the one that stores + # the model attributes. Counters are updated with the `incr` and + # `decr` methods, which interact directly with Redis. Their value + # can't be assigned as with regular attributes. # - # @post.comments.first.content - # # => "Indeed!" + # Example: # - # @post.author.name - # # => "Albert" + # class User < Ohm::Model + # counter :points + # end # - # *Important*: Please note that even though a collection is a - # {Ohm::Model::Set set}, you should not add or remove objects from this - # collection directly. + # u = User.create + # u.incr :points # - # @see Ohm::Model.reference - # @param name [Symbol] Name of the collection. - # @param model [Constant] Model where the reference is defined. - # @param reference [Symbol] Reference as defined in the associated - # model. + # Ohm.redis.hget "User:1:counters", "points" + # # => 1 # - # @see file:README.html#collections Collections Explained. - def self.collection(name, model, reference = to_reference) - model = Wrapper.wrap(model) - define_method(name) { - model.unwrap.find(:"#{reference}_id" => send(:id)) - } - end - - # Used by {Ohm::Model.collection} to infer the reference. + # Note: You can't use counters until you save the model. If you + # try to do it, you'll receive an Ohm::MissingID error. # - # @return [Symbol] Representation of this class in an all-lowercase - # format, separated by underscores and demodulized. - def self.to_reference - name.to_s. - match(/^(?:.*::)*(.*)$/)[1]. - gsub(/([a-z\d])([A-Z])/, '\1_\2'). - downcase.to_sym - end - - # @private - def self.define_memoized_method(name, &block) + def self.counter(name) define_method(name) do - @_memo[name] ||= instance_eval(&block) + return 0 if new? + + key[:counters].hget(name).to_i end end - # Allows you to find an {Ohm::Model} instance by its *id*. - # - # @param [#to_s] id The id of the model you want to find. - # @return [Ohm::Model, nil] The instance of Ohm::Model or nil of it does - # not exist. - def self.[](id) - new(:id => id) if id && exists?(id) + # An Ohm::Set wrapper for Model.key[:all]. + def self.all + Set.new(key[:all], key, self) end - # @private Used for conveniently doing [1, 2].map(&Post) for example. - def self.to_proc - lambda { |id| new(:id => id) } + # Syntactic sugar for Model.new(atts).save + def self.create(atts = {}) + new(atts).save end - # Returns a {Ohm::Model::Set set} containing all the members of a given - # class. + # Manipulate the Redis hash of attributes directly. # - # @example + # Example: # - # class Post < Ohm::Model + # class User < Ohm::Model + # attribute :name # end # - # post = Post.create + # u = User.create(name: "John") + # u.key.hget(:name) + # # => John # - # Post.all.include?(post) - # # => true + # For more details see + # http://github.com/soveran/nest # - # post.delete - # - # Post.all.include?(post) - # # => false - def self.all - Ohm::Model::Index.new(key[:all], Wrapper.wrap(self)) + def key + model.key[id] end - # All the defined attributes within a class. - # @see Ohm::Model.attribute - def self.attributes - @@attributes[self] - end - - # All the defined counters within a class. - # @see Ohm::Model.counter - def self.counters - @@counters[self] - end - - # All the defined collections within a class. This will be comprised of - # all {Ohm::Model::Set sets} and {Ohm::Model::List lists} defined within - # your class. + # Initialize a model using a dictionary of attributes. # - # @example - # class Post < Ohm::Model - # set :authors, Author - # list :comments, Comment - # end + # Example: # - # Post.collections == [:authors, :comments] - # # => true + # u = User.new(name: "John") # - # @see Ohm::Model.list - # @see Ohm::Model.set - def self.collections - @@collections[self] + def initialize(atts = {}) + @attributes = {} + @_memo = {} + update_attributes(atts) end - # All the defined indices within a class. - # @see Ohm::Model.index - def self.indices - @@indices[self] - end - - # Convenience method to create and return the newly created object. + # 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 + # Example: # - # class Post < Ohm::Model - # attribute :title - # end + # class User < Ohm::Model; end # - # post = Post.create(:title => "A new post") + # u = User.create + # u.id + # # => 1 # - # @param [Hash] args attribute-value pairs for the object. - # @return [Ohm::Model] an instance of the class you're trying to create. - def self.create(*args) - model = new(*args) - model.create - model + # u.key + # # => User:1 + # + def id + raise MissingID if not defined?(@id) + @id end - # Search across multiple indices and return the intersection of the sets. + # Check for equality by doing the following assertions: # - # @example Finds all the user events for the supplied days - # event1 = Event.create day: "2009-09-09", author: "Albert" - # event2 = Event.create day: "2009-09-09", author: "Benoit" - # event3 = Event.create day: "2009-09-10", author: "Albert" + # 1. That the passed model is of the same type. + # 2. That they represent the same Redis key. # - # [event1] == Event.find(author: "Albert", day: "2009-09-09").to_a - # # => true - def self.find(hash) - unless hash.kind_of?(Hash) - raise ArgumentError, - "You need to supply a hash with filters. " + - "If you want to find by ID, use #{self}[id] instead." - end - - all.find(hash) + def ==(other) + other.kind_of?(model) && other.key == key + rescue MissingID + false end - # Encode a value, making it safe to use as a key. Internally used by - # {Ohm::Model.index_key_for} to canonicalize the indexed values. - # - # @param [#to_s] value Any object you want to be able to use as a key. - # @return [String] A string which is safe to use as a key. - # @see Ohm::Model.index_key_for - def self.encode(value) - Base64.encode64(value.to_s).gsub("\n", "") + # Preload all the attributes of this model from Redis. Used + # internally by `Model::[]`. + def load! + update_attributes(key.hgetall) unless new? + return self end - # Constructor for all subclasses of {Ohm::Model}, which optionally - # takes a Hash of attribute value pairs. + # Read an attribute remotly from Redis. Useful if you want to get + # the most recent value of the attribute and not rely on locally + # cached value. # - # Starting with Ohm 0.1.0, you can use custom ids instead of being forced - # to use auto incrementing numeric ids, but keep in mind that you have - # to pass in the preferred id during object initialization. + # Example: # - # @example + # User.create(name: "A") # - # class User < Ohm::Model - # end + # Session 1 | Session 2 + # --------------|------------------------ + # u = User[1] | u = User[1] + # u.name = "B" | + # u.save | + # | u.name == "A" + # | u.get(:name) == "B" # - # class Post < Ohm::Model - # attribute :title - # reference :user, User - # end + def get(att) + @attributes[att] = key.hget(att) + end + + # Update an attribute value atomically. The best usecase for this + # is when you simply want to update one value. # - # user = User.create - # p1 = Post.new(:title => "Redis", :user_id => user.id) - # p1.save + # Note: This method is dangerous because it doesn't update indices + # and uniques. Use it wisely. The safe equivalent is `update`. # - # p1.user_id == user.id - # # => true - # - # p1.user == user - # # => true - # - # # You can also just pass the actual User object, which is the better - # # way to do it: - # Post.new(:title => "Different way", :user => user).user == user - # # => true - # - # # Let's try and generate custom ids - # p2 = Post.new(:id => "ohm-redis-library", :title => "Lib") - # p2 == Post["ohm-redis-library"] - # # => true - # - # @param [Hash] attrs Attribute value pairs. - def initialize(attrs = {}) - @id = nil - @_memo = {} - @_attributes = Hash.new { |hash, key| hash[key] = read_remote(key) } - update_attributes(attrs) + def set(att, val) + val.to_s.empty? ? key.hdel(att) : key.hset(att, val) + @attributes[att] = val end - # @return [true, false] Whether or not this object has an id. def new? - !@id + !defined?(@id) end - # Create this model if it passes all validations. - # - # @return [Ohm::Model, nil] The newly created object or nil if it fails - # validation. - def create - return unless valid? - initialize_id - - mutex do - create_model_membership - write - add_to_indices - end + # Increment a counter atomically. Internally uses HINCRBY. + def incr(att, count = 1) + key[:counters].hincrby(att, count) end - # Create or update this object based on the state of #new?. - # - # @return [Ohm::Model, nil] The saved object or nil if it fails - # validation. - def save - return create if new? - return unless valid? - - mutex do - write - update_indices - end + # Decrement a counter atomically. Internally uses HINCRBY. + def decr(att, count = 1) + incr(att, -count) end - # Update this object, optionally accepting new attributes. + # Return a value that allows the use of models as hash keys. # - # @param [Hash] attrs Attribute value pairs to use for the updated - # version - # @return [Ohm::Model, nil] The updated object or nil if it fails - # validation. - def update(attrs) - update_attributes(attrs) - save - end - - # Locally update all attributes without persisting the changes. - # Internally used by {Ohm::Model#initialize} and {Ohm::Model#update} - # to set attribute value pairs. + # Example: # - # @param [Hash] attrs Attribute value pairs. - def update_attributes(attrs) - attrs.each do |key, value| - send(:"#{key}=", value) - end - end - - # Delete this object from the _Redis_ datastore, ensuring that all - # indices, attributes, collections, etc are also deleted with it. + # h = {} # - # @return [Ohm::Model] Returns a reference of itself. - def delete - delete_from_indices - delete_attributes(collections) unless collections.empty? - delete_model_membership - self - end - - # Increment the counter denoted by :att. + # u = User.new # - # @param [Symbol] att Attribute to increment. - # @param [Fixnum] count An optional increment step to use. - def incr(att, count = 1) - unless counters.include?(att) - raise ArgumentError, "#{att.inspect} is not a counter." - end - - write_local(att, key.hincrby(att, count)) + # h[:u] = u + # h[:u] == u + # # => true + # + def hash + new? ? super : key.hash end + alias :eql? :== - # Decrement the counter denoted by :att. - # - # @param [Symbol] att Attribute to decrement. - # @param [Fixnum] count An optional decrement step to use. - def decr(att, count = 1) - incr(att, -count) + def attributes + @attributes end - # Export the id and errors of the object. The `to_hash` takes the opposite - # approach of providing all the attributes and instead favors a white - # listed approach. + # Export the ID and the errors of the model. The approach of Ohm + # is to whitelist public attributes, as opposed to exporting each + # (possibly sensitive) attribute. # - # @example + # Example: # - # person = Person.create(:name => "John Doe") - # person.to_hash == { :id => '1' } - # # => true + # class User < Ohm::Model + # attribute :name + # end # - # # if the person asserts presence of name, the errors will be included - # person = Person.create(:name => "John Doe") - # person.name = nil - # person.valid? - # # => false + # u = User.create(name: "John") + # u.to_hash + # # => { id: "1" } # - # person.to_hash == { :id => '1', :errors => [[:name, :not_present]] } - # # => true + # In order to add additional attributes, you can override `to_hash`: # - # # for cases where you want to provide white listed attributes just do: + # class User < Ohm::Model + # attribute :name # - # class Person < Ohm::Model # def to_hash - # super.merge(:name => name) + # super.merge(name: name) # end # end # - # # now we have the name when doing a to_hash - # person = Person.create(:name => "John Doe") - # person.to_hash == { :id => '1', :name => "John Doe" } - # # => true + # u = User.create(name: "John") + # u.to_hash + # # => { id: "1", name: "John" } + # def to_hash attrs = {} attrs[:id] = id unless new? - attrs[:errors] = errors unless errors.empty? - attrs + attrs[:errors] = errors if errors.any? + + return attrs end - # Returns the JSON representation of the {#to_hash} for this object. - # Defining a custom {#to_hash} method will also affect this and return - # a corresponding JSON representation of whatever you have in your - # {#to_hash}. + # Export a JSON representation of the model by encoding `to_hash`. + def to_json(*args) + to_hash.to_json(*args) + end + + # Persist the model attributes and update indices and unique + # indices. The `counter`s and `set`s are not touched during save. # - # @example - # require "json" + # If the model is not valid, nil is returned. Otherwise, the + # persisted model is returned. # - # class Post < Ohm::Model - # attribute :title + # Example: # - # def to_hash - # super.merge(:title => title) + # class User < Ohm::Model + # attribute :name + # + # def validate + # assert_present :name # end # end # - # p1 = Post.create(:title => "Delta Force") - # p1.to_hash == { :id => "1", :title => "Delta Force" } - # # => true + # User.new(name: nil).save + # # => nil # - # p1.to_json == "{\"id\":\"1\",\"title\":\"Delta Force\"}" + # u = User.new(name: "John").save + # u.kind_of?(User) # # => true # - # @return [String] The JSON representation of this object defined in - # terms of {#to_hash}. - def to_json(*args) - to_hash.to_json(*args) + def save(&block) + return if not valid? + save!(&block) end - # Convenience wrapper for {Ohm::Model.attributes}. - def attributes - self.class.attributes - end + # Saves the model without checking for validity. Refer to + # `Model#save` for more details. + def save! + transaction do |t| + t.watch(*_unique_keys) + t.watch(key) if not new? - # Convenience wrapper for {Ohm::Model.counters}. - def counters - self.class.counters - end + t.before do + _initialize_id if new? + end - # Convenience wrapper for {Ohm::Model.collections}. - def collections - self.class.collections - end + t.read do |store| + _verify_uniques + store.existing = key.hgetall + store.uniques = _read_index_type(:uniques) + store.indices = _read_index_type(:indices) + end - # Convenience wrapper for {Ohm::Model.indices}. - def indices - self.class.indices - end + t.write do |store| + model.key[:all].sadd(id) + _delete_uniques(store.existing) + _delete_indices(store.existing) + _save + _save_indices(store.indices) + _save_uniques(store.uniques) + end - # Implementation of equality checking. Equality is defined by two simple - # rules: - # - # 1. They have the same class. - # 2. They have the same key (_Redis_ key e.g. Post:1 == Post:1). - # - # @return [true, false] Whether or not the passed object is equal. - def ==(other) - other.kind_of?(self.class) && other.key == key - rescue MissingID - false - end - alias :eql? :== + yield t if block_given? + end - # Allows you to safely use an instance of {Ohm::Model} as a key in a - # Ruby hash without running into weird scenarios. - # - # @example - # - # class Post < Ohm::Model - # end - # - # h = {} - # p1 = Post.new - # h[p1] = "Ruby" - # h[p1] == "Ruby" - # # => true - # - # p1.save - # h[p1] == "Ruby" - # # => false - # - # @return [Fixnum] An integer representing this object to be used - # as the index for hashes in Ruby. - def hash - new? ? super : key.hash + return self end - # Lock the object before executing the block, and release it once the - # block is done. + # Delete the model, including all the following keys: # - # This is used during {#create} and {#save} to ensure that no race - # conditions occur. + # - <Model>:<id> + # - <Model>:<id>:counters + # - <Model>:<id>:<set name> # - # @see http://code.google.com/p/redis/wiki/SetnxCommand SETNX in the - # Redis Command Reference. - def mutex - lock! - yield - self - ensure - unlock! - end - - # Returns everything, including {Ohm::Model.attributes attributes}, - # {Ohm::Model.collections collections}, {Ohm::Model.counters counters}, - # and the id of this object. + # If the model has uniques or indices, they're also cleaned up. # - # Useful for debugging and for doing irb work. - def inspect - everything = (attributes + collections + counters).map do |att| - value = begin - send(att) - rescue MissingID - nil - end + def delete + transaction do |t| + t.read do |store| + store.existing = key.hgetall + end - [att, value.inspect] - end + t.write do |store| + _delete_uniques(store.existing) + _delete_indices(store.existing) + model.collections.each { |e| key[e].del } + model.key[:all].srem(id) + key[:counters].del + key.del + end - sprintf("#<%s:%s %s>", - self.class, - new? ? "?" : id, - everything.map {|e| e.join("=") }.join(" ") - ) + yield t if block_given? + end end - # Makes the model connect to a different Redis instance. This is useful - # for scaling a large application, where one model can be stored in a - # different Redis instance, and some other groups of models can be - # in another Redis instance. + # Update the model attributes and call save. # - # This approach of splitting models is a lot simpler than doing a - # distributed *Redis* solution and may well be the right solution for - # certain cases. + # Example: # - # @example + # User[1].update(name: "John") # - # class Post < Ohm::Model - # connect :port => 6380, :db => 2 + # # It's the same as: # - # attribute :body - # end + # u = User[1] + # u.update_attributes(name: "John") + # u.save # - # # Since these settings are usually environment-specific, - # # you may want to call this method from outside of the class - # # definition: - # Post.connect(:port => 6380, :db => 2) - # - # @see file:README.html#connecting Ohm.connect options documentation. - def self.connect(options = {}) - Ohm.threaded[self] = nil - @options = options + def update(attributes) + update_attributes(attributes) + save end - # @return [Ohm::Key] A key scoped to the model which uses this object's - # id. - # - # @see http://github.com/soveran/nest The Nest library. - def key - self.class.key[id] + # Write the dictionary of key-value pairs to the model. + def update_attributes(atts) + atts.each { |att, val| send(:"#{att}=", val) } end protected - attr_writer :id + def self.to_reference + name.to_s. + match(/^(?:.*::)*(.*)$/)[1]. + gsub(/([a-z\d])([A-Z])/, '\1_\2'). + downcase.to_sym + end + def self.indices + @indices ||= [] + end - # Write all the attributes and counters of this object. The operation - # is actually a 2-step process: - # - # 1. Delete the current key, e.g. Post:2. - # 2. Set all of the new attributes (using HMSET). - # - # The DEL and HMSET operations are wrapped in a MULTI EXEC block to ensure - # the atomicity of the write operation. - # - # @see http://code.google.com/p/redis/wiki/DelCommand DEL in the - # Redis Command Reference. - # @see http://code.google.com/p/redis/wiki/HmsetCommand HMSET in the - # Redis Command Reference. - # @see http://code.google.com/p/redis/wiki/MultiExecCommand MULTI EXEC - # in the Redis Command Reference. - def write - unless (attributes + counters).empty? - atts = (attributes + counters).inject([]) { |ret, att| - value = send(att).to_s + def self.uniques + @uniques ||= [] + end - ret.push(att, value) if not value.empty? - ret - } + def self.collections + @collections ||= [] + end - db.multi do - key.del - key.hmset(*atts.flatten) if atts.any? - end + def self.filters(dict) + unless dict.kind_of?(Hash) + raise ArgumentError, + "You need to supply a hash with filters. " + + "If you want to find by ID, use #{self}[id] instead." end + + dict.map { |k, v| toindices(k, v) }.flatten end - # Write a single attribute both locally and remotely. It's very important - # to know that this method skips validation checks, therefore you must - # ensure data integrity and validity in your application code. - # - # @param [Symbol, String] att The name of the attribute to write. - # @param [#to_s] value The value of the attribute to write. - # - # @see http://code.google.com/p/redis/wiki/HdelCommand HDEL in the - # Redis Command Reference. - # @see http://code.google.com/p/redis/wiki/HsetCommand HSET in the - # Redis Command Reference. - def write_remote(att, value) - write_local(att, value) + def self.toindices(att, val) + raise IndexNotFound unless indices.include?(att) - if value.to_s.empty? - key.hdel(att) + if val.kind_of?(Enumerable) + val.map { |v| key[:indices][att][v] } else - key.hset(att, value) + [key[:indices][att][val]] end end - # Wraps any missing constants lazily in {Ohm::Model::Wrapper} delaying - # the evaluation of constants until they are actually needed. - # - # @see Ohm::Model::Wrapper - # @see http://en.wikipedia.org/wiki/Lazy_evaluation Lazy evaluation - def self.const_missing(name) - wrapper = Wrapper.new(name) { const_get(name) } - - # Allow others to hook to const_missing. - begin - super(name) - rescue NameError - end - - wrapper + def self.new_id + key[:id].incr end - private + attr_writer :id - # Provides access to the Redis database. This is shared accross all models and instances. - def self.db - return Ohm.redis unless defined?(@options) - - Ohm.threaded[self] ||= Redis.connect(@options) + def transaction + txn = Transaction.new { |t| yield t } + txn.commit(db) end - # Allows you to do key manipulations scoped solely to your class. - def self.key - Key.new(self, db) + def model + self.class end - def self.exists?(id) - key[:all].sismember(id) + def db + model.db end - # The meat of the ID generation code for Ohm. For cases where you want to - # customize ID generation (i.e. use GUIDs or Base62 ids) then you simply - # override this method in your model. - # - # @example - # - # module UUID - # def self.new - # `uuidgen`.strip - # end - # end - # - # class Post < Ohm::Model - # - # private - # def initialize_id - # @id ||= UUID.new - # end - # end - # - def initialize_id - @id ||= self.class.key[:id].incr.to_s + def _initialize_id + @id = model.new_id.to_s end - def db - self.class.db + def _skip_empty(atts) + {}.tap do |ret| + atts.each do |att, val| + ret[att] = send(att).to_s unless val.to_s.empty? + end + end end - def delete_attributes(atts) - db.del(*atts.map { |att| key[att] }) + def _unique_keys + model.uniques.map { |att| model.key[:uniques][att] } end - def create_model_membership - self.class.all << self - end - - def delete_model_membership + def _save key.del - self.class.all.delete(self) + key.hmset(*_skip_empty(attributes).flatten) end - def update_indices - delete_from_indices - add_to_indices + def _verify_uniques + if att = _detect_duplicate + raise UniqueIndexViolation, "#{att} is not unique." + end end - def add_to_indices - indices.each do |att| - next add_to_index(att) unless collection?(send(att)) - send(att).each { |value| add_to_index(att, value) } + def _detect_duplicate + model.uniques.detect do |att| + id = model.key[:uniques][att].hget(send(att)) + id && id != self.id.to_s end end - def collection?(value) - self.class.collection?(value) + def _read_index_type(type) + {}.tap do |ret| + model.send(type).each do |att| + ret[att] = send(att) + end + end end - def self.collection?(value) - value.kind_of?(Enumerable) && - value.kind_of?(String) == false + def _save_uniques(uniques) + uniques.each do |att, val| + model.key[:uniques][att].hset(val, id) + end end - def add_to_index(att, value = send(att)) - index = index_key_for(att, value) - index.sadd(id) - key[:_indices].sadd(index) - end - - def delete_from_indices - key[:_indices].smembers.each do |index| - db.srem(index, id) + def _delete_uniques(atts) + model.uniques.each do |att| + model.key[:uniques][att].hdel(atts[att.to_s]) end - - key[:_indices].del end - # Get the value of a specific attribute. An important fact about - # attributes in Ohm is that they are all loaded lazily. - # - # @param [Symbol] att The attribute you you want to get. - # @return [String] The value of att. - def read_local(att) - @_attributes[att] - end + def _delete_indices(atts) + model.indices.each do |att| + val = atts[att.to_s] - # Write the value of an attribute locally, without persisting it. - # - # @param [Symbol] att The attribute you want to set. - # @param [#to_s] value The value of the attribute you want to set. - def write_local(att, value) - @_attributes[att] = value - end - - # Used internally be the @_attributes hash to lazily load attributes - # when you need them. You may also use this in your code if you know what - # you are doing. - # - # @param [Symbol] att The attribute you you want to get. - # @return [String] The value of att. - def read_remote(att) - unless new? - value = key.hget(att) - value.respond_to?(:force_encoding) ? - value.force_encoding("UTF-8") : - value + if val + model.key[:indices][att][val].srem(id) + end end end - # Read attributes en masse locally. - def read_locals(attrs) - attrs.map do |att| - send(att) + def _save_indices(indices) + indices.each do |att, val| + model.toindices(att, val).each do |index| + index.sadd(id) + end end end + end - # Read attributes en masse remotely. - def read_remotes(attrs) - attrs.map do |att| - read_remote(att) - end - end + class Lua + attr :dir + attr :redis + attr :files + attr :scripts - # Get the index name for a specific index and value pair. The return value - # is an instance of {Ohm::Key}, which you can readily do Redis operations - # on. - # - # @example - # - # class Post < Ohm::Model - # attribute :title - # index :title - # end - # - # post = Post.create(:title => "Foo") - # key = Post.index_key_for(:title, "Foo") - # key == "Post:title:Rm9v" - # key.scard == 1 - # key.smembers == [post.id] - # # => true - # - # @param [Symbol] name The name of the index. - # @param [#to_s] value The value for the index. - # @return [Ohm::Key] A {Ohm::Key key} which you can treat as a string, - # but also do Redis operations on. - def self.index_key_for(name, value) - raise IndexNotFound, name unless indices.include?(name) - key[name][encode(value)] + def initialize(dir, redis) + @dir = dir + @redis = redis + @files = Hash.new { |h, cmd| h[cmd] = read(cmd) } + @scripts = {} end - # Thin wrapper around {Ohm::Model.index_key_for}. - def index_key_for(att, value) - self.class.index_key_for(att, value) + def run_file(file, options) + run(files[file], options) end - # Lock the object so no other instances can modify it. - # This method implements the design pattern for locks - # described at: http://code.google.com/p/redis/wiki/SetnxCommand - # - # @see Model#mutex - def lock! - until key[:_lock].setnx(Time.now.to_f + 0.5) - next unless timestamp = key[:_lock].get - sleep(0.1) and next unless lock_expired?(timestamp) + def run(script, options) + keys = options[:keys] + argv = options[:argv] - break unless timestamp = key[:_lock].getset(Time.now.to_f + 0.5) - break if lock_expired?(timestamp) + begin + redis.evalsha(sha(script), keys.size, *keys, *argv) + rescue RuntimeError + redis.eval(script, keys.size, *keys, *argv) end end - # Release the lock. - # @see Model#mutex - def unlock! - key[:_lock].del + private + def read(file) + File.read("%s/%s.lua" % [dir, file]) end - def lock_expired?(timestamp) - timestamp.to_f < Time.now.to_f + def sha(script) + Digest::SHA1.hexdigest(script) end end end