mongo_trails 10.3.1

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +2 -0
  3. data/.gitignore +1 -0
  4. data/.travis.yml +13 -0
  5. data/Appraisals +7 -0
  6. data/Gemfile +7 -0
  7. data/Gemfile.lock +62 -0
  8. data/LICENSE +20 -0
  9. data/README.md +36 -0
  10. data/Rakefile +13 -0
  11. data/gemfiles/rails_5.gemfile +9 -0
  12. data/gemfiles/rails_5.gemfile.lock +63 -0
  13. data/gemfiles/rails_6.gemfile +9 -0
  14. data/gemfiles/rails_6.gemfile.lock +63 -0
  15. data/lib/mongo_trails.rb +154 -0
  16. data/lib/mongo_trails/attribute_serializers/README.md +10 -0
  17. data/lib/mongo_trails/attribute_serializers/attribute_serializer_factory.rb +27 -0
  18. data/lib/mongo_trails/attribute_serializers/cast_attribute_serializer.rb +51 -0
  19. data/lib/mongo_trails/attribute_serializers/object_attribute.rb +41 -0
  20. data/lib/mongo_trails/attribute_serializers/object_changes_attribute.rb +44 -0
  21. data/lib/mongo_trails/cleaner.rb +60 -0
  22. data/lib/mongo_trails/compatibility.rb +51 -0
  23. data/lib/mongo_trails/config.rb +41 -0
  24. data/lib/mongo_trails/events/base.rb +323 -0
  25. data/lib/mongo_trails/events/create.rb +32 -0
  26. data/lib/mongo_trails/events/destroy.rb +42 -0
  27. data/lib/mongo_trails/events/update.rb +60 -0
  28. data/lib/mongo_trails/frameworks/cucumber.rb +33 -0
  29. data/lib/mongo_trails/frameworks/rails.rb +4 -0
  30. data/lib/mongo_trails/frameworks/rails/controller.rb +109 -0
  31. data/lib/mongo_trails/frameworks/rails/engine.rb +43 -0
  32. data/lib/mongo_trails/frameworks/rspec.rb +43 -0
  33. data/lib/mongo_trails/frameworks/rspec/helpers.rb +29 -0
  34. data/lib/mongo_trails/has_paper_trail.rb +86 -0
  35. data/lib/mongo_trails/model_config.rb +249 -0
  36. data/lib/mongo_trails/mongo_support/config.rb +9 -0
  37. data/lib/mongo_trails/mongo_support/version.rb +56 -0
  38. data/lib/mongo_trails/queries/versions/where_object.rb +65 -0
  39. data/lib/mongo_trails/queries/versions/where_object_changes.rb +75 -0
  40. data/lib/mongo_trails/record_history.rb +51 -0
  41. data/lib/mongo_trails/record_trail.rb +304 -0
  42. data/lib/mongo_trails/reifier.rb +130 -0
  43. data/lib/mongo_trails/request.rb +166 -0
  44. data/lib/mongo_trails/serializers/json.rb +46 -0
  45. data/lib/mongo_trails/serializers/yaml.rb +43 -0
  46. data/lib/mongo_trails/type_serializers/postgres_array_serializer.rb +48 -0
  47. data/lib/mongo_trails/version_concern.rb +336 -0
  48. data/lib/mongo_trails/version_number.rb +23 -0
  49. data/mongo_trails.gemspec +38 -0
  50. metadata +180 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ # Represents the history of a single record.
5
+ # @api private
6
+ class RecordHistory
7
+ # @param versions - ActiveRecord::Relation - All versions of the record.
8
+ # @param version_class - Class - Usually PaperTrail::Version,
9
+ # but it could also be a custom version class.
10
+ # @api private
11
+ def initialize(versions, version_class)
12
+ @versions = versions
13
+ @version_class = version_class
14
+ end
15
+
16
+ # Returns ordinal position of `version` in `sequence`.
17
+ # @api private
18
+ def index(version)
19
+ sequence.to_a.index(version)
20
+ end
21
+
22
+ private
23
+
24
+ # Returns `@versions` in chronological order.
25
+ # @api private
26
+ def sequence
27
+ if @version_class.primary_key_is_int?
28
+ @versions.select(primary_key).order(primary_key.asc)
29
+ else
30
+ @versions.
31
+ select([table[:created_at], primary_key]).
32
+ order(@version_class.timestamp_sort_order)
33
+ end
34
+ end
35
+
36
+ # @return - Arel::Attribute - Attribute representing the primary key
37
+ # of the version table. The column's data type is usually a serial
38
+ # integer (the rails convention) but not always.
39
+ # @api private
40
+ def primary_key
41
+ table[@version_class.primary_key]
42
+ end
43
+
44
+ # @return - Arel::Table - The version table, usually named `versions`, but
45
+ # not always.
46
+ # @api private
47
+ def table
48
+ @version_class.arel_table
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mongo_trails/events/create"
4
+ require "mongo_trails/events/destroy"
5
+ require "mongo_trails/events/update"
6
+
7
+ module PaperTrail
8
+ # Represents the "paper trail" for a single record.
9
+ class RecordTrail
10
+ RAILS_GTE_5_1 = ::ActiveRecord.gem_version >= ::Gem::Version.new("5.1.0.beta1")
11
+
12
+ def initialize(record)
13
+ @record = record
14
+ end
15
+
16
+ # Invoked after rollbacks to ensure versions records are not created for
17
+ # changes that never actually took place. Optimization: Use lazy `reset`
18
+ # instead of eager `reload` because, in many use cases, the association will
19
+ # not be used.
20
+ def clear_rolled_back_versions
21
+ versions_reset
22
+ end
23
+
24
+ def versions_reset
25
+ @record.class.paper_trail.version_class.reset
26
+ end
27
+
28
+ # Invoked via`after_update` callback for when a previous version is
29
+ # reified and then saved.
30
+ def clear_version_instance
31
+ @record.send("#{@record.class.version_association_name}=", nil)
32
+ end
33
+
34
+ # Is PT enabled for this particular record?
35
+ # @api private
36
+ def enabled?
37
+ PaperTrail.enabled? &&
38
+ PaperTrail.request.enabled? &&
39
+ PaperTrail.request.enabled_for_model?(@record.class)
40
+ end
41
+
42
+ # Returns true if this instance is the current, live one;
43
+ # returns false if this instance came from a previous version.
44
+ def live?
45
+ source_version.nil?
46
+ end
47
+
48
+ # Returns the object (not a Version) as it became next.
49
+ # NOTE: if self (the item) was not reified from a version, i.e. it is the
50
+ # "live" item, we return nil. Perhaps we should return self instead?
51
+ def next_version
52
+ subsequent_version = source_version.next
53
+ subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
54
+ rescue StandardError # TODO: Rescue something more specific
55
+ nil
56
+ end
57
+
58
+ # Returns who put `@record` into its current state.
59
+ #
60
+ # @api public
61
+ def originator
62
+ (source_version || versions.last).try(:whodunnit)
63
+ end
64
+
65
+ # Returns the object (not a Version) as it was most recently.
66
+ #
67
+ # @api public
68
+ def previous_version
69
+ (source_version ? source_version.previous : versions.last).try(:reify)
70
+ end
71
+
72
+ def record_create
73
+ return unless enabled?
74
+
75
+ build_version_on_create(in_after_callback: true).tap do |version|
76
+ version.save!
77
+ # Because the version object was created using version_class.new instead
78
+ # of versions_assoc.build?, the association cache is unaware. So, we
79
+ # invalidate the `versions` association cache with `reset`.
80
+ versions_reset
81
+ end
82
+ end
83
+
84
+ # PT-AT extends this method to add its transaction id.
85
+ #
86
+ # @api private
87
+ def data_for_create
88
+ {}
89
+ end
90
+
91
+ # `recording_order` is "after" or "before". See ModelConfig#on_destroy.
92
+ #
93
+ # @api private
94
+ # @return - The created version object, so that plugins can use it, e.g.
95
+ # paper_trail-association_tracking
96
+ def record_destroy(recording_order)
97
+ return unless enabled? && !@record.new_record?
98
+ in_after_callback = recording_order == "after"
99
+ event = Events::Destroy.new(@record, in_after_callback)
100
+
101
+ # Merge data from `Event` with data from PT-AT. We no longer use
102
+ # `data_for_destroy` but PT-AT still does.
103
+ data = event.data.merge(data_for_destroy)
104
+
105
+ version = @record.class.paper_trail.version_class.create(data)
106
+ if version.errors.any?
107
+ log_version_errors(version, :destroy)
108
+ else
109
+ assign_and_reset_version_association(version)
110
+ version
111
+ end
112
+ end
113
+
114
+ # PT-AT extends this method to add its transaction id.
115
+ #
116
+ # @api private
117
+ def data_for_destroy
118
+ {}
119
+ end
120
+
121
+ # @api private
122
+ # @return - The created version object, so that plugins can use it, e.g.
123
+ # paper_trail-association_tracking
124
+ def record_update(force:, in_after_callback:, is_touch:)
125
+ return unless enabled?
126
+
127
+ version = build_version_on_update(
128
+ force: force,
129
+ in_after_callback: in_after_callback,
130
+ is_touch: is_touch
131
+ )
132
+ return unless version
133
+
134
+ if version.save
135
+ # Because the version object was created using version_class.new instead
136
+ # of versions_assoc.build?, the association cache is unaware. So, we
137
+ # invalidate the `versions` association cache with `reset`.
138
+ versions_reset
139
+ version
140
+ else
141
+ log_version_errors(version, :update)
142
+ end
143
+ end
144
+
145
+ # PT-AT extends this method to add its transaction id.
146
+ #
147
+ # @api private
148
+ def data_for_update
149
+ {}
150
+ end
151
+
152
+ # @api private
153
+ # @return - The created version object, so that plugins can use it, e.g.
154
+ # paper_trail-association_tracking
155
+ def record_update_columns(changes)
156
+ return unless enabled?
157
+ event = Events::Update.new(@record, false, false, changes)
158
+
159
+ # Merge data from `Event` with data from PT-AT. We no longer use
160
+ # `data_for_update_columns` but PT-AT still does.
161
+ data = event.data.merge(data_for_update_columns)
162
+
163
+ versions_assoc = @record.send(@record.class.versions_association_name)
164
+ version = versions_assoc.create(data)
165
+ if version.errors.any?
166
+ log_version_errors(version, :update)
167
+ else
168
+ version
169
+ end
170
+ end
171
+
172
+ # PT-AT extends this method to add its transaction id.
173
+ #
174
+ # @api private
175
+ def data_for_update_columns
176
+ {}
177
+ end
178
+
179
+ # Invoked via callback when a user attempts to persist a reified
180
+ # `Version`.
181
+ def reset_timestamp_attrs_for_update_if_needed
182
+ return if live?
183
+ @record.send(:timestamp_attributes_for_update_in_model).each do |column|
184
+ @record.send("restore_#{column}!")
185
+ end
186
+ end
187
+
188
+ # AR callback.
189
+ # @api private
190
+ def save_version?
191
+ if_condition = @record.paper_trail_options[:if]
192
+ unless_condition = @record.paper_trail_options[:unless]
193
+ (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
194
+ end
195
+
196
+ def source_version
197
+ version
198
+ end
199
+
200
+ # Save, and create a version record regardless of options such as `:on`,
201
+ # `:if`, or `:unless`.
202
+ #
203
+ # Arguments are passed to `save`.
204
+ #
205
+ # This is an "update" event. That is, we record the same data we would in
206
+ # the case of a normal AR `update`.
207
+ def save_with_version(*args)
208
+ ::PaperTrail.request(enabled: false) do
209
+ @record.save(*args)
210
+ end
211
+ record_update(force: true, in_after_callback: false, is_touch: false)
212
+ end
213
+
214
+ # Like the `update_column` method from `ActiveRecord::Persistence`, but also
215
+ # creates a version to record those changes.
216
+ # @api public
217
+ def update_column(name, value)
218
+ update_columns(name => value)
219
+ end
220
+
221
+ # Like the `update_columns` method from `ActiveRecord::Persistence`, but also
222
+ # creates a version to record those changes.
223
+ # @api public
224
+ def update_columns(attributes)
225
+ # `@record.update_columns` skips dirty-tracking, so we can't just use
226
+ # `@record.changes` or @record.saved_changes` from `ActiveModel::Dirty`.
227
+ # We need to build our own hash with the changes that will be made
228
+ # directly to the database.
229
+ changes = {}
230
+ attributes.each do |k, v|
231
+ changes[k] = [@record[k], v]
232
+ end
233
+ @record.update_columns(attributes)
234
+ record_update_columns(changes)
235
+ end
236
+
237
+ # Returns the object (not a Version) as it was at the given timestamp.
238
+ def version_at(timestamp, reify_options = {})
239
+ # Because a version stores how its object looked *before* the change,
240
+ # we need to look for the first version created *after* the timestamp.
241
+ v = versions.subsequent(timestamp, true).first
242
+ return v.reify(reify_options) if v
243
+ @record unless @record.destroyed?
244
+ end
245
+
246
+ # Returns the objects (not Versions) as they were between the given times.
247
+ def versions_between(start_time, end_time)
248
+ versions = send(@record.class.versions_association_name).between(start_time, end_time)
249
+ versions.collect { |version| version_at(version.created_at) }
250
+ end
251
+
252
+ private
253
+
254
+ # @api private
255
+ def assign_and_reset_version_association(version)
256
+ @record.send("#{@record.class.version_association_name}=", version)
257
+ @record.send(@record.class.versions_association_name).reset
258
+ end
259
+
260
+ # @api private
261
+ def build_version_on_create(in_after_callback:)
262
+ event = Events::Create.new(@record, in_after_callback)
263
+
264
+ # Merge data from `Event` with data from PT-AT. We no longer use
265
+ # `data_for_create` but PT-AT still does.
266
+ data = event.data.merge!(data_for_create)
267
+
268
+ # Pure `version_class.new` reduces memory usage compared to `versions_assoc.build`
269
+ @record.class.paper_trail.version_class.new(data)
270
+ end
271
+
272
+ # @api private
273
+ def build_version_on_update(force:, in_after_callback:, is_touch:)
274
+ event = Events::Update.new(@record, in_after_callback, is_touch, nil)
275
+ return unless force || event.changed_notably?
276
+
277
+ # Merge data from `Event` with data from PT-AT. We no longer use
278
+ # `data_for_update` but PT-AT still does. To save memory, we use `merge!`
279
+ # instead of `merge`.
280
+ data = event.data.merge!(data_for_update)
281
+
282
+ # Using `version_class.new` reduces memory usage compared to
283
+ # `versions_assoc.build`. It's a trade-off though. We have to clear
284
+ # the association cache (see `versions.reset`) and that could cause an
285
+ # additional query in certain applications.
286
+ @record.class.paper_trail.version_class.new(data)
287
+ end
288
+
289
+ def log_version_errors(version, action)
290
+ version.logger&.warn(
291
+ "Unable to create version for #{action} of #{@record.class.name}" \
292
+ "##{@record.id}: " + version.errors.full_messages.join(", ")
293
+ )
294
+ end
295
+
296
+ def version
297
+ @record.public_send(@record.class.version_association_name)
298
+ end
299
+
300
+ def versions
301
+ @record.public_send(@record.class.versions_association_name)
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mongo_trails/attribute_serializers/object_attribute"
4
+
5
+ module PaperTrail
6
+ # Given a version record and some options, builds a new model object.
7
+ # @api private
8
+ module Reifier
9
+ class << self
10
+ # See `VersionConcern#reify` for documentation.
11
+ # @api private
12
+ def reify(version, options)
13
+ options = apply_defaults_to(options, version)
14
+ attrs = version.object_deserialized
15
+ model = init_model(attrs, options, version)
16
+ reify_attributes(model, version, attrs)
17
+ model.send "#{model.class.version_association_name}=", version
18
+ model
19
+ end
20
+
21
+ private
22
+
23
+ # Given a hash of `options` for `.reify`, return a new hash with default
24
+ # values applied.
25
+ # @api private
26
+ def apply_defaults_to(options, version)
27
+ {
28
+ version_at: version.created_at,
29
+ mark_for_destruction: false,
30
+ has_one: false,
31
+ has_many: false,
32
+ belongs_to: false,
33
+ has_and_belongs_to_many: false,
34
+ unversioned_attributes: :nil
35
+ }.merge(options)
36
+ end
37
+
38
+ # Initialize a model object suitable for reifying `version` into. Does
39
+ # not perform reification, merely instantiates the appropriate model
40
+ # class and, if specified by `options[:unversioned_attributes]`, sets
41
+ # unversioned attributes to `nil`.
42
+ #
43
+ # Normally a polymorphic belongs_to relationship allows us to get the
44
+ # object we belong to by calling, in this case, `item`. However this
45
+ # returns nil if `item` has been destroyed, and we need to be able to
46
+ # retrieve destroyed objects.
47
+ #
48
+ # In this situation we constantize the `item_type` to get hold of the
49
+ # class...except when the stored object's attributes include a `type`
50
+ # key. If this is the case, the object we belong to is using single
51
+ # table inheritance (STI) and the `item_type` will be the base class,
52
+ # not the actual subclass. If `type` is present but empty, the class is
53
+ # the base class.
54
+ def init_model(attrs, options, version)
55
+ klass = version_reification_class(version, attrs)
56
+
57
+ # The `dup` option and destroyed version always returns a new object,
58
+ # otherwise we should attempt to load item or to look for the item
59
+ # outside of default scope(s).
60
+ model = if options[:dup] == true || version.event == "destroy"
61
+ klass.new
62
+ else
63
+ find_cond = { klass.primary_key => version.item_id }
64
+
65
+ version.item || klass.unscoped.where(find_cond).first || klass.new
66
+ end
67
+
68
+ if options[:unversioned_attributes] == :nil && !model.new_record?
69
+ init_unversioned_attrs(attrs, model)
70
+ end
71
+
72
+ model
73
+ end
74
+
75
+ # Look for attributes that exist in `model` and not in this version.
76
+ # These attributes should be set to nil. Modifies `attrs`.
77
+ # @api private
78
+ def init_unversioned_attrs(attrs, model)
79
+ (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
80
+ end
81
+
82
+ # Reify onto `model` an attribute named `k` with value `v` from `version`.
83
+ #
84
+ # `ObjectAttribute#deserialize` will return the mapped enum value and in
85
+ # Rails < 5, the []= uses the integer type caster from the column
86
+ # definition (in general) and thus will turn a (usually) string to 0
87
+ # instead of the correct value.
88
+ #
89
+ # @api private
90
+ def reify_attribute(k, v, model, version)
91
+ if model.has_attribute?(k)
92
+ model[k.to_sym] = v
93
+ elsif model.respond_to?("#{k}=")
94
+ model.send("#{k}=", v)
95
+ elsif version.logger
96
+ version.logger.warn(
97
+ "Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
98
+ )
99
+ end
100
+ end
101
+
102
+ # Reify onto `model` all the attributes of `version`.
103
+ # @api private
104
+ def reify_attributes(model, version, attrs)
105
+ AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs)
106
+ attrs.each do |k, v|
107
+ reify_attribute(k, v, model, version)
108
+ end
109
+ end
110
+
111
+ # Given a `version`, return the class to reify. This method supports
112
+ # Single Table Inheritance (STI) with custom inheritance columns.
113
+ #
114
+ # For example, imagine a `version` whose `item_type` is "Animal". The
115
+ # `animals` table is an STI table (it has cats and dogs) and it has a
116
+ # custom inheritance column, `species`. If `attrs["species"]` is "Dog",
117
+ # this method returns the constant `Dog`. If `attrs["species"]` is blank,
118
+ # this method returns the constant `Animal`. You can see this particular
119
+ # example in action in `spec/models/animal_spec.rb`.
120
+ #
121
+ # TODO: Duplication: similar `constantize` in VersionConcern#version_limit
122
+ def version_reification_class(version, attrs)
123
+ inheritance_column_name = version.item_type.constantize.inheritance_column
124
+ inher_col_value = attrs[inheritance_column_name]
125
+ class_name = inher_col_value.blank? ? version.item_type : inher_col_value
126
+ class_name.constantize
127
+ end
128
+ end
129
+ end
130
+ end