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-5391 - Add #pluck_each, extract out PluckEnumerator #5497

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
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
Loading