Skip to content

Commit 1f075db

Browse files
authored
MONGOID-5270 Implement .tally method for Mongoid#Criteria (#5344)
* MONGOID-5270 initial implementation of tally * MONGOID-5270 finish and fully test tally feature * MONGOID-5270 add typeless field test * MONGOID-5270 don't change existing behavior * MONGOID-5270 correct documentation * MONGOID-5702 docs, release note, renaming * MONGOID-5270 add none case * MONGOID-5270 tweaks to pluck and tally logic to make them function correctly and consistently * MONGOID-5270 update comment * MONGOID-5270 add tally implementation * MONGOID-5270 change to not use tally * MONGOID-5270 add duplicate keys test * MONGOID-5270 move fetch_and_demongoize and change indifferent access * MONGOID-5270 use eq instead of be * Revert "MONGOID-5270 use eq instead of be" This reverts commit 25d65ef.
1 parent 9a11a6b commit 1f075db

File tree

13 files changed

+1160
-17
lines changed

13 files changed

+1160
-17
lines changed

docs/reference/queries.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,27 @@ Mongoid also has some helpful methods on criteria.
13801380
# expands out to "managers.name" in the query:
13811381
Band.all.pluck('managers.n')
13821382

1383+
* - ``Criteria#tally``
1384+
1385+
*Get a mapping of values to counts for the provided field.*
1386+
1387+
*This method accepts the dot notation, thus permitting referencing
1388+
fields in embedded associations.*
1389+
1390+
*This method respects :ref:`field aliases <field-aliases>`,
1391+
including those defined in embedded documents.*
1392+
1393+
-
1394+
.. code-block:: ruby
1395+
1396+
Band.all.tally(:name)
1397+
1398+
Band.all.tally('cities.name')
1399+
1400+
# Using the earlier definition of Manager,
1401+
# expands out to "managers.name" in the query:
1402+
Band.all.tally('managers.n')
1403+
13831404

13841405
Eager Loading
13851406
=============

docs/release-notes/mongoid-8.0.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ it was stored and returned as a String.
527527
# => Mongoid 7: "data!"
528528
# => Mongoid 8: <BSON::Binary:0x2600 type=generic data=0x6461746121...>
529529

530+
530531
``Decimal128``-backed ``BigDecimal`` fields
531532
-------------------------------------------
532533

@@ -540,3 +541,35 @@ feature flag turned off, values assigned to fields of type ``BigDecimal`` are
540541
stored as Strings. See the section on :ref:`BigDecimal Fields <bigdecimal-fields>`
541542
for more details.
542543

544+
545+
Implemented ``.tally`` method on ``Mongoid#Criteria``
546+
-----------------------------------------------------
547+
548+
Mongoid 8 implements the ``.tally`` method on ``Mongoid#Criteria``. ``tally``
549+
takes a field name as a parameter and returns a mapping from values to their
550+
counts. For example, take the following model:
551+
552+
.. code::
553+
554+
class User
555+
include Mongoid::Document
556+
field :age
557+
end
558+
559+
and the following documents in the database:
560+
561+
.. code::
562+
563+
{ _id: 1, age: 21 }
564+
{ _id: 2, age: 21 }
565+
{ _id: 3, age: 22 }
566+
567+
Calling ``tally`` on the age field yields the following:
568+
569+
.. code::
570+
571+
User.tally("age")
572+
# => { 21 => 2, 22 => 1 }
573+
574+
The ``tally`` method accepts the dot notation and field aliases. It also
575+
allows for tallying localized fields.

lib/mongoid/association/macros.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module Macros
1414
class_attribute :relations
1515

1616
# A hash that maps aliases to their associations. This is used when
17-
# associations specify the `as` option, or on a referenced association.
17+
# associations specify the `store_as` option, or on a referenced association.
1818
# On a referenced association, this is used to map the foreign key to
1919
# the association's name. For example, if we had the following
2020
# relationship:

lib/mongoid/contextual/memory.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ def pluck(*fields)
200200
documents.pluck(*fields)
201201
end
202202

