candy 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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