mongoid-history 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.coveralls.yml +1 -1
  3. data/.document +5 -5
  4. data/.github/workflows/test.yml +72 -0
  5. data/.gitignore +46 -46
  6. data/.rspec +2 -2
  7. data/.rubocop.yml +6 -6
  8. data/.rubocop_todo.yml +99 -99
  9. data/CHANGELOG.md +173 -163
  10. data/CONTRIBUTING.md +117 -118
  11. data/Dangerfile +1 -1
  12. data/Gemfile +49 -40
  13. data/LICENSE.txt +20 -20
  14. data/README.md +609 -608
  15. data/RELEASING.md +66 -67
  16. data/Rakefile +24 -24
  17. data/UPGRADING.md +53 -53
  18. data/lib/mongoid/history/attributes/base.rb +72 -72
  19. data/lib/mongoid/history/attributes/create.rb +45 -45
  20. data/lib/mongoid/history/attributes/destroy.rb +34 -34
  21. data/lib/mongoid/history/attributes/update.rb +104 -104
  22. data/lib/mongoid/history/options.rb +177 -177
  23. data/lib/mongoid/history/trackable.rb +588 -583
  24. data/lib/mongoid/history/tracker.rb +247 -247
  25. data/lib/mongoid/history/version.rb +5 -5
  26. data/lib/mongoid/history.rb +77 -77
  27. data/lib/mongoid-history.rb +1 -1
  28. data/mongoid-history.gemspec +25 -25
  29. data/perf/benchmark_modified_attributes_for_create.rb +65 -65
  30. data/perf/gc_suite.rb +21 -21
  31. data/spec/integration/embedded_in_polymorphic_spec.rb +112 -112
  32. data/spec/integration/integration_spec.rb +976 -976
  33. data/spec/integration/multi_relation_spec.rb +47 -47
  34. data/spec/integration/multiple_trackers_spec.rb +68 -68
  35. data/spec/integration/nested_embedded_documents_spec.rb +64 -64
  36. data/spec/integration/nested_embedded_documents_tracked_in_parent_spec.rb +124 -124
  37. data/spec/integration/nested_embedded_polymorphic_documents_spec.rb +115 -115
  38. data/spec/integration/subclasses_spec.rb +47 -47
  39. data/spec/integration/track_history_order_spec.rb +84 -84
  40. data/spec/integration/validation_failure_spec.rb +76 -76
  41. data/spec/spec_helper.rb +32 -30
  42. data/spec/support/error_helpers.rb +7 -0
  43. data/spec/support/mongoid.rb +11 -11
  44. data/spec/support/mongoid_history.rb +12 -12
  45. data/spec/unit/attributes/base_spec.rb +141 -141
  46. data/spec/unit/attributes/create_spec.rb +342 -342
  47. data/spec/unit/attributes/destroy_spec.rb +228 -228
  48. data/spec/unit/attributes/update_spec.rb +342 -342
  49. data/spec/unit/callback_options_spec.rb +165 -165
  50. data/spec/unit/embedded_methods_spec.rb +87 -87
  51. data/spec/unit/history_spec.rb +58 -58
  52. data/spec/unit/my_instance_methods_spec.rb +555 -555
  53. data/spec/unit/options_spec.rb +365 -365
  54. data/spec/unit/singleton_methods_spec.rb +406 -406
  55. data/spec/unit/store/default_store_spec.rb +11 -11
  56. data/spec/unit/store/request_store_spec.rb +13 -13
  57. data/spec/unit/trackable_spec.rb +1057 -987
  58. data/spec/unit/tracker_spec.rb +190 -190
  59. metadata +9 -7
  60. data/.travis.yml +0 -36
@@ -1,583 +1,588 @@
1
- module Mongoid
2
- module History
3
- module Trackable
4
- extend ActiveSupport::Concern
5
-
6
- module ClassMethods
7
- def track_history(options = {})
8
- extend RelationMethods
9
-
10
- history_options = Mongoid::History::Options.new(self, options)
11
-
12
- field history_options.options[:version_field].to_sym, type: Integer
13
-
14
- unless history_options.options[:modifier_field].nil?
15
- belongs_to_modifier_options = { class_name: Mongoid::History.modifier_class_name }
16
- belongs_to_modifier_options[:inverse_of] = history_options.options[:modifier_field_inverse_of] if history_options.options.key?(:modifier_field_inverse_of)
17
- belongs_to_modifier_options[:optional] = true if history_options.options[:modifier_field_optional] && Mongoid::Compatibility::Version.mongoid6_or_newer?
18
- belongs_to history_options.options[:modifier_field].to_sym, belongs_to_modifier_options
19
- end
20
-
21
- include MyInstanceMethods
22
- extend SingletonMethods
23
-
24
- delegate :history_trackable_options, to: 'self.class'
25
- delegate :track_history?, to: 'self.class'
26
-
27
- callback_options = history_options.options.slice(:if, :unless)
28
- around_update :track_update, **callback_options if history_options.options[:track_update]
29
- around_create :track_create, **callback_options if history_options.options[:track_create]
30
- around_destroy :track_destroy, **callback_options if history_options.options[:track_destroy]
31
-
32
- unless respond_to? :mongoid_history_options
33
- class_attribute :mongoid_history_options, instance_accessor: false
34
- end
35
-
36
- self.mongoid_history_options = history_options
37
- end
38
-
39
- def history_settings(options = {})
40
- options = Mongoid::History.default_settings.merge(options.symbolize_keys)
41
- options = options.slice(*Mongoid::History.default_settings.keys)
42
- options[:paranoia_field] = aliased_fields[options[:paranoia_field].to_s] || options[:paranoia_field].to_s
43
- Mongoid::History.trackable_settings ||= {}
44
- Mongoid::History.trackable_settings[name.to_sym] = options
45
- end
46
-
47
- def track_history?
48
- Mongoid::History.enabled? && Mongoid::History.store[track_history_flag] != false
49
- end
50
-
51
- def dynamic_enabled?
52
- Mongoid::Compatibility::Version.mongoid3? || (self < Mongoid::Attributes::Dynamic).present?
53
- end
54
-
55
- def disable_tracking
56
- original_flag = Mongoid::History.store[track_history_flag]
57
- Mongoid::History.store[track_history_flag] = false
58
- yield if block_given?
59
- ensure
60
- Mongoid::History.store[track_history_flag] = original_flag if block_given?
61
- end
62
-
63
- def enable_tracking
64
- original_flag = Mongoid::History.store[track_history_flag]
65
- Mongoid::History.store[track_history_flag] = true
66
- yield if block_given?
67
- ensure
68
- Mongoid::History.store[track_history_flag] = original_flag if block_given?
69
- end
70
-
71
- alias disable_tracking! disable_tracking
72
- alias enable_tracking! enable_tracking
73
-
74
- def track_history_flag
75
- "mongoid_history_#{name.underscore}_trackable_enabled".to_sym
76
- end
77
-
78
- def tracker_class
79
- klass = history_trackable_options[:tracker_class_name] || Mongoid::History.tracker_class_name
80
- klass.is_a?(Class) ? klass : klass.to_s.camelize.constantize
81
- end
82
- end
83
-
84
- module MyInstanceMethods
85
- def history_tracks
86
- @history_tracks ||= self.class.tracker_class.where(
87
- scope: related_scope,
88
- association_chain: association_hash
89
- ).asc(:version)
90
- end
91
-
92
- # undo :from => 1, :to => 5
93
- # undo 4
94
- # undo :last => 10
95
- def undo(modifier = nil, options_or_version = nil)
96
- versions = get_versions_criteria(options_or_version).to_a
97
- versions.sort! { |v1, v2| v2.version <=> v1.version }
98
-
99
- versions.each do |v|
100
- undo_attr = v.undo_attr(modifier)
101
- if Mongoid::Compatibility::Version.mongoid3? # update_attributes! not bypassing rails 3 protected attributes
102
- assign_attributes(undo_attr, without_protection: true)
103
- else # assign_attributes with 'without_protection' option does not work with rails 4/mongoid 4
104
- self.attributes = undo_attr
105
- end
106
- end
107
- end
108
-
109
- # undo! :from => 1, :to => 5
110
- # undo! 4
111
- # undo! :last => 10
112
- def undo!(modifier = nil, options_or_version = nil)
113
- undo(modifier, options_or_version)
114
- save!
115
- end
116
-
117
- def redo!(modifier = nil, options_or_version = nil)
118
- versions = get_versions_criteria(options_or_version).to_a
119
- versions.sort! { |v1, v2| v1.version <=> v2.version }
120
-
121
- versions.each do |v|
122
- redo_attr = v.redo_attr(modifier)
123
- if Mongoid::Compatibility::Version.mongoid3?
124
- assign_attributes(redo_attr, without_protection: true)
125
- save!
126
- else
127
- update_attributes!(redo_attr)
128
- end
129
- end
130
- end
131
-
132
- def _get_relation(name)
133
- send(self.class.relation_alias(name))
134
- end
135
-
136
- def _create_relation(name, value)
137
- send("create_#{self.class.relation_alias(name)}!", value)
138
- end
139
-
140
- private
141
-
142
- def get_versions_criteria(options_or_version)
143
- if options_or_version.is_a? Hash
144
- options = options_or_version
145
- if options[:from] && options[:to]
146
- lower = options[:from] >= options[:to] ? options[:to] : options[:from]
147
- upper = options[:from] < options[:to] ? options[:to] : options[:from]
148
- versions = history_tracks.where(:version.in => (lower..upper).to_a)
149
- elsif options[:last]
150
- versions = history_tracks.limit(options[:last])
151
- else
152
- raise 'Invalid options, please specify (:from / :to) keys or :last key.'
153
- end
154
- else
155
- options_or_version = options_or_version.to_a if options_or_version.is_a?(Range)
156
- version_field_name = history_trackable_options[:version_field]
157
- version = options_or_version || attributes[version_field_name] || attributes[version_field_name.to_s]
158
- version = [version].flatten
159
- versions = history_tracks.where(:version.in => version)
160
- end
161
- versions.desc(:version)
162
- end
163
-
164
- def related_scope
165
- scope = history_trackable_options[:scope]
166
-
167
- # Use top level document if its name is specified in the scope
168
- root_document_name = traverse_association_chain.first['name'].singularize.underscore.tr('/', '_').to_sym
169
- if scope.is_a?(Array) && scope.include?(root_document_name)
170
- scope = root_document_name
171
- else
172
- scope = _parent.collection_name.to_s.singularize.to_sym if scope.is_a?(Array)
173
- if Mongoid::Compatibility::Version.mongoid3?
174
- scope = metadata.inverse_class_name.tableize.singularize.to_sym if metadata.present? && scope == metadata.as
175
- elsif Mongoid::Compatibility::Version.mongoid6_or_older?
176
- scope = relation_metadata.inverse_class_name.tableize.singularize.to_sym if relation_metadata.present? && scope == relation_metadata.as
177
- elsif Mongoid::Compatibility::Version.mongoid7_or_newer?
178
- scope = _association.inverse_class_name.tableize.singularize.to_sym if _association.present? && scope == _association.as
179
- end
180
- end
181
-
182
- scope
183
- end
184
-
185
- def traverse_association_chain(node = self)
186
- list = node._parent ? traverse_association_chain(node._parent) : []
187
- list << association_hash(node)
188
- list
189
- end
190
-
191
- def association_hash(node = self)
192
- # We prefer to look up associations through the parent record because
193
- # we're assured, through the object creation, it'll exist. Whereas we're not guaranteed
194
- # the child to parent (embedded_in, belongs_to) relation will be defined
195
- if node._parent
196
- meta = node._parent.relations.values.find do |relation|
197
- if Mongoid::Compatibility::Version.mongoid3?
198
- relation.class_name == node.metadata.class_name.to_s && relation.name == node.metadata.name
199
- elsif Mongoid::Compatibility::Version.mongoid6_or_older?
200
- relation.class_name == node.relation_metadata.class_name.to_s &&
201
- relation.name == node.relation_metadata.name
202
- elsif Mongoid::Compatibility::Version.mongoid7_or_newer?
203
- relation.class_name == node._association.class_name.to_s &&
204
- relation.name == node._association.name
205
- end
206
- end
207
- end
208
-
209
- # if root node has no meta, and should use class name instead
210
- name = meta ? meta.key.to_s : node.class.name
211
-
212
- ActiveSupport::OrderedHash['name', name, 'id', node.id]
213
- end
214
-
215
- # Returns a Hash of field name to pairs of original and modified values
216
- # for each tracked field for a given action.
217
- #
218
- # @param [ String | Symbol ] action The modification action (:create, :update, :destroy)
219
- #
220
- # @return [ Hash<String, Array<Object>> ] the pairs of original and modified
221
- # values for each field
222
- def modified_attributes_for_action(action)
223
- case action.to_sym
224
- when :destroy then modified_attributes_for_destroy
225
- when :create then modified_attributes_for_create
226
- else modified_attributes_for_update
227
- end
228
- end
229
-
230
- def modified_attributes_for_update
231
- @modified_attributes_for_update ||= Mongoid::History::Attributes::Update.new(self).attributes
232
- end
233
-
234
- def modified_attributes_for_create
235
- @modified_attributes_for_create ||= Mongoid::History::Attributes::Create.new(self).attributes
236
- end
237
-
238
- def modified_attributes_for_destroy
239
- @modified_attributes_for_destroy ||= Mongoid::History::Attributes::Destroy.new(self).attributes
240
- end
241
-
242
- def history_tracker_attributes(action)
243
- return @history_tracker_attributes if @history_tracker_attributes
244
-
245
- modifier_field = history_trackable_options[:modifier_field]
246
- @history_tracker_attributes = {
247
- association_chain: traverse_association_chain,
248
- scope: related_scope
249
- }
250
- @history_tracker_attributes[:modifier] = send(modifier_field) if modifier_field
251
-
252
- original, modified = transform_changes(modified_attributes_for_action(action))
253
-
254
- @history_tracker_attributes[:original] = original
255
- @history_tracker_attributes[:modified] = modified
256
- @history_tracker_attributes
257
- end
258
-
259
- def track_create(&block)
260
- track_history_for_action(:create, &block)
261
- end
262
-
263
- def track_update(&block)
264
- track_history_for_action(:update, &block)
265
- end
266
-
267
- def track_destroy(&block)
268
- track_history_for_action(:destroy, &block) unless destroyed?
269
- end
270
-
271
- def clear_trackable_memoization
272
- @history_tracker_attributes = nil
273
- @modified_attributes_for_create = nil
274
- @modified_attributes_for_update = nil
275
- @history_tracks = nil
276
- end
277
-
278
- # Transform hash of pair of changes into an `original` and `modified` hash
279
- # Nested document keys (key name with dots) are expanded
280
- #
281
- # @param [Hash<Array>] changes
282
- #
283
- # @return [Array<Hash<?>,Hash<?>>] <description>
284
- def transform_changes(changes)
285
- original = {}
286
- modified = {}
287
- changes.each_pair do |k, modification_pair|
288
- o, m = modification_pair
289
- original.deep_merge!(expand_nested_document_key_value(k, o)) unless o.nil?
290
- modified.deep_merge!(expand_nested_document_key_value(k, m)) unless m.nil?
291
- end
292
-
293
- [original, modified]
294
- end
295
-
296
- # Handle nested document tracking of changes
297
- #
298
- # @example
299
- #
300
- # expand_nested_document_key('embedded.document.changed_field', 'old'])
301
- # #=> { 'embedded' => {'document' => { 'changed_field' => 'old' }}}
302
- #
303
- # @param [String] document_key key with dots
304
- # @param [?] value
305
- #
306
- # @return [Hash<String, ?>]
307
- def expand_nested_document_key_value(document_key, value)
308
- expanded_key = value
309
- document_key.to_s.split('.').reverse.each do |key|
310
- expanded_key = { key => expanded_key }
311
- end
312
- expanded_key
313
- end
314
-
315
- def increment_current_version
316
- current_version = (send(history_trackable_options[:version_field]) || 0) + 1
317
- send("#{history_trackable_options[:version_field]}=", current_version)
318
- current_version
319
- end
320
-
321
- protected
322
-
323
- def track_history_for_action?(action)
324
- track_history? && !(action.to_sym == :update && modified_attributes_for_update.blank?)
325
- end
326
-
327
- def track_history_for_action(action)
328
- if track_history_for_action?(action)
329
- current_version = increment_current_version
330
- last_track = self.class.tracker_class.create!(
331
- history_tracker_attributes(action.to_sym)
332
- .merge(version: current_version, action: action.to_s, trackable: self)
333
- )
334
- end
335
-
336
- clear_trackable_memoization
337
-
338
- begin
339
- yield
340
- rescue => e
341
- if track_history_for_action?(action)
342
- send("#{history_trackable_options[:version_field]}=", current_version - 1)
343
- last_track.destroy
344
- end
345
- raise e
346
- end
347
- end
348
- end
349
-
350
- module RelationMethods
351
- # Returns a relation class for the given field.
352
- #
353
- # @param [ String | Symbol ] field The name of the field.
354
- #
355
- # @return [ nil | Constant ] Class being related.
356
- def relation_class_of(field)
357
- meta = meta_of(field)
358
- return meta.class_name.constantize if meta
359
- end
360
-
361
- # Indicates whether there is an Embedded::One relation for the given embedded field.
362
- #
363
- # @param [ String | Symbol ] embed The name of the embedded field.
364
- #
365
- # @return [ Boolean ] true if there is an Embedded::One relation for the given embedded field.
366
- def embeds_one?(field)
367
- relation_of(field) == if Mongoid::Compatibility::Version.mongoid7_or_newer?
368
- Mongoid::Association::Embedded::EmbedsOne::Proxy
369
- else
370
- Mongoid::Relations::Embedded::One
371
- end
372
- end
373
-
374
- # Indicates whether there is an Embedded::Many relation for the given embedded field.
375
- #
376
- # @param [ String | Symbol ] field The name of the embedded field.
377
- #
378
- # @return [ Boolean ] true if there is an Embedded::Many relation for the given embedded field.
379
- def embeds_many?(field)
380
- relation_of(field) == if Mongoid::Compatibility::Version.mongoid7_or_newer?
381
- Mongoid::Association::Embedded::EmbedsMany::Proxy
382
- else
383
- Mongoid::Relations::Embedded::Many
384
- end
385
- end
386
-
387
- # Retrieves the database representation of an embedded field name, in case the :store_as option is used.
388
- #
389
- # @param [ String | Symbol ] embed The name or alias of the embedded field.
390
- #
391
- # @return [ String ] The database name of the embedded field.
392
- def relation_alias(embed)
393
- relation_aliases[embed]
394
- end
395
-
396
- protected
397
-
398
- # Return the reflected metadata for a relation.
399
- #
400
- # @param [ String ] field The database field name for a relation.
401
- #
402
- # @return [ nil | Mongoid::Relations::Metadata ]
403
- def meta_of(field)
404
- @meta_of ||= {}
405
- return @meta_of[field] if @meta_of.key?(field)
406
- @meta_of[field] = reflect_on_association(relation_alias(field))
407
- end
408
-
409
- # Returns a relation for the given field.
410
- #
411
- # @param [ String | Symbol ] field The name of the field.
412
- #
413
- # @return [ nil | Constant ] Type of relation.
414
- def relation_of(field)
415
- meta = meta_of(field)
416
- meta ? meta.relation : nil
417
- end
418
-
419
- # Retrieves the memoized hash of embedded aliases and their associated database representations.
420
- #
421
- # @return [ Hash < String, String > ] hash of embedded aliases (keys) to database representations (values)
422
- def relation_aliases
423
- @relation_aliases ||= relations.inject(HashWithIndifferentAccess.new) do |h, (k, v)|
424
- store_as = Mongoid::Compatibility::Version.mongoid7_or_newer? ? v.store_as : v[:store_as]
425
- h[store_as || k] = k
426
- h
427
- end
428
- end
429
- end
430
-
431
- module SingletonMethods
432
- # Whether or not the field or embedded relation should be tracked.
433
- #
434
- # @param [ String | Symbol ] field_or_relation The name or alias of the field OR the name of embedded relation
435
- # @param [ String | Symbol ] action The optional action name (:create, :update, or :destroy)
436
- #
437
- # @return [ Boolean ] whether or not the field or embedded relation is tracked for the given action
438
- def tracked?(field_or_relation, action = :update)
439
- tracked_field?(field_or_relation, action) || tracked_relation?(field_or_relation)
440
- end
441
-
442
- # Whether or not the field should be tracked.
443
- #
444
- # @param [ String | Symbol ] field The name or alias of the field
445
- # @param [ String | Symbol ] action The optional action name (:create, :update, or :destroy)
446
- #
447
- # @return [ Boolean ] whether or not the field is tracked for the given action
448
- def tracked_field?(field, action = :update)
449
- dynamic_field?(field) || tracked_fields_for_action(action).include?(database_field_name(field))
450
- end
451
-
452
- # Checks if field is dynamic.
453
- #
454
- # @param [ String | Symbol ] field The name of the dynamic field
455
- #
456
- # @return [ Boolean ] whether or not the field is dynamic
457
- def dynamic_field?(field)
458
- dynamic_enabled? &&
459
- !fields.keys.include?(database_field_name(field)) &&
460
- !embedded_relations.map { |_, v| v.key }.include?(database_field_name(field))
461
- end
462
-
463
- def field_format(field)
464
- field_formats[database_field_name(field)]
465
- end
466
-
467
- # Retrieves the list of tracked fields for a given action.
468
- #
469
- # @param [ String | Symbol ] action The action name (:create, :update, or :destroy)
470
- #
471
- # @return [ Array < String > ] the list of tracked fields for the given action
472
- def tracked_fields_for_action(action)
473
- case action.to_sym
474
- when :destroy then tracked_fields + reserved_tracked_fields
475
- else tracked_fields
476
- end
477
- end
478
-
479
- # Retrieves the memoized base list of tracked fields, excluding reserved fields.
480
- #
481
- # @return [ Array < String > ] the base list of tracked database field names
482
- def tracked_fields
483
- @tracked_fields ||= history_trackable_options[:fields] + history_trackable_options[:dynamic]
484
- end
485
-
486
- # Retrieves the memoized list of reserved tracked fields, which are only included for certain actions.
487
- #
488
- # @return [ Array < String > ] the list of reserved database field names
489
- def reserved_tracked_fields
490
- @reserved_tracked_fields ||= begin
491
- fields = ['_id', history_trackable_options[:version_field].to_s]
492
- modifier_field = history_trackable_options[:modifier_field]
493
- fields << "#{modifier_field}_id" if modifier_field
494
- fields
495
- end
496
- end
497
-
498
- def field_formats
499
- @field_formats ||= history_trackable_options[:format]
500
- end
501
-
502
- # Whether or not the relation should be tracked.
503
- #
504
- # @param [ String | Symbol ] relation The name of the relation
505
- #
506
- # @return [ Boolean ] whether or not the relation is tracked
507
- def tracked_relation?(relation)
508
- tracked_embeds_one?(relation) || tracked_embeds_many?(relation)
509
- end
510
-
511
- # Whether or not the embeds_one relation should be tracked.
512
- #
513
- # @param [ String | Symbol ] relation The name of the embeds_one relation
514
- #
515
- # @return [ Boolean ] whether or not the embeds_one relation is tracked
516
- def tracked_embeds_one?(relation)
517
- tracked_embeds_one.include?(database_field_name(relation))
518
- end
519
-
520
- # Retrieves the memoized list of tracked embeds_one relations
521
- #
522
- # @return [ Array < String > ] the list of tracked embeds_one relations
523
- def tracked_embeds_one
524
- @tracked_embeds_one ||= begin
525
- reflect_on_all_associations(:embeds_one)
526
- .map(&:key)
527
- .select { |rel| history_trackable_options[:relations][:embeds_one].include? rel }
528
- end
529
- end
530
-
531
- def tracked_embeds_one_attributes(relation)
532
- history_trackable_options[:relations][:embeds_one][database_field_name(relation)]
533
- end
534
-
535
- # Whether or not the embeds_many relation should be tracked.
536
- #
537
- # @param [ String | Symbol ] relation The name of the embeds_many relation
538
- #
539
- # @return [ Boolean ] whether or not the embeds_many relation is tracked
540
- def tracked_embeds_many?(relation)
541
- tracked_embeds_many.include?(database_field_name(relation))
542
- end
543
-
544
- # Retrieves the memoized list of tracked embeds_many relations
545
- #
546
- # @return [ Array < String > ] the list of tracked embeds_many relations
547
- def tracked_embeds_many
548
- @tracked_embeds_many ||= begin
549
- reflect_on_all_associations(:embeds_many)
550
- .map(&:key)
551
- .select { |rel| history_trackable_options[:relations][:embeds_many].include? rel }
552
- end
553
- end
554
-
555
- def tracked_embeds_many_attributes(relation)
556
- history_trackable_options[:relations][:embeds_many][database_field_name(relation)]
557
- end
558
-
559
- def trackable_scope
560
- collection_name.to_s.singularize.to_sym
561
- end
562
-
563
- def history_trackable_options
564
- @history_trackable_options ||= mongoid_history_options.prepared
565
- end
566
-
567
- def clear_trackable_memoization
568
- @reserved_tracked_fields = nil
569
- @history_trackable_options = nil
570
- @trackable_settings = nil
571
- @tracked_fields = nil
572
- @tracked_embeds_one = nil
573
- @tracked_embeds_many = nil
574
- end
575
-
576
- def inherited(subclass)
577
- super
578
- subclass.mongoid_history_options = Mongoid::History::Options.new(subclass, mongoid_history_options.options)
579
- end
580
- end
581
- end
582
- end
583
- end
1
+ module Mongoid
2
+ module History
3
+ module Trackable
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+ def track_history(options = {})
8
+ extend RelationMethods
9
+
10
+ history_options = Mongoid::History::Options.new(self, options)
11
+
12
+ field history_options.options[:version_field].to_sym, type: Integer
13
+
14
+ unless history_options.options[:modifier_field].nil?
15
+ belongs_to_modifier_options = { class_name: Mongoid::History.modifier_class_name }
16
+ belongs_to_modifier_options[:inverse_of] = history_options.options[:modifier_field_inverse_of] if history_options.options.key?(:modifier_field_inverse_of)
17
+ belongs_to_modifier_options[:optional] = true if history_options.options[:modifier_field_optional] && Mongoid::Compatibility::Version.mongoid6_or_newer?
18
+ belongs_to history_options.options[:modifier_field].to_sym, belongs_to_modifier_options
19
+ end
20
+
21
+ include MyInstanceMethods
22
+ extend SingletonMethods
23
+
24
+ delegate :history_trackable_options, to: 'self.class'
25
+ delegate :track_history?, to: 'self.class'
26
+
27
+ callback_options = history_options.options.slice(:if, :unless)
28
+ around_update :track_update, **callback_options if history_options.options[:track_update]
29
+ around_create :track_create, **callback_options if history_options.options[:track_create]
30
+ around_destroy :track_destroy, **callback_options if history_options.options[:track_destroy]
31
+
32
+ unless respond_to? :mongoid_history_options
33
+ class_attribute :mongoid_history_options, instance_accessor: false
34
+ end
35
+
36
+ self.mongoid_history_options = history_options
37
+ end
38
+
39
+ def history_settings(options = {})
40
+ options = Mongoid::History.default_settings.merge(options.symbolize_keys)
41
+ options = options.slice(*Mongoid::History.default_settings.keys)
42
+ options[:paranoia_field] = aliased_fields[options[:paranoia_field].to_s] || options[:paranoia_field].to_s
43
+ Mongoid::History.trackable_settings ||= {}
44
+ Mongoid::History.trackable_settings[name.to_sym] = options
45
+ end
46
+
47
+ def track_history?
48
+ Mongoid::History.enabled? && Mongoid::History.store[track_history_flag] != false
49
+ end
50
+
51
+ def dynamic_enabled?
52
+ Mongoid::Compatibility::Version.mongoid3? || (self < Mongoid::Attributes::Dynamic).present?
53
+ end
54
+
55
+ def disable_tracking
56
+ original_flag = Mongoid::History.store[track_history_flag]
57
+ Mongoid::History.store[track_history_flag] = false
58
+ yield if block_given?
59
+ ensure
60
+ Mongoid::History.store[track_history_flag] = original_flag if block_given?
61
+ end
62
+
63
+ def enable_tracking
64
+ original_flag = Mongoid::History.store[track_history_flag]
65
+ Mongoid::History.store[track_history_flag] = true
66
+ yield if block_given?
67
+ ensure
68
+ Mongoid::History.store[track_history_flag] = original_flag if block_given?
69
+ end
70
+
71
+ alias disable_tracking! disable_tracking
72
+ alias enable_tracking! enable_tracking
73
+
74
+ def track_history_flag
75
+ "mongoid_history_#{name.underscore}_trackable_enabled".to_sym
76
+ end
77
+
78
+ def tracker_class
79
+ klass = history_trackable_options[:tracker_class_name] || Mongoid::History.tracker_class_name
80
+ klass.is_a?(Class) ? klass : klass.to_s.camelize.constantize
81
+ end
82
+ end
83
+
84
+ module MyInstanceMethods
85
+ def history_tracks
86
+ @history_tracks ||= self.class.tracker_class.where(
87
+ scope: related_scope,
88
+ association_chain: association_hash
89
+ ).asc(:version)
90
+ end
91
+
92
+ # undo :from => 1, :to => 5
93
+ # undo 4
94
+ # undo :last => 10
95
+ def undo(modifier = nil, options_or_version = nil)
96
+ versions = get_versions_criteria(options_or_version).to_a
97
+ versions.sort! { |v1, v2| v2.version <=> v1.version }
98
+
99
+ versions.each do |v|
100
+ undo_attr = v.undo_attr(modifier)
101
+ if Mongoid::Compatibility::Version.mongoid3? # update_attributes! not bypassing rails 3 protected attributes
102
+ assign_attributes(undo_attr, without_protection: true)
103
+ else # assign_attributes with 'without_protection' option does not work with rails 4/mongoid 4
104
+ self.attributes = undo_attr
105
+ end
106
+ end
107
+ end
108
+
109
+ # undo! :from => 1, :to => 5
110
+ # undo! 4
111
+ # undo! :last => 10
112
+ def undo!(modifier = nil, options_or_version = nil)
113
+ undo(modifier, options_or_version)
114
+ save!
115
+ end
116
+
117
+ def redo!(modifier = nil, options_or_version = nil)
118
+ versions = get_versions_criteria(options_or_version).to_a
119
+ versions.sort! { |v1, v2| v1.version <=> v2.version }
120
+
121
+ versions.each do |v|
122
+ redo_attr = v.redo_attr(modifier)
123
+ if Mongoid::Compatibility::Version.mongoid3?
124
+ assign_attributes(redo_attr, without_protection: true)
125
+ save!
126
+ else
127
+ update_attributes!(redo_attr)
128
+ end
129
+ end
130
+ end
131
+
132
+ def _get_relation(name)
133
+ send(self.class.relation_alias(name))
134
+ end
135
+
136
+ def _create_relation(name, value)
137
+ send("create_#{self.class.relation_alias(name)}!", value)
138
+ end
139
+
140
+ private
141
+
142
+ def ancestor_flagged_for_destroy?(doc)
143
+ doc && (doc.flagged_for_destroy? || ancestor_flagged_for_destroy?(doc._parent))
144
+ end
145
+
146
+ def get_versions_criteria(options_or_version)
147
+ if options_or_version.is_a? Hash
148
+ options = options_or_version
149
+ if options[:from] && options[:to]
150
+ lower = options[:from] >= options[:to] ? options[:to] : options[:from]
151
+ upper = options[:from] < options[:to] ? options[:to] : options[:from]
152
+ versions = history_tracks.where(:version.in => (lower..upper).to_a)
153
+ elsif options[:last]
154
+ versions = history_tracks.limit(options[:last])
155
+ else
156
+ raise 'Invalid options, please specify (:from / :to) keys or :last key.'
157
+ end
158
+ else
159
+ options_or_version = options_or_version.to_a if options_or_version.is_a?(Range)
160
+ version_field_name = history_trackable_options[:version_field]
161
+ version = options_or_version || attributes[version_field_name] || attributes[version_field_name.to_s]
162
+ version = [version].flatten
163
+ versions = history_tracks.where(:version.in => version)
164
+ end
165
+ versions.desc(:version)
166
+ end
167
+
168
+ def related_scope
169
+ scope = history_trackable_options[:scope]
170
+
171
+ # Use top level document if its name is specified in the scope
172
+ root_document_name = traverse_association_chain.first['name'].singularize.underscore.tr('/', '_').to_sym
173
+ if scope.is_a?(Array) && scope.include?(root_document_name)
174
+ scope = root_document_name
175
+ else
176
+ scope = _parent.collection_name.to_s.singularize.to_sym if scope.is_a?(Array)
177
+ if Mongoid::Compatibility::Version.mongoid3?
178
+ scope = metadata.inverse_class_name.tableize.singularize.to_sym if metadata.present? && scope == metadata.as
179
+ elsif Mongoid::Compatibility::Version.mongoid6_or_older?
180
+ scope = relation_metadata.inverse_class_name.tableize.singularize.to_sym if relation_metadata.present? && scope == relation_metadata.as
181
+ elsif Mongoid::Compatibility::Version.mongoid7_or_newer?
182
+ scope = _association.inverse_class_name.tableize.singularize.to_sym if _association.present? && scope == _association.as
183
+ end
184
+ end
185
+
186
+ scope
187
+ end
188
+
189
+ def traverse_association_chain(node = self)
190
+ (node._parent ? traverse_association_chain(node._parent) : []).tap { |list| list << association_hash(node) }
191
+ end
192
+
193
+ def association_hash(node = self)
194
+ # We prefer to look up associations through the parent record because
195
+ # we're assured, through the object creation, it'll exist. Whereas we're not guaranteed
196
+ # the child to parent (embedded_in, belongs_to) relation will be defined
197
+ if node._parent
198
+ meta = node._parent.relations.values.find do |relation|
199
+ if Mongoid::Compatibility::Version.mongoid3?
200
+ relation.class_name == node.metadata.class_name.to_s && relation.name == node.metadata.name
201
+ elsif Mongoid::Compatibility::Version.mongoid6_or_older?
202
+ relation.class_name == node.relation_metadata.class_name.to_s &&
203
+ relation.name == node.relation_metadata.name
204
+ elsif Mongoid::Compatibility::Version.mongoid7_or_newer?
205
+ relation.class_name == node._association.class_name.to_s &&
206
+ relation.name == node._association.name
207
+ end
208
+ end
209
+ end
210
+
211
+ # if root node has no meta, and should use class name instead
212
+ name = meta ? meta.key.to_s : node.class.name
213
+
214
+ ActiveSupport::OrderedHash['name', name, 'id', node.id]
215
+ end
216
+
217
+ # Returns a Hash of field name to pairs of original and modified values
218
+ # for each tracked field for a given action.
219
+ #
220
+ # @param [ String | Symbol ] action The modification action (:create, :update, :destroy)
221
+ #
222
+ # @return [ Hash<String, Array<Object>> ] the pairs of original and modified
223
+ # values for each field
224
+ def modified_attributes_for_action(action)
225
+ case action.to_sym
226
+ when :destroy then modified_attributes_for_destroy
227
+ when :create then modified_attributes_for_create
228
+ else modified_attributes_for_update
229
+ end
230
+ end
231
+
232
+ def modified_attributes_for_update
233
+ @modified_attributes_for_update ||= Mongoid::History::Attributes::Update.new(self).attributes
234
+ end
235
+
236
+ def modified_attributes_for_create
237
+ @modified_attributes_for_create ||= Mongoid::History::Attributes::Create.new(self).attributes
238
+ end
239
+
240
+ def modified_attributes_for_destroy
241
+ @modified_attributes_for_destroy ||= Mongoid::History::Attributes::Destroy.new(self).attributes
242
+ end
243
+
244
+ def history_tracker_attributes(action)
245
+ return @history_tracker_attributes if @history_tracker_attributes
246
+
247
+ modifier_field = history_trackable_options[:modifier_field]
248
+ @history_tracker_attributes = {
249
+ association_chain: traverse_association_chain,
250
+ scope: related_scope
251
+ }
252
+ @history_tracker_attributes[:modifier] = send(modifier_field) if modifier_field
253
+
254
+ original, modified = transform_changes(modified_attributes_for_action(action))
255
+
256
+ @history_tracker_attributes[:original] = original
257
+ @history_tracker_attributes[:modified] = modified
258
+ @history_tracker_attributes
259
+ end
260
+
261
+ def track_create(&block)
262
+ track_history_for_action(:create, &block)
263
+ end
264
+
265
+ def track_update(&block)
266
+ track_history_for_action(:update, &block)
267
+ end
268
+
269
+ def track_destroy(&block)
270
+ track_history_for_action(:destroy, &block) unless destroyed?
271
+ end
272
+
273
+ def clear_trackable_memoization
274
+ @history_tracker_attributes = @modified_attributes_for_create = @modified_attributes_for_update = @history_tracks = nil
275
+ end
276
+
277
+ # Transform hash of pair of changes into an `original` and `modified` hash
278
+ # Nested document keys (key name with dots) are expanded
279
+ #
280
+ # @param [Hash<Array>] changes
281
+ #
282
+ # @return [Array<Hash<?>,Hash<?>>] <description>
283
+ def transform_changes(changes)
284
+ original = {}
285
+ modified = {}
286
+ changes.each_pair do |k, modification_pair|
287
+ o, m = modification_pair
288
+ original.deep_merge!(expand_nested_document_key_value(k, o)) unless o.nil?
289
+ modified.deep_merge!(expand_nested_document_key_value(k, m)) unless m.nil?
290
+ end
291
+
292
+ [original, modified]
293
+ end
294
+
295
+ # Handle nested document tracking of changes
296
+ #
297
+ # @example
298
+ #
299
+ # expand_nested_document_key('embedded.document.changed_field', 'old'])
300
+ # #=> { 'embedded' => {'document' => { 'changed_field' => 'old' }}}
301
+ #
302
+ # @param [String] document_key key with dots
303
+ # @param [?] value
304
+ #
305
+ # @return [Hash<String, ?>]
306
+ def expand_nested_document_key_value(document_key, value)
307
+ expanded_key = value
308
+ document_key.to_s.split('.').reverse.each do |key|
309
+ expanded_key = { key => expanded_key }
310
+ end
311
+ expanded_key
312
+ end
313
+
314
+ def next_version
315
+ (send(history_trackable_options[:version_field]) || 0) + 1
316
+ end
317
+
318
+ def increment_current_version
319
+ next_version.tap { |version| send("#{history_trackable_options[:version_field]}=", version) }
320
+ end
321
+
322
+ protected
323
+
324
+ def track_history_for_action?(action)
325
+ track_history? && !(action.to_sym == :update && modified_attributes_for_update.blank?)
326
+ end
327
+
328
+ def increment_current_version?(action)
329
+ action != :destroy && !ancestor_flagged_for_destroy?(_parent)
330
+ end
331
+
332
+ def track_history_for_action(action)
333
+ if track_history_for_action?(action)
334
+ current_version = increment_current_version?(action) ? increment_current_version : next_version
335
+ last_track = self.class.tracker_class.create!(
336
+ history_tracker_attributes(action.to_sym)
337
+ .merge(version: current_version, action: action.to_s, trackable: self)
338
+ )
339
+ end
340
+
341
+ clear_trackable_memoization
342
+
343
+ begin
344
+ yield
345
+ rescue => e
346
+ if track_history_for_action?(action)
347
+ send("#{history_trackable_options[:version_field]}=", current_version - 1)
348
+ last_track.destroy
349
+ end
350
+ raise e
351
+ end
352
+ end
353
+ end
354
+
355
+ module RelationMethods
356
+ # Returns a relation class for the given field.
357
+ #
358
+ # @param [ String | Symbol ] field The name of the field.
359
+ #
360
+ # @return [ nil | Constant ] Class being related.
361
+ def relation_class_of(field)
362
+ meta = meta_of(field)
363
+ return meta.class_name.constantize if meta
364
+ end
365
+
366
+ # Indicates whether there is an Embedded::One relation for the given embedded field.
367
+ #
368
+ # @param [ String | Symbol ] embed The name of the embedded field.
369
+ #
370
+ # @return [ Boolean ] true if there is an Embedded::One relation for the given embedded field.
371
+ def embeds_one?(field)
372
+ relation_of(field) == if Mongoid::Compatibility::Version.mongoid7_or_newer?
373
+ Mongoid::Association::Embedded::EmbedsOne::Proxy
374
+ else
375
+ Mongoid::Relations::Embedded::One
376
+ end
377
+ end
378
+
379
+ # Indicates whether there is an Embedded::Many relation for the given embedded field.
380
+ #
381
+ # @param [ String | Symbol ] field The name of the embedded field.
382
+ #
383
+ # @return [ Boolean ] true if there is an Embedded::Many relation for the given embedded field.
384
+ def embeds_many?(field)
385
+ relation_of(field) == if Mongoid::Compatibility::Version.mongoid7_or_newer?
386
+ Mongoid::Association::Embedded::EmbedsMany::Proxy
387
+ else
388
+ Mongoid::Relations::Embedded::Many
389
+ end
390
+ end
391
+
392
+ # Retrieves the database representation of an embedded field name, in case the :store_as option is used.
393
+ #
394
+ # @param [ String | Symbol ] embed The name or alias of the embedded field.
395
+ #
396
+ # @return [ String ] The database name of the embedded field.
397
+ def relation_alias(embed)
398
+ relation_aliases[embed]
399
+ end
400
+
401
+ protected
402
+
403
+ # Return the reflected metadata for a relation.
404
+ #
405
+ # @param [ String ] field The database field name for a relation.
406
+ #
407
+ # @return [ nil | Mongoid::Relations::Metadata ]
408
+ def meta_of(field)
409
+ @meta_of ||= {}
410
+ return @meta_of[field] if @meta_of.key?(field)
411
+ @meta_of[field] = reflect_on_association(relation_alias(field))
412
+ end
413
+
414
+ # Returns a relation for the given field.
415
+ #
416
+ # @param [ String | Symbol ] field The name of the field.
417
+ #
418
+ # @return [ nil | Constant ] Type of relation.
419
+ def relation_of(field)
420
+ meta = meta_of(field)
421
+ meta ? meta.relation : nil
422
+ end
423
+
424
+ # Retrieves the memoized hash of embedded aliases and their associated database representations.
425
+ #
426
+ # @return [ Hash < String, String > ] hash of embedded aliases (keys) to database representations (values)
427
+ def relation_aliases
428
+ @relation_aliases ||= relations.inject(HashWithIndifferentAccess.new) do |h, (k, v)|
429
+ store_as = Mongoid::Compatibility::Version.mongoid7_or_newer? ? v.store_as : v[:store_as]
430
+ h[store_as || k] = k
431
+ h
432
+ end
433
+ end
434
+ end
435
+
436
+ module SingletonMethods
437
+ # Whether or not the field or embedded relation should be tracked.
438
+ #
439
+ # @param [ String | Symbol ] field_or_relation The name or alias of the field OR the name of embedded relation
440
+ # @param [ String | Symbol ] action The optional action name (:create, :update, or :destroy)
441
+ #
442
+ # @return [ Boolean ] whether or not the field or embedded relation is tracked for the given action
443
+ def tracked?(field_or_relation, action = :update)
444
+ tracked_field?(field_or_relation, action) || tracked_relation?(field_or_relation)
445
+ end
446
+
447
+ # Whether or not the field should be tracked.
448
+ #
449
+ # @param [ String | Symbol ] field The name or alias of the field
450
+ # @param [ String | Symbol ] action The optional action name (:create, :update, or :destroy)
451
+ #
452
+ # @return [ Boolean ] whether or not the field is tracked for the given action
453
+ def tracked_field?(field, action = :update)
454
+ dynamic_field?(field) || tracked_fields_for_action(action).include?(database_field_name(field))
455
+ end
456
+
457
+ # Checks if field is dynamic.
458
+ #
459
+ # @param [ String | Symbol ] field The name of the dynamic field
460
+ #
461
+ # @return [ Boolean ] whether or not the field is dynamic
462
+ def dynamic_field?(field)
463
+ dynamic_enabled? &&
464
+ !fields.keys.include?(database_field_name(field)) &&
465
+ !embedded_relations.map { |_, v| v.key }.include?(database_field_name(field))
466
+ end
467
+
468
+ def field_format(field)
469
+ field_formats[database_field_name(field)]
470
+ end
471
+
472
+ # Retrieves the list of tracked fields for a given action.
473
+ #
474
+ # @param [ String | Symbol ] action The action name (:create, :update, or :destroy)
475
+ #
476
+ # @return [ Array < String > ] the list of tracked fields for the given action
477
+ def tracked_fields_for_action(action)
478
+ case action.to_sym
479
+ when :destroy then tracked_fields + reserved_tracked_fields
480
+ else tracked_fields
481
+ end
482
+ end
483
+
484
+ # Retrieves the memoized base list of tracked fields, excluding reserved fields.
485
+ #
486
+ # @return [ Array < String > ] the base list of tracked database field names
487
+ def tracked_fields
488
+ @tracked_fields ||= history_trackable_options[:fields] + history_trackable_options[:dynamic]
489
+ end
490
+
491
+ # Retrieves the memoized list of reserved tracked fields, which are only included for certain actions.
492
+ #
493
+ # @return [ Array < String > ] the list of reserved database field names
494
+ def reserved_tracked_fields
495
+ @reserved_tracked_fields ||= begin
496
+ fields = ['_id', history_trackable_options[:version_field].to_s]
497
+ modifier_field = history_trackable_options[:modifier_field]
498
+ fields << "#{modifier_field}_id" if modifier_field
499
+ fields
500
+ end
501
+ end
502
+
503
+ def field_formats
504
+ @field_formats ||= history_trackable_options[:format]
505
+ end
506
+
507
+ # Whether or not the relation should be tracked.
508
+ #
509
+ # @param [ String | Symbol ] relation The name of the relation
510
+ #
511
+ # @return [ Boolean ] whether or not the relation is tracked
512
+ def tracked_relation?(relation)
513
+ tracked_embeds_one?(relation) || tracked_embeds_many?(relation)
514
+ end
515
+
516
+ # Whether or not the embeds_one relation should be tracked.
517
+ #
518
+ # @param [ String | Symbol ] relation The name of the embeds_one relation
519
+ #
520
+ # @return [ Boolean ] whether or not the embeds_one relation is tracked
521
+ def tracked_embeds_one?(relation)
522
+ tracked_embeds_one.include?(database_field_name(relation))
523
+ end
524
+
525
+ # Retrieves the memoized list of tracked embeds_one relations
526
+ #
527
+ # @return [ Array < String > ] the list of tracked embeds_one relations
528
+ def tracked_embeds_one
529
+ @tracked_embeds_one ||= begin
530
+ reflect_on_all_associations(:embeds_one)
531
+ .map(&:key)
532
+ .select { |rel| history_trackable_options[:relations][:embeds_one].include? rel }
533
+ end
534
+ end
535
+
536
+ def tracked_embeds_one_attributes(relation)
537
+ history_trackable_options[:relations][:embeds_one][database_field_name(relation)]
538
+ end
539
+
540
+ # Whether or not the embeds_many relation should be tracked.
541
+ #
542
+ # @param [ String | Symbol ] relation The name of the embeds_many relation
543
+ #
544
+ # @return [ Boolean ] whether or not the embeds_many relation is tracked
545
+ def tracked_embeds_many?(relation)
546
+ tracked_embeds_many.include?(database_field_name(relation))
547
+ end
548
+
549
+ # Retrieves the memoized list of tracked embeds_many relations
550
+ #
551
+ # @return [ Array < String > ] the list of tracked embeds_many relations
552
+ def tracked_embeds_many
553
+ @tracked_embeds_many ||= begin
554
+ reflect_on_all_associations(:embeds_many)
555
+ .map(&:key)
556
+ .select { |rel| history_trackable_options[:relations][:embeds_many].include? rel }
557
+ end
558
+ end
559
+
560
+ def tracked_embeds_many_attributes(relation)
561
+ history_trackable_options[:relations][:embeds_many][database_field_name(relation)]
562
+ end
563
+
564
+ def trackable_scope
565
+ collection_name.to_s.singularize.to_sym
566
+ end
567
+
568
+ def history_trackable_options
569
+ @history_trackable_options ||= mongoid_history_options.prepared
570
+ end
571
+
572
+ def clear_trackable_memoization
573
+ @reserved_tracked_fields = nil
574
+ @history_trackable_options = nil
575
+ @trackable_settings = nil
576
+ @tracked_fields = nil
577
+ @tracked_embeds_one = nil
578
+ @tracked_embeds_many = nil
579
+ end
580
+
581
+ def inherited(subclass)
582
+ super
583
+ subclass.mongoid_history_options = Mongoid::History::Options.new(subclass, mongoid_history_options.options)
584
+ end
585
+ end
586
+ end
587
+ end
588
+ end