/
touchable.rb
236 lines (207 loc) · 7.83 KB
/
touchable.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# frozen_string_literal: true
# rubocop:todo all
module Mongoid
# Mixin module which is included in Mongoid::Document to add "touch"
# functionality to update a document's timestamp(s) atomically.
module Touchable
# Used to provide mixin functionality.
#
# @todo Refactor using ActiveSupport::Concern
module InstanceMethods
# Suppresses the invocation of touch callbacks, for the class that
# includes this module, for the duration of the block.
#
# @example Suppress touch callbacks on Person documents:
# person.suppress_touch_callbacks { ... }
#
# @api private
def suppress_touch_callbacks
Touchable.suppress_touch_callbacks(self.class.name) { yield }
end
# Queries whether touch callbacks are being suppressed for the class
# that includes this module.
#
# @return [ true | false ] Whether touch callbacks are suppressed.
#
# @api private
def touch_callbacks_suppressed?
Touchable.touch_callbacks_suppressed?(self.class.name)
end
# Touch the document, in effect updating its updated_at timestamp and
# optionally the provided field to the current time. If any belongs_to
# associations exist with a touch option, they will be updated as well.
#
# @example Update the updated_at timestamp.
# document.touch
#
# @example Update the updated_at and provided timestamps.
# document.touch(:audited)
#
# @note This will not autobuild associations if those options are set.
#
# @param [ Symbol ] field The name of an additional field to update.
#
# @return [ true/false ] false if document is new_record otherwise true.
def touch(field = nil)
return false if _root.new_record?
begin
touches = _gather_touch_updates(Time.current, field)
_root.send(:persist_atomic_operations, '$set' => touches) if touches.present?
_run_touch_callbacks_from_root
ensure
_clear_touch_updates(field)
end
true
end
# Recursively sets touchable fields on the current document and each of its
# parents, including the root node. Returns the combined atomic $set
# operations to be performed on the root document.
#
# @param [ Time ] now The timestamp used for synchronizing the touched time.
# @param [ Symbol ] field The name of an additional field to update.
#
# @return [ Hash<String, Time> ] The touch operations to perform as an atomic $set.
#
# @api private
def _gather_touch_updates(now, field = nil)
return if touch_callbacks_suppressed?
field = database_field_name(field)
write_attribute(:updated_at, now) if respond_to?("updated_at=")
write_attribute(field, now) if field
touches = _extract_touches_from_atomic_sets(field) || {}
touches.merge!(_parent._gather_touch_updates(now) || {}) if _touchable_parent?
touches
end
# Clears changes for the model caused by touch operation.
#
# @param [ Symbol ] field The name of an additional field to update.
#
# @api private
def _clear_touch_updates(field = nil)
remove_change(:updated_at)
remove_change(field) if field
_parent._clear_touch_updates if _touchable_parent?
end
# Recursively runs :touch callbacks for the document and its parents,
# beginning with the root document and cascading through each successive
# child document.
#
# @api private
def _run_touch_callbacks_from_root
return if touch_callbacks_suppressed?
_parent._run_touch_callbacks_from_root if _touchable_parent?
run_callbacks(:touch)
end
# Indicates whether the parent exists and is touchable.
#
# @api private
def _touchable_parent?
_parent && _association&.inverse_association&.touchable?
end
private
# Extract and remove the atomic updates for the touch operation(s)
# from the currently enqueued atomic $set operations.
#
# @param [ Symbol ] field The optional field.
#
# @return [ Hash ] The field-value pairs to update atomically.
#
# @api private
def _extract_touches_from_atomic_sets(field = nil)
updates = atomic_updates['$set']
return {} unless updates
touchable_keys = Set['updated_at', 'u_at']
touchable_keys << field.to_s if field.present?
updates.keys.each_with_object({}) do |key, touches|
if touchable_keys.include?(key.split('.').last)
touches[key] = updates.delete(key)
end
end
end
end
extend self
# Add the association to the touchable associations if the touch option was
# provided.
#
# @example Add the touchable.
# Model.define_touchable!(assoc)
#
# @param [ Mongoid::Association::Relatable ] association The association metadata.
#
# @return [ Class ] The model class.
def define_touchable!(association)
name = association.name
method_name = define_relation_touch_method(name, association)
association.inverse_class.tap do |klass|
klass.after_save method_name
klass.after_destroy method_name
# Embedded docs handle touch updates recursively within
# the #touch method itself
klass.after_touch method_name unless association.embedded?
end
end
# Suppresses touch callbacks for the named class, for the duration of
# the associated block.
#
# @api private
def suppress_touch_callbacks(name)
save, touch_callback_statuses[name] = touch_callback_statuses[name], true
yield
ensure
touch_callback_statuses[name] = save
end
# Queries whether touch callbacks are being suppressed for the named
# class.
#
# @return [ true | false ] Whether touch callbacks are suppressed.
#
# @api private
def touch_callbacks_suppressed?(name)
touch_callback_statuses[name]
end
private
# The key to use to store the active touch callback suppression statuses
SUPPRESS_TOUCH_CALLBACKS_KEY = "[mongoid]:suppress-touch-callbacks"
# Returns a hash to be used to store and query the various touch callback
# suppression statuses for different classes.
#
# @return [ Hash ] The hash that contains touch callback suppression
# statuses
def touch_callback_statuses
Thread.current[SUPPRESS_TOUCH_CALLBACKS_KEY] ||= {}
end
# Define the method that will get called for touching belongs_to
# associations.
#
# @api private
#
# @example Define the touch association.
# Model.define_relation_touch_method(:band)
# Model.define_relation_touch_method(:band, :band_updated_at)
#
# @param [ Symbol ] name The name of the association.
# @param [ Mongoid::Association::Relatable ] association The association metadata.
#
# @return [ Symbol ] The method name.
def define_relation_touch_method(name, association)
relation_classes = if association.polymorphic?
association.send(:inverse_association_classes)
else
[ association.relation_class ]
end
method_name = "touch_#{name}_after_create_or_destroy"
association.inverse_class.class_eval do
define_method(method_name) do
without_autobuild do
if !touch_callbacks_suppressed? && relation = __send__(name)
# This looks up touch_field at runtime, rather than at method definition time.
# If touch_field is nil, it will only touch the default field (updated_at).
relation.touch(association.touch_field)
end
end
end
end
method_name.to_sym
end
end
end