Skip to content

Commit

Permalink
MONGOID-5411 allow results to be returned as demongoized hashes (#5877)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
jamis authored Oct 23, 2024
1 parent dfe79fc commit d1a4925
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 9 deletions.
71 changes: 65 additions & 6 deletions lib/mongoid/contextual/mongo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -880,8 +880,18 @@ def documents_for_iteration
#
# @param [ Document ] document The document to yield to.
def yield_document(document, &block)
doc = document.respond_to?(:_id) ?
document : Factory.from_db(klass, document, criteria)
doc = if document.respond_to?(:_id)
document
elsif criteria.raw_results?
if criteria.typecast_results?
demongoize_hash(klass, document)
else
document
end
else
Factory.from_db(klass, document, criteria)
end

yield(doc)
end

Expand Down Expand Up @@ -979,6 +989,48 @@ def recursive_demongoize(field_name, value, is_translation)
demongoize_with_field(field, value, is_translation)
end

# Demongoizes (converts from database to Ruby representation) the values
# of the given hash as if it were the raw representation of a document of
# the given klass.
#
# @note this method will modify the given hash, in-place, for performance
# reasons. If you wish to preserve the original hash, duplicate it before
# passing it to this method.
#
# @param [ Document ] klass the Document class that the given hash ought
# to represent
# @param [ Hash | nil ] hash the Hash instance containing the values to
# demongoize.
#
# @return [ Hash | nil ] the demongoized result (nil if the input Hash
# was nil)
#
# @api private
def demongoize_hash(klass, hash)
return nil unless hash

hash.each_key do |key|
value = hash[key]

# does the key represent a declared field on the document?
if (field = klass.fields[key])
hash[key] = field.demongoize(value)
next
end

# does the key represent an emebedded relation on the document?
aliased_name = klass.aliased_associations[key] || key
if (assoc = klass.relations[aliased_name])
case value
when Array then value.each { |h| demongoize_hash(assoc.klass, h) }
when Hash then demongoize_hash(assoc.klass, value)
end
end
end

hash
end

# Demongoize the value for the given field. If the field is nil or the
# field is a translations field, the value is demongoized using its class.
#
Expand Down Expand Up @@ -1013,10 +1065,17 @@ def demongoize_with_field(field, value, is_translation)
# @return [ Array<Document> | Document ] The list of documents or a
# single document.
def process_raw_docs(raw_docs, limit)
docs = raw_docs.map do |d|
Factory.from_db(klass, d, criteria)
end
docs = eager_load(docs)
docs = if criteria.raw_results?
if criteria.typecast_results?
raw_docs.map { |doc| demongoize_hash(klass, doc) }
else
raw_docs
end
else
mapped = raw_docs.map { |doc| Factory.from_db(klass, doc, criteria) }
eager_load(mapped)
end

limit ? docs : docs.first
end

Expand Down
63 changes: 63 additions & 0 deletions lib/mongoid/criteria.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,67 @@ def embedded?
!!@embedded
end

# Produce a clone of the current criteria object with it's "raw"
# setting set to the given value. A criteria set to "raw" will return
# all results as raw hashes. If `typed` is true, the values in the hashes
# will be typecast according to the fields that they correspond to.
#
# When "raw" is not set (or if `raw_results` is false), the criteria will
# return all results as instantiated Document instances.
#
# @example Return query results as raw hashes:
# Person.where(city: 'Boston').raw
#
# @param [ true | false ] raw_results Whether the new criteria should be
# placed in "raw" mode or not.
# @param [ true | false ] typed Whether the raw results should be typecast
# before being returned. Default is true if raw_results is false, and
# false otherwise.
#
# @return [ Criteria ] the cloned criteria object.
def raw(raw_results = true, typed: nil)
# default for typed is true when raw_results is false, and false when
# raw_results is true.
typed = !raw_results if typed.nil?

if !typed && !raw_results
raise ArgumentError, 'instantiated results must be typecast'
end

clone.tap do |criteria|
criteria._raw_results = { raw: raw_results, typed: typed }
end
end

# An internal helper for getting/setting the "raw" flag on a given criteria
# object.
#
# @return [ nil | Hash ] If set, it is a hash with two keys, :raw and :typed,
# that describe whether raw results should be returned, and whether they
# ought to be typecast.
#
# @api private
attr_accessor :_raw_results

# Predicate that answers the question: is this criteria object currently
# in raw mode? (See #raw for a description of raw mode.)
#
# @return [ true | false ] whether the criteria is in raw mode or not.
def raw_results?
_raw_results && _raw_results[:raw]
end

# Predicate that answers the question: should the results returned by
# this criteria object be typecast? (See #raw for a description of this.)
# The answer is meaningless unless #raw_results? is true, since if
# instantiated document objects are returned they will always be typecast.
#
# @return [ true | false ] whether the criteria should return typecast
# results.
def typecast_results?
_raw_results && _raw_results[:typed]
end

# Extract a single id from the provided criteria. Could be in an $and
# query or a straight _id query.
#
Expand Down Expand Up @@ -278,6 +339,7 @@ def merge!(other)
self.documents = other.documents.dup unless other.documents.empty?
self.scoping_options = other.scoping_options
self.inclusions = (inclusions + other.inclusions).uniq
self._raw_results = self._raw_results || other._raw_results
self
end

Expand Down Expand Up @@ -513,6 +575,7 @@ def initialize_copy(other)
@inclusions = other.inclusions.dup
@scoping_options = other.scoping_options
@documents = other.documents.dup
self._raw_results = other._raw_results
@context = nil
super
end
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/findable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ module Findable
:none,
:pick,
:pluck,
:raw,
:read,
:second,
:second!,
Expand Down
16 changes: 13 additions & 3 deletions spec/mongoid/contextual/mongo_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1240,16 +1240,26 @@
subscriber = Mrss::EventSubscriber.new
context.view.client.subscribe(Mongo::Monitoring::COMMAND, subscriber)

enum.next
# first batch
5.times { enum.next }

find_events = subscriber.all_events.select do |evt|
evt.command_name == 'find'
end
expect(find_events.length).to be(2)
expect(find_events.length).to be > 0
get_more_events = subscriber.all_events.select do |evt|
evt.command_name == 'getMore'
end
expect(get_more_events.length).to be == 0

# force the second batch to be loaded
enum.next

get_more_events = subscriber.all_events.select do |evt|
evt.command_name == 'getMore'
end
expect(get_more_events.length).to be(0)
expect(get_more_events.length).to be > 0

ensure
context.view.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
end
Expand Down
171 changes: 171 additions & 0 deletions spec/mongoid/criteria_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2269,6 +2269,177 @@ def self.ages; self; end
end
end

describe '#raw' do
let(:result) { results[0] }

context 'when the parameters are inconsistent' do
let(:results) { criteria.raw(false, typed: false).to_a }
let(:criteria) { Person }

it 'raises an ArgumentError' do
expect { result }.to raise_error(ArgumentError)
end
end

context 'when returning untyped results' do
let(:results) { criteria.raw.to_a }

context 'without associations' do
before do
Band.create(name: 'the band',
active: true,
genres: %w[ abc def ],
member_count: 112,
rating: 4.2,
created: Time.now,
updated: Time.now,
sales: 1_234_567.89,
decimal: 9_876_543.21,
decibels: 140..170,
deleted: false,
mojo: Math::PI,
tags: { 'one' => 1, 'two' => 2 },
location: LatLng.new(41.74, -111.83))
end

let(:criteria) { Band.where(name: 'the band') }

it 'returns a hash' do
expect(result).to be_a(Hash)
end

it 'does not demongoize the result' do
expect(result['genres']).to be_a(Array)
expect(result['decibels']).to be == { 'min' => 140, 'max' => 170 }
expect(result['location']).to be == [ -111.83, 41.74 ]
end
end

context 'with associations' do
before do
Person.create({
addresses: [ Address.new(end_date: 2.months.from_now) ],
passport: Passport.new(exp: 1.year.from_now)
})
end

let(:criteria) { Person }

it 'demongoizes the embedded relation' do
expect(result['addresses']).to be_a(Array)
expect(result['addresses'][0]['end_date']).to be_a(Time)

# `pass` is how it is stored, `passport` is how it is aliased
expect(result['pass']).to be_a(Hash)
expect(result['pass']['exp']).to be_a(Time)
end
end

context 'with projections' do
before { Person.create(title: 'sir', dob: Date.new(1980, 1, 1)) }

context 'using #only' do
let(:criteria) { Person.only(:dob) }

it 'produces a hash with only the _id and the requested key' do
expect(result).to be_a(Hash)
expect(result.keys).to be == %w[ _id dob ]
expect(result['dob']).to be == Date.new(1980, 1, 1)
end
end

context 'using #without' do
let(:criteria) { Person.without(:dob) }

it 'produces a hash that excludes requested key' do
expect(result).to be_a(Hash)
expect(result.keys).not_to include('dob')
expect(result.keys).to be_present
end
end
end
end

context 'when returning typed results' do
let(:results) { criteria.raw(typed: true).to_a }

context 'without associations' do
before do
Band.create(name: 'the band',
active: true,
genres: %w[ abc def ],
member_count: 112,
rating: 4.2,
created: Time.now,
updated: Time.now,
sales: 1_234_567.89,
decimal: 9_876_543.21,
decibels: 140..170,
deleted: false,
mojo: Math::PI,
tags: { 'one' => 1, 'two' => 2 },
location: LatLng.new(41.74, -111.83))
end

let(:criteria) { Band.where(name: 'the band') }

it 'returns a hash' do
expect(result).to be_a(Hash)
end

it 'demongoizes the result' do
expect(result['genres']).to be_a(Array)
expect(result['decibels']).to be_a(Range)
expect(result['location']).to be_a(LatLng)
end
end

context 'with associations' do
before do
Person.create({
addresses: [ Address.new(end_date: 2.months.from_now) ],
passport: Passport.new(exp: 1.year.from_now)
})
end

let(:criteria) { Person }

it 'demongoizes the embedded relation' do
expect(result['addresses']).to be_a(Array)
expect(result['addresses'][0]['end_date']).to be_a(Date)

# `pass` is how it is stored, `passport` is how it is aliased
expect(result['pass']).to be_a(Hash)
expect(result['pass']['exp']).to be_a(Date)
end
end

context 'with projections' do
before { Person.create(title: 'sir', dob: Date.new(1980, 1, 1)) }

context 'using #only' do
let(:criteria) { Person.only(:dob) }

it 'produces a hash with only the _id and the requested key' do
expect(result).to be_a(Hash)
expect(result.keys).to be == %w[ _id dob ]
expect(result['dob']).to be == Date.new(1980, 1, 1)
end
end

context 'using #without' do
let(:criteria) { Person.without(:dob) }

it 'produces a hash that excludes requested key' do
expect(result).to be_a(Hash)
expect(result.keys).not_to include('dob')
expect(result.keys).to be_present
end
end
end
end
end

describe "#max_scan" do
max_server_version '4.0'

Expand Down

0 comments on commit d1a4925

Please sign in to comment.