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/HISTORY.markdown +27 -0
- data/LICENSE.markdown +110 -0
- data/README.markdown +269 -41
- data/Rakefile +7 -9
- data/VERSION +1 -1
- data/candy.gemspec +35 -21
- data/lib/candy.rb +11 -138
- data/lib/candy/array.rb +74 -0
- data/lib/candy/collection.rb +151 -0
- data/lib/candy/crunch.rb +85 -44
- data/lib/candy/embeddable.rb +34 -0
- data/lib/candy/factory.rb +30 -0
- data/lib/candy/hash.rb +30 -0
- data/lib/candy/piece.rb +210 -0
- data/lib/candy/wrapper.rb +39 -13
- data/spec/candy/array_spec.rb +50 -0
- data/spec/candy/collection_spec.rb +102 -0
- data/spec/candy/crunch_spec.rb +17 -36
- data/spec/candy/hash_spec.rb +38 -0
- data/spec/candy/piece_spec.rb +259 -0
- data/spec/candy/wrapper_spec.rb +5 -12
- data/spec/candy_spec.rb +1 -191
- data/spec/spec_helper.rb +5 -1
- data/spec/support/kitkat_fixture.rb +10 -0
- data/spec/support/zagnuts_fixture.rb +10 -0
- metadata +62 -36
- data/LICENSE +0 -20
data/lib/candy/crunch.rb
CHANGED
@@ -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
|
-
#
|
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 ||=
|
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
|
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 ||=
|
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.
|
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.
|
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
|
-
|
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
|
+
|
data/lib/candy/hash.rb
ADDED
@@ -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
|
data/lib/candy/piece.rb
ADDED
@@ -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
|