Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MONGOID-5411 allow results to be returned as demongoized hashes #5877

Merged
merged 4 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading