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.
@@ -2,47 +2,83 @@ require 'mongo'
2
2
  require 'etc' # To get the current username for database default
3
3
 
4
4
  module Candy
5
+ # Our option accessors live here so that someone could include just the
6
+ # 'candy/crunch' module and make it standalone.
7
+
8
+ # Overrides the host and resets the connection, db, and collection.
9
+ def self.host=(val)
10
+ @connection = nil
11
+ @host = val
12
+ end
13
+
14
+ # Overrides the port and resets the connection, db, and collection.
15
+ def self.port=(val)
16
+ @connection = nil
17
+ @port = val
18
+ end
19
+
20
+ # Overrides the options hash and resets the connection, db, and collection.
21
+ def self.connection_options=(val)
22
+ @connection = nil
23
+ @connection_options = val
24
+ end
25
+
26
+ # Passed to the default connection. If not set, Mongo's default of localhost will be used.
27
+ def self.host
28
+ @host
29
+ end
30
+
31
+ # Passed to the default connection. If not set, Mongo's default of 27017 will be used.
32
+ def self.port
33
+ @port
34
+ end
35
+
36
+ # A hash passed to the default connection. See the Mongo::Connection documentation for valid options.
37
+ def self.connection_options
38
+ @connection_options ||= {}
39
+ end
40
+
41
+ # First clears any collection and database we're talking to, then accepts a connection you provide.
42
+ # You're responsible for your own host, port and options if you use this.
43
+ def self.connection=(val)
44
+ self.db = nil
45
+ @connection = val
46
+ end
47
+
48
+ # Returns the connection you gave, or creates a default connection to the default host and port.
49
+ def self.connection
50
+ @connection ||= Mongo::Connection.new(host, port, connection_options)
51
+ end
52
+
53
+ # Accepts a database you provide. You can provide a Mongo::DB object or a string with the database
54
+ # name. If you provide a Mongo::DB object, the default connection is not used, and the :strict flag
55
+ # should be false or default collection lookup will fail.
56
+ def self.db=(val)
57
+ case val
58
+ when Mongo::DB
59
+ @db = val
60
+ when String
61
+ @db = Mongo::DB.new(val, connection)
62
+ when nil
63
+ @db = nil
64
+ else
65
+ raise ConnectionError, "The db attribute needs a Mongo::DB object or a name string."
66
+ end
67
+ end
68
+
69
+ # Returns the database you gave, or creates a default database named for your username (or 'candy' if it
70
+ # can't find a username).
71
+ def self.db
72
+ @db ||= Mongo::DB.new(Etc.getlogin || 'candy', connection, :strict => false)
73
+ end
5
74
 
6
75
  # All of the hard crunchy bits that connect us to a collection within a Mongo database.
7
76
  module Crunch
8
77
  module ClassMethods
9
-
10
- # Passed to the default connection. It uses the $MONGO_HOST global if that's set and you don't override it.
11
- def host
12
- @host ||= $MONGO_HOST
13
- end
14
-
15
- # Passed to the default connection. It uses the $MONGO_PORT global if that's set and you don't override it.
16
- def port
17
- @port ||= $MONGO_PORT
18
- end
19
-
20
- # A hash passed to the default connection. It uses the $MONGO_OPTIONS global if that's set and you don't override it.
21
- def options
22
- @options ||= ($MONGO_OPTIONS || {})
23
- end
24
-
25
- # Overrides the host and resets the connection, db, and collection.
26
- def host=(val)
27
- @connection = nil
28
- @host = val
29
- end
30
-
31
- # Overrides the port and resets the connection, db, and collection.
32
- def port=(val)
33
- @connection = nil
34
- @port = val
35
- end
36
-
37
- # Overrides the options hash and resets the connection, db, and collection.
38
- def options=(val)
39
- @connection = nil
40
- @options = val
41
- end
42
-
43
- # Returns the connection you gave, or creates a default connection to the default host and port.
78
+
79
+ # Returns the connection you gave, or uses the application-level Candy collection.
44
80
  def connection
45
- @connection ||= Mongo::Connection.new(host, port, options)
81
+ @connection ||= Candy.connection
46
82
  end
47
83
 
48
84
  # First clears any collection and database we're talking to, then accepts a connection you provide.
@@ -69,10 +105,9 @@ module Candy
69
105
  end
70
106
  end
71
107
 
