Skip to content

Commit 5be5568

Browse files
committed
MONGOID-5900, MONGOID-5901 Fix #pluck on associations (mongodb#6067)
* use the specialized implementation of pluck * use a custom pluck implementation * new file * incorporate Johnny's changes from mongodb#6044 * use same submodules as master * Make changes suggested by copilot review * rubocop appeasement * fix broken refactoring
1 parent 1d3deaa commit 5be5568

File tree

13 files changed

+610
-127
lines changed

13 files changed

+610
-127
lines changed

.rubocop.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ Metrics/ModuleLength:
7272
Metrics/MethodLength:
7373
Max: 20
7474

75+
Metrics/PerceivedComplexity:
76+
Max: 10
77+
78+
Metrics/CyclomaticComplexity:
79+
Max: 10
80+
81+
Metrics/AbcSize:
82+
Max: 20
83+
7584
RSpec/BeforeAfterAll:
7685
Enabled: false
7786

lib/mongoid/association/embedded/embeds_many/proxy.rb

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class EmbedsMany
1414
# the array of child documents.
1515
class Proxy < Association::Many
1616
include Batchable
17+
extend Forwardable
1718

1819
# Class-level methods for the Proxy class.
1920
module ClassMethods
@@ -54,6 +55,8 @@ def foreign_key_suffix
5455

5556
extend ClassMethods
5657

58+
def_delegators :criteria, :find, :pluck
59+
5760
# Instantiate a new embeds_many association.
5861
#
5962
# @example Create the new association.
@@ -312,35 +315,6 @@ def exists?(id_or_conditions = :none)
312315
end
313316
end
314317

315-
# Finds a document in this association through several different
316-
# methods.
317-
#
318-
# This method delegates to +Mongoid::Criteria#find+. If this method is
319-
# not given a block, it returns one or many documents for the provided
320-
# _id values.
321-
#
322-
# If this method is given a block, it returns the first document
323-
# of those found by the current Criteria object for which the block
324-
# returns a truthy value.
325-
#
326-
# @example Find a document by its id.
327-
# person.addresses.find(BSON::ObjectId.new)
328-
#
329-
# @example Find documents for multiple ids.
330-
# person.addresses.find([ BSON::ObjectId.new, BSON::ObjectId.new ])
331-
#
332-
# @example Finds the first matching document using a block.
333-
# person.addresses.find { |addr| addr.state == 'CA' }
334-
#
335-
# @param [ Object... ] *args Various arguments.
336-
# @param &block Optional block to pass.
337-
# @yield [ Object ] Yields each enumerable element to the block.
338-
#
339-
# @return [ Document | Array<Document> | nil ] A document or matching documents.
340-
def find(...)
341-
criteria.find(...)
342-
end
343-
344318
# Get all the documents in the association that are loaded into memory.
345319
#
346320
# @example Get the in memory documents.

lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ def embedded?
5353
# @param [ Document... ] *args Any number of documents.
5454
#
5555
# @return [ Array<Document> ] The loaded docs.
56-
#
57-
# rubocop:disable Metrics/AbcSize
5856
def <<(*args)
5957
docs = args.flatten
6058
return concat(docs) if docs.size > 1
@@ -89,7 +87,6 @@ def <<(*args)
8987
end
9088
unsynced(_base, foreign_key) and self
9189
end
92-
# rubocop:enable Metrics/AbcSize
9390

9491
alias push <<
9592

@@ -360,8 +357,6 @@ def clear_target_for_nullify
360357
# in bulk
361358
# @param [ Array ] inserts the list of Hashes of attributes that will
362359
# be inserted (corresponding to the ``docs`` list)
363-
#
364-
# rubocop:disable Metrics/AbcSize
365360
def append_document(doc, ids, docs, inserts)
366361
return unless doc
367362

@@ -379,7 +374,6 @@ def append_document(doc, ids, docs, inserts)
379374
unsynced(_base, foreign_key)
380375
end
381376
end
382-
# rubocop:enable Metrics/AbcSize
383377
end
384378
end
385379
end

lib/mongoid/association/referenced/has_many/enumerable.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22
# rubocop:todo all
33

4+
require 'mongoid/pluckable'
5+
46
module Mongoid
57
module Association
68
module Referenced
@@ -12,6 +14,7 @@ class HasMany
1214
class Enumerable
1315
extend Forwardable
1416
include ::Enumerable
17+
include Pluckable
1518

1619
# The three main instance variables are collections of documents.
1720
#
@@ -374,6 +377,43 @@ def marshal_load(data)
374377
@_added, @_loaded, @_unloaded, @executed = data
375378
end
376379

380+
# Plucks the given field names from the documents in the target.
381+
# If the collection has been loaded, it plucks from the loaded
382+
# documents; otherwise, it plucks from the unloaded criteria.
383+
# Regardless, it also plucks from any added documents.
384+
#
385+
# @param [ Symbol... ] *fields The field names to pluck.
386+
#
387+
# @return [ Array | Array<Array> ] The array of field values. If
388+
# multiple fields are given, an array of arrays is returned.
389+
def pluck(*keys)
390+
[].tap do |results|
391+
if _loaded? || _added.any?
392+
klass = @_association.klass
393+
prepared = prepare_pluck(keys, document_class: klass)
394+
end
395+
396+
if _loaded?
397+
docs = _loaded.values.map { |v| BSON::Document.new(v.attributes) }
398+
results.concat pluck_from_documents(docs, prepared[:field_names], document_class: klass)
399+
elsif _unloaded
400+
criteria = if _added.any?
401+
ids_to_exclude = _added.keys
402+
_unloaded.not(:_id.in => ids_to_exclude)
403+
else
404+
_unloaded
405+
end
406+
407+
results.concat criteria.pluck(*keys)
408+
end
409+
410+
if _added.any?
411+
docs = _added.values.map { |v| BSON::Document.new(v.attributes) }
412+
results.concat pluck_from_documents(docs, prepared[:field_names], document_class: klass)
413+
end
414+
end
415+
end
416+
377417
# Reset the enumerable back to its persisted state.
378418
#
379419
# @example Reset the enumerable.

lib/mongoid/association/referenced/has_many/proxy.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def embedded?
3333

3434
extend ClassMethods
3535

36-
def_delegator :criteria, :count
36+
def_delegators :criteria, :count
3737
def_delegators :_target, :first, :in_memory, :last, :reset, :uniq
3838

3939
# Instantiate a new references_many association. Will set the foreign key
@@ -281,6 +281,22 @@ def nullify
281281

282282
alias nullify_all nullify
283283

284+
# Plucks the given field names from the documents in the
285+
# association. It is safe to use whether the association is
286+
# loaded or not, and whether there are unsaved documents in the
287+
# association or not.
288+
#
289+
# @example Pluck the titles of all posts.
290+
# person.posts.pluck(:title)
291+
#
292+
# @param [ Symbol... ] *fields The field names to pluck.
293+
#
294+
# @return [ Array | Array<Array> ] The array of field values. If
295+
# multiple fields are given, an array of arrays is returned.
296+
def pluck(*fields)
297+
_target.pluck(*fields)
298+
end
299+
284300
# Clear the association. Will delete the documents from the db if they are
285301
# already persisted.
286302
#

lib/mongoid/contextual/mongo.rb

Lines changed: 8 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# rubocop:todo all
33

44
require 'mongoid/atomic_update_preparer'
5+
require 'mongoid/pluckable'
56
require "mongoid/contextual/mongo/documents_loader"
67
require "mongoid/contextual/atomic"
78
require "mongoid/contextual/aggregable/mongo"
@@ -22,6 +23,7 @@ class Mongo
2223
include Atomic
2324
include Association::EagerLoadable
2425
include Queryable
26+
include Pluckable
2527

2628
# Options constant.
2729
OPTIONS = [ :hint,
@@ -331,23 +333,12 @@ def map_reduce(map, reduce)
331333
# in the array will be a single value. Otherwise, each
332334
# result in the array will be an array of values.
333335
def pluck(*fields)
334-
# Multiple fields can map to the same field name. For example, plucking
335-
# a field and its _translations field map to the same field in the database.
336-
# because of this, we need to keep track of the fields requested.
337-
normalized_field_names = []
338-
normalized_select = fields.inject({}) do |hash, f|
339-
db_fn = klass.database_field_name(f)
340-
normalized_field_names.push(db_fn)
341-
hash[klass.cleanse_localized_field_names(f)] = true
342-
hash
343-
end
344-
345-
view.projection(normalized_select).reduce([]) do |plucked, doc|
346-
values = normalized_field_names.map do |n|
347-
extract_value(doc, n)
348-
end
349-
plucked << (values.size == 1 ? values.first : values)
350-
end
336+
# Multiple fields can map to the same field name. For example,
337+
# plucking a field and its _translations field map to the same
338+
# field in the database. because of this, we need to prepare the
339+
# projection specifically.
340+
prep = prepare_pluck(fields, prepare_projection: true)
341+
pluck_from_documents(view.projection(prep[:projection]), prep[:field_names])
351342
end
352343

353344
# Pick the single field values from the database.
@@ -893,78 +884,6 @@ def acknowledged_write?
893884
collection.write_concern.nil? || collection.write_concern.acknowledged?
894885
end
895886

896-
# Fetch the element from the given hash and demongoize it using the
897-
# given field. If the obj is an array, map over it and call this method
898-
# on all of its elements.
899-
#
900-
# @param [ Hash | Array<Hash> ] obj The hash or array of hashes to fetch from.
901-
# @param [ String ] meth The key to fetch from the hash.
902-
# @param [ Field ] field The field to use for demongoization.
903-
#
904-
# @return [ Object ] The demongoized value.
905-
#
906-
# @api private
907-
def fetch_and_demongoize(obj, meth, field)
908-
if obj.is_a?(Array)
909-
obj.map { |doc| fetch_and_demongoize(doc, meth, field) }
910-
else
911-
res = obj.try(:fetch, meth, nil)
912-
field ? field.demongoize(res) : res.class.demongoize(res)
913-
end
914-
end
915-
916-
# Extracts the value for the given field name from the given attribute
917-
# hash.
918-
#
919-
# @param [ Hash ] attrs The attributes hash.
920-
# @param [ String ] field_name The name of the field to extract.
921-
#
922-
# @param [ Object ] The value for the given field name
923-
def extract_value(attrs, field_name)
924-
i = 1
925-
num_meths = field_name.count('.') + 1
926-
curr = attrs.dup
927-
928-
klass.traverse_association_tree(field_name) do |meth, obj, is_field|
929-
field = obj if is_field
930-
is_translation = false
931-
# If no association or field was found, check if the meth is an
932-
# _translations field.
933-
if obj.nil? & tr = meth.match(/(.*)_translations\z/)&.captures&.first
934-
is_translation = true
935-
meth = tr
936-
end
937-
938-
# 1. If curr is an array fetch from all elements in the array.
939-
# 2. If the field is localized, and is not an _translations field
940-
# (_translations fields don't show up in the fields hash).
941-
# - If this is the end of the methods, return the translation for
942-
# the current locale.
943-
# - Otherwise, return the whole translations hash so the next method
944-
# can select the language it wants.
945-
# 3. If the meth is an _translations field, do not demongoize the
946-
# value so the full hash is returned.
947-
# 4. Otherwise, fetch and demongoize the value for the key meth.
948-
curr = if curr.is_a? Array
949-
res = fetch_and_demongoize(curr, meth, field)
950-
res.empty? ? nil : res
951-
elsif !is_translation && field&.localized?
952-
if i < num_meths
953-
curr.try(:fetch, meth, nil)
954-
else
955-
fetch_and_demongoize(curr, meth, field)
956-
end
957-
elsif is_translation
958-
curr.try(:fetch, meth, nil)
959-
else
960-
fetch_and_demongoize(curr, meth, field)
961-
end
962-
963-
i += 1
964-
end
965-
curr
966-
end
967-
968887
# Recursively demongoize the given value. This method recursively traverses
969888
# the class tree to find the correct field to use to demongoize the value.
970889
#

0 commit comments

Comments
 (0)