lib/ohm.rb in ohm-0.1.0.rc6 vs lib/ohm.rb in ohm-0.1.0

- old
+ new

@@ -9,112 +9,339 @@ 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. This is shared accross all models and instances. + # 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. + # + # The better way to access the _Redis_ database and do raw _Redis_ + # commands would be one of the following: + # + # 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. + # + # 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}. + # + # @example + # + # class Post < Ohm::Model + # def comment_ids + # key[:comments].zrange(0, -1) + # end + # + # def add_comment_id(id) + # key[:comments].zadd(Time.now.to_i, id) + # end + # + # def remove_comment_id(id) + # # Let's use the db style here just to demonstrate. + # db.zrem key[:comments], id + # end + # end + # + # Post.key[:latest].sadd(1) + # Post.key[:latest].smembers == ["1"] + # # => true + # + # Post.key[:latest] == "Post:latest" + # # => true + # + # 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) 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 end + # @private Used internally by Ohm for thread safety. def self.threaded Thread.current[:ohm] ||= {} end - # Connect to a redis database. + # Connect to a _Redis_ database. # - # @param options [Hash] options to create a message with. - # @option options [#to_s] :host ('127.0.0.1') Host of the redis database. - # @option options [#to_s] :port (6379) Port number. - # @option options [#to_s] :db (0) Database number. - # @option options [#to_s] :timeout (0) Database timeout in seconds. + # It is also worth mentioning that you can pass in a *URI* e.g. + # + # Ohm.connect :url => "redis://127.0.0.1:6379/0" + # + # Note that the value *0* refers to the database number for the given + # _Redis_ instance. + # + # Also you can use {Ohm.connect} without any arguments. The behavior will + # be as follows: + # + # # Connect to redis://127.0.0.1:6379/0 + # Ohm.connect + # + # # Connect to redis://10.0.0.100:22222/5 + # ENV["REDIS_URL"] = "redis://10.0.0.100:22222/5" + # Ohm.connect + # + # @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 end - # Return a connection to Redis. + # @private Return a connection to Redis. # - # This is a wapper around Redis.connect(options) + # 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. + # 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. 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 + # 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 # Wraps a model name for lazy evaluation. class Wrapper < BasicObject + + # 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 class << self def method_missing(method_id, *args) - ::Kernel.raise ::NoMethodError, "You tried to call #{@name}##{method_id}, but #{@name} is not defined on #{@caller}" + ::Kernel.raise( + ::NoMethodError, + "You tried to call %s#%s, but %s is not defined on %s" % [ + @name, method_id, @name, @caller + ] + ) end end 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 } end + # Evaluates the passed block in {Ohm::Model::Wrapper#initialize}. + # + # @return [Class] The wrapped class. def unwrap @block.call end + # Since {Ohm::Model::Wrapper} is a subclass of _BasicObject_ we have + # to manually declare this. + # + # @return [Wrapper] def class Wrapper end + # @return [String] A string describing this lazy object. def inspect "<Wrapper for #{@name} (in #{@caller})>" end 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 + # 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 end + # Adds an instance of {Ohm::Model} to this collection. + # + # @param [#id] model A model with an ID. def add(model) self << model end - def sort(_options = {}) + # 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 - options = _options.dup - options[:start] ||= 0 - options[:limit] = [options[:start], options[:limit]] if options[:limit] + opts = options.dup + opts[:start] ||= 0 + opts[:limit] = [opts[:start], opts[:limit]] if opts[:limit] - key.sort(options).map(&model) + key.sort(opts).map(&model) end # Sort the model instances by the given attribute. # # @example Sorting elements by name: @@ -123,146 +350,500 @@ # User.create :name => "A" # # user = User.all.sort_by(:name, :order => "ALPHA").first # user.name == "A" # # => true - def sort_by(att, _options = {}) + # + # @see file:README.html#sorting Sorting in the README. + def sort_by(att, options = {}) return [] unless key.exists - options = _options.dup - options.merge!(:by => model.key["*->#{att}"]) + opts = options.dup + opts.merge!(:by => model.key["*->#{att}"]) - if options[:get] - key.sort(options.merge(:get => model.key["*->#{options[:get]}"])) + if opts[:get] + key.sort(opts.merge(:get => model.key["*->#{opts[:get]}"])) else - sort(options) + sort(opts) 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 + # 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 + # @return [true, false] Whether or not this collection is empty. def empty? !key.exists end + # @return [Array] Array representation of this collection. def to_a all end 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[id]) } end + # 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) end - def << model + # 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 << + # 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 + # 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 + # 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 + # 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 + # 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) end - def first(_options = {}) - options = _options.dup - options.merge!(:limit => 1) + # 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) - if options[:by] - sort_by(options.delete(:by), options).first + if opts[:by] + sort_by(opts.delete(:by), opts).first else - sort(options).first + sort(opts).first end 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 inspect "#<Set (#{model}): #{key.smembers.inspect}>" end protected - + # @private def apply(operation, key, source, target) target.send(operation, key, *source) Set.new(target, Wrapper.wrap(model)) 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) # Yes, Array() is different in 1.8.x. + values = [values] unless values.kind_of?(Array) values.each do |v| keys << model.index_key_for(key, v) end end end end 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 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[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 + # `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[id] } when Pattern[Range, nil] then @@ -270,92 +851,215 @@ 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 end + # All validations which need to access the _Redis_ database goes 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. + # 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(attrs) - result = db.sinter(*Array(attrs).map { |att| index_key_for(att, send(att)) }) - assert result.empty? || !new? && result.include?(id.to_s), [attrs, :not_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 end include Validations + # Raised when you try and get the *id* of an {Ohm::Model} without an id. + # + # class Post < Ohm::Model + # list :comments, Comment + # end + # + # class Comment < Ohm::Model + # end + # + # ex = nil + # begin + # Post.new.id + # rescue Exception => e + # ex = e + # end + # + # ex.kind_of?(Ohm::Model::MissingID) + # # => 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. + # + # # following from the example above: + # post = Post.new + # + # ex = nil + # begin + # post.comments << Comment.new + # rescue Exception => e + # ex = e + # end + # + # ex.kind_of?(Ohm::Model::MissingID) + # # => true + # + # # Correct way: + # post = Post.new + # + # 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." + "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. + # + # class Post < Ohm::Model + # attribute :title + # end + # + # post = Post.create(:title => "Ohm") + # + # ex = nil + # begin + # Post.find(:title => "Ohm") + # rescue Exception => e + # ex = e + # end + # + # ex.kind_of?(Ohm::Model::IndexNotFound) + # # => true + # + # To correct this problem, simply define a _:title_ *index* in your class. + # + # class Post < Ohm::Model + # attribute :title + # index :title + # end class IndexNotFound < Error def initialize(att) @att = att end def message "Index #{@att.inspect} not found." end end - @@attributes = Hash.new { |hash, key| hash[key] = [] } + @@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] = [] } + @@counters = Hash.new { |hash, key| hash[key] = [] } + @@indices = Hash.new { |hash, key| hash[key] = [] } - attr_writer :id - def id @id or raise MissingID 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. + # 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. # + # If you're looking to have typecasting built in, you may want to look at + # Ohm::Typecast in Ohm::Contrib. + # # @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 @@ -364,63 +1068,89 @@ end attributes << name unless attributes.include?(name) end - # Defines a counter attribute for the model. This attribute can't be assigned, only incremented - # or decremented. It will be zero by default. + # Defines a counter attribute for the model. This attribute can't be + # assigned, only incremented or decremented. It will be zero by default. # - # @param name [Symbol] Name of the counter. + # @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. + # 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. # - # @param name [Symbol] Name of the list. + # @example + # + # class Post < Ohm::Model + # list :comments, Comment + # end + # + # class Comment < Ohm::Model + # end + # + # # WRONG!!! + # post = Post.new + # post.comments << Comment.create + # + # # 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 retrival order is irrelevant, and - # operations like union, join, and membership checks are important. + # 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 name [Symbol] Name of the set. + # @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) end # Creates an index (a set) that will be used for finding instances. # - # If you want to find a model instance by some attribute value, then an index for that - # attribute must exist. + # If you want to find a model instance by some attribute value, then an + # index for that attribute must exist. # # @example + # # class User < Ohm::Model # attribute :email # index :email # end # # # Now this is possible: - # User.find email: "ohm@example.com" + # User.find :email => "ohm@example.com" # - # @param name [Symbol] Name of the attribute to be indexed. + # @param [Symbol] name Name of the attribute to be indexed. def self.index(att) indices << att unless indices.include?(att) end # Define a reference to another object. # # @example + # # class Comment < Ohm::Model # attribute :content # reference :post, Post # end # @@ -444,11 +1174,12 @@ # @comment.post = nil # # @comment.post # # => nil # - # @see Ohm::Model::collection + # @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=" @@ -474,12 +1205,12 @@ @_memo.delete(name) write_local(reader, value) end end - # Define a collection of objects which have a {Ohm::Model::reference reference} - # to this model. + # Define a collection of objects which have a + # {Ohm::Model.reference reference} to this model. # # class Comment < Ohm::Model # attribute :content # reference :post, Post # end @@ -497,69 +1228,140 @@ # # you need to specify it in the third param. # collection :posts, Post, :author # end # # @person = Person.create :name => "Albert" - # @post = Post.create :content => "Interesting stuff", :author => @person + # @post = Post.create :content => "Interesting stuff", + # :author => @person # @comment = Comment.create :content => "Indeed!", :post => @post # # @post.comments.first.content # # => "Indeed!" # # @post.author.name # # => "Albert" # - # *Important*: please note that even though a collection is a {Ohm::Set Set}, - # you should not add or remove objects from this collection directly. + # *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. # - # @see Ohm::Model::reference + # @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. + # @param reference [Symbol] Reference as defined in the associated + # model. + # + # @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)) } + define_method(name) { + model.unwrap.find(:"#{reference}_id" => send(:id)) + } end + # Used by {Ohm::Model.collection} to infer the reference. + # + # @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 + 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) define_method(name) do @_memo[name] ||= instance_eval(&block) 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) end + # @private Used for conveniently doing [1, 2].map(&Post) for example. def self.to_proc Proc.new { |id| self[id] } end + # Returns a {Ohm::Model::Set set} containing all the members of a given + # class. + # + # @example + # + # class Post < Ohm::Model + # end + # + # post = Post.create + # + # Post.all.include?(post) + # # => true + # + # post.delete + # + # Post.all.include?(post) + # # => false def self.all Ohm::Model::Index.new(key[:all], Wrapper.wrap(self)) 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. + # + # @example + # class Post < Ohm::Model + # set :authors, Author + # list :comments, Comment + # end + # + # Post.collections == [:authors, :comments] + # # => true + # + # @see Ohm::Model.list + # @see Ohm::Model.set def self.collections @@collections[self] 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. + # + # @example + # + # class Post < Ohm::Model + # attribute :title + # end + # + # post = Post.create(:title => "A new post") + # + # @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 end @@ -569,31 +1371,86 @@ # @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" # - # assert_equal [event1], Event.find(author: "Albert", day: "2009-09-09") + # [event1] == Event.find(author: "Albert", day: "2009-09-09").to_a + # # => true def self.find(hash) - raise ArgumentError, "You need to supply a hash with filters. If you want to find by ID, use #{self}[id] instead." unless hash.kind_of?(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) 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", "") end + # Constructor for all subclasses of {Ohm::Model}, which optionally + # takes a Hash of attribute value pairs. + # + # 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 + # + # class User < Ohm::Model + # end + # + # class Post < Ohm::Model + # attribute :title + # reference :user, User + # end + # + # user = User.create + # p1 = Post.new(:title => "Redis", :user_id => user.id) + # p1.save + # + # 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) end + # @return [true, false] Whether or not this object has an id. def new? !@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 @@ -601,56 +1458,80 @@ write add_to_indices end 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 end + # Update this object, optionally accepting new attributes. + # + # @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. + # + # @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. + # + # @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. # - # @param att [Symbol] Attribute to increment. + # @param [Symbol] att Attribute to increment. + # @param [Fixnum] count An optional increment step to use. def incr(att, count = 1) - raise ArgumentError, "#{att.inspect} is not a counter." unless counters.include?(att) + unless counters.include?(att) + raise ArgumentError, "#{att.inspect} is not a counter." + end + write_local(att, key.hincrby(att, count)) end # Decrement the counter denoted by :att. # - # @param att [Symbol] Attribute to decrement. + # @param [Symbol] att Attribute to decrement. + # @param [Fixnum] count An optional decrement step to use. def decr(att, count = 1) incr(att, -count) 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. + # approach of providing all the attributes and instead favors a white + # listed approach. # # @example # # person = Person.create(:name => "John Doe") # person.to_hash == { :id => '1' } @@ -682,50 +1563,118 @@ attrs[:id] = id unless new? attrs[:errors] = errors unless errors.empty? 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}. + # + # @example + # require "json" + # + # class Post < Ohm::Model + # attribute :title + # + # def to_hash + # super.merge(:title => title) + # end + # end + # + # p1 = Post.create(:title => "Delta Force") + # p1.to_hash == { :id => "1", :title => "Delta Force" } + # # => true + # + # p1.to_json == "{\"id\":\"1\",\"title\":\"Delta Force\"}" + # # => true + # + # @return [String] The JSON representation of this object defined in + # terms of {#to_hash}. def to_json(*args) to_hash.to_json(*args) end + # Convenience wrapper for {Ohm::Model.attributes}. def attributes self.class.attributes end + # Convenience wrapper for {Ohm::Model.counters}. def counters self.class.counters end + # Convenience wrapper for {Ohm::Model.collections}. def collections self.class.collections end + # Convenience wrapper for {Ohm::Model.indices}. def indices self.class.indices 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? :== + # 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 end - # Lock the object before executing the block, and release it once the block is done. + # Lock the object before executing the block, and release it once the + # block is done. + # + # This is used during {#create} and {#save} to ensure that no race + # conditions occur. + # + # @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. + # + # Useful for debugging and for doing irb work. def inspect everything = (attributes + collections + counters).map do |att| value = begin send(att) rescue MissingID @@ -733,15 +1682,26 @@ end [att, value.inspect] end - "#<#{self.class}:#{new? ? "?" : id} #{everything.map {|e| e.join("=") }.join(" ")}>" + sprintf("#<%s:%s %s>", + self.class, + new? ? "?" : id, + everything.map {|e| e.join("=") }.join(" ") + ) end - # Makes the model connect to a different Redis instance. + # 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. # + # 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 # # class Post < Ohm::Model # connect :port => 6380, :db => 2 # @@ -751,20 +1711,41 @@ # # 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) self.db = Ohm.connection(*options) end protected + attr_writer :id + # @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] 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 @@ -777,20 +1758,36 @@ key.hmset(*atts.flatten) if atts.any? end end 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) if value.to_s.empty? key.hdel(att) else key.hset(att, value) 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 @@ -810,18 +1807,39 @@ def self.db=(connection) Ohm.threaded[self] = connection end + # Allows you to do key manipulations scoped solely to your class. def self.key Key.new(self, db) end def self.exists?(id) key[:all].sismember(id) 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 end def db @@ -874,44 +1892,84 @@ 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 + # 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 end end + # Read attributes en masse locally. def read_locals(attrs) attrs.map do |att| send(att) end end + # Read attributes en masse remotely. def read_remotes(attrs) attrs.map do |att| read_remote(att) end end - def self.index_key_for(att, value) - raise IndexNotFound, att unless indices.include?(att) - key[att][encode(value)] + # 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)] end + # Thin wrapper around {Ohm::Model.index_key_for}. def index_key_for(att, value) self.class.index_key_for(att, value) end # Lock the object so no other instances can modify it. @@ -933,10 +1991,11 @@ # @see Model#mutex def unlock! key[:_lock].del end - def lock_expired? timestamp + def lock_expired?(timestamp) timestamp.to_f < Time.now.to_f end end end +