72
- # Returns the database you gave, or creates a default database named for your username (or 'candy' if it
73
- # can't find a username).
108
+ # Returns the database you gave, or uses the application-level Candy database.
74
109
  def db
75
- @db ||= Mongo::DB.new($MONGO_DB || Etc.getlogin || 'candy', connection, :strict => false)
110
+ @db ||= Candy.db
76
111
  end
77
112
 
78
113
  # Accepts either a Mongo::Collection object or a string with the collection name. If you provide a
@@ -82,7 +117,7 @@ module Candy
82
117
  when Mongo::Collection
83
118
  @collection = val
84
119
  when String
85
- @collection = db.create_collection(val)
120
+ @collection = db.collection(val)
86
121
  when nil
87
122
  @collection = nil
88
123
  else
@@ -92,7 +127,7 @@ module Candy
92
127
 
93
128
  # Returns the collection you gave, or creates a default collection named for the current class.
94
129
  def collection
95
- @collection ||= db.create_collection(name)
130
+ @collection ||= db.collection(name)
96
131
  end
97
132
 
98
133
  # Creates an index on the specified property, with an optional direction specified as either :asc or :desc.
@@ -109,13 +144,19 @@ module Candy
109
144
  end
110
145
  end
111
146
 
112
- module InstanceMethods
113
-
147
+ # We're implementing FindAndModify on Mongo 1.4 until the Ruby driver gets around to being updated...
148
+ def findAndModify(query, update, sort={})
149
+ command = OrderedHash[
150
+ findandmodify: self.collection.name,
151
+ query: query,
152
+ update: update,
153
+ sort: sort
154
+ ]
155
+ result = self.class.db.command(command)
114
156
  end
115
157
 
116
158
  def self.included(receiver)
117
159
  receiver.extend ClassMethods
118
- receiver.send :include, InstanceMethods
119
160
  end
120
161
  end
121
162
  end
