candy 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile CHANGED
@@ -5,22 +5,20 @@ begin
5
5
  require 'jeweler'
6
6
  Jeweler::Tasks.new do |gem|
7
7
  gem.name = "candy"
8
- gem.summary = %Q{The simplest MongoDB ORM}
8
+ gem.summary = %Q{Transparent persistence for MongoDB}
9
9
  gem.description = <<DESCRIPTION
10
- Candy is a lightweight ORM for the MongoDB database. If MongoMapper is Rails, Candy is Sinatra.
11
- It provides a module you mix into any class, enabling the class to connect to Mongo on its own
12
- and push its objects into a collection. Candied objects act like OpenStructs, allowing attributes
13
- to be defined and updated in Mongo immediately without having to be declared in the class.
14
- Mongo's atomic operators are used whenever possible, and a smart serializer (Candy::Wrapper)
15
- converts almost any object for assignment to any attribute.
10
+ Candy provides simple, transparent object persistence for the MongoDB database. Classes that
11
+ include Candy modules save all properties to Mongo automatically, can be recursively embedded,
12
+ and can retrieve records with chainable open-ended class methods, eliminating the need for
13
+ method calls like 'save' and 'find.'
16
14
  DESCRIPTION
17
15
 
18
16
  gem.email = "sfeley@gmail.com"
19
17
  gem.homepage = "http://github.com/SFEley/candy"
20
18
  gem.authors = ["Stephen Eley"]
21
- gem.add_dependency "mongo", ">= 0.18"
19
+ gem.add_dependency "mongo", ">= 0.19.1"
22
20
  gem.add_development_dependency "rspec", ">= 1.2.9"
23
- gem.add_development_dependency "yard", ">= 0"
21
+ # gem.add_development_dependency "yard", ">= 0"
24
22
  gem.add_development_dependency "mocha", ">= 0.9.8"
25
23
  # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
