Skip to content
35 changes: 35 additions & 0 deletions docs/reference/queries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,41 @@ Mongoid also has some helpful methods on criteria.
Band.all.pluck(:name, :likes)
#=> [ ["Daft Punk", 342], ["Aphex Twin", 98], ["Ween", 227] ]

* - ``Criteria#pluck_each``

*This method returns an Enumerator for the results of ``pluck``.
A block may optionally be given, which will be called once for
each result.*

*Similar to the ``each`` method, this method will use the
`MongoDB getMore command
<https://mongodb.com/docs/manual/reference/command/getMore/>`_
to load results in batches. This is useful for working with
large query results.*

*The method arguments and field normalization behavior are
otherwise identical to ``pluck``.*

-
.. code-block:: ruby

Band.all.pluck_each(:name)
#=> #<Enumerator: ... >

Band.all.pluck_each(:name, 'address.city', :founded) do |name, city, founded|
puts "#{name} from #{city} started in #{founded}"
end
# =>
# The Rolling Stones from London started in 1962
# The Beatles from Liverpool started in 1960
# The Monkees from Los Angeles started in 1966
#=> [ ["Berry Gordy", "Tommy Mottola"], [], ["Quincy Jones"] ]

# Accepts multiple field arguments, in which case
# the result will be returned as an Array of Arrays.
Band.all.pluck(:name, :likes)
#=> [ ["Daft Punk", 342], ["Aphex Twin", 98], ["Ween", 227] ]

* - ``Criteria#read``

*Sets the read preference for the criteria.*
Expand Down
29 changes: 29 additions & 0 deletions docs/release-notes/mongoid-9.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,35 @@ defaults to ``true``.
When set to false, the older, inconsistent behavior is restored.


``Criteria#pluck_each`` Method Added
----------------------------------------

The newly introduced ``Criteria#pluck_each`` method returns
an Enumerator for the results of ``pluck``, or if a block is given,
calls the block once for each pluck result in a progressively-loaded
fashion.

Previously, calling ``criteria.pluck(:name).each`` would load the
entire result set into Ruby's memory before iterating over the results.
In contrast, ``criteria.pluck_each(:name)`` uses the `MongoDB getMore command
<https://mongodb.com/docs/manual/reference/command/getMore/>`_
to load results in batches, similar to how ``criteria.each`` behaves.
This is useful for working with large query results.

The method arguments and behavior of ``pluck_each`` are otherwise
identical to ``pluck``.

.. code-block:: ruby

Band.all.pluck_each(:name, 'address.city', :founded) do |name, city, founded|
puts "#{name} from #{city} started in #{founded}"
end
# =>
# The Rolling Stones from London started in 1962
# The Beatles from Liverpool started in 1960
# The Monkees from Los Angeles started in 1966


Support Field Aliases on Index Options
--------------------------------------

Expand Down
16 changes: 16 additions & 0 deletions lib/mongoid/contextual/memory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ def pluck(*fields)
end
end

# Iterate through plucked field values in memory.
#
# @example Iterate through the values for null context.
# context.pluck_each(:name) { |name| puts name }
#
# @param [ [ String | Symbol ]... ] *fields Field(s) to pluck.
# @param [ Proc ] &block The block to call once for each plucked
# result.
#
# @return [ Enumerator | Memory ] An enumerator, or the context
# if a block was given.
def pluck_each(*fields, &block)
enum = pluck(*fields).each(&block)
block_given? ? self : enum
end

# Pick the field values in memory.
#
# @example Get the values in memory.
Expand Down
109 changes: 21 additions & 88 deletions lib/mongoid/contextual/mongo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require "mongoid/contextual/command"
require "mongoid/contextual/geo_near"
require "mongoid/contextual/map_reduce"
require "mongoid/contextual/mongo/pluck_enumerator"
require "mongoid/association/eager_loadable"