203+
def tally(field)
204+
return documents.each_with_object({}) do |d, acc|
205+
v = retrieve_value_at_path(d, field)
206+
acc[v] ||= 0
207+
acc[v] += 1
208+
end
209+
end
210+
203211
# Skips the provided number of documents.
204212
#
205213
# @example Skip the documents.
@@ -415,6 +423,62 @@ def prepare_remove(doc)
415423
def _session
416424
@criteria.send(:_session)
417425
end
426+
427+
# Retrieve the value for the current document at the given field path.
428+
#
429+
# For example, if I have the following models:
430+
#
431+
# User has_many Accounts
432+
# address is a hash on Account
433+
#
434+
# u = User.new(accounts: [ Account.new(address: { street: "W 50th" }) ])
435+
# retrieve_value_at_path(u, "user.accounts.address.street")
436+
# # => [ "W 50th" ]
437+
#
438+
# Note that the result is in an array since accounts is an array. If it
439+
# was nested in two arrays the result would be in a 2D array.
440+
#
441+
# @param [ Object ] document The object to traverse the field path.
442+
# @param [ String ] field_path The dotted string that represents the path
443+
# to the value.
444+
#
445+
# @return [ Object | nil ] The value at the given field path or nil if it
446+
# doesn't exist.
447+
def retrieve_value_at_path(document, field_path)
448+
return if field_path.blank? || !document
449+
segment, remaining = field_path.to_s.split('.', 2)
450+
451+
curr = if document.is_a?(Document)
452+
# Retrieves field for segment to check localization. Only does one
453+
# iteration since there's no dots
454+
res = if remaining
455+
field = document.class.traverse_association_tree(segment)
456+
# If this is a localized field, and there are remaining, get the
457+
# _translations hash so that we can get the specified translation in
458+
# the remaining
459+
if field&.localized?
460+
document.send("#{segment}_translations")
461+
end
462+
end
463+
res.nil? ? document.send(segment) : res
464+
elsif document.is_a?(Hash)
465+
# TODO: Remove the indifferent access when implementing MONGOID-5410.
466+
document.key?(segment.to_s) ?
467+
document[segment.to_s] :
468+
document[segment.to_sym]
469+
else
470+
nil
471+
end
472+
473+
return curr unless remaining
474+
475+
if curr.is_a?(Array)
476+
# compact is used for consistency with server behavior.
477+
curr.map { |d| retrieve_value_at_path(d, remaining) }.compact
478+
else
479+
retrieve_value_at_path(curr, remaining)
480+
end
481+
end
418482
end
419483
end
420484
end

lib/mongoid/contextual/mongo.rb

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,75 @@ def pluck(*fields)
454454
end
455455
end
456456

457+
# Get a hash of counts for the values of a single field. For example,
458+
# if the following documents were in the database:
459+
#
460+
# { _id: 1, age: 21 }
461+
# { _id: 2, age: 21 }
462+
# { _id: 3, age: 22 }
463+
#
464+
# Model.tally("age")
465+
#
466+
# would yield the following result:
467+
#
468+
# { 21 => 2, 22 => 1 }
469+
#
470+
# When tallying a field inside an array or embeds_many association:
471+
#
472+
# { _id: 1, array: [ { x: 1 }, { x: 2 } ] }
473+
# { _id: 2, array: [ { x: 1 }, { x: 2 } ] }
474+
# { _id: 3, array: [ { x: 1 }, { x: 3 } ] }
475+
#
476+
# Model.tally("array.x")
477+
#
478+
# The keys of the resulting hash are arrays:
479+
#
480+
# { [ 1, 2 ] => 2, [ 1, 3 ] => 1 }
481+
#
482+
# Note that if tallying an element in an array of hashes, and the key
483+
# doesn't exist in some of the hashes, tally will not include those
484+
# nil keys in the resulting hash:
485+
#
486+
# { _id: 1, array: [ { x: 1 }, { x: 2 }, { y: 3 } ] }
487+
#
488+
# Model.tally("array.x")
489+
# # => { [ 1, 2 ] => 1 }
490+
#
491+
# @param [ String | Symbol ] field The field name.
492+
#
493+
# @return [ Hash ] The hash of counts.
494+
def tally(field)
495+
name = klass.cleanse_localized_field_names(field)
496+
497+
fld = klass.traverse_association_tree(name)
498+
pipeline = [ { "$group" => { _id: "$#{name}", counts: { "$sum": 1 } } } ]
499+
pipeline.unshift("$match" => view.filter) unless view.filter.blank?
500+
501+
collection.aggregate(pipeline).reduce({}) do |tallies, doc|
502+
is_translation = "#{name}_translations" == field.to_s
503+
val = doc["_id"]
504+
505+
key = if val.is_a?(Array)
506+
val.map do |v|
507+
demongoize_with_field(fld, v, is_translation)
508+
end
509+
else
510+
demongoize_with_field(fld, val, is_translation)
511+
end
512+
513+
# The only time where a key will already exist in the tallies hash
514+
# is when the values are stored differently in the database, but
515+
# demongoize to the same value. A good example of when this happens
516+
# is when using localized fields. While the server query won't group
517+
# together hashes that have other values in different languages, the
518+
# demongoized value is just the translation in the current locale,
519+
# which can be the same across multiple of those unequal hashes.
520+
tallies[key] ||= 0
521+
tallies[key] += doc["counts"]
522+
tallies
523+
end
524+
end
525+
457526
# Skips the provided number of documents.
458527
#
459528
# @example Skip the documents.
@@ -695,6 +764,26 @@ def acknowledged_write?
695764
collection.write_concern.nil? || collection.write_concern.acknowledged?
696765
end
697766