@@ -0,0 +1,34 @@
1
+ module Candy
2
+
3
+ # Shared methods to create associations between top-level objects and embedded objects (hashes,
4
+ # arrays, or Candy::Pieces).
5
+ module Embeddable
6
+ # Tells an embedded object whom it belongs to and what attribute it's associated with. When
7
+ # its own state changes, it can use this information to update the parent.
8
+ def adopt(parent, attribute)
9
+ @__candy_parent = parent
10
+ @__candy_parent_key = attribute
11
+ end
12
+
13
+ private
14
+ # If we're an attribute of another object, set our field names accordingly.
15
+ def embedded(fields)
16
+ new_fields = {}
17
+ fields.each{|k,v| new_fields["#{@__candy_parent_key}.#{k}".to_sym] = v}
18
+ new_fields
19
+ end
20
+
21
+ # Convert hashes and arrays to CandyHashes and CandyArrays.
22
+ def embeddify(value)
23
+ case value
24
+ when CandyHash then value
25
+ when Hash then CandyHash.embed(value)
26
+ when CandyArray then value
27
+ when Array then CandyArray.embed(*value) # Explode our array into separate arguments
28
+ else
29
+ value
30
+ end
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ require 'candy/qualified_const_get'
2
+ module Candy
3
+
4
+ # Utility methods that can generate new methods or classes for some of Candy's magic.
5
+ module Factory
6
+
7
+ # Creates a method with the same name as a provided class, in the same namespace as
8
+ # that class, which delegates to a given class method of that class. (Whew. Make sense?)
9
+ def self.magic_method(klass, method, params='')
10
+ ns = namespace(klass)
11
+ my_name = klass.name.sub(ns, '').to_sym
12
+ parent = (ns == '' ? Object : qualified_const_get(ns))
13
+ unless parent.method_defined?(my_name)
14
+ parent.class_eval <<-CLASS
15
+ def #{my_name}(#{params})
16
+ #{klass}.#{method}(#{params.gsub(/\s?=(.+?),/,',')})
17
+ end
18
+ CLASS
19
+ end
20
+ end
21
+
22
+ private
23
+ # Retrieves the 'BlahModule::BleeModule::' part of a class name, so that we
24
+ # can put other things in the same namespace.
25
+ def self.namespace(receiver)
26
+ receiver.name[/^.*::/] || '' # Hooray for greedy matching
27
+ end
28
+ end
29
+ end
30
+
@@ -0,0 +1,30 @@
1
+ require 'candy/piece'
2
+
3
+ module Candy
4
+
5
+ # A subclass of Hash that behaves like a Candy::Piece. This class has two major uses:
6
+ #
7
+ # * It's a convenient starting point if you just want to store a bunch of data in Mongo
8
+ # and don't need to implement any business logic in your own classes; and
9
+ # * It's the default when you embed hashed data in another Candy::Piece and don't
10
+ # supply another object class. Because it doesn't need to store a classname, using
11
+ # it means less metadata in your collections.
12
+ #
13
+ # If you don't tell them otherwise, top-level CandyHash objects store themselves in
14
+ # the 'candy' collection. You can change that at any time by setting a different
15
+ # collection at the class or object level.
16
+ class CandyHash < Hash
17
+ include Crunch
18
+ include Piece
19
+
20
+ self.collection = 'candy'
21
+
22
+
23
+ # Overrides the default behavior in Candy::Piece so that we DO NOT add our
24
+ # class name to the saved values.
25
+ def to_mongo
26
+ candy
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,210 @@
1
+ require 'candy/array'
2
+ require 'candy/crunch'
3
+ require 'candy/embeddable'
4
+ require 'candy/factory'
5
+ require 'candy/hash'
6
+
7
+ module Candy
8
+
9
+ # Handles autopersistence and single-object retrieval for an arbitrary Ruby class.
10
+ # For retrieving many objects, include Candy::Collection somewhere else or use
11
+ # the magic Candy() factory.
12
+ module Piece
13
+
14
+ module ClassMethods
15
+ include Crunch::ClassMethods
16
+
17
+ # Retrieves a single object from Mongo by its search attributes, or nil if it can't be found.
18
+ def first(conditions={})
19
+ conditions = {'_id' => conditions} unless conditions.is_a?(Hash)
20
+ if record = collection.find_one(conditions)
21
+ self.new(record)
22
+ end
23
+ end
24
+
25
+ # Performs an 'upsert' into the collection. The first parameter is a field name or array of fields
26
+ # which act as our "key" fields -- if a document in the system matches the values from the hash,
27
+ # it'll be updated. Otherwise, an insert will occur. The second parameter tells us what to set or
28
+ # insert.
29
+ def update(key_or_keys, fields)
30
+ search_keys = {}
31
+ Array(key_or_keys).each do |key|
32
+ search_keys[key] = Wrapper.wrap(fields[key])
33
+ end
34
+ collection.update search_keys, fields, :upsert => true
35
+ end
36
+
37
+ # Makes a new object with a given state that is _not_ immediately saved, but is
38
+ # held in memory instead. The principal use for this is to embed it in other
39
+ # documents. Except for the unsaved state, this functions identically to 'new'
40
+ # and will pass all its arguments to the initializer. (Note that you can still
41
+ # embed documents that _have_ been saved--but then you'll have the data in two
42
+ # places.)
43
+ def embed(*args, &block)
44
+ if args[-1].is_a?(Hash)
45
+ args[-1].merge!(EMBED_KEY => true)
46
+ else
47
+ args.push({EMBED_KEY => true})
48
+ end
49
+ self.new(*args)
50
+ end
51
+
52
+ # Deep magic! Finds and returns a single object by the named attribute.
53
+ def method_missing(name, *args, &block)
54
+ if args.size == 1 or args.size == 2 # If we don't have a value, or have more than
55
+ search = {name => args.shift} # just a simple options hash, this must not be for us.
56
+ search.merge!(args.shift) if args[0] # We might have other conditions
57
+ first(search)
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ private
64
+ # Creates a method in the same namespace as the included class that points to
65
+ # 'first', for easier semantics.
66
+ def self.extended(receiver)
67
+ Factory.magic_method(receiver, 'first', 'conditions={}')
68
+ end
69
+ end
70
+
71
+ # HERE STARTETH THE MODULE PROPER. (The above are the class methods.)
72
+ include Crunch
73
+ include Embeddable
74
+
75
+
76
+ # Our initializer checks the LAST argument passed to it, and pops it off the chain if it's a hash.
77
+ # If the hash contains an '_id' field we assume we're being constructed from a MongoDB document;
78
+ # otherwise we assume we're a new document and insert ourselves into the database.
79
+ def initialize(*args, &block)
80
+ if args[-1].is_a?(Hash)
81
+ data = args.pop
82
+ if @__candy_id = data.delete('_id') # We're an existing document
83
+ @__candy = self.from_mongo(Wrapper.unwrap(data))
84
+ elsif data.delete(EMBED_KEY) # We're being embedded: take any data, but don't save to Mongo
85
+ @__candy = data
86
+ else
87
+ set data # Insert the data we're given
88
+ end
89
+ end
90
+ super
91
+ end
92
+
93
+ # Shortcut to the document ID.
94
+ def id
95
+ @__candy_id
96
+ end
97
+
98
+ # Pull our document from the database if we know our ID.
99
+ def retrieve_document
100
+ Wrapper.unwrap(collection.find_one({'_id' => id})) if id
101
+ end
102
+
103
+
104
+ # Returns the hash of memoized values.
105
+ def candy
106
+ @__candy ||= retrieve_document || {}
107
+ end
108
+
109
+ # Objects are equal if they point to the same MongoDB record (unless both have IDs of nil, in which case
110
+ # they're never equal.)
111
+ def ==(subject)
112
+ return false if id.nil?
113
+ return false unless subject.respond_to?(:id)
114
+ self.id == subject.id
115
+ end
116
+
117
+ # Candy's magic ingredient. Assigning to any unknown attribute will push that value into the Mongo collection.
118
+ # Retrieving any unknown attribute will return that value from this record in the Mongo collection.
119
+ def method_missing(name, *args, &block)
120
+ if name =~ /(.*)=$/ # We're assigning
121
+ self[$1.to_sym] = args[0]
122
+ elsif name =~ /(.*)\?$/ # We're asking
123
+ (self[$1.to_sym] ? true : false)
124
+ else
125
+ self[name]
126
+ end
127
+ end
128
+
129
+ # Hash-like getter. If we don't have a value yet, we pull from the database looking for one.
130
+ # Fields pulled from the database are keyed as symbols in the hash.
131
+ def [](key)
132
+ candy[key]
133
+ end
134
+
135
+ # Hash-like setter. Updates the object's internal state, and writes to the database if the state
136
+ # has changed. Keys should be passed in as symbols for best consistency with the database.
137
+ def []=(key, value)
138
+ property = embeddify(value)
139
+ candy[key] = property
140
+ if property.respond_to?(:to_mongo)
141
+ property.adopt(self, key)
142
+ set key => property.to_mongo
143
+ else
144
+ set key => property
145
+ end
146
+ end
147
+
148
+ # Clears memoized data so that the next read pulls from the database.
149
+ def refresh
150
+ @__candy = nil
151
+ self
152
+ end
153
+
154
+ # The MongoDB collection object that everything saves to. Defaults to the class's
155
+ # collection, which in turn defaults to the classname.
156
+ def collection
157
+ @__candy_collection ||= self.class.collection
158
+ end
159
+
160
+ # This is normally set at the class level (with a default of the classname) but you
161
+ # can override it on a per-object basis if you need to.
162
+ def collection=(val)
163
+ @__candy_collection = val
164
+ end
165
+
166
+ # Convenience method for debugging. Shows the class, the Mongo ID, and the saved state hash.
167
+ def to_s
168
+ "#{self.class.name} (#{id})#{candy.inspect}"
169
+ end
170
+
171
+
172
+ # Converts the object into a hash for MongoDB storage. Keep in mind that wrapping happens _after_
173
+ # this stage, so it's best to use symbols for keys and leave internal arrays and hashes alone.
174
+ def to_mongo
175
+ candy.merge(CLASS_KEY => self.class.name)
176
+ end
177
+
178
+ # A hoook for specific object classes to set their internal state using the hash passed in by
179
+ # MongoDB. If you override this method, delete any hash keys you need for your own purposes
180
+ # and then call 'super' on the remainder.
181
+ def from_mongo(hash)
182
+ hash
183
+ end
184
+
185
+
186
+ # Given a hash of property/value pairs, sets those values in Mongo using the atomic $set if
187
+ # we have a document ID. Otherwise inserts them and sets the object's ID.
188
+ def set(fields)
189
+ operate :set, fields
190
+ end
191
+
192
+ # A generic updater that performs the atomic operation specified on a value nested arbitrarily deeply.
193
+ #
194
+ def operate(operator, fields)
195
+ if @__candy_parent
196
+ @__candy_parent.operate operator, embedded(fields)
197
+ else
198
+ @__candy_id = collection.insert({}) unless id # Ensure we have something to update
199
+ collection.update({'_id' => id}, {"$#{operator}" => Wrapper.wrap(fields)})
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+
206
+ def self.included(receiver)
207
+ receiver.extend ClassMethods
208
+ end
209
+ end
210
+ end