module Mongoid
Expand Down Expand Up @@ -357,23 +358,27 @@ def map_reduce(map, reduce)
# in the array will be a single value. Otherwise, each
# result in the array will be an array of values.
def pluck(*fields)
# Multiple fields can map to the same field name. For example, plucking
# a field and its _translations field map to the same field in the database.
# because of this, we need to keep track of the fields requested.
normalized_field_names = []
normalized_select = fields.inject({}) do |hash, f|
db_fn = klass.database_field_name(f)
normalized_field_names.push(db_fn)
hash[klass.cleanse_localized_field_names(f)] = true
hash
end
pluck_each(*fields).to_a
end

view.projection(normalized_select).reduce([]) do |plucked, doc|
values = normalized_field_names.map do |n|
extract_value(doc, n)
end
plucked << (values.size == 1 ? values.first : values)
end
# Iterate through plucked field value(s) from the database
# for the context. Yields result values progressively as they are
# read from the database. The yielded results are normalized
# according to their Mongoid field types.
#
# @example Iterate through the plucked values from the database.
# context.pluck_each(:name) { |name| puts name }
#
# @param [ [ String | Symbol ]... ] *fields Field(s) to pluck,
# which may include nested fields using dot-notation.
# @param [ Proc ] &block The block to call once for each plucked
# result.
#
# @return [ Enumerator | Mongoid::Contextual::Mongo ] The enumerator,
# or the context if a block was given.
def pluck_each(*fields, &block)
enum = PluckEnumerator.new(klass, view, fields).each(&block)
block_given? ? self : enum
end

# Pick the single field values from the database.
Expand Down Expand Up @@ -919,78 +924,6 @@ def acknowledged_write?
collection.write_concern.nil? || collection.write_concern.acknowledged?
end

# Fetch the element from the given hash and demongoize it using the
# given field. If the obj is an array, map over it and call this method
# on all of its elements.
#
# @param [ Hash | Array<Hash> ] obj The hash or array of hashes to fetch from.
# @param [ String ] meth The key to fetch from the hash.
# @param [ Field ] field The field to use for demongoization.
#
# @return [ Object ] The demongoized value.
#
# @api private
def fetch_and_demongoize(obj, meth, field)
if obj.is_a?(Array)
obj.map { |doc| fetch_and_demongoize(doc, meth, field) }
else
res = obj.try(:fetch, meth, nil)
field ? field.demongoize(res) : res.class.demongoize(res)
end
end

# Extracts the value for the given field name from the given attribute
# hash.
#
# @param [ Hash ] attrs The attributes hash.
# @param [ String ] field_name The name of the field to extract.
#
# @param [ Object ] The value for the given field name
def extract_value(attrs, field_name)
i = 1
num_meths = field_name.count('.') + 1
curr = attrs.dup

klass.traverse_association_tree(field_name) do |meth, obj, is_field|
field = obj if is_field
is_translation = false
# If no association or field was found, check if the meth is an
# _translations field.
if obj.nil? & tr = meth.match(/(.*)_translations\z/)&.captures&.first
is_translation = true
meth = tr
end

# 1. If curr is an array fetch from all elements in the array.
# 2. If the field is localized, and is not an _translations field
# (_translations fields don't show up in the fields hash).
# - If this is the end of the methods, return the translation for
# the current locale.
# - Otherwise, return the whole translations hash so the next method
# can select the language it wants.
# 3. If the meth is an _translations field, do not demongoize the
# value so the full hash is returned.
# 4. Otherwise, fetch and demongoize the value for the key meth.
curr = if curr.is_a? Array
res = fetch_and_demongoize(curr, meth, field)
res.empty? ? nil : res
elsif !is_translation && field&.localized?
if i < num_meths
curr.try(:fetch, meth, nil)
else
fetch_and_demongoize(curr, meth, field)
end
elsif is_translation
curr.try(:fetch, meth, nil)
else
fetch_and_demongoize(curr, meth, field)
end

i += 1
end
curr
end

# Recursively demongoize the given value. This method recursively traverses
# the class tree to find the correct field to use to demongoize the value.
#
Expand Down
147 changes: 147 additions & 0 deletions lib/mongoid/contextual/mongo/pluck_enumerator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# frozen_string_literal: true

