Skip to content

Commit ee53ba9

Browse files
authored
MONGOID-5016 default embedded touch to true (#5554)
* first steps toward a `touch: true` default for embedded_in relations * add touch suppression so that `touch: false` can override `touch: true` * typo * refactor the deeply nested callbacks to another method * update release notes
1 parent 582d505 commit ee53ba9

File tree

13 files changed

+164
-55
lines changed

13 files changed

+164
-55
lines changed

docs/release-notes/mongoid-9.0.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,24 @@ persistence operation per parent document, even when using multiple
191191
levels of nested embedded documents.
192192

193193

194+
``embedded_in`` associations now default to ``touch: true``
195+
-----------------------------------------------------------
196+
197+
Updating an embedded subdocument will now automatically touch the parent,
198+
unless you explicitly set ``touch: false`` on the relation:
199+
200+
.. code-block:: ruby
201+
202+
class Address
203+
include Mongoid::Document
204+
include Mongoid::Timestamps
205+
206+
embedded_in :mall, touch: false
207+
end
208+
209+
For all other associations, the default remains ``touch: false``.
210+
211+
194212
Flipped default for ``:replace`` option in ``#upsert``
195213
------------------------------------------------------
196214

lib/mongoid/association/accessors.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,12 +341,11 @@ def self.define_setter!(association)
341341
klass.re_define_method("#{name}=") do |object|
342342
without_autobuild do
343343
if value = get_relation(name, association, object)
344-
if value.respond_to?(:substitute)
345-
set_relation(name, value.substitute(object.substitutable))
346-
else
347-
value = __build__(name, value, association)
348-
set_relation(name, value.substitute(object.substitutable))
344+
if !value.respond_to?(:substitute)
345+
value = __build__(name, value, association)
349346
end
347+
348+
set_relation(name, value.substitute(object.substitutable))
350349
else
351350
__build__(name, object.substitutable, association)
352351
end

lib/mongoid/association/embedded/embedded_in.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class EmbeddedIn
3434
#
3535
# @return [ self ]
3636
def setup!
37+
setup_defaults!
3738
setup_instance_methods!
3839
@owner_class.embedded = true
3940
self
@@ -92,6 +93,11 @@ def nested_builder(attributes, options)
9293

9394
private
9495

96+
# Set up default values for any options used by this association.
97+
def setup_defaults!
98+
@options[:touch] = true unless @options.key?(:touch)
99+
end
100+
95101
def setup_instance_methods!
96102
define_getter!
97103
define_setter!

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ def substitute(replacement)
8383
unbind_one
8484
unless replacement
8585
update_attributes_hash(replacement)
86+
87+
# when `touch: true` is the default (see MONGOID-5016), creating
88+
# an embedded document will touch the parent, and will cause the
89+
# _descendants list to be initialized and memoized. If the object
90+
# is then deleted, we need to make sure and un-memoize that list,
91+
# otherwise when the update happens, the memoized _descendants list
92+
# gets used and the "deleted" subdocument gets added again.
93+
_reset_memoized_descendants!
94+
8695
return nil
8796
end
8897
replacement = Factory.build(klass, replacement) if replacement.is_a?(::Hash)

lib/mongoid/attributes/processing.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def process_nested
116116
def process_pending
117117
process_nested and process_relations
118118
pending_nested.clear and pending_relations.clear
119+
_reset_memoized_descendants!
119120
end
120121

121122
# Process all the pending associations that needed to wait until ids were set

lib/mongoid/persistable/updatable.rb

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -102,21 +102,12 @@ def prepare_update(options = {})
102102
invalid?(options[:context] || :update)
103103
process_flagged_destroys
104104
update_children = cascadable_children(:update)
105-
process_touch_option(options, update_children)
106-
run_callbacks(:commit, with_children: true, skip_if: -> { in_transaction? }) do
107-
run_callbacks(:save, with_children: false) do
108-
run_callbacks(:update, with_children: false) do
109-
run_callbacks(:persist_parent, with_children: false) do
110-
_mongoid_run_child_callbacks(:save) do
111-
_mongoid_run_child_callbacks(:update, children: update_children) do
112-
result = yield(self)
113-
self.previously_new_record = false
114-
post_process_persist(result, options)
115-
true
116-
end
117-
end
118-
end
119-
end
105+
process_touch_option(options, update_children) do
106+
run_all_callbacks_for_update(update_children) do
107+
result = yield(self)
108+
self.previously_new_record = false
109+
post_process_persist(result, options)
110+
true
120111
end
121112
end
122113
end
@@ -182,9 +173,12 @@ def update_document(options = {})
182173
# @option options [ true | false ] :touch Whether or not the updated_at
183174
# attribute will be updated with the current time.
184175
def process_touch_option(options, children)
185-
unless options.fetch(:touch, true)
176+
if options.fetch(:touch, true)
177+
yield
178+
else
186179
timeless
187180
children.each(&:timeless)
181+
suppress_touch_callbacks { yield }
188182
end
189183
end
190184

@@ -212,6 +206,28 @@ def enforce_immutability_of_id_field!
212206
end
213207
end
214208
end
209+
210+
# Consolidates all the callback invocations into a single place, to
211+
# avoid cluttering the logic in #prepare_update.
212+
#
213+
# @param [ Array<Document> ] update_children The children that the
214+
# :update callbacks will be executed on.
215+
def run_all_callbacks_for_update(update_children)
216+
run_callbacks(:commit, with_children: true, skip_if: -> { in_transaction? }) do
217+
run_callbacks(:save, with_children: false) do
218+
run_callbacks(:update, with_children: false) do
219+
run_callbacks(:persist_parent, with_children: false) do
220+
_mongoid_run_child_callbacks(:save) do
221+
_mongoid_run_child_callbacks(:update, children: update_children) do
222+
yield
223+
end # _mongoid_run_child_callbacks :update
224+
end # _mongoid_run_child_callbacks :save
225+
end # :persist_parent
226+
end # :update
227+
end # :save
228+
end # :commit
229+
end
230+
215231
end
216232
end
217233
end

lib/mongoid/touchable.rb

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,29 @@
22

33
module Mongoid
44
module Touchable
5-
65
module InstanceMethods
76

7+
# Suppresses the invocation of touch callbacks, for the class that
8+
# includes this module, for the duration of the block.
9+
#
10+
# @example Suppress touch callbacks on Person documents:
11+
# person.suppress_touch_callbacks { ... }
12+
#
13+
# @api private
14+
def suppress_touch_callbacks
15+
Touchable.suppress_touch_callbacks(self.class.name) { yield }
16+
end
17+
18+
# Queries whether touch callbacks are being suppressed for the class
19+
# that includes this module.
20+
#
21+
# @return [ true | false ] Whether touch callbacks are suppressed.
22+
#
23+
# @api private
24+
def touch_callbacks_suppressed?
25+
Touchable.touch_callbacks_suppressed?(self.class.name)
26+
end
27+
828
# Touch the document, in effect updating its updated_at timestamp and
929
# optionally the provided field to the current time. If any belongs_to
1030
# associations exist with a touch option, they will be updated as well.
@@ -45,7 +65,10 @@ def touch(field = nil)
4565
#
4666
# @api private
4767
def _gather_touch_updates(now, field = nil)
68+
return if touch_callbacks_suppressed?
69+
4870
field = database_field_name(field)
71+
4972
write_attribute(:updated_at, now) if respond_to?("updated_at=")
5073
write_attribute(field, now) if field
5174

@@ -71,6 +94,7 @@ def _clear_touch_updates(field = nil)
7194
#
7295
# @api private
7396
def _run_touch_callbacks_from_root
97+
return if touch_callbacks_suppressed?
7498
_parent._run_touch_callbacks_from_root if _touchable_parent?
7599
run_callbacks(:touch)
76100
end
@@ -131,8 +155,41 @@ def define_touchable!(association)
131155
end
132156
end
133157

158+
# Suppresses touch callbacks for the named class, for the duration of
159+
# the associated block.
160+
#
161+
# @api private
162+
def suppress_touch_callbacks(name)
163+
save, touch_callback_statuses[name] = touch_callback_statuses[name], true
164+
yield
165+
ensure
166+
touch_callback_statuses[name] = save
167+
end
168+
169+
# Queries whether touch callbacks are being suppressed for the named
170+
# class.
171+
#
172+
# @return [ true | false ] Whether touch callbacks are suppressed.
173+
#
174+
# @api private
175+
def touch_callbacks_suppressed?(name)
176+
touch_callback_statuses[name]
177+
end
178+
134179
private
135180

181+
# The key to use to store the active touch callback suppression statuses
182+
SUPPRESS_TOUCH_CALLBACKS_KEY = "[mongoid]:suppress-touch-callbacks"
183+
184+
# Returns a hash to be used to store and query the various touch callback
185+
# suppression statuses for different classes.
186+
#
187+
# @return [ Hash ] The hash that contains touch callback suppression
188+
# statuses
189+
def touch_callback_statuses
190+
Thread.current[SUPPRESS_TOUCH_CALLBACKS_KEY] ||= {}
191+
end
192+
136193
# Define the method that will get called for touching belongs_to
137194
# associations.
138195
#
@@ -153,12 +210,11 @@ def define_relation_touch_method(name, association)
153210
[ association.relation_class ]
154211
end
155212

156-
relation_classes.each { |c| c.send(:include, InstanceMethods) }
157213
method_name = "touch_#{name}_after_create_or_destroy"
158214
association.inverse_class.class_eval do
159215
define_method(method_name) do
160216
without_autobuild do
161-
if relation = __send__(name)
217+
if !touch_callbacks_suppressed? && relation = __send__(name)
162218
# This looks up touch_field at runtime, rather than at method definition time.
163219
# If touch_field is nil, it will only touch the default field (updated_at).
164220
relation.touch(association.touch_field)

spec/integration/associations/embedded_spec.rb

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,6 @@
178178
let(:parent) { EomParent.create! }
179179

180180
it 'updates' do
181-
pending 'https://jira.mongodb.org/browse/MONGOID-3252'
182-
183181
first_updated_at = parent.updated_at
184182

185183
parent.child = EomChild.new
@@ -193,8 +191,6 @@
193191
let(:parent) { EmmCongress.create! }
194192

195193
it 'updates' do
196-
pending 'https://jira.mongodb.org/browse/MONGOID-3252'
197-
198194
first_updated_at = parent.updated_at
199195

200196
parent.legislators << EmmLegislator.new
@@ -238,8 +234,6 @@
238234
let(:parent) { EomParent.create!(child: EomChild.new) }
239235

240236
it 'updates' do
241-
pending 'https://jira.mongodb.org/browse/MONGOID-3252'
242-
243237
first_updated_at = parent.updated_at
244238

245239
parent.child = nil

spec/mongoid/association/embedded/embedded_in_spec.rb

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,27 @@ class EmbeddedObject; include Mongoid::Document; end
156156
describe '#touchable?' do
157157

158158
context 'when :touch is in the options' do
159+
shared_examples_for ':touch is in the options' do
160+
it 'returns the value in the options' do
161+
expect(association.send(:touchable?)).to be(options[:touch])
162+
end
163+
end
159164

160-
let(:options) do
161-
{ touch: true}
165+
context 'when the option is true' do
166+
let(:options) { { touch: true } }
167+
it_behaves_like ':touch is in the options'
162168
end
163169

164-
it 'returns true' do
165-
expect(association.send(:touchable?)).to be(true)
170+
context 'when the option is false' do
171+
let(:options) { { touch: false } }
172+
it_behaves_like ':touch is in the options'
166173
end
167174
end
168175

169176
context 'when :touch is not in the options' do
170177

171-
it 'return false' do
172-
expect(association.send(:touchable?)).to be(false)
178+
it 'return the default value' do
179+
expect(association.send(:touchable?)).to be(true)
173180
end
174181
end
175182
end
@@ -582,7 +589,11 @@ class EmbeddedObject; include Mongoid::Document; end
582589
context 'when the :class_name option is specified' do
583590

584591
let(:options) do
585-
{ class_name: 'OtherContainer' }
592+
# `touch: true` is the default (see MONGOID-5016), which means by
593+
# default, callbacks are added to the referenced class. In this case,
594+
# the class does not exist, so we must explicitly set `touch: false`
595+
# to prevent Mongoid from attempting to load the bogus class.
596+
{ touch: false, class_name: 'OtherContainer' }
586597
end
587598

588599
it 'returns the class name option' do

spec/mongoid/association/embedded/embeds_many_models.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ class EmmHatch
172172
include Mongoid::Document
173173

174174
# No :class_name option on this association intentionally.
175-
embedded_in :tank
175+
# Also, re: MONGOID-5016, `touch: true` is the default, which means Mongoid
176+
# will try to load the associated class in order to add relevant callbacks.
177+
# We must set `touch: false` here to avoid Mongoid trying to load a
178+
# non-existent class.
179+
embedded_in :tank, touch: false
176180
end
177181

178182
class EmmPost
@@ -235,4 +239,3 @@ class EmmChild
235239
field :order, type: Integer
236240
field :t
237241
end
238-

0 commit comments

Comments
 (0)