Skip to content

Commit d1a4925

Browse files
authored
MONGOID-5411 allow results to be returned as demongoized hashes (#5877)
* MONGOID-5411 allow results to be returned as demongoized hashes * tests * modify the hash in-place as an optimization * Add a new default mode for raw Returns the hashes exactly as fetched from the database.
1 parent dfe79fc commit d1a4925

File tree

5 files changed

+313
-9
lines changed

5 files changed

+313
-9
lines changed

lib/mongoid/contextual/mongo.rb

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -880,8 +880,18 @@ def documents_for_iteration
880880
#
881881
# @param [ Document ] document The document to yield to.
882882
def yield_document(document, &block)
883-
doc = document.respond_to?(:_id) ?
884-
document : Factory.from_db(klass, document, criteria)
883+
doc = if document.respond_to?(:_id)
884+
document
885+
elsif criteria.raw_results?
886+
if criteria.typecast_results?
887+
demongoize_hash(klass, document)
888+
else
889+
document
890+
end
891+
else
892+
Factory.from_db(klass, document, criteria)
893+
end
894+
885895
yield(doc)
886896
end
887897

@@ -979,6 +989,48 @@ def recursive_demongoize(field_name, value, is_translation)
979989
demongoize_with_field(field, value, is_translation)
980990
end
981991

992+
# Demongoizes (converts from database to Ruby representation) the values
993+
# of the given hash as if it were the raw representation of a document of
994+
# the given klass.
995+
#
996+
# @note this method will modify the given hash, in-place, for performance
997+
# reasons. If you wish to preserve the original hash, duplicate it before
998+
# passing it to this method.
999+
#
1000+
# @param [ Document ] klass the Document class that the given hash ought
1001+
# to represent
1002+
# @param [ Hash | nil ] hash the Hash instance containing the values to
1003+
# demongoize.
1004+
#
1005+
# @return [ Hash | nil ] the demongoized result (nil if the input Hash
1006+
# was nil)
1007+
#
1008+
# @api private
1009+
def demongoize_hash(klass, hash)
1010+
return nil unless hash
1011+
1012+
hash.each_key do |key|
1013+
value = hash[key]
1014+
1015+
# does the key represent a declared field on the document?
1016+
if (field = klass.fields[key])
1017+
hash[key] = field.demongoize(value)
1018+
next
1019+
end
1020+
1021+
# does the key represent an emebedded relation on the document?
1022+
aliased_name = klass.aliased_associations[key] || key
1023+
if (assoc = klass.relations[aliased_name])
1024+
case value
1025+
when Array then value.each { |h| demongoize_hash(assoc.klass, h) }
1026+
when Hash then demongoize_hash(assoc.klass, value)
1027+
end
1028+
end
1029+
end
1030+
1031+
hash
1032+
end
1033+
9821034
# Demongoize the value for the given field. If the field is nil or the
9831035
# field is a translations field, the value is demongoized using its class.
9841036
#
@@ -1013,10 +1065,17 @@ def demongoize_with_field(field, value, is_translation)
10131065
# @return [ Array<Document> | Document ] The list of documents or a
10141066
# single document.
10151067
def process_raw_docs(raw_docs, limit)
1016-
docs = raw_docs.map do |d|
1017-
Factory.from_db(klass, d, criteria)
1018-
end
1019-
docs = eager_load(docs)
1068+
docs = if criteria.raw_results?
1069+
if criteria.typecast_results?
1070+
raw_docs.map { |doc| demongoize_hash(klass, doc) }
1071+
else
1072+
raw_docs
1073+
end
1074+
else
1075+
mapped = raw_docs.map { |doc| Factory.from_db(klass, doc, criteria) }
1076+
eager_load(mapped)
1077+
end
1078+
10201079
limit ? docs : docs.first
10211080
end
10221081

lib/mongoid/criteria.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,67 @@ def embedded?
172172
!!@embedded
173173
end
174174

175+
# Produce a clone of the current criteria object with it's "raw"
176+
# setting set to the given value. A criteria set to "raw" will return
177+
# all results as raw hashes. If `typed` is true, the values in the hashes
178+
# will be typecast according to the fields that they correspond to.
179+
#
180+
# When "raw" is not set (or if `raw_results` is false), the criteria will
181+
# return all results as instantiated Document instances.
182+
#
183+
# @example Return query results as raw hashes:
184+
# Person.where(city: 'Boston').raw
185+
#
186+
# @param [ true | false ] raw_results Whether the new criteria should be
187+
# placed in "raw" mode or not.
188+
# @param [ true | false ] typed Whether the raw results should be typecast
189+
# before being returned. Default is true if raw_results is false, and
190+
# false otherwise.
191+
#
192+
# @return [ Criteria ] the cloned criteria object.
193+
def raw(raw_results = true, typed: nil)
194+
# default for typed is true when raw_results is false, and false when
195+
# raw_results is true.
196+
typed = !raw_results if typed.nil?
197+
198+
if !typed && !raw_results
199+
raise ArgumentError, 'instantiated results must be typecast'
200+
end
201+
202+
clone.tap do |criteria|
203+
criteria._raw_results = { raw: raw_results, typed: typed }
204+
end
205+
end
206+
207+
# An internal helper for getting/setting the "raw" flag on a given criteria
208+
# object.
209+
#
210+
# @return [ nil | Hash ] If set, it is a hash with two keys, :raw and :typed,
211+
# that describe whether raw results should be returned, and whether they
212+
# ought to be typecast.
213+
#
214+
# @api private
215+
attr_accessor :_raw_results
216+
217+
# Predicate that answers the question: is this criteria object currently
218+
# in raw mode? (See #raw for a description of raw mode.)
219+
#
220+
# @return [ true | false ] whether the criteria is in raw mode or not.
221+
def raw_results?
222+
_raw_results && _raw_results[:raw]
223+
end
224+
225+
# Predicate that answers the question: should the results returned by
226+
# this criteria object be typecast? (See #raw for a description of this.)
227+
# The answer is meaningless unless #raw_results? is true, since if
228+
# instantiated document objects are returned they will always be typecast.
229+
#
230+
# @return [ true | false ] whether the criteria should return typecast
231+
# results.
232+
def typecast_results?
233+
_raw_results && _raw_results[:typed]
234+
end
235+
175236
# Extract a single id from the provided criteria. Could be in an $and
176237
# query or a straight _id query.
177238
#
@@ -278,6 +339,7 @@ def merge!(other)
278339
self.documents = other.documents.dup unless other.documents.empty?
279340
self.scoping_options = other.scoping_options
280341
self.inclusions = (inclusions + other.inclusions).uniq
342+
self._raw_results = self._raw_results || other._raw_results
281343
self
282344
end
283345

@@ -513,6 +575,7 @@ def initialize_copy(other)
513575
@inclusions = other.inclusions.dup
514576
@scoping_options = other.scoping_options
515577
@documents = other.documents.dup
578+
self._raw_results = other._raw_results
516579
@context = nil
517580
super
518581
end

lib/mongoid/findable.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ module Findable
4646
:none,
4747
:pick,
4848
:pluck,
49+
:raw,
4950
:read,
5051
:second,
5152
:second!,

spec/mongoid/contextual/mongo_spec.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,16 +1240,26 @@
12401240
subscriber = Mrss::EventSubscriber.new
12411241
context.view.client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
12421242

1243-
enum.next
1243+
# first batch
1244+
5.times { enum.next }
12441245

12451246
find_events = subscriber.all_events.select do |evt|
12461247
evt.command_name == 'find'
12471248
end
1248-
expect(find_events.length).to be(2)
1249+
expect(find_events.length).to be > 0
1250+
get_more_events = subscriber.all_events.select do |evt|
1251+
evt.command_name == 'getMore'
1252+
end
1253+
expect(get_more_events.length).to be == 0
1254+
1255+
# force the second batch to be loaded
1256+
enum.next
1257+
12491258
get_more_events = subscriber.all_events.select do |evt|
12501259
evt.command_name == 'getMore'
12511260
end
1252-
expect(get_more_events.length).to be(0)
1261+
expect(get_more_events.length).to be > 0
1262+
12531263
ensure
12541264
context.view.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
12551265
end

spec/mongoid/criteria_spec.rb

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,6 +2269,177 @@ def self.ages; self; end
22692269
end
22702270
end
22712271

2272+
describe '#raw' do
2273+
let(:result) { results[0] }
2274+
2275+
context 'when the parameters are inconsistent' do
2276+
let(:results) { criteria.raw(false, typed: false).to_a }
2277+
let(:criteria) { Person }
2278+
2279+
it 'raises an ArgumentError' do
2280+
expect { result }.to raise_error(ArgumentError)
2281+
end
2282+
end
2283+
2284+
context 'when returning untyped results' do
2285+
let(:results) { criteria.raw.to_a }
2286+
2287+
context 'without associations' do
2288+
before do
2289+
Band.create(name: 'the band',
2290+
active: true,
2291+
genres: %w[ abc def ],
2292+
member_count: 112,
2293+
rating: 4.2,
2294+
created: Time.now,
2295+
updated: Time.now,
2296+
sales: 1_234_567.89,
2297+
decimal: 9_876_543.21,
2298+
decibels: 140..170,
2299+
deleted: false,
2300+
mojo: Math::PI,
2301+
tags: { 'one' => 1, 'two' => 2 },
2302+
location: LatLng.new(41.74, -111.83))
2303+
end
2304+
2305+
let(:criteria) { Band.where(name: 'the band') }
2306+
2307+
it 'returns a hash' do
2308+
expect(result).to be_a(Hash)
2309+
end
2310+
2311+
it 'does not demongoize the result' do
2312+
expect(result['genres']).to be_a(Array)
2313+
expect(result['decibels']).to be == { 'min' => 140, 'max' => 170 }
2314+
expect(result['location']).to be == [ -111.83, 41.74 ]
2315+
end
2316+
end
2317+
2318+
context 'with associations' do
2319+
before do
2320+
Person.create({
2321+
addresses: [ Address.new(end_date: 2.months.from_now) ],
2322+
passport: Passport.new(exp: 1.year.from_now)
2323+
})
2324+
end
2325+
2326+
let(:criteria) { Person }
2327+
2328+
it 'demongoizes the embedded relation' do
2329+
expect(result['addresses']).to be_a(Array)
2330+
expect(result['addresses'][0]['end_date']).to be_a(Time)
2331+
2332+
# `pass` is how it is stored, `passport` is how it is aliased
2333+
expect(result['pass']).to be_a(Hash)
2334+
expect(result['pass']['exp']).to be_a(Time)
2335+
end
2336+
end
2337+
2338+
context 'with projections' do
2339+
before { Person.create(title: 'sir', dob: Date.new(1980, 1, 1)) }
2340+
2341+
context 'using #only' do
2342+
let(:criteria) { Person.only(:dob) }
2343+
2344+
it 'produces a hash with only the _id and the requested key' do
2345+
expect(result).to be_a(Hash)
2346+
expect(result.keys).to be == %w[ _id dob ]
2347+
expect(result['dob']).to be == Date.new(1980, 1, 1)
2348+
end
2349+
end
2350+
2351+
context 'using #without' do
2352+
let(:criteria) { Person.without(:dob) }
2353+
2354+
it 'produces a hash that excludes requested key' do
2355+
expect(result).to be_a(Hash)
2356+
expect(result.keys).not_to include('dob')
2357+
expect(result.keys).to be_present
2358+
end
2359+
end
2360+
end
2361+
end
2362+
2363+
context 'when returning typed results' do
2364+
let(:results) { criteria.raw(typed: true).to_a }
2365+
2366+
context 'without associations' do
2367+
before do
2368+
Band.create(name: 'the band',
2369+
active: true,
2370+
genres: %w[ abc def ],
2371+
member_count: 112,
2372+
rating: 4.2,
2373+
created: Time.now,
2374+
updated: Time.now,
2375+
sales: 1_234_567.89,
2376+
decimal: 9_876_543.21,
2377+
decibels: 140..170,
2378+
deleted: false,
2379+
mojo: Math::PI,
2380+
tags: { 'one' => 1, 'two' => 2 },
2381+
location: LatLng.new(41.74, -111.83))
2382+
end
2383+
2384+
let(:criteria) { Band.where(name: 'the band') }
2385+
2386+
it 'returns a hash' do
2387+
expect(result).to be_a(Hash)
2388+
end
2389+
2390+
it 'demongoizes the result' do
2391+
expect(result['genres']).to be_a(Array)
2392+
expect(result['decibels']).to be_a(Range)
2393+
expect(result['location']).to be_a(LatLng)
2394+
end
2395+
end
2396+
2397+
context 'with associations' do
2398+
before do
2399+
Person.create({
2400+
addresses: [ Address.new(end_date: 2.months.from_now) ],
2401+
passport: Passport.new(exp: 1.year.from_now)
2402+
})
2403+
end
2404+
2405+
let(:criteria) { Person }
2406+
2407+
it 'demongoizes the embedded relation' do
2408+
expect(result['addresses']).to be_a(Array)
2409+
expect(result['addresses'][0]['end_date']).to be_a(Date)
2410+
2411+
# `pass` is how it is stored, `passport` is how it is aliased
2412+
expect(result['pass']).to be_a(Hash)
2413+
expect(result['pass']['exp']).to be_a(Date)
2414+
end
2415+
end
2416+
2417+
context 'with projections' do
2418+
before { Person.create(title: 'sir', dob: Date.new(1980, 1, 1)) }
2419+
2420+
context 'using #only' do
2421+
let(:criteria) { Person.only(:dob) }
2422+
2423+
it 'produces a hash with only the _id and the requested key' do
2424+
expect(result).to be_a(Hash)
2425+
expect(result.keys).to be == %w[ _id dob ]
2426+
expect(result['dob']).to be == Date.new(1980, 1, 1)
2427+
end
2428+
end
2429+
2430+
context 'using #without' do
2431+
let(:criteria) { Person.without(:dob) }
2432+
2433+
it 'produces a hash that excludes requested key' do
2434+
expect(result).to be_a(Hash)
2435+
expect(result.keys).not_to include('dob')
2436+
expect(result.keys).to be_present
2437+
end
2438+
end
2439+
end
2440+
end
2441+
end
2442+
22722443
describe "#max_scan" do
22732444
max_server_version '4.0'
22742445

0 commit comments

Comments
 (0)