26
24
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.2.1
@@ -5,53 +5,70 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{candy}
8
- s.version = "0.1.0"
8
+ s.version = "0.2.1"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Stephen Eley"]
12
- s.date = %q{2010-02-16}
13
- s.description = %q{Candy is a lightweight ORM for the MongoDB database. If MongoMapper is Rails, Candy is Sinatra.
14
- It provides a module you mix into any class, enabling the class to connect to Mongo on its own
15
- and push its objects into a collection. Candied objects act like OpenStructs, allowing attributes
16
- to be defined and updated in Mongo immediately without having to be declared in the class.
17
- Mongo's atomic operators are used whenever possible, and a smart serializer (Candy::Wrapper)
18
- converts almost any object for assignment to any attribute.
12
+ s.date = %q{2010-04-05}
13
+ s.description = %q{Candy provides simple, transparent object persistence for the MongoDB database. Classes that
14
+ include Candy modules save all properties to Mongo automatically, can be recursively embedded,
15
+ and can retrieve records with chainable open-ended class methods, eliminating the need for
16
+ method calls like 'save' and 'find.'
19
17
  }
20
18
  s.email = %q{sfeley@gmail.com}
21
19
  s.extra_rdoc_files = [
22
- "LICENSE",
20
+ "LICENSE.markdown",
23
21
  "README.markdown"
24
22
  ]
25
23
  s.files = [
26
24
  ".document",
27
25
  ".gitignore",
28
- "LICENSE",
26
+ "HISTORY.markdown",
27
+ "LICENSE.markdown",
29
28
  "README.markdown",
30
29
  "Rakefile",
31
30
  "VERSION",
32
31
  "candy.gemspec",
33
32
  "lib/candy.rb",
33
+ "lib/candy/array.rb",
34
+ "lib/candy/collection.rb",
34
35
  "lib/candy/crunch.rb",
36
+ "lib/candy/embeddable.rb",
35
37
  "lib/candy/exceptions.rb",
38
+ "lib/candy/factory.rb",
39
+ "lib/candy/hash.rb",
40
+ "lib/candy/piece.rb",
36
41
  "lib/candy/qualified_const_get.rb",
37
42
  "lib/candy/wrapper.rb",
43
+ "spec/candy/array_spec.rb",
44
+ "spec/candy/collection_spec.rb",
38
45
  "spec/candy/crunch_spec.rb",
46
+ "spec/candy/hash_spec.rb",
47
+ "spec/candy/piece_spec.rb",
39
48
  "spec/candy/wrapper_spec.rb",
40
49
  "spec/candy_spec.rb",
41
50
  "spec/spec.opts",
42
51
  "spec/spec.watchr",
43
- "spec/spec_helper.rb"
52
+ "spec/spec_helper.rb",
53
+ "spec/support/kitkat_fixture.rb",
54
+ "spec/support/zagnuts_fixture.rb"
44
55
  ]
45
56
  s.homepage = %q{http://github.com/SFEley/candy}
46
57
  s.rdoc_options = ["--charset=UTF-8"]
47
58
  s.require_paths = ["lib"]
48
- s.rubygems_version = %q{1.3.5}
49
- s.summary = %q{The simplest MongoDB ORM}
59
+ s.rubygems_version = %q{1.3.6}
60
+ s.summary = %q{Transparent persistence for MongoDB}
50
61
  s.test_files = [
51
- "spec/candy/crunch_spec.rb",
62
+ "spec/candy/array_spec.rb",
63
+ "spec/candy/collection_spec.rb",
64
+ "spec/candy/crunch_spec.rb",
65
+ "spec/candy/hash_spec.rb",
66
+ "spec/candy/piece_spec.rb",
52
67
  "spec/candy/wrapper_spec.rb",
53
68
  "spec/candy_spec.rb",
54
- "spec/spec_helper.rb"
69
+ "spec/spec_helper.rb",
70
+ "spec/support/kitkat_fixture.rb",
71
+ "spec/support/zagnuts_fixture.rb"
55
72
  ]
56
73
 
57
74
  if s.respond_to? :specification_version then
@@ -59,20 +76,17 @@ converts almost any object for assignment to any attribute.
59
76
  s.specification_version = 3
60
77
 
61
78
  if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
62
- s.add_runtime_dependency(%q<mongo>, [">= 0.18"])
79
+ s.add_runtime_dependency(%q<mongo>, [">= 0.19.1"])
63
80
  s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
64
- s.add_development_dependency(%q<yard>, [">= 0"])
65
81
  s.add_development_dependency(%q<mocha>, [">= 0.9.8"])
66
82
  else
67
- s.add_dependency(%q<mongo>, [">= 0.18"])
83
+ s.add_dependency(%q<mongo>, [">= 0.19.1"])
68
84
  s.add_dependency(%q<rspec>, [">= 1.2.9"])
69
- s.add_dependency(%q<yard>, [">= 0"])
70
85
  s.add_dependency(%q<mocha>, [">= 0.9.8"])
71
86
  end
72
87
  else
73
- s.add_dependency(%q<mongo>, [">= 0.18"])
88
+ s.add_dependency(%q<mongo>, [">= 0.19.1"])
74
89
  s.add_dependency(%q<rspec>, [">= 1.2.9"])
75
- s.add_dependency(%q<yard>, [">= 0"])
76
90
  s.add_dependency(%q<mocha>, [">= 0.9.8"])
77
91
  end
78
92
  end
@@ -1,140 +1,13 @@
1
- require 'candy/exceptions'
2
- require 'candy/crunch'
3
- require 'candy/wrapper'
1
+ # encoding: utf-8
4
2
 
5
- # Mix me into your classes and Mongo will like them!
6
- module Candy
7
-
8
- module ClassMethods
9
- include Crunch::ClassMethods
10
-
11
- attr_reader :stamp_create, :stamp_update
12
-
13
- # Retrieves an object from Mongo by its ID and returns it. Returns nil if the ID isn't found in Mongo.
14
- def find(id)
15
- if collection.find_one(id)
16
- self.new({:_candy => id})
17
- end
18
- end
19
-
20
- # Retrieves a single object from Mongo by its search attributes, or nil if it can't be found.
21
- def first(conditions={})
22
- options = extract_options(conditions)
23
- if record = collection.find_one(conditions, options)
24
- find(record["_id"])
25
- else
26
- nil
27
- end
28
- end
29
-
30
- # Retrieves all objects matching the search attributes as an Enumerator (even if it's an empty one).
31
- # The option fields from Mongo::Collection#find can be passed as well, and will be extracted from the
32
- # condition set if they're found.
33
- def all(conditions={})
34
- options = extract_options(conditions)
35
- cursor = collection.find(conditions, options)
36
- Enumerator.new do |yielder|
37
- while record = cursor.next_document do
38
- yielder.yield find(record["_id"])
39
- end
40
- end
41
- end
42
-
43
- # Configures objects to set `created_at` and `updated_at` properties at the appropriate times.
44
- # Pass `:create` or `:update` to limit it to just one or the other. Defaults to both.
45
- def timestamp(*args)
46
- args = [:create, :update] if args.empty?
47
- @stamp_create = args.include?(:create)
48
- @stamp_update = args.include?(:update)
49
- end
50
-
51
- private
52
- # Returns a hash of options matching those enabled in Mongo::Collection#find, if any of them exist
53
- # in the set of search conditions.
54
- def extract_options(conditions)
55
- options = {:fields => []}
56
- [:fields, :skip, :limit, :sort, :hint, :snapshot, :timeout].each do |option|
57
- options[option] = conditions.delete(option) if conditions[option]
58
- end
59
- options
60
- end
61
-
62
- end
63
-
64
- module InstanceMethods
65
- include Crunch::InstanceMethods
66
-
67
- # We push ourselves into the DB before going on with our day.
68
- def initialize(*args, &block)
69
- @__candy = check_for_candy(args) ||
70
- self.class.collection.insert(self.class.stamp_create ? {:created_at => Time.now.utc} : {})
71
- super
72
- end
3
+ # Make me one with everything...
4
+ Dir[File.join(File.dirname(__FILE__), 'candy', '*.rb')].each {|f| require f}
73
5
 
74
- # Shortcut to the document ID.
75
- def id
76
- @__candy
77
- end
78
-
79
- # Candy's magic ingredient. Assigning to any unknown attribute will push that value into the Mongo collection.
80
- # Retrieving any unknown attribute will return that value from this record in the Mongo collection.
81
- def method_missing(name, *args, &block)
82
- if name =~ /(.*)=$/ # We're assigning
83
- set $1, Wrapper.wrap(args[0])
84
- else
85
- Wrapper.unwrap(self.class.collection.find_one(@__candy, :fields => [name.to_s])[name.to_s])
86
- end
87
- end
88
-
89
- # Given either a property/value pair or a hash (which can contain several property/value pairs), sets those
90
- # values in Mongo using the atomic $set. The first form is functionally equivalent to simply using the
91
- # magic assignment operator; i.e., `me.set(:foo, 'bar')` is the same as `me.foo = bar`.
92
- def set(*args)
93
- if args.length > 1 # This is the property/value form
94
- hash = {args[0] => args[1]}
95
- else
96
- hash = args[0]
97
- end
98
- hash.merge!(:updated_at => Time.now.utc) if self.class.stamp_update
99
- update '$set' => hash
100
- end
101
-
102
- # Given a Candy array property, appends a value or values to the end of that array using the atomic $push.
103
- # (Note that we don't actually check the property to make sure it's an array and $push is valid. If it isn't,
104
- # this operation will silently fail.)
105
- def push(property, *values)
106
- values.each do |value|
107
- update '$push' => {property => Wrapper.wrap(value)}
108
- end
109
- end
110
-
111
- # Given a Candy integer property, increments it by the given value (which defaults to 1) using the atomic $inc.
112
- # (Note that we don't actually check the property to make sure it's an integer and $inc is valid. If it isn't,
113
- # this operation will silently fail.)
114
- def inc(property, increment=1)
115
- update '$inc' => {property => increment}
116
- end
117
- private
118
-
119
- # Returns the secret decoder ring buried in the arguments to "new"
120
- def check_for_candy(args)
121
- if (candidate = args.pop).is_a?(Hash) and candidate[:_candy]
122
- candidate[:_candy]
123
- else # This must not be for us, so put it back
124
- args.push candidate if candidate
125
- nil
126
- end
127
- end
128
-
129
- # Updates the Mongo document with the given element or elements.
130
- def update(element)
131
- self.class.collection.update({"_id" => @__candy}, element)
132
- end
133
-
134
- end
135
-
136
- def self.included(receiver)
137
- receiver.extend ClassMethods
138
- receiver.send :include, InstanceMethods
139
- end
140
- end
6
+ # Special keys for Candy metadata in the Mongo store. We try to keep these to a minimum,
7
+ # and we're using moderately obscure Unicode symbols to reduce the odds of collisions.
8
+ # If by some strange happenstance you might have single-character keys in your Mongo
9
+ # collections that use the 'CIRCLED LATIN SMALL LETTER' Unicode set, you may need to
10
+ # change these constants. Just be consistent about it if you want to use embedded
11
+ # documents in Candy.
12
+ CLASS_KEY = 'ⓒ'.to_sym
13
+ EMBED_KEY = 'ⓔ'.to_sym
@@ -0,0 +1,74 @@
1
+ require 'candy/crunch'
2
+ require 'candy/embeddable'
3
+
4
+ module Candy
5
+
6
+ # An array-like object that saves itself to a parent Candy::Piece object. MongoDB's atomic
7
+ # array operators are used extensively to perform concurrency-friendly updates of individual
8
+ # array elements without rewriting the whole array.
9
+ class CandyArray
10
+ include Crunch
11
+ include Embeddable
12
+
13
+ # Included for purposes of 'embeddable' compatbility, but does nothing except pass its
14
+ # parameters to the new object. Since you can't save an array on its own anyway, there's
15
+ # no need to flag it as "don't save."
16
+ def self.embed(*args, &block)
17
+ self.new(*args, &block)
18
+ end
19
+
20
+ # Sets the initial array state.
21
+ def initialize(*args, &block)
22
+ @__candy = args
23
+ end
24
+
25
+ # Set a value at a specified index in our array. Note that this operation _does not_ confirm that the
26
+ # array in Mongo still matches our current state. If concurrent updates have happened, you might end up
27
+ # overwriting something other than what you thought.
28
+ def []=(index, val)
29
+ property = embeddify(val)
30
+ @__candy_parent.set embedded(index => property)
31
+ self.candy[index] = property
32
+ end
33
+
34
+ # Retrieves the value from our internal array.
35
+ def [](index)
36
+ candy[index]
37
+ end
38
+
39
+ # Appends a value to our array.
40
+ def <<(val)
41
+ property = embeddify(val)
42
+ @__candy_parent.operate :push, @__candy_parent_key => property
43
+ self.candy << property
44
+ end
45
+ alias_method :push, :<<
46
+
47
+ # Pops the front off the MongoDB array and returns it, then resyncs the array.
48
+ # (Thus supporting real-time concurrency for queue-like behavior.)
49
+ def shift(n=1)
50
+ doc = @__candy_parent.findAndModify({"_id" => @__candy_parent.id}, {'$pop' => {@__candy_parent_key => -1}})
51
+ @__candy = doc['value'][@__candy_parent_key.to_s]
52
+ @__candy.shift
53
+ end
54
+
55
+ # Returns the array of memoized values.
56
+ def candy
57
+ @__candy ||= []
58
+ end
59
+ alias_method :to_mongo, :candy
60
+ alias_method :to_ary, :candy
61
+
62
+ # Array equality.
63
+ def ==(val)
64
+ self.to_ary == val
65
+ end
66
+
67
+ # Array length.
68
+ def length
69
+ self.to_ary.length
70
+ end
71
+ alias_method :size, :length
72
+
73
+ end
74
+ end
@@ -0,0 +1,151 @@
1
+ require 'candy/crunch'
2
+ require 'candy/hash'
3
+
4
+ module Candy
5
+
6
+ # Handles Mongo queries for cursors upon a particular Mongo collection.
7
+ module Collection
8
+ FIND_OPTIONS = [:fields, :skip, :limit, :sort, :hint, :snapshot, :timeout]
9
+ UP_SORTS = [Mongo::ASCENDING, 'ascending', 'asc', :ascending, :asc, 1, :up]
10
+ DOWN_SORTS = [Mongo::DESCENDING, 'descending', 'desc', :descending, :desc, -1, :down]
11
+
12
+ include Enumerable
13
+
14
+ module ClassMethods
15
+ include Crunch::ClassMethods
16
+
17
+ attr_reader :_candy_piece
18
+
19
+ # Sets the collection that all queries run against, qualified by
20
+ # the namespace of the current class. (I.e., if this class is
21
+ # BigModule::LittleModule::People, `collects :person` will look for
22
+ # a collection named "BigModule::LittleModule::Person".) You can also
23
+ # specify a class that includes Candy::Piece that will be instantiated
24
+ # for all found records. Otherwise the collection name is used as a
25
+ # default, and CandyHash is a fallback.
26
+ def collects(collection, piece = nil)
27
+ collectible = namespace + camelcase(collection)
28
+ piecemeal = (piece ? namespace + camelcase(piece) : collectible)
29
+ self.collection = collectible
30
+ @_candy_piece = Kernel.qualified_const_get(piecemeal) || CandyHash
31
+ end
32
+
33
+ def all(options={})
34
+ self.new(options)
35
+ end
36
+
37
+ def method_missing(name, *args, &block)
38
+ coll = self.new
39
+ coll.send(name, *args, &block)
40
+ end
41
+ private
42
+ # Retrieves the 'BlahModule::BleeModule::' part of the class name, so that we
43
+ # can put other things in the same namespace.
44
+ def namespace
45
+ name[/^.*::/] || '' # Hooray for greedy matching
46
+ end
47
+
48
+ # Modified from ActiveSupport (http://rubyonrails.org)
49
+ def camelcase(stringy)
50
+ stringy.to_s.gsub(/(?:^|_)(.)/) {$1.upcase}
51
+ end
52
+
53
+ # Creates a method in the same namespace as the included class that points to
54
+ # 'all', for easier semantics.
55
+ def self.extended(receiver)
56
+ Factory.magic_method(receiver, 'all', 'conditions={}')
57
+ end
58
+
59
+ end # Here endeth the ClassMethods module
60
+
61
+ def initialize(*args, &block)
62
+ conditions = args.pop || {}
63
+ super
64
+ @_candy_query = {}
65
+ if conditions.is_a?(Hash)
66
+ @_candy_options = {:fields => '_id'}.merge(extract_options(conditions))
67
+ @_candy_query.merge!(conditions)
68
+ else
69
+ @_candy_options = {:fields => '_id'}
70
+ end
71
+ refresh_cursor
72
+ end
73
+
74
+ def method_missing(name, *args, &block)
75
+ if @_candy_cursor.respond_to?(name)
76
+ @_candy_cursor.send(name, *args, &block)
77
+ elsif FIND_OPTIONS.include?(name)
78
+ @_candy_options[name] = args.shift
79
+ refresh_cursor
80
+ self
81
+ else
82
+ @_candy_query[name] = args.shift
83
+ @_candy_query.merge!(args) if args.is_a?(Hash)
84
+ refresh_cursor
85
+ self
86
+ end
87
+ end
88
+
89
+
90
+ # Makes our collection enumerable. This relies heavily on Mongo::Cursor methods --
91
+ # we only reimplement it so that the objects we return can be Candy objects.
92
+ def each
93
+ while this = @_candy_cursor.next_document
94
+ yield self.class._candy_piece.new(this)
95
+ end
96
+ end
97
+
98
+ # Get our next document as a Candy object, if there is one.
99
+ def next
100
+ if this = @_candy_cursor.next_document
101
+ self.class._candy_piece.new(this)
102
+ end
103
+ end
104
+
105
+ # Determines the sort order for this collection, with somewhat simpler semantics than
106
+ # the MongoDB options. Each value is either a field name (which defaults to ascending sort)
107
+ # or a direction (which modifies the field name immediately prior) or a two-element array of
108
+ # same. Direction can be :up or :down in addition to Mongo's accepted values of :ascending,
109
+ # 'asc', 1, etc.
110
+ #
111
+ # As an added bonus, sorts can also be chained. If you call #sort more than once then all
112
+ # sorts will be applied in the order in which you called them.
113
+ def sort(*fields)
114
+ @_candy_sort ||= []
115
+ temp_sort = []
116
+ until fields.flatten.empty?
117
+ this = fields.pop # We're going backwards so that we can test for modifiers
118
+ if UP_SORTS.include?(this)
119
+ temp_sort.unshift [fields.pop, Mongo::ASCENDING]
120
+ elsif DOWN_SORTS.include?(this)
121
+ temp_sort.unshift [fields.pop, Mongo::DESCENDING]
122
+ else
123
+ temp_sort.unshift [this, Mongo::ASCENDING]
124
+ end
125
+ end
126
+ @_candy_sort += temp_sort
127
+ @_candy_cursor.sort(@_candy_sort)
128
+ self
129
+ end
130
+
131
+
132
+ private
133
+
134
+ def refresh_cursor
135
+ @_candy_cursor = self.class.collection.find(@_candy_query, @_candy_options)
136
+ end
137
+
138
+ def extract_options(hash)
139
+ options = {}
140
+ (FIND_OPTIONS & hash.keys).each do |key|
141
+ options[key] = hash.delete(key)
142
+ end
143
+ options
144
+ end
145
+
146
+ def self.included(receiver)
147
+ receiver.extend ClassMethods
148
+ end
149
+ end
150
+ end
151
+