Skip to content

Commit bd8f7f5

Browse files
committed
Fix MONGOID-5198 Calling children_changed? on a deep cyclical data structure will cause semi-infinite looping
1 parent b7acf87 commit bd8f7f5

File tree

9 files changed

+181
-45
lines changed

9 files changed

+181
-45
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def build(attributes = {}, type = nil)
7272
doc.apply_post_processed_defaults
7373
yield(doc) if block_given?
7474
doc.run_callbacks(:build) { doc }
75-
_base._reset_memoized_children!
75+
_base._reset_memoized_descendants!
7676
doc
7777
end
7878

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def initialize(base, target, association)
3131
characterize_one(_target)
3232
bind_one
3333
characterize_one(_target)
34-
_base._reset_memoized_children!
34+
_base._reset_memoized_descendants!
3535
_target.save if persistable?
3636
end
3737
end

lib/mongoid/atomic.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def atomic_updates(_use_indexes = false)
120120
process_flagged_destroys
121121
mods = Modifiers.new
122122
generate_atomic_updates(mods, self)
123-
_children.each do |child|
123+
_descendants.each do |child|
124124
child.process_flagged_destroys
125125
generate_atomic_updates(mods, child)
126126
end

lib/mongoid/changeable.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ def changed?
2828

2929
# Have any children (embedded documents) of this document changed?
3030
#
31-
# @example Have any children changed?
32-
# model.children_changed?
31+
# @note This intentionally only considers children and not descendants.
3332
#
3433
# @return [ true, false ] If any children have changed.
3534
def children_changed?
@@ -81,7 +80,7 @@ def move_changes
8180
# @example Handle post persistence.
8281
# document.post_persist
8382
def post_persist
84-
reset_persisted_children
83+
reset_persisted_descendants
8584
move_changes
8685
end
8786

lib/mongoid/persistable/creatable.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def insert_as_root
8383
# @return [ true ] true.
8484
def post_process_insert
8585
self.new_record = false
86-
flag_children_persisted
86+
flag_descendants_persisted
8787
true
8888
end
8989

lib/mongoid/traversable.rb

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -115,49 +115,82 @@ def self.get_discriminator_mapping(value)
115115
end
116116
end
117117

118-
# Get all child +Documents+ to this +Document+, going n levels deep if
119-
# necessary. This is used when calling update persistence operations from
118+
# Get all child +Documents+ to this +Document+
119+
#
120+
# @return [ Array<Document> ] All child documents in the hierarchy.
121+
#
122+
# @api private
123+
def _children
124+
@__children ||= collect_children
125+
end
126+
127+
# Get all descendant +Documents+ of this +Document+ recursively.
128+
# This is used when calling update persistence operations from
120129
# the root document, where changes in the entire tree need to be
121130
# determined. Note that persistence from the embedded documents will
122131
# always be preferred, since they are optimized calls... This operation
123132
# can get expensive in domains with large hierarchies.
124133
#
125-
# @example Get all the document's children.
126-
# person._children
134+
# @return [ Array<Document> ] All descendant documents in the hierarchy.
127135
#
128-
# @return [ Array<Document> ] All child documents in the hierarchy.
129-
def _children
130-
@__children ||= collect_children
136+
# @api private
137+
def _descendants
138+
@__descendants ||= collect_descendants
131139
end
132140

133141
# Collect all the children of this document.
134142
#
135-
# @example Collect all the children.
136-
# document.collect_children
137-
#
138143
# @return [ Array<Document> ] The children.
144+
#
145+
# @api private
139146
def collect_children
140147
children = []
141148
embedded_relations.each_pair do |name, association|
142149
without_autobuild do
143150
child = send(name)
144-
Array.wrap(child).each do |doc|
145-
children.push(doc)
146-
children.concat(doc._children)
147-
end if child
151+
if child
152+
children += Array.wrap(child)
153+
end
148154
end
149155
end
150156
children
151157
end
152158

153-
# Marks all children as being persisted.
159+
# Collect all the descendants of this document.
154160
#
155-
# @example Flag all the children.
156-
# document.flag_children_persisted
161+
# @return [ Array<Document> ] The descendants.
162+
#
163+
# @api private
164+
def collect_descendants
165+
children = []
166+
to_expand = []
167+
expanded = {}
168+
embedded_relations.each_pair do |name, association|
169+
without_autobuild do
170+
child = send(name)
171+
if child
172+
to_expand += Array.wrap(child)
173+
end
174+
end
175+
end
176+
until to_expand.empty?
177+
expanding = to_expand
178+
to_expand = []
179+
expanding.each do |child|
180+
next if expanded[child]
181+
expanded[child] = true
182+
children << child
183+
to_expand += child._children
184+
end
185+
end
186+
children
187+
end
188+
189+
# Marks all descendants as being persisted.
157190
#
158-
# @return [ Array<Document> ] The flagged children.
159-
def flag_children_persisted
160-
_children.each do |child|
191+
# @return [ Array<Document> ] The flagged descendants.
192+
def flag_descendants_persisted
193+
_descendants.each do |child|
161194
child.new_record = false
162195
end
163196
end
@@ -204,33 +237,28 @@ def remove_child(child)
204237
end
205238
end
206239

