document_hydrator 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,9 @@
1
+ # Include plugins
2
+ require 'autotest/fsevent'
3
+ require 'autotest/growl'
4
+
5
+ # Skip some paths
6
+ Autotest.add_hook :initialize do |autotest|
7
+ %w{.git .DS_Store ._* vendor}.each { |exception| autotest.add_exception(exception) }
8
+ false
9
+ end
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source 'http://rubygems.org'
2
+
3
+ # Add dependencies to develop your gem here.
4
+ # Include everything needed to run rake, tests, features, etc.
5
+ group :development do
6
+ gem 'bundler', '~> 1.0.0'
7
+ gem 'jeweler', '~> 1.6.2'
8
+ gem 'rspec', '~> 2.6.0'
9
+ gem 'ZenTest', '~> 4.4.2'
10
+ gem 'autotest-growl'
11
+ gem 'autotest-fsevent'
12
+ gem 'bson_ext', :platforms => :ruby
13
+ gem 'bson', :platforms => :jruby
14
+ gem 'mongo'
15
+ gem 'SystemTimer', :platforms => :ruby_18
16
+ end
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Greg Spurrier
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,184 @@
1
+ # DocumentHydrator
2
+
3
+ DocumentHydrator takes a document, represented as a Ruby Hash, and
4
+ efficiently updates it so that embedded references to other documents
5
+ are replaced with their corresponding subdocuments.
6
+
7
+ Along with the document, DocumentHydrator takes a path (or array of
8
+ paths) specifying the location of the references to be expanded and a
9
+ Proc--known as the hydration proc--that is capable of providing
10
+ expanded subdocuments for those references. The hydration proc is
11
+ guaranteed to be called at most once during any given invocation of
12
+ DocumentHydrator, ensuring efficient hydration of multiple
13
+ subdocuments.
14
+
15
+ ## Hydration Procs
16
+ Hydration procs are responsible for transforming an array of document
17
+ references into a hash that maps those references to their
18
+ corresponding subdocuments. The subdocuments must themselves be
19
+ hashes.
20
+
21
+ DocumentHydrator provides a hydration proc factory that makes it
22
+ simple to hydrate a document when pulling the subdocuments from a
23
+ MongoDB collection. Use of the factory is described in the "Hydrating
24
+ Documents with MongoDB Collections" section found later in the
25
+ document.
26
+
27
+ Most of the following examples illustrate DocumentHydrator
28
+ functionality that is independent of the choice of hydration proc. In
29
+ order to keep them stand-alone, a simple "identity" hydration proc
30
+ will be used:
31
+
32
+ identity_hydrator = Proc.new do |ids|
33
+ ids.inject({}) do |hash, id|
34
+ hash[id] = { 'id' => id }
35
+ hash
36
+ end
37
+ end
38
+
39
+ It simply maps IDs to hashes containing the ID under the key 'id'. For
40
+ example:
41
+
42
+ identity_hydrator.call([1, 2, 3])
43
+ # => {1=>{"id"=>1}, 2=>{"id"=>2}, 3=>{"id"=>3}}
44
+
45
+ ## A Simple Example
46
+ Armed with `identity_hydrator`, the simplest example is:
47
+
48
+ doc = { 'thing' => 1, 'gizmo' => 3 }
49
+ DocumentHydrator.hydrate_document(doc, 'thing', identity_hydrator)
50
+ # => {"thing"=>{"id"=>1},"gizmo"=>3}
51
+
52
+ In this case DocumentHydrator is asked to hydrate a single document,
53
+ `doc`, by replacing the reference found at `doc['thing']` to its
54
+ corresponding subdocument, as provided by `identity_hydrator`.
55
+
56
+ ## Paths
57
+ In the example above, the path was the name of a top level key in
58
+ the document to be hydrated. DocumentHydrator also supports paths
59
+ to arrays, nested paths, and nested paths that contain intermediate
60
+ arrays.
61
+
62
+ For example, consider the document:
63
+
64
+ status_update = {
65
+ 'user' => 19,
66
+ 'text' => 'I am loving MongoDB!',
67
+ 'likers' => [37, 42, 99],
68
+ 'comments' => [
69
+ { 'user' => 88, 'text' => 'Me too!' },
70
+ { 'user' => 99, 'text' => 'Drinking the Kool-Aid, eh?' },
71
+ { 'user' => 88, 'text' => "Don't be a hater. :)" }
72
+ ]
73
+ }
74
+
75
+ The following are all valid hydration paths referencing user IDs in
76
+ `status_update`:
77
+
78
+ * `'user'` -- single ID
79
+ * `'likers'` -- array of IDs
80
+ * `'comments.user'` -- single ID contained within an array of objects
81
+
82
+ ## Multi-path Hydration
83
+
84
+ DocumentHydrator will accept an array of paths to all be hydrated
85
+ concurrently:
86
+
87
+
88
+ DocumentHydrator.hydrate_document(status_update,
89
+ ['user', 'likers', 'comments.user'],
90
+ identity_hydrator)
91
+ pp status_update
92
+ # {"user"=>{"id"=>19},
93
+ # "text"=>"I am loving MongoDB!",
94
+ # "likers"=>[{"id"=>37}, {"id"=>42}, {"id"=>99}],
95
+ # "comments"=>
96
+ # [{"user"=>{"id"=>88}, "text"=>"Me too!"},
97
+ # {"user"=>{"id"=>99}, "text"=>"Drinking the KoolAid, eh?"},
98
+ # {"user"=>{"id"=>88}, "text"=>"Don't be a hater. :)"}]}
99
+
100
+ Regardless of the number of paths, the hydration is accomplished with a
101
+ single call to the hydration proc.
102
+
103
+ ## Multi-document Hydration
104
+ Multiple documents may be hydrated at once using
105
+ `DocumentHydrator.hydrate_documents`:
106
+
107
+ doc1 = { 'thing' => 1, 'gizmo' => 3 }
108
+ doc2 = { 'thing' => 2, 'gizmo' => 3 }
109
+ DocumentHydrator.hydrate_documents([doc1, doc2], 'thing', identity_hydrator)
110
+ # => [{"thing"=>{"id"=>1}, "gizmo"=>3}, {"thing"=>{"id"=>2}, "gizmo"=>3}]
111
+
112
+ The only difference between `hydrate_document` and `hydrate_documents`
113
+ is that the latter takes an array of documents. The other parameters
114
+ are the same.
115
+
116
+ ## _id Suffix Stripping
117
+ DocumentHydrator automatically strips any 'id' or 'ids' suffixes from
118
+ keys that are the last step in a hydration path:
119
+
120
+ doc = {
121
+ 'user_id' => 33,
122
+ 'follower_ids' => [11, 23]
123
+ }
124
+ DocumentHydrator.hydrate_document(doc,
125
+ ['user_id', 'follower_ids'], identity_hydrator)
126
+ # => {"user"=>{"id"=>33}, "followers"=>[{"id"=>11}, {"id"=>23}]}
127
+
128
+ Notice that the document now has the keys 'user' and 'followers'.
129
+
130
+ ## Hydrating Documents with MongoDB Collections
131
+ DocumentHydrator provides a hydration proc factory that makes it
132
+ simple to hydrate documents with subdocuments that are fecthed from a
133
+ MongoDB collection.
134
+
135
+ The following examples require a bit of setup:
136
+
137
+ require 'mongo'
138
+ db = Mongo::Connection.new.db('document_hydrator_example')
139
+ users_collection = db['users']
140
+ users_collection.remove
141
+ users_collection.insert('_id' => 1, 'name' => 'Fred', 'age' => 33)
142
+ users_collection.insert('_id' => 2, 'name' => 'Wilma', 'age' => 30)
143
+ users_collection.insert('_id' => 3, 'name' => 'Barney', 'age' => 29)
144
+ users_collection.insert('_id' => 4, 'name' => 'Betty', 'age' => 28)
145
+
146
+ Now create a hydration proc that fetches documents from the users
147
+ collection:
148
+
149
+ user_hydrator = DocumentHydrator::HydrationProc::Mongo.collection(users_collection)
150
+
151
+ Note that DocumentHydrator::HydrationProc::Mongo is automatically
152
+ loaded by `require 'document_dehydrator'` if the MongoDB Ruby Driver
153
+ has already been loaded.
154
+
155
+ Here is the hydration proc in action:
156
+
157
+ doc = { 'user_ids' => [1, 3] }
158
+ Documenthydrator.hydrate_document(doc, 'user_ids', user_hydrator)
159
+ # => {"users"=>[{"_id"=>1, "name"=>"Fred", "age"=>33}, {"_id"=>3, "name"=>"Barney", "age"=>29}]}
160
+
161
+ ### Limiting Fields in Subdocuments
162
+ By default a Mongo collection hydrator will return all of the fields
163
+ that are present for the subdocument in the database. This can be
164
+ changed, however, by passing an optional `:fields` argument to the
165
+ factory. This option takes the same form as it does for
166
+ Mongo::Collection#find.
167
+
168
+ For example:
169
+
170
+ user_hydrator = DocumentHydrator::HydrationProc::Mongo.collection(users_collection,
171
+ :fields => { '_id' => 0, 'name' => 1 })
172
+ DocumentHydrator.hydrate_document(doc, 'user_ids', user_hydrator)
173
+ # => {"users"=>[{"name"=>"Fred"}, {"name"=>"Barney"}]}
174
+
175
+ ## Supported Rubies
176
+ DocumentHydrator has been tested with:
177
+
178
+ * Ruby 1.8.7 (p334)
179
+ * Ruby 1.9.2 (p180)
180
+ * JRuby 1.6.2
181
+
182
+ ## Copyright
183
+ Copyright (c) 2011 Greg Spurrier. See LICENSE.txt for
184
+ further details.
@@ -0,0 +1,29 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+ require 'rake'
11
+
12
+ require 'jeweler'
13
+ Jeweler::Tasks.new do |gem|
14
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
15
+ gem.name = "document_hydrator"
16
+ gem.homepage = "http://github.com/gregspurrier/document_hydrator"
17
+ gem.license = "MIT"
18
+ gem.summary = %Q{DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.}
19
+ gem.description = %Q{DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.}
20
+ gem.email = "greg.spurrier@gmail.com"
21
+ gem.authors = ["Greg Spurrier"]
22
+ # dependencies defined in Gemfile
23
+ end
24
+ Jeweler::RubygemsDotOrgTasks.new
25
+
26
+ require 'rspec/core/rake_task'
27
+ RSpec::Core::RakeTask.new
28
+
29
+ task :default => :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,81 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{document_hydrator}
8
+ s.version = "0.1.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Greg Spurrier"]
12
+ s.date = %q{2011-06-19}
13
+ s.description = %q{DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.}
14
+ s.email = %q{greg.spurrier@gmail.com}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE.txt",
17
+ "README.markdown"
18
+ ]
19
+ s.files = [
20
+ ".autotest",
21
+ ".rspec",
22
+ "Gemfile",
23
+ "LICENSE.txt",
24
+ "README.markdown",
25
+ "Rakefile",
26
+ "VERSION",
27
+ "document_hydrator.gemspec",
28
+ "lib/document_hydrator.rb",
29
+ "lib/document_hydrator/hydration_proc/mongo.rb",
30
+ "lib/document_hydrator/inflector.rb",
31
+ "lib/document_hydrator/inflector/inflections.rb",
32
+ "spec/document_hydrator_spec.rb",
33
+ "spec/hydration_proc/mongo_spec.rb",
34
+ "spec/spec_helper.rb"
35
+ ]
36
+ s.homepage = %q{http://github.com/gregspurrier/document_hydrator}
37
+ s.licenses = ["MIT"]
38
+ s.require_paths = ["lib"]
39
+ s.rubygems_version = %q{1.6.2}
40
+ s.summary = %q{DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.}
41
+
42
+ if s.respond_to? :specification_version then
43
+ s.specification_version = 3
44
+
45
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
46
+ s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
47
+ s.add_development_dependency(%q<jeweler>, ["~> 1.6.2"])
48
+ s.add_development_dependency(%q<rspec>, ["~> 2.6.0"])
49
+ s.add_development_dependency(%q<ZenTest>, ["~> 4.4.2"])
50
+ s.add_development_dependency(%q<autotest-growl>, [">= 0"])
51
+ s.add_development_dependency(%q<autotest-fsevent>, [">= 0"])
52
+ s.add_development_dependency(%q<bson_ext>, [">= 0"])
53
+ s.add_development_dependency(%q<bson>, [">= 0"])
54
+ s.add_development_dependency(%q<mongo>, [">= 0"])
55
+ s.add_development_dependency(%q<SystemTimer>, [">= 0"])
56
+ else
57
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
58
+ s.add_dependency(%q<jeweler>, ["~> 1.6.2"])
59
+ s.add_dependency(%q<rspec>, ["~> 2.6.0"])
60
+ s.add_dependency(%q<ZenTest>, ["~> 4.4.2"])
61
+ s.add_dependency(%q<autotest-growl>, [">= 0"])
62
+ s.add_dependency(%q<autotest-fsevent>, [">= 0"])
63
+ s.add_dependency(%q<bson_ext>, [">= 0"])
64
+ s.add_dependency(%q<bson>, [">= 0"])
65
+ s.add_dependency(%q<mongo>, [">= 0"])
66
+ s.add_dependency(%q<SystemTimer>, [">= 0"])
67
+ end
68
+ else
69
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
70
+ s.add_dependency(%q<jeweler>, ["~> 1.6.2"])
71
+ s.add_dependency(%q<rspec>, ["~> 2.6.0"])
72
+ s.add_dependency(%q<ZenTest>, ["~> 4.4.2"])
73
+ s.add_dependency(%q<autotest-growl>, [">= 0"])
74
+ s.add_dependency(%q<autotest-fsevent>, [">= 0"])
75
+ s.add_dependency(%q<bson_ext>, [">= 0"])
76
+ s.add_dependency(%q<bson>, [">= 0"])
77
+ s.add_dependency(%q<mongo>, [">= 0"])
78
+ s.add_dependency(%q<SystemTimer>, [">= 0"])
79
+ end
80
+ end
81
+
@@ -0,0 +1,83 @@
1
+ require 'document_hydrator/inflector'
2
+ if defined? Mongo
3
+ require 'document_hydrator/hydration_proc/mongo'
4
+ end
5
+
6
+ module DocumentHydrator
7
+ class <<self
8
+ # Given a +document+ hash, a path or array of paths describing locations of object IDs within
9
+ # the hash, and a function that will convert object IDs to a hash of subdocument hashes indexed
10
+ # by object ID, modifies the document hash so that all of the IDs referenced by the paths are
11
+ # replaced with the corresponding subdocuments.
12
+ #
13
+ # Path examples:
14
+ #
15
+ # document = {
16
+ # 'owner' => 99,
17
+ # 'clients' => [100, 101],
18
+ # 'comments' => [
19
+ # { 'user' => 10, text => 'hi' },
20
+ # { 'user' => 11, text => 'hello' }
21
+ # ]
22
+ # }
23
+ #
24
+ # Each of these are valid paths:
25
+ # - 'owner'
26
+ # - 'clients'
27
+ # - 'comments.user'
28
+ #
29
+ # Returns the document to allow for chaining.
30
+ def hydrate_document(document, path_or_paths, hydration_proc)
31
+ hydrate_documents([document],path_or_paths, hydration_proc)
32
+ document
33
+ end
34
+
35
+ def hydrate_documents(documents, path_or_paths, hydration_proc)
36
+ # Traverse the documents replacing each ID with a corresponding dehydrated document
37
+ dehydrated_subdocuments = Hash.new { |h, k| h[k] = Hash.new }
38
+ documents.each do |document|
39
+ paths = path_or_paths.kind_of?(Array) ? path_or_paths : [path_or_paths]
40
+ paths.each do |path|
41
+ replace_ids_with_dehydrated_documents(document, path.split('.'), dehydrated_subdocuments)
42
+ end
43
+ end
44
+
45
+ # Rehydrate the documents that we discovered during traversal all in one go
46
+ ids = dehydrated_subdocuments.keys
47
+ hydrated_subdocuments = hydration_proc.call(ids)
48
+ ids.each {|id| dehydrated_subdocuments[id].replace(hydrated_subdocuments[id])}
49
+
50
+ documents
51
+ end
52
+
53
+ private
54
+
55
+ def replace_ids_with_dehydrated_documents(document, path_steps, dehydrated_documents)
56
+ step = path_steps.first
57
+ next_steps = path_steps[1..-1]
58
+ if document.has_key?(step)
59
+ subdocument = document[step]
60
+ if next_steps.empty?
61
+ # End of the path, do the hydration, dropping any _id or _ids suffix
62
+ if step =~ /_ids?$/
63
+ document.delete(step)
64
+ step = step.sub(/_id(s?)$/, '')
65
+ step = Inflector.pluralize(step) if $1 == 's'
66
+ end
67
+ if subdocument.kind_of?(Array)
68
+ document[step] = subdocument.map {|id| dehydrated_documents[id] }
69
+ else
70
+ document[step] = dehydrated_documents[subdocument]
71
+ end
72
+ else
73
+ # Keep on stepping
74
+ if subdocument.kind_of?(Array)
75
+ subdocument.each { |item| replace_ids_with_dehydrated_documents(item, next_steps, dehydrated_documents) }
76
+ else
77
+ replace_ids_with_dehydrated_documents(subdocument, next_steps, dehydrated_documents)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,38 @@
1
+ module DocumentHydrator
2
+ module HydrationProc
3
+ module Mongo
4
+ class <<self
5
+ # Create a hydration proc that fetches subdocuments by ID from
6
+ # the provided collection.
7
+ #
8
+ # coll - The Mongo::Collection containing the subdocuments
9
+ # options - (Optional) hash of options to pass to MongoDB::Collection#find.
10
+ # Defaults to {}.
11
+ #
12
+ # Returns a Proc that maps IDs to their corresponding subdocuments
13
+ # within the collection.
14
+ def collection(coll, options = {})
15
+ Proc.new do |ids|
16
+ if options[:fields]
17
+ # We need to _id key in order to assemble the results hash.
18
+ # If the caller has requested that it be omitted from the
19
+ # result, re-enable it and then strip later.
20
+ field_selectors = options[:fields]
21
+ id_key = field_selectors.keys.detect { |k| k.to_s == '_id' }
22
+ if id_key && field_selectors[id_key] == 0
23
+ field_selectors.delete(id_key)
24
+ strip_id = true
25
+ end
26
+ end
27
+ subdocuments = coll.find({ '_id' => { '$in' => ids } }, options)
28
+ subdocuments.inject({}) do |hash, subdocument|
29
+ hash[subdocument['_id']] = subdocument
30
+ subdocument.delete('_id') if strip_id
31
+ hash
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ require 'document_hydrator/inflector/inflections'
2
+
3
+ module DocumentHydrator
4
+ # Inflection rules extracted from ActiveSupport 3.0.9.
5
+ Inflector.inflections do |inflect|
6
+ inflect.plural(/$/, 's')
7
+ inflect.plural(/s$/i, 's')
8
+ inflect.plural(/(ax|test)is$/i, '\1es')
9
+ inflect.plural(/(octop|vir)us$/i, '\1i')
10
+ inflect.plural(/(octop|vir)i$/i, '\1i')
11
+ inflect.plural(/(alias|status)$/i, '\1es')
12
+ inflect.plural(/(bu)s$/i, '\1ses')
13
+ inflect.plural(/(buffal|tomat)o$/i, '\1oes')
14
+ inflect.plural(/([ti])um$/i, '\1a')
15
+ inflect.plural(/([ti])a$/i, '\1a')
16
+ inflect.plural(/sis$/i, 'ses')
17
+ inflect.plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves')
18
+ inflect.plural(/(hive)$/i, '\1s')
19
+ inflect.plural(/([^aeiouy]|qu)y$/i, '\1ies')
20
+ inflect.plural(/(x|ch|ss|sh)$/i, '\1es')
21
+ inflect.plural(/(matr|vert|ind)(?:ix|ex)$/i, '\1ices')
22
+ inflect.plural(/([m|l])ouse$/i, '\1ice')
23
+ inflect.plural(/([m|l])ice$/i, '\1ice')
24
+ inflect.plural(/^(ox)$/i, '\1en')
25
+ inflect.plural(/^(oxen)$/i, '\1')
26
+ inflect.plural(/(quiz)$/i, '\1zes')
27
+
28
+ inflect.irregular('person', 'people')
29
+ inflect.irregular('man', 'men')
30
+ inflect.irregular('child', 'children')
31
+ inflect.irregular('sex', 'sexes')
32
+ inflect.irregular('move', 'moves')
33
+ inflect.irregular('cow', 'kine')
34
+
35
+ inflect.uncountable(%w(equipment information rice money species series fish sheep jeans))
36
+ end
37
+ end
@@ -0,0 +1,88 @@
1
+ # Extracted from ActiveSupport::Inflector in ActiveSupport 3.0.9.
2
+ module DocumentHydrator
3
+ module Inflector
4
+ class Inflections
5
+ def self.instance
6
+ @__instance__ ||= new
7
+ end
8
+
9
+ attr_reader :plurals, :uncountables
10
+
11
+ def initialize
12
+ @plurals, @uncountables, @humans = [], [], []
13
+ end
14
+
15
+ # Specifies a new pluralization rule and its replacement. The rule can either be a string or a regular expression.
16
+ # The replacement should always be a string that may include references to the matched data from the rule.
17
+ def plural(rule, replacement)
18
+ @uncountables.delete(rule) if rule.is_a?(String)
19
+ @uncountables.delete(replacement)
20
+ @plurals.insert(0, [rule, replacement])
21
+ end
22
+
23
+ # Specifies a new irregular that applies to both pluralization and singularization at the same time. This can only be used
24
+ # for strings, not regular expressions. You simply pass the irregular in singular and plural form.
25
+ #
26
+ # Examples:
27
+ # irregular 'octopus', 'octopi'
28
+ # irregular 'person', 'people'
29
+ def irregular(singular, plural)
30
+ @uncountables.delete(singular)
31
+ @uncountables.delete(plural)
32
+ if singular[0,1].upcase == plural[0,1].upcase
33
+ plural(Regexp.new("(#{singular[0,1]})#{singular[1..-1]}$", "i"), '\1' + plural[1..-1])
34
+ plural(Regexp.new("(#{plural[0,1]})#{plural[1..-1]}$", "i"), '\1' + plural[1..-1])
35
+ else
36
+ plural(Regexp.new("#{singular[0,1].upcase}(?i)#{singular[1..-1]}$"), plural[0,1].upcase + plural[1..-1])
37
+ plural(Regexp.new("#{singular[0,1].downcase}(?i)#{singular[1..-1]}$"), plural[0,1].downcase + plural[1..-1])
38
+ plural(Regexp.new("#{plural[0,1].upcase}(?i)#{plural[1..-1]}$"), plural[0,1].upcase + plural[1..-1])
39
+ plural(Regexp.new("#{plural[0,1].downcase}(?i)#{plural[1..-1]}$"), plural[0,1].downcase + plural[1..-1])
40
+ end
41
+ end
42
+
43
+ # Add uncountable words that shouldn't be attempted inflected.
44
+ #
45
+ # Examples:
46
+ # uncountable "money"
47
+ # uncountable "money", "information"
48
+ # uncountable %w( money information rice )
49
+ def uncountable(*words)
50
+ (@uncountables << words).flatten!
51
+ end
52
+ end
53
+
54
+ # Yields a singleton instance of Inflector::Inflections so you can specify additional
55
+ # inflector rules.
56
+ #
57
+ # Example:
58
+ # ActiveSupport::Inflector.inflections do |inflect|
59
+ # inflect.uncountable "rails"
60
+ # end
61
+ def self.inflections
62
+ if block_given?
63
+ yield Inflections.instance
64
+ else
65
+ Inflections.instance
66
+ end
67
+ end
68
+
69
+ # Returns the plural form of the word in the string.
70
+ #
71
+ # Examples:
72
+ # "post".pluralize # => "posts"
73
+ # "octopus".pluralize # => "octopi"
74
+ # "sheep".pluralize # => "sheep"
75
+ # "words".pluralize # => "words"
76
+ # "CamelOctopus".pluralize # => "CamelOctopi"
77
+ def self.pluralize(word)
78
+ result = word.to_s.dup
79
+
80
+ if word.empty? || inflections.uncountables.include?(result.downcase)
81
+ result
82
+ else
83
+ inflections.plurals.each { |(rule, replacement)| break if result.gsub!(rule, replacement) }
84
+ result
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,170 @@
1
+ require 'spec_helper'
2
+
3
+ describe DocumentHydrator do
4
+ class Dummy
5
+ class <<self
6
+ attr_reader :invocation_count
7
+
8
+ def reset_invocation_count
9
+ @invocation_count = 0
10
+ end
11
+
12
+ def ids_to_document_hash(ids)
13
+ @invocation_count += 1
14
+ ids.inject({}) do |hash, id|
15
+ hash[id] = { 'id' => id }
16
+ hash
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ before(:each) do
23
+ Dummy.reset_invocation_count
24
+ @hydration_proc = lambda { |ids| Dummy.ids_to_document_hash(ids) }
25
+ end
26
+
27
+ describe '.hydrate_document' do
28
+ context 'with a simple path to an array of ids' do
29
+ it 'replaces the array with an array of hydrated documents' do
30
+ orig = { 'key1' => 37, 'users' => [27, 39] }
31
+ expected = { 'key1' => 37, 'users' => [{ 'id' => 27 }, { 'id' => 39 }] }
32
+ DocumentHydrator.hydrate_document(orig.dup, 'users', @hydration_proc).should == expected
33
+ end
34
+
35
+ it 'leaves the array empty if it was empty' do
36
+ orig = { 'key1' => 37, 'users' => [] }
37
+ DocumentHydrator.hydrate_document(orig.dup, 'users', @hydration_proc).should == orig
38
+ end
39
+
40
+ it 'makes no modification to the document when the path does not exist' do
41
+ orig = { 'key1' => 37, 'users' => [3, 5] }
42
+ DocumentHydrator.hydrate_document(orig.dup, 'losers', @hydration_proc).should == orig
43
+ end
44
+ end
45
+
46
+ context 'with a simple path to an individual id' do
47
+ it 'replaces the id with the corresponding hydrated document' do
48
+ orig = { 'key1' => 37, 'user' => 72}
49
+ expected = { 'key1' => 37, 'user' => { 'id' => 72 }}
50
+ DocumentHydrator.hydrate_document(orig.dup, 'user', @hydration_proc).should == expected
51
+ end
52
+ end
53
+
54
+ context 'with a compound path to an array of ids' do
55
+ it 'replaces the array with an array of hydrated documents' do
56
+ orig = { 'key1' => 37, 'foo' => { 'users' => [27, 39] } }
57
+ expected = { 'key1' => 37, 'foo' => { 'users' => [{ 'id' => 27 }, { 'id' => 39 }] } }
58
+ DocumentHydrator.hydrate_document(orig.dup, 'foo.users', @hydration_proc).should == expected
59
+ end
60
+
61
+ it 'makes no modification to the document when the path does not exist' do
62
+ orig = { 'key1' => 37, 'foo' => { 'users' => [27, 39] } }
63
+ DocumentHydrator.hydrate_document(orig.dup, 'bar.users', @hydration_proc).should == orig
64
+ end
65
+ end
66
+
67
+ context 'with a compound path that includes an array as an intermediate step' do
68
+ it 'hydrates all of the expanded paths' do
69
+ orig = {
70
+ 'key1' => 37,
71
+ 'foos' => [ { 'users' => [27, 39] }, { 'users' => [27, 88] } ]
72
+ }
73
+ expected = {
74
+ 'key1' => 37,
75
+ 'foos' => [ { 'users' => [{ 'id' => 27 }, { 'id' => 39 }] },
76
+ { 'users' => [{ 'id' => 27 }, { 'id' => 88 }] }]
77
+ }
78
+ DocumentHydrator.hydrate_document(orig.dup, 'foos.users', @hydration_proc).should == expected
79
+ end
80
+ end
81
+
82
+ context 'with an array of paths' do
83
+ before(:each) do
84
+ @orig = {
85
+ 'key1' => 77,
86
+ 'users' => [1, 2, 3, 4],
87
+ 'stuff' => {
88
+ 'monkeys' => [99, 1]
89
+ },
90
+ 'blah' => {
91
+ 'nested_stuff' => [
92
+ { 'user' => 99 },
93
+ { 'user' => 101 }
94
+ ]
95
+ }
96
+ }
97
+ @paths = ['users', 'stuff.monkeys', 'blah.nested_stuff.user']
98
+ end
99
+
100
+ it 'achieves the same result as hydrating each path individually' do
101
+ expected = @orig.dup.tap do |document|
102
+ @paths.each { |path| DocumentHydrator.hydrate_document(document, path, @hydration_proc) }
103
+ end
104
+
105
+ DocumentHydrator.hydrate_document(@orig.dup, @paths, @hydration_proc).should == expected
106
+ end
107
+
108
+ it 'invokes the hydration proc only once' do
109
+ DocumentHydrator.hydrate_document(@orig.dup, @paths, @hydration_proc)
110
+ Dummy.invocation_count.should == 1
111
+ end
112
+ end
113
+
114
+ context "with a path whose terminal key ends with '_id'" do
115
+ it "removes the '_id' suffix during hydration" do
116
+ orig = {
117
+ 'key1' => 37,
118
+ 'user_id' => 99
119
+ }
120
+ expected = {
121
+ 'key1' => 37,
122
+ 'user' => { 'id' => 99 }
123
+ }
124
+ DocumentHydrator.hydrate_document(orig.dup, 'user_id', @hydration_proc).should == expected
125
+ end
126
+ end
127
+
128
+ context "with a path whose terminal key ends with '_ids'" do
129
+ it "removes the '_ids' suffix and pluralizes the root during hydration" do
130
+ orig = {
131
+ 'key1' => 37,
132
+ 'foos' => [ { 'user_ids' => [27, 39] }, { 'user_ids' => [27, 88] } ]
133
+ }
134
+ expected = {
135
+ 'key1' => 37,
136
+ 'foos' => [ { 'users' => [{ 'id' => 27 }, { 'id' => 39 }] },
137
+ { 'users' => [{ 'id' => 27 }, { 'id' => 88 }] }]
138
+ }
139
+ DocumentHydrator.hydrate_document(orig.dup, 'foos.user_ids', @hydration_proc).should == expected
140
+ end
141
+ end
142
+ end
143
+
144
+ describe '.hydrate_documents' do
145
+ before(:each) do
146
+ @documents = [
147
+ {
148
+ 'random_key' => 37,
149
+ 'creator' => 99,
150
+ 'users' => [37, 42]
151
+ },
152
+ {
153
+ 'random_key' => 37,
154
+ 'users' => [88, 42]
155
+ }
156
+ ]
157
+ @paths = ['creator', 'users']
158
+ end
159
+
160
+ it 'gives the same result as hydrating each document individually' do
161
+ individual_results = @documents.map { |doc| DocumentHydrator.hydrate_document(doc.dup, @paths, @hydration_proc) }
162
+ DocumentHydrator.hydrate_documents(@documents, @paths, @hydration_proc).should == individual_results
163
+ end
164
+
165
+ it 'invokes the hydration proc only once' do
166
+ DocumentHydrator.hydrate_documents(@documents, @paths, @hydration_proc)
167
+ Dummy.invocation_count.should == 1
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,66 @@
1
+ require 'spec_helper'
2
+
3
+ describe DocumentHydrator::HydrationProc::Mongo, '.collection' do
4
+ before(:each) do
5
+ db = Mongo::Connection.new.db('document_hydrator_test')
6
+ @users_collection = db['users']
7
+ @users_collection.remove
8
+ @users_collection.insert('_id' => 1, 'name' => 'Fred')
9
+ @users_collection.insert('_id' => 2, 'name' => 'Wilma')
10
+ @users_collection.insert('_id' => 3, 'name' => 'Barney')
11
+ @users_collection.insert('_id' => 4, 'name' => 'Betty')
12
+ end
13
+
14
+ it 'returns a hydration proc that fetches subdocuments from the provided Mongo::Collection' do
15
+ hydrator = DocumentHydrator::HydrationProc::Mongo.collection(@users_collection)
16
+ expected = {
17
+ 1 => @users_collection.find_one('_id' => 1),
18
+ 3 => @users_collection.find_one('_id' => 3),
19
+ }
20
+ hydrator.call([1,3]).should == expected
21
+ end
22
+
23
+ it 'integrates with DocumentHydrator' do
24
+ document = { 'users' => [2, 4] }
25
+ expected = { 'users' => [
26
+ { '_id' => 2, 'name' => 'Wilma' },
27
+ { '_id' => 4, 'name' => 'Betty' }
28
+ ]
29
+ }
30
+
31
+ hydrator = DocumentHydrator::HydrationProc::Mongo.collection(@users_collection)
32
+ DocumentHydrator.hydrate_document(document, ['users'], hydrator).should == expected
33
+ end
34
+
35
+ context 'with optional finder options' do
36
+ it 'passes them to Mongo::Collection#find' do
37
+ options = { :fields => { 'name' => 1 } }
38
+ @users_collection.should_receive(:find).with(anything, options).and_return([])
39
+
40
+ hydrator = DocumentHydrator::HydrationProc::Mongo.collection(@users_collection, options)
41
+ hydrator.call([1, 3])
42
+ end
43
+
44
+ it "handles the case of '_id' being explicitly removed from result set" do
45
+ options = { :fields => { 'name' => 1, '_id' => 0 } }
46
+ expected = {
47
+ 1 => { 'name' => 'Fred' },
48
+ 3 => { 'name' => 'Barney' }
49
+ }
50
+
51
+ hydrator = DocumentHydrator::HydrationProc::Mongo.collection(@users_collection, options)
52
+ hydrator.call([1, 3]).should == expected
53
+ end
54
+
55
+ it "handles the case of :_id being explicitly removed from result set" do
56
+ options = { :fields => { :name => 1, :_id => 0 } }
57
+ expected = {
58
+ 1 => { 'name' => 'Fred' },
59
+ 3 => { 'name' => 'Barney' }
60
+ }
61
+
62
+ hydrator = DocumentHydrator::HydrationProc::Mongo.collection(@users_collection, options)
63
+ hydrator.call([1, 3]).should == expected
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ require 'rubygems'
2
+ require 'mongo'
3
+ require 'document_hydrator'
metadata ADDED
@@ -0,0 +1,229 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: document_hydrator
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Greg Spurrier
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-06-19 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ prerelease: false
23
+ type: :development
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ hash: 23
30
+ segments:
31
+ - 1
32
+ - 0
33
+ - 0
34
+ version: 1.0.0
35
+ name: bundler
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ prerelease: false
39
+ type: :development
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ hash: 11
46
+ segments:
47
+ - 1
48
+ - 6
49
+ - 2
50
+ version: 1.6.2
51
+ name: jeweler
52
+ version_requirements: *id002
53
+ - !ruby/object:Gem::Dependency
54
+ prerelease: false
55
+ type: :development
56
+ requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ hash: 23
62
+ segments:
63
+ - 2
64
+ - 6
65
+ - 0
66
+ version: 2.6.0
67
+ name: rspec
68
+ version_requirements: *id003
69
+ - !ruby/object:Gem::Dependency
70
+ prerelease: false
71
+ type: :development
72
+ requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ hash: 43
78
+ segments:
79
+ - 4
80
+ - 4
81
+ - 2
82
+ version: 4.4.2
83
+ name: ZenTest
84
+ version_requirements: *id004
85
+ - !ruby/object:Gem::Dependency
86
+ prerelease: false
87
+ type: :development
88
+ requirement: &id005 !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ hash: 3
94
+ segments:
95
+ - 0
96
+ version: "0"
97
+ name: autotest-growl
98
+ version_requirements: *id005
99
+ - !ruby/object:Gem::Dependency
100
+ prerelease: false
101
+ type: :development
102
+ requirement: &id006 !ruby/object:Gem::Requirement
103
+ none: false
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ hash: 3
108
+ segments:
109
+ - 0
110
+ version: "0"
111
+ name: autotest-fsevent
112
+ version_requirements: *id006
113
+ - !ruby/object:Gem::Dependency
114
+ prerelease: false
115
+ type: :development
116
+ requirement: &id007 !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ hash: 3
122
+ segments:
123
+ - 0
124
+ version: "0"
125
+ name: bson_ext
126
+ version_requirements: *id007
127
+ - !ruby/object:Gem::Dependency
128
+ prerelease: false
129
+ type: :development
130
+ requirement: &id008 !ruby/object:Gem::Requirement
131
+ none: false
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ hash: 3
136
+ segments:
137
+ - 0
138
+ version: "0"
139
+ name: bson
140
+ version_requirements: *id008
141
+ - !ruby/object:Gem::Dependency
142
+ prerelease: false
143
+ type: :development
144
+ requirement: &id009 !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ hash: 3
150
+ segments:
151
+ - 0
152
+ version: "0"
153
+ name: mongo
154
+ version_requirements: *id009
155
+ - !ruby/object:Gem::Dependency
156
+ prerelease: false
157
+ type: :development
158
+ requirement: &id010 !ruby/object:Gem::Requirement
159
+ none: false
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ hash: 3
164
+ segments:
165
+ - 0
166
+ version: "0"
167
+ name: SystemTimer
168
+ version_requirements: *id010
169
+ description: DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.
170
+ email: greg.spurrier@gmail.com
171
+ executables: []
172
+
173
+ extensions: []
174
+
175
+ extra_rdoc_files:
176
+ - LICENSE.txt
177
+ - README.markdown
178
+ files:
179
+ - .autotest
180
+ - .rspec
181
+ - Gemfile
182
+ - LICENSE.txt
183
+ - README.markdown
184
+ - Rakefile
185
+ - VERSION
186
+ - document_hydrator.gemspec
187
+ - lib/document_hydrator.rb
188
+ - lib/document_hydrator/hydration_proc/mongo.rb
189
+ - lib/document_hydrator/inflector.rb
190
+ - lib/document_hydrator/inflector/inflections.rb
191
+ - spec/document_hydrator_spec.rb
192
+ - spec/hydration_proc/mongo_spec.rb
193
+ - spec/spec_helper.rb
194
+ has_rdoc: true
195
+ homepage: http://github.com/gregspurrier/document_hydrator
196
+ licenses:
197
+ - MIT
198
+ post_install_message:
199
+ rdoc_options: []
200
+
201
+ require_paths:
202
+ - lib
203
+ required_ruby_version: !ruby/object:Gem::Requirement
204
+ none: false
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ hash: 3
209
+ segments:
210
+ - 0
211
+ version: "0"
212
+ required_rubygems_version: !ruby/object:Gem::Requirement
213
+ none: false
214
+ requirements:
215
+ - - ">="
216
+ - !ruby/object:Gem::Version
217
+ hash: 3
218
+ segments:
219
+ - 0
220
+ version: "0"
221
+ requirements: []
222
+
223
+ rubyforge_project:
224
+ rubygems_version: 1.6.2
225
+ signing_key:
226
+ specification_version: 3
227
+ summary: DocumentHydrator takes a document, represented as a Ruby Hash, and efficiently updates it so that embedded references to other documents are replaced with their corresponding subdocuments.
228
+ test_files: []
229
+