document_hydrator 0.1.0

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.
@@ -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
+