Skip to content

Commit e39ae6f

Browse files
authored
MONGOID-4911 Range expansion must use $elemMatch to ensure both $gte and $lte match the same array element (#5188)
* MONGOID-4911 make range use elemMatch * MONGOID-4911 tests evolve_multi * MONGOID-4911 add range and matcher tests * MONGOID-4911 add more range tests * MONGOID-4911 refactor get_serializer * MONGOID-4911 add matcher test * MONGOID-4911 fix aliased associations and refactor distinct * MONGOID-4911 test aliased fields * MONGOID-4911 refactor using traverse association tree * MONGOID-4911 update comments and update logic for traverse * MONGOID-4911 fix belongs_to bug * MONGOID-4911 update doc string for database_field_name * MONGOID-4911 update tests and comments * MONGOID-4911 add tests and update comments * empty commit
1 parent 95bc4ef commit e39ae6f

File tree

15 files changed

+624
-103
lines changed

15 files changed

+624
-103
lines changed

lib/mongoid/contextual/mongo.rb

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -712,15 +712,18 @@ def fetch_and_demongoize(d, meth, klass)
712712
end
713713
end
714714

715+
i = 1
716+
num_meths = field_name.count('.') + 1
715717
k = klass
716-
meths = field_name.split('.')
717-
meths.each_with_index.inject(attrs) do |curr, (meth, i)|
718+
curr = attrs.dup
719+
720+
klass.traverse_association_tree(field_name) do |meth, obj, is_field|
718721
is_translation = false
719-
if !k.fields.key?(meth) && !k.relations.key?(meth)
720-
if tr = meth.match(/(.*)_translations\z/)&.captures&.first
721-
is_translation = true
722-
meth = tr
723-
end
722+
# If no association or field was found, check if the meth is an
723+
# _translations field.
724+
if obj.nil? & tr = meth.match(/(.*)_translations\z/)&.captures&.first
725+
is_translation = true
726+
meth = tr
724727
end
725728

726729
# 1. If curr is an array fetch from all elements in the array.
@@ -733,11 +736,11 @@ def fetch_and_demongoize(d, meth, klass)
733736
# 3. If the meth is an _translations field, do not demongoize the
734737
# value so the full hash is returned.
735738
# 4. Otherwise, fetch and demongoize the value for the key meth.
736-
if curr.is_a? Array
739+
curr = if curr.is_a? Array
737740
res = curr.map { |x| fetch_and_demongoize(x, meth, k) }
738741
res.empty? ? nil : res
739742
elsif !is_translation && k.fields[meth]&.localized?
740-
if i < meths.length-1
743+
if i < num_meths
741744
curr.try(:fetch, meth, nil)
742745
else
743746
fetch_and_demongoize(curr, meth, k)
@@ -746,18 +749,15 @@ def fetch_and_demongoize(d, meth, klass)
746749
curr.try(:fetch, meth, nil)
747750
else
748751
fetch_and_demongoize(curr, meth, k)
749-
end.tap do
750-
if as = k.try(:aliased_associations)
751-
if a = as.fetch(meth, nil)
752-
meth = a
753-
end
754-
end
752+
end
755753

756-
if relation = k.relations[meth]
757-
k = relation.klass
758-
end
754+
# If it's a relation, update the current klass with the relation klass.
755+
if !is_field && !obj.nil?
756+
k = obj.klass
759757
end
758+
i += 1
760759
end
760+
curr
761761
end
762762

763763
# Recursively demongoize the given value. This method recursively traverses
@@ -770,29 +770,20 @@ def fetch_and_demongoize(d, meth, klass)
770770
#
771771
# @return [ Object ] The demongoized value.
772772
def recursive_demongoize(field_name, value, is_translation)
773-
k = klass
774-
field_name.split('.').each do |meth|
775-
if as = k.try(:aliased_associations)
776-
if a = as.fetch(meth, nil)
777-
meth = a.to_s
778-
end
779-
end
780-
781-
if relation = k.relations[meth]
782-
k = relation.klass
783-
elsif field = k.fields[meth]
784-
# If it's a localized field that's not a hash, don't demongoize
785-
# again, we already have the translation. If it's an _translation
786-
# field, don't demongoize, we want the full hash not just a
787-
# specific translation.
788-
if field.localized? && (!value.is_a?(Hash) || is_translation)
789-
return value.class.demongoize(value)
790-
else
791-
return field.demongoize(value)
792-
end
773+
field = klass.traverse_association_tree(field_name)
774+
775+
if field
776+
# If it's a localized field that's not a hash, don't demongoize
777+
# again, we already have the translation. If it's an _translations
778+
# field, don't demongoize, we want the full hash not just a
779+
# specific translation.
780+
if field.localized? && (!value.is_a?(Hash) || is_translation)
781+
value.class.demongoize(value)
793782
else
794-
return value.class.demongoize(value)
783+
field.demongoize(value)
795784
end
785+
else
786+
value.class.demongoize(value)
796787
end
797788
end
798789
end

lib/mongoid/criteria.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def initialize(klass)
230230
@klass = klass
231231
@embedded = nil
232232
@none = nil
233-
klass ? super(klass.aliased_fields, klass.fields, klass.relations) : super({}, {}, {})
233+
klass ? super(klass.aliased_fields, klass.fields, klass.relations, klass.aliased_associations) : super({}, {}, {}, {})
234234
end
235235

236236
# Merges another object with this +Criteria+ and returns a new criteria.

lib/mongoid/criteria/queryable.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ def ==(other)
6060
# @param [ Hash ] aliases The optional field aliases.
6161
# @param [ Hash ] serializers The optional field serializers.
6262
# @param [ Hash ] associations The optional associations.
63+
# @param [ Hash ] aliased_associations The optional aliased associations.
6364
# @param [ Symbol ] driver The driver being used.
6465
#
6566
# @api private
66-
def initialize(aliases = {}, serializers = {}, associations = {}, driver = :mongo)
67+
def initialize(aliases = {}, serializers = {}, associations = {}, aliased_associations = {}, driver = :mongo)
6768
@aliases, @driver, @serializers = aliases, driver.to_sym, serializers
68-
@options = Options.new(aliases, serializers)
69-
@selector = Selector.new(aliases, serializers, associations)
69+
@options = Options.new(aliases, serializers, associations, aliased_associations)
70+
@selector = Selector.new(aliases, serializers, associations, aliased_associations)
7071
@pipeline = Pipeline.new(aliases)
7172
@aggregating = nil
7273
yield(self) if block_given?

lib/mongoid/criteria/queryable/options.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def to_pipeline
8484
#
8585
# @return [ Options ] The copied options.
8686
def __deep_copy__
87-
self.class.new(aliases, serializers) do |copy|
87+
self.class.new(aliases, serializers, associations, aliased_associations) do |copy|
8888
each_pair do |key, value|
8989
copy.merge!(key => value.__deep_copy__)
9090
end

lib/mongoid/criteria/queryable/selector.rb

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ def store(key, value)
5353
store_name = name
5454
store_value = evolve_multi(value)
5555
else
56-
store_name = localized_key(name, serializer)
57-
store_value = evolve(serializer, value)
56+
store_name, store_value = store_creds(name, serializer, value)
5857
end
5958
super(store_name, store_value)
6059
end
@@ -74,6 +73,24 @@ def to_pipeline
7473

7574
private
7675

76+
# Get the store name and store value. If the value is of type range,
77+
# we need may need to change the store_name as well as the store_value,
78+
# therefore, we cannot just use the evole method.
79+
#
80+
# @param [ String ] name The name of the field.
81+
# @param [ Object ] serializer The optional serializer for the field.
82+
# @param [ Object ] value The value to serialize.
83+
#
84+
# @return [ Array<String, String> ] The store name and store value.
85+
def store_creds(name, serializer, value)
86+
store_name = localized_key(name, serializer)
87+
if Range === value
88+
evolve_range(store_name, serializer, value)
89+
else
90+
[ store_name, evolve(serializer, value) ]
91+
end
92+
end
93+
7794
# Evolves a multi-list selection, like an $and or $or criterion, and
7895
# performs the necessary serialization.
7996
#
@@ -103,11 +120,10 @@ def evolve_multi(specs)
103120
# some reason, although per its documentation Smash supposedly
104121
# owns both.
105122
name, serializer = storage_pair(key)
106-
final_key = localized_key(name, serializer)
107123
# This performs type conversions on the value and transformations
108124
# that depend on the type of the field that the value is stored
109125
# in, but not transformations that have to do with query shape.
110-
evolved_value = evolve(serializer, value)
126+
final_key, evolved_value = store_creds(name, serializer, value)
111127

112128
# This builds a query shape around the value, when the query
113129
# involves complex keys. For example, {:foo.lt => 5} produces
@@ -140,7 +156,7 @@ def evolve(serializer, value)
140156
when Array
141157
evolve_array(serializer, value)
142158
when Range
143-
evolve_range(serializer, value)
159+
value.__evolve_range__(serializer: serializer)
144160
else
145161
(serializer || value.class).evolve(value)
146162
end
@@ -184,16 +200,71 @@ def evolve_hash(serializer, value)
184200
end
185201
end
186202

187-
# Evolve a single key selection with range values.
203+
# Evolve a single key selection with range values. This method traverses
204+
# the association tree to build a query for the given value and
205+
# serializer. There are three parts to the query here:
188206
#
189-
# @param [ Object ] serializer The optional serializer for the field.
190-
# @param [ Range ] value The Range to serialize.
207+
# (1) "klass.child.gchild" => {
208+
# "$elemMatch" => {
209+
# (2) "ggchild.field" => (3) { "$gte" => 6, "$lte" => 10 }
210+
# }
211+
# }
212+
# (1) The first n fields are dotted together until the last
213+
# embeds_many or field of type array. In the above case, gchild
214+
# would be an embeds_many or Array, and ggchild would be an
215+
# embeds_one or a hash.
216+
# (2) The last fields are used inside the $elemMatch. This one is
217+
# actually optional, and will be ignored if the last field is an
218+
# array or embeds_many. If the last field is an array (1), (2) and
219+
# (3) will look like:
220+
#
221+
# "klass.child.gchild.ggchild.field" => {
222+
# { "$elemMatch" => { "$gte" => 6, "$lte" => 10 } }
223+
# }
224+
#
225+
# (3) This is calculated by:
191226
#
192-
# @return [ Range ] The serialized Range.
227+
# value.__evolve_range__(serializer: serializer).
193228
#
194229
# @api private
195-
def evolve_range(serializer, value)
196-
value.__evolve_range__(serializer: serializer)
230+
#
231+
# @param [ String ] key The to store the range for.
232+
# @param [ Object ] serializer The optional serializer for the field.
233+
# @param [ Range ] value The Range to serialize.
234+
#
235+
# @return [ Array<String, Hash> ] The store name and serialized Range.
236+
def evolve_range(key, serializer, value)
237+
v = value.__evolve_range__(serializer: serializer)
238+
assocs = []
239+
Fields.traverse_association_tree(key, serializers, associations, aliased_associations) do |meth, obj, is_field|
240+
assocs.push([meth, obj, is_field])
241+
end
242+
243+
# Iterate backwards until you get a field with type
244+
# Array or an embeds_many association.
245+
inner_key = ""
246+
loop do
247+
# If there are no arrays or embeds_many associations, just return
248+
# the key and value without $elemMatch.
249+
return [ key, v ] if assocs.empty?
250+
251+
meth, obj, is_field = assocs.last
252+
break if (is_field && obj.type == Array) || (!is_field && obj.is_a?(Association::Embedded::EmbedsMany))
253+
254+
assocs.pop
255+
inner_key = "#{meth}.#{inner_key}"
256+
end
257+
258+
# If the last array or embeds_many association is the last field,
259+
# the inner key (2) is ignored, and the outer key (1) is the original
260+
# key.
261+
if inner_key.blank?
262+
[ key, { "$elemMatch" => v }]
263+
else
264+
store_key = assocs.map(&:first).join('.')
265+
store_value = { "$elemMatch" => { inner_key.chop => v } }
266+
[ store_key, store_value ]
267+
end
197268
end
198269

199270
# Determines if the selection is a multi-select, like an $and or $or or $nor

lib/mongoid/criteria/queryable/smash.rb

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@ class Smash < Hash
1616
# @attribute [r] associations The associations.
1717
attr_reader :associations
1818

19+
# @attribute [r] aliased_associations The aliased_associations.
20+
attr_reader :aliased_associations
21+
1922
# Perform a deep copy of the smash.
2023
#
2124
# @example Perform a deep copy.
2225
# smash.__deep_copy__
2326
#
2427
# @return [ Smash ] The copied hash.
2528
def __deep_copy__
26-
self.class.new(aliases, serializers, associations) do |copy|
29+
self.class.new(aliases, serializers, associations, aliased_associations) do |copy|
2730
each_pair do |key, value|
2831
copy.store(key, value.__deep_copy__)
2932
end
@@ -41,8 +44,15 @@ def __deep_copy__
4144
# responsible for serializing values. The keys of the hash must be
4245
# strings that match the field name, and the values must respond to
4346
# #localized? and #evolve(object).
44-
def initialize(aliases = {}, serializers = {}, associations = {})
45-
@aliases, @serializers, @associations = aliases, serializers, associations
47+
# @param [ Hash ] associations An optional hash of names to association
48+
# objects.
49+
# @param [ Hash ] aliased_associations An optional hash of mappings from
50+
# aliases for associations to their actual field names in the database.
51+
def initialize(aliases = {}, serializers = {}, associations = {}, aliased_associations = {})
52+
@aliases = aliases
53+
@serializers = serializers
54+
@associations = associations
55+
@aliased_associations = aliased_associations
4656
yield(self) if block_given?
4757
end
4858

@@ -91,7 +101,7 @@ def localized_key(name, serializer)
91101
# serializer.
92102
def storage_pair(key)
93103
field = key.to_s
94-
name = aliases[field] || field
104+
name = Fields.database_field_name(field, associations, aliases, aliased_associations)
95105
[ name, get_serializer(name) ]
96106
end
97107

@@ -109,21 +119,7 @@ def get_serializer(name)
109119
if s = serializers[name]
110120
s
111121
else
112-
klass = nil
113-
serializer = nil
114-
name.split('.').each do |meth|
115-
fs = klass ? klass.fields : serializers
116-
if field = fs[meth]
117-
serializer = field
118-
else
119-
serializer = nil
120-
rs = klass ? klass.associations : associations
121-
if rel = rs[meth]
122-
klass = rel.klass
123-
end
124-
end
125-
end
126-
serializer
122+
Fields.traverse_association_tree(name, serializers, associations, aliased_associations)
127123
end
128124
end
129125
end

0 commit comments

Comments
 (0)