767+
# Fetch the element from the given hash and demongoize it using the
768+
# given field. If the obj is an array, map over it and call this method
769+
# on all of its elements.
770+
#
771+
# @param [ Hash | Array<Hash> ] obj The hash or array of hashes to fetch from.
772+
# @param [ String ] meth The key to fetch from the hash.
773+
# @param [ Field ] field The field to use for demongoization.
774+
#
775+
# @return [ Object ] The demongoized value.
776+
#
777+
# @api private
778+
def fetch_and_demongoize(obj, meth, field)
779+
if obj.is_a?(Array)
780+
obj.map { |doc| fetch_and_demongoize(doc, meth, field) }
781+
else
782+
res = obj.try(:fetch, meth, nil)
783+
field ? field.demongoize(res) : res.class.demongoize(res)
784+
end
785+
end
786+
698787
# Extracts the value for the given field name from the given attribute
699788
# hash.
700789
#
@@ -703,21 +792,13 @@ def acknowledged_write?
703792
#
704793
# @param [ Object ] The value for the given field name
705794
def extract_value(attrs, field_name)
706-
def fetch_and_demongoize(d, meth, klass)
707-
res = d.try(:fetch, meth, nil)
708-
if field = klass.fields[meth]
709-
field.demongoize(res)
710-
else
711-
res.class.demongoize(res)
712-
end
713-
end
714-
715795
i = 1
716796
num_meths = field_name.count('.') + 1
717797
k = klass
718798
curr = attrs.dup
719799

720800
klass.traverse_association_tree(field_name) do |meth, obj, is_field|
801+
field = obj if is_field
721802
is_translation = false
722803
# If no association or field was found, check if the meth is an
723804
# _translations field.
@@ -737,18 +818,18 @@ def fetch_and_demongoize(d, meth, klass)
737818
# value so the full hash is returned.
738819
# 4. Otherwise, fetch and demongoize the value for the key meth.
739820
curr = if curr.is_a? Array
740-
res = curr.map { |x| fetch_and_demongoize(x, meth, k) }
821+
res = fetch_and_demongoize(curr, meth, field)
741822
res.empty? ? nil : res
742-
elsif !is_translation && k.fields[meth]&.localized?
823+
elsif !is_translation && field&.localized?
743824
if i < num_meths
744825
curr.try(:fetch, meth, nil)
745826
else
746-
fetch_and_demongoize(curr, meth, k)
827+
fetch_and_demongoize(curr, meth, field)
747828
end
748829
elsif is_translation
749830
curr.try(:fetch, meth, nil)
750831
else
751-
fetch_and_demongoize(curr, meth, k)
832+
fetch_and_demongoize(curr, meth, field)
752833
end
753834

754835
# If it's a relation, update the current klass with the relation klass.
@@ -771,12 +852,28 @@ def fetch_and_demongoize(d, meth, klass)
771852
# @return [ Object ] The demongoized value.
772853
def recursive_demongoize(field_name, value, is_translation)
773854
field = klass.traverse_association_tree(field_name)
855+
demongoize_with_field(field, value, is_translation)
856+
end
774857

858+
# Demongoize the value for the given field. If the field is nil or the
859+
# field is a translations field, the value is demongoized using its class.
860+
#
861+
# @param [ Field ] field The field to use to demongoize.
862+
# @param [ Object ] value The value to demongoize.
863+
# @param [ Boolean ] is_translation The field we are retrieving is an
864+
# _translations field.
865+
#
866+
# @return [ Object ] The demongoized value.
867+
#
868+
# @api private
869+
def demongoize_with_field(field, value, is_translation)
775870
if field
776871
# If it's a localized field that's not a hash, don't demongoize
777872
# again, we already have the translation. If it's an _translations
778873
# field, don't demongoize, we want the full hash not just a
779874
# specific translation.
875+
# If it is a hash, and it's not a transaltions field, we need to
876+
# demongoize to get the correct translation.
780877
if field.localized? && (!value.is_a?(Hash) || is_translation)
781878
value.class.demongoize(value)
782879
else

lib/mongoid/contextual/none.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ def pluck(*args)
9595
[]
9696
end
9797

98+
# Tally the field values in null context.
99+
#
100+
# @example Get the values for null context.
101+
# context.tally(:name)
102+
#
103+
# @param [ String, Symbol ] arg Field to tally.
104+
#
105+
# @return [ Hash ] An empty Hash.
106+
def tally(arg)
107+
{}
108+
end
109+
98110
# Create the new null context.
99111
#
100112
# @example Create the new context.

lib/mongoid/findable.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ module Findable
4343
:sum,
4444
:text_search,
4545
:update,
46-
:update_all
46+
:update_all,
47+
:tally,
4748

4849
# Returns a count of records in the database.
4950
# If you want to specify conditions use where.

0 commit comments

Comments
 (0)