207-
# After children are persisted we can call this to move all their changes
208-
# and flag them as persisted in one call.
240+
# After descendants are persisted we can call this to move all their
241+
# changes and flag them as persisted in one call.
209242
#
210-
# @example Reset the children.
211-
# document.reset_persisted_children
212-
#
213-
# @return [ Array<Document> ] The children.
214-
def reset_persisted_children
215-
_children.each do |child|
243+
# @return [ Array<Document> ] The descendants.
244+
def reset_persisted_descendants
245+
_descendants.each do |child|
216246
child.move_changes
217247
child.new_record = false
218248
end
219-
_reset_memoized_children!
249+
_reset_memoized_descendants!
220250
end
221251

222-
# Resets the memoized children on the object. Called internally when an
252+
# Resets the memoized descendants on the object. Called internally when an
223253
# embedded array changes size.
224254
#
225-
# @api semiprivate
226-
#
227-
# @example Reset the memoized children.
228-
# document._reset_memoized_children!
229-
#
230255
# @return [ nil ] nil.
231-
def _reset_memoized_children!
232-
_parent._reset_memoized_children! if _parent
256+
#
257+
# @api private
258+
def _reset_memoized_descendants!
259+
_parent._reset_memoized_descendants! if _parent
233260
@__children = nil
261+
@__descendants = nil
234262
end
235263

236264
# Return the root document in the object graph. If the current document
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require_relative '../../mongoid/association/embedded/embeds_many_models'
5+
require_relative '../../mongoid/association/embedded/embeds_one_models'
6+
7+
describe 'embedded associations' do
8+
describe 'dirty tracking' do
9+
context 'when association is cyclic' do
10+
before do
11+
# create deeply nested record
12+
a = EmmOuter.create(level: 0)
13+
level = 1
14+
iter = a.inners.create(level: level)
15+
loop do
16+
iter.friends.create(level: (level += 1))
17+
iter = iter.friends[0]
18+
break if level == 40
19+
end
20+
end
21+
22+
let(:subject) { EmmOuter.first }
23+
24+
it 'performs dirty tracking efficiently' do
25+
subject.changed?.should be false
26+
end
27+
end
28+
end
29+
end

spec/mongoid/association/embedded/embeds_many_models.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,19 @@ class EmmProduct
5151

5252
field :name, type: String
5353
end
54+
55+
class EmmInner
56+
include Mongoid::Document
57+
58+
embeds_many :friends, :class_name => self.name, :cyclic => true
59+
embedded_in :parent, :class_name => self.name, :cyclic => true
60+
61+
field :level, :type => Integer
62+
end
63+
64+
class EmmOuter
65+
include Mongoid::Document
66+
embeds_many :inners, class_name: 'EmmInner'
67+
68+
field :level, :type => Integer
69+
end

spec/mongoid/traversable_spec.rb

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,72 @@
6262
expect(person._children).to include(address)
6363
end
6464

65+
it "does not include embedded documents multiple levels deep" do
66+
expect(person._children).not_to include(location)
67+
end
68+
end
69+
end
70+
71+
describe "#_descendants" do
72+
73+
let(:person) do
74+
Person.new(title: "King")
75+
end
76+
77+
context "with one level of embedding" do
78+
79+
let(:name) do
80+
Name.new(first_name: "Titus")
81+
end
82+
83+
let(:address) do
84+
Address.new(street: "Queen St")
85+
end
86+
87+
before do
88+
person.name = name
89+
person.addresses << address
90+
end
91+
92+
it "includes embeds_one documents" do
93+
expect(person._descendants).to include(name)
94+
end
95+
96+
it "includes embeds_many documents" do
97+
expect(person._descendants).to include(address)
98+
end
99+
end
100+
101+
context "with multiple levels of embedding" do
102+
103+
let(:name) do
104+
Name.new(first_name: "Titus")
105+
end
106+
107+
let(:address) do
108+
Address.new(street: "Queen St")
109+
end
110+
111+
let(:location) do
112+
Location.new(name: "Work")
113+
end
114+
115+
before do
116+
person.name = name
117+
address.locations << location
118+
person.addresses << address
119+
end
120+
121+
it "includes embeds_one documents" do
122+
expect(person._descendants).to include(name)
123+
end
124+
125+
it "includes embeds_many documents" do
126+
expect(person._descendants).to include(address)
127+
end
128+
65129
it "includes embedded documents multiple levels deep" do
66-
expect(person._children).to include(location)
130+
expect(person._descendants).to include(location)
67131
end
68132
end
69133
end

0 commit comments

Comments
 (0)