module Mongoid
module Contextual
class Mongo

# Utility class to add enumerable behavior for Criteria#pluck_each.
#
# @api private
class PluckEnumerator
include Enumerable

# Create the new PluckEnumerator.
#
# @api private
#
# @example Initialize a PluckEnumerator.
# PluckEnumerator.new(klass, view, fields)
#
# @param [ Class ] klass The base of the binding.
# @param [ Mongo::Collection::View ] view The Mongo view context.
# @param [ String | Symbol ] *fields Field(s) to pluck,
# which may include nested fields using dot-notation.
def initialize(klass, view, fields)
@klass = klass
@view = view
@fields = fields
end

# Iterate through plucked field value(s) from the database
# for the view context. Yields result values progressively as
# they are read from the database. The yielded results are
# normalized according to their Mongoid field types.
#
# @api private
#
# @example Iterate through the plucked values from the database.
# context.pluck_each(:name) { |name| puts name }
#
# @param [ Proc ] &block The block to call once for each plucked
# result.
#
# @return [ Enumerator | PluckEnumerator ] The enumerator, or
# self if a block was given.
def each(&block)
return to_enum unless block_given?

@view.projection(normalized_field_names.index_with(true)).each do |doc|
yield_result(doc, &block)
end

self
end

private

def database_field_names
@database_field_names ||= @fields.map {|f| @klass.database_field_name(f) }
end

def normalized_field_names
@normalized_field_names ||= @fields.map {|f| @klass.cleanse_localized_field_names(f) }
end

def yield_result(doc)
values = database_field_names.map {|n| extract_value(doc, n) }
yield(values.size == 1 ? values.first : values)
end

# Fetch the element from the given hash and demongoize it using the
# given field. If the obj is an array, map over it and call this method
# on all of its elements.
#
# @param [ Hash | Array<Hash> ] obj The hash or array of hashes to fetch from.
# @param [ String ] meth The key to fetch from the hash.
# @param [ Field ] field The field to use for demongoization.
#
# @return [ Object ] The demongoized value.
#
# @api private
def fetch_and_demongoize(obj, meth, field)
if obj.is_a?(Array)
obj.map { |doc| fetch_and_demongoize(doc, meth, field) }
else
res = obj.try(:fetch, meth, nil)
field ? field.demongoize(res) : res.class.demongoize(res)
end
end

# Extracts the value for the given field name from the given attribute
# hash.
#
# @param [ Hash ] attrs The attributes hash.
# @param [ String ] field_name The name of the field to extract.
#
# @return [ Object ] The value for the given field name
#
# @api private
def extract_value(attrs, field_name)
i = 1
num_meths = field_name.count('.') + 1
curr = attrs.dup

@klass.traverse_association_tree(field_name) do |meth, obj, is_field|
field = obj if is_field
is_translation = false
# If no association or field was found, check if the meth is an
# _translations field.
if obj.nil? & tr = meth.match(/(.*)_translations\z/)&.captures&.first
is_translation = true
meth = tr
end

# 1. If curr is an array fetch from all elements in the array.
# 2. If the field is localized, and is not an _translations field
# (_translations fields don't show up in the fields hash).
# - If this is the end of the methods, return the translation for
# the current locale.
# - Otherwise, return the whole translations hash so the next method
# can select the language it wants.
# 3. If the meth is an _translations field, do not demongoize the
# value so the full hash is returned.
# 4. Otherwise, fetch and demongoize the value for the key meth.
curr = if curr.is_a? Array
res = fetch_and_demongoize(curr, meth, field)
res.empty? ? nil : res
elsif !is_translation && field&.localized?
if i < num_meths
curr.try(:fetch, meth, nil)
else
fetch_and_demongoize(curr, meth, field)
end
elsif is_translation
curr.try(:fetch, meth, nil)
else
fetch_and_demongoize(curr, meth, field)
end

i += 1
end

curr
end
end
end
end
end
Loading