Skip to content

Fix MONGOID-5198 Calling children_changed? on a deep cyclical data structure will cause semi-infinite looping #5104

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

Merged
merged 1 commit into from
Dec 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/mongoid/association/embedded/embeds_many/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def build(attributes = {}, type = nil)
doc.apply_post_processed_defaults
yield(doc) if block_given?
doc.run_callbacks(:build) { doc }
_base._reset_memoized_children!
_base._reset_memoized_descendants!
doc
end

Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/association/embedded/embeds_one/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def initialize(base, target, association)
characterize_one(_target)
bind_one
characterize_one(_target)
_base._reset_memoized_children!
_base._reset_memoized_descendants!
_target.save if persistable?
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/atomic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def atomic_updates(_use_indexes = false)
process_flagged_destroys
mods = Modifiers.new
generate_atomic_updates(mods, self)
_children.each do |child|
_descendants.each do |child|
child.process_flagged_destroys
generate_atomic_updates(mods, child)
end
Expand Down
5 changes: 2 additions & 3 deletions lib/mongoid/changeable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ def changed?

# Have any children (embedded documents) of this document changed?
#
# @example Have any children changed?
# model.children_changed?
# @note This intentionally only considers children and not descendants.
#
# @return [ true, false ] If any children have changed.
def children_changed?
Expand Down Expand Up @@ -81,7 +80,7 @@ def move_changes
# @example Handle post persistence.
# document.post_persist
def post_persist
reset_persisted_children
reset_persisted_descendants
move_changes
end

Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/persistable/creatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def insert_as_root
# @return [ true ] true.
def post_process_insert
self.new_record = false
flag_children_persisted
flag_descendants_persisted
true
end

Expand Down
102 changes: 65 additions & 37 deletions lib/mongoid/traversable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,49 +115,82 @@ def self.get_discriminator_mapping(value)
end
end

# Get all child +Documents+ to this +Document+, going n levels deep if
# necessary. This is used when calling update persistence operations from
# Get all child +Documents+ to this +Document+
#
# @return [ Array<Document> ] All child documents in the hierarchy.
#
# @api private
def _children
@__children ||= collect_children
end

# Get all descendant +Documents+ of this +Document+ recursively.
# This is used when calling update persistence operations from
# the root document, where changes in the entire tree need to be
# determined. Note that persistence from the embedded documents will
# always be preferred, since they are optimized calls... This operation
# can get expensive in domains with large hierarchies.
#
# @example Get all the document's children.
# person._children
# @return [ Array<Document> ] All descendant documents in the hierarchy.
#
# @return [ Array<Document> ] All child documents in the hierarchy.
def _children
@__children ||= collect_children
# @api private
def _descendants
@__descendants ||= collect_descendants
end

# Collect all the children of this document.
#
# @example Collect all the children.
# document.collect_children
#
# @return [ Array<Document> ] The children.
#
# @api private
def collect_children
children = []
embedded_relations.each_pair do |name, association|
without_autobuild do
child = send(name)
Array.wrap(child).each do |doc|
children.push(doc)
children.concat(doc._children)
end if child
if child
children += Array.wrap(child)
end
end
end
children
end

# Marks all children as being persisted.
# Collect all the descendants of this document.
#
# @example Flag all the children.
# document.flag_children_persisted
# @return [ Array<Document> ] The descendants.
#
# @api private
def collect_descendants
children = []
to_expand = []
expanded = {}
embedded_relations.each_pair do |name, association|
without_autobuild do
child = send(name)
if child
to_expand += Array.wrap(child)
end
end
end
until to_expand.empty?
expanding = to_expand
to_expand = []
expanding.each do |child|
next if expanded[child]
expanded[child] = true
children << child
to_expand += child._children
end
end
children
end

# Marks all descendants as being persisted.
#
# @return [ Array<Document> ] The flagged children.
def flag_children_persisted
_children.each do |child|
# @return [ Array<Document> ] The flagged descendants.
def flag_descendants_persisted
_descendants.each do |child|
child.new_record = false
end
end
Expand Down Expand Up @@ -204,33 +237,28 @@ def remove_child(child)
end
end

# After children are persisted we can call this to move all their changes
# and flag them as persisted in one call.
# After descendants are persisted we can call this to move all their
# changes and flag them as persisted in one call.
#
# @example Reset the children.
# document.reset_persisted_children
#
# @return [ Array<Document> ] The children.
def reset_persisted_children
_children.each do |child|
# @return [ Array<Document> ] The descendants.
def reset_persisted_descendants
_descendants.each do |child|
child.move_changes
child.new_record = false
end
_reset_memoized_children!
_reset_memoized_descendants!
end

# Resets the memoized children on the object. Called internally when an
# Resets the memoized descendants on the object. Called internally when an
# embedded array changes size.
#
# @api semiprivate
#
# @example Reset the memoized children.
# document._reset_memoized_children!
#
# @return [ nil ] nil.
def _reset_memoized_children!
_parent._reset_memoized_children! if _parent
#
# @api private
def _reset_memoized_descendants!
_parent._reset_memoized_descendants! if _parent
@__children = nil
@__descendants = nil
end

# Return the root document in the object graph. If the current document
Expand Down
29 changes: 29 additions & 0 deletions spec/integration/associations/embedded_dirty_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

require 'spec_helper'
require_relative '../../mongoid/association/embedded/embeds_many_models'
require_relative '../../mongoid/association/embedded/embeds_one_models'

describe 'embedded associations' do
describe 'dirty tracking' do
context 'when association is cyclic' do
before do
# create deeply nested record
a = EmmOuter.create(level: 0)
level = 1
iter = a.inners.create(level: level)
loop do
iter.friends.create(level: (level += 1))
iter = iter.friends[0]
break if level == 40
end
end

let(:subject) { EmmOuter.first }

it 'performs dirty tracking efficiently' do
subject.changed?.should be false
end
end
end
end
16 changes: 16 additions & 0 deletions spec/mongoid/association/embedded/embeds_many_models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,19 @@ class EmmProduct

field :name, type: String
end

class EmmInner
include Mongoid::Document

embeds_many :friends, :class_name => self.name, :cyclic => true
embedded_in :parent, :class_name => self.name, :cyclic => true

field :level, :type => Integer
end

class EmmOuter
include Mongoid::Document
embeds_many :inners, class_name: 'EmmInner'

field :level, :type => Integer
end
66 changes: 65 additions & 1 deletion spec/mongoid/traversable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,72 @@
expect(person._children).to include(address)
end

it "does not include embedded documents multiple levels deep" do
expect(person._children).not_to include(location)
end
end
end

describe "#_descendants" do

let(:person) do
Person.new(title: "King")
end

context "with one level of embedding" do

let(:name) do
Name.new(first_name: "Titus")
end

let(:address) do
Address.new(street: "Queen St")
end

before do
person.name = name
person.addresses << address
end

it "includes embeds_one documents" do
expect(person._descendants).to include(name)
end

it "includes embeds_many documents" do
expect(person._descendants).to include(address)
end
end

context "with multiple levels of embedding" do

let(:name) do
Name.new(first_name: "Titus")
end

let(:address) do
Address.new(street: "Queen St")
end

let(:location) do
Location.new(name: "Work")
end

before do
person.name = name
address.locations << location
person.addresses << address
end

it "includes embeds_one documents" do
expect(person._descendants).to include(name)
end

it "includes embeds_many documents" do
expect(person._descendants).to include(address)
end

it "includes embedded documents multiple levels deep" do
expect(person._children).to include(location)
expect(person._descendants).to include(location)
end
end
end
Expand Down