mongoid-history 0.8.3 → 0.8.5

Sign up to get free protection for your applications and to get access to all the features.
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