paper_trail 10.3.1 → 14.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/lib/generators/paper_trail/install/install_generator.rb +25 -7
  4. data/lib/generators/paper_trail/install/templates/create_versions.rb.erb +4 -2
  5. data/lib/generators/paper_trail/migration_generator.rb +5 -4
  6. data/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb +4 -2
  7. data/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +24 -10
  8. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +17 -45
  9. data/lib/paper_trail/compatibility.rb +3 -3
  10. data/lib/paper_trail/config.rb +0 -33
  11. data/lib/paper_trail/errors.rb +33 -0
  12. data/lib/paper_trail/events/base.rb +92 -69
  13. data/lib/paper_trail/events/destroy.rb +1 -1
  14. data/lib/paper_trail/events/update.rb +23 -7
  15. data/lib/paper_trail/frameworks/active_record.rb +9 -2
  16. data/lib/paper_trail/frameworks/rails/controller.rb +1 -9
  17. data/lib/paper_trail/frameworks/rails/railtie.rb +30 -0
  18. data/lib/paper_trail/frameworks/rails.rb +1 -2
  19. data/lib/paper_trail/has_paper_trail.rb +1 -1
  20. data/lib/paper_trail/model_config.rb +46 -46
  21. data/lib/paper_trail/queries/versions/where_attribute_changes.rb +50 -0
  22. data/lib/paper_trail/queries/versions/where_object.rb +1 -1
  23. data/lib/paper_trail/queries/versions/where_object_changes.rb +9 -14
  24. data/lib/paper_trail/queries/versions/where_object_changes_from.rb +57 -0
  25. data/lib/paper_trail/queries/versions/where_object_changes_to.rb +57 -0
  26. data/lib/paper_trail/record_trail.rb +80 -64
  27. data/lib/paper_trail/reifier.rb +41 -26
  28. data/lib/paper_trail/request.rb +22 -25
  29. data/lib/paper_trail/serializers/json.rb +0 -10
  30. data/lib/paper_trail/serializers/yaml.rb +38 -13
  31. data/lib/paper_trail/type_serializers/postgres_array_serializer.rb +1 -14
  32. data/lib/paper_trail/version_concern.rb +86 -41
  33. data/lib/paper_trail/version_number.rb +3 -3
  34. data/lib/paper_trail.rb +22 -40
  35. metadata +106 -45
  36. data/lib/paper_trail/frameworks/rails/engine.rb +0 -45
@@ -7,8 +7,6 @@ require "paper_trail/events/update"
7
7
  module PaperTrail
8
8
  # Represents the "paper trail" for a single record.
9
9
  class RecordTrail
10
- RAILS_GTE_5_1 = ::ActiveRecord.gem_version >= ::Gem::Version.new("5.1.0.beta1")
11
-
12
10
  def initialize(record)
13
11
  @record = record
14
12
  end
@@ -27,14 +25,6 @@ module PaperTrail
27
25
  @record.send("#{@record.class.version_association_name}=", nil)
28
26
  end
29
27
 
30
- # Is PT enabled for this particular record?
31
- # @api private
32
- def enabled?
33
- PaperTrail.enabled? &&
34
- PaperTrail.request.enabled? &&
35
- PaperTrail.request.enabled_for_model?(@record.class)
36
- end
37
-
38
28
  # Returns true if this instance is the current, live one;
39
29
  # returns false if this instance came from a previous version.
40
30
  def live?
@@ -77,13 +67,6 @@ module PaperTrail
77
67
  end
78
68
  end
79
69
 
80
- # PT-AT extends this method to add its transaction id.
81
- #
82
- # @api private
83
- def data_for_create
84
- {}
85
- end
86
-
87
70
  # `recording_order` is "after" or "before". See ModelConfig#on_destroy.
88
71
  #
89
72
  # @api private
@@ -107,14 +90,12 @@ module PaperTrail
107
90
  end
108
91
  end
109
92
 
110
- # PT-AT extends this method to add its transaction id.
111
- #
112
- # @api private
113
- def data_for_destroy
114
- {}
115
- end
116
-
117
93
  # @api private
94
+ # @param force [boolean] Insert a `Version` even if `@record` has not
95
+ # `changed_notably?`.
96
+ # @param in_after_callback [boolean] True when called from an `after_update`
97
+ # or `after_touch` callback.
98
+ # @param is_touch [boolean] True when called from an `after_touch` callback.
118
99
  # @return - The created version object, so that plugins can use it, e.g.
119
100
  # paper_trail-association_tracking
120
101
  def record_update(force:, in_after_callback:, is_touch:)
@@ -138,40 +119,6 @@ module PaperTrail
138
119
  end
139
120
  end
140
121
 
141
- # PT-AT extends this method to add its transaction id.
142
- #
143
- # @api private
144
- def data_for_update
145
- {}
146
- end
147
-
148
- # @api private
149
- # @return - The created version object, so that plugins can use it, e.g.
150
- # paper_trail-association_tracking
151
- def record_update_columns(changes)
152
- return unless enabled?
153
- event = Events::Update.new(@record, false, false, changes)
154
-
155
- # Merge data from `Event` with data from PT-AT. We no longer use
156
- # `data_for_update_columns` but PT-AT still does.
157
- data = event.data.merge(data_for_update_columns)
158
-
159
- versions_assoc = @record.send(@record.class.versions_association_name)
160
- version = versions_assoc.create(data)
161
- if version.errors.any?
162
- log_version_errors(version, :update)
163
- else
164
- version
165
- end
166
- end
167
-
168
- # PT-AT extends this method to add its transaction id.
169
- #
170
- # @api private
171
- def data_for_update_columns
172
- {}
173
- end
174
-
175
122
  # Invoked via callback when a user attempts to persist a reified
176
123
  # `Version`.
177
124
  def reset_timestamp_attrs_for_update_if_needed
@@ -196,15 +143,17 @@ module PaperTrail
196
143
  # Save, and create a version record regardless of options such as `:on`,
197
144
  # `:if`, or `:unless`.
198
145
  #
199
- # Arguments are passed to `save`.
146
+ # `in_after_callback`: Indicates if this method is being called within an
147
+ # `after` callback. Defaults to `false`.
148
+ # `options`: Optional arguments passed to `save`.
200
149
  #
201
150
  # This is an "update" event. That is, we record the same data we would in
202
151
  # the case of a normal AR `update`.
203
- def save_with_version(*args)
152
+ def save_with_version(in_after_callback: false, **options)
204
153
  ::PaperTrail.request(enabled: false) do
205
- @record.save(*args)
154
+ @record.save(**options)
206
155
  end
207
- record_update(force: true, in_after_callback: false, is_touch: false)
156
+ record_update(force: true, in_after_callback: in_after_callback, is_touch: false)
208
157
  end
209
158
 
210
159
  # Like the `update_column` method from `ActiveRecord::Persistence`, but also
@@ -269,11 +218,22 @@ module PaperTrail
269
218
  def build_version_on_update(force:, in_after_callback:, is_touch:)
270
219
  event = Events::Update.new(@record, in_after_callback, is_touch, nil)
271
220
  return unless force || event.changed_notably?
221
+ data = event.data
222
+
223
+ # Copy the (recently set) `updated_at` from the record to the `created_at`
224
+ # of the `Version`. Without this feature, these two timestamps would
225
+ # differ by a few milliseconds. To some people, it seems a little
226
+ # unnatural to tamper with creation timestamps in this way. But, this
227
+ # feature has existed for a long time, almost a decade now, and some users
228
+ # may rely on it now.
229
+ if @record.respond_to?(:updated_at)
230
+ data[:created_at] = @record.updated_at
231
+ end
272
232
 
273
233
  # Merge data from `Event` with data from PT-AT. We no longer use
274
234
  # `data_for_update` but PT-AT still does. To save memory, we use `merge!`
275
235
  # instead of `merge`.
276
- data = event.data.merge!(data_for_update)
236
+ data.merge!(data_for_update)
277
237
 
278
238
  # Using `version_class.new` reduces memory usage compared to
279
239
  # `versions_assoc.build`. It's a trade-off though. We have to clear
@@ -282,13 +242,69 @@ module PaperTrail
282
242
  @record.class.paper_trail.version_class.new(data)
283
243
  end
284
244
 
245
+ # PT-AT extends this method to add its transaction id.
246
+ #
247
+ # @api public
248
+ def data_for_create
249
+ {}
250
+ end
251
+
252
+ # PT-AT extends this method to add its transaction id.
253
+ #
254
+ # @api public
255
+ def data_for_destroy
256
+ {}
257
+ end
258
+
259
+ # PT-AT extends this method to add its transaction id.
260
+ #
261
+ # @api public
262
+ def data_for_update
263
+ {}
264
+ end
265
+
266
+ # PT-AT extends this method to add its transaction id.
267
+ #
268
+ # @api public
269
+ def data_for_update_columns
270
+ {}
271
+ end
272
+
273
+ # Is PT enabled for this particular record?
274
+ # @api private
275
+ def enabled?
276
+ PaperTrail.enabled? &&
277
+ PaperTrail.request.enabled? &&
278
+ PaperTrail.request.enabled_for_model?(@record.class)
279
+ end
280
+
285
281
  def log_version_errors(version, action)
286
282
  version.logger&.warn(
287
283
  "Unable to create version for #{action} of #{@record.class.name}" \
288
- "##{@record.id}: " + version.errors.full_messages.join(", ")
284
+ "##{@record.id}: " + version.errors.full_messages.join(", ")
289
285
  )
290
286
  end
291
287
 
288
+ # @api private
289
+ # @return - The created version object, so that plugins can use it, e.g.
290
+ # paper_trail-association_tracking
291
+ def record_update_columns(changes)
292
+ return unless enabled?
293
+ data = Events::Update.new(@record, false, false, changes).data
294
+
295
+ # Merge data from `Event` with data from PT-AT. We no longer use
296
+ # `data_for_update_columns` but PT-AT still does.
297
+ data.merge!(data_for_update_columns)
298
+
299
+ versions_assoc = @record.send(@record.class.versions_association_name)
300
+ version = versions_assoc.create(data)
301
+ if version.errors.any?
302
+ log_version_errors(version, :update)
303
+ else
304
+ version
305
+ end
306
+ end
307
+
292
308
  def version
293
309
  @record.public_send(@record.class.version_association_name)
294
310
  end
@@ -52,26 +52,29 @@ module PaperTrail
52
52
  # not the actual subclass. If `type` is present but empty, the class is
53
53
  # the base class.
54
54
  def init_model(attrs, options, version)
55
- if options[:dup] != true && version.item
56
- model = version.item
57
- if options[:unversioned_attributes] == :nil
58
- init_unversioned_attrs(attrs, model)
59
- end
60
- else
61
- klass = version_reification_class(version, attrs)
62
- # The `dup` option always returns a new object, otherwise we should
63
- # attempt to look for the item outside of default scope(s).
64
- find_cond = { klass.primary_key => version.item_id }
65
- if options[:dup] || (item_found = klass.unscoped.where(find_cond).first).nil?
66
- model = klass.new
67
- elsif options[:unversioned_attributes] == :nil
68
- model = item_found
69
- init_unversioned_attrs(attrs, model)
70
- end
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
+ version.item || init_model_by_finding_item_id(klass, version) || klass.new
64
+ end
65
+
66
+ if options[:unversioned_attributes] == :nil && !model.new_record?
67
+ init_unversioned_attrs(attrs, model)
71
68
  end
69
+
72
70
  model
73
71
  end
74
72
 
73
+ # @api private
74
+ def init_model_by_finding_item_id(klass, version)
75
+ klass.unscoped.where(klass.primary_key => version.item_id).first
76
+ end
77
+
75
78
  # Look for attributes that exist in `model` and not in this version.
76
79
  # These attributes should be set to nil. Modifies `attrs`.
77
80
  # @api private
@@ -88,9 +91,7 @@ module PaperTrail
88
91
  #
89
92
  # @api private
90
93
  def reify_attribute(k, v, model, version)
91
- enums = model.class.respond_to?(:defined_enums) ? model.class.defined_enums : {}
92
- is_enum_without_type_caster = ::ActiveRecord::VERSION::MAJOR < 5 && enums.key?(k)
93
- if model.has_attribute?(k) && !is_enum_without_type_caster
94
+ if model.has_attribute?(k)
94
95
  model[k.to_sym] = v
95
96
  elsif model.respond_to?("#{k}=")
96
97
  model.send("#{k}=", v)
@@ -111,21 +112,35 @@ module PaperTrail
111
112
  end
112
113
 
113
114
  # Given a `version`, return the class to reify. This method supports
114
- # Single Table Inheritance (STI) with custom inheritance columns.
115
+ # Single Table Inheritance (STI) with custom inheritance columns and
116
+ # custom inheritance column values.
115
117
  #
116
118
  # For example, imagine a `version` whose `item_type` is "Animal". The
117
119
  # `animals` table is an STI table (it has cats and dogs) and it has a
118
120
  # custom inheritance column, `species`. If `attrs["species"]` is "Dog",
119
121
  # this method returns the constant `Dog`. If `attrs["species"]` is blank,
120
- # this method returns the constant `Animal`. You can see this particular
121
- # example in action in `spec/models/animal_spec.rb`.
122
+ # this method returns the constant `Animal`.
123
+ #
124
+ # The values contained in the inheritance columns may be non-camelized
125
+ # strings (e.g. 'dog' instead of 'Dog'). To reify classes in this case
126
+ # we need to call the parents class `sti_class_for` method to retrieve
127
+ # the correct record class.
122
128
  #
123
- # TODO: Duplication: similar `constantize` in VersionConcern#version_limit
129
+ # You can see these particular examples in action in
130
+ # `spec/models/animal_spec.rb` and `spec/models/plant_spec.rb`
124
131
  def version_reification_class(version, attrs)
125
- inheritance_column_name = version.item_type.constantize.inheritance_column
132
+ clazz = version.item_type.constantize
133
+ inheritance_column_name = clazz.inheritance_column
126
134
  inher_col_value = attrs[inheritance_column_name]
127
- class_name = inher_col_value.blank? ? version.item_type : inher_col_value
128
- class_name.constantize
135
+ return clazz if inher_col_value.blank?
136
+
137
+ # Rails 6.1 adds a public method for clients to use to customize STI classes. If that
138
+ # method is not available, fall back to using the private one
139
+ if clazz.public_methods.include?(:sti_class_for)
140
+ return clazz.sti_class_for(inher_col_value)
141
+ end
142
+
143
+ clazz.send(:find_sti_class, inher_col_value)
129
144
  end
130
145
  end
131
146
  end
@@ -12,9 +12,6 @@ module PaperTrail
12
12
  #
13
13
  # @api private
14
14
  module Request
15
- class InvalidOption < RuntimeError
16
- end
17
-
18
15
  class << self
19
16
  # Sets any data from the controller that you want PaperTrail to store.
20
17
  # See also `PaperTrail::Rails::Controller#info_for_paper_trail`.
@@ -78,28 +75,6 @@ module PaperTrail
78
75
  !!store.fetch(:"enabled_for_#{model}", true)
79
76
  end
80
77
 
81
- # @api private
82
- def merge(options)
83
- options.to_h.each do |k, v|
84
- store[k] = v
85
- end
86
- end
87
-
88
- # @api private
89
- def set(options)
90
- store.clear
91
- merge(options)
92
- end
93
-
94
- # Returns a deep copy of the internal hash from our RequestStore. Keys are
95
- # all symbols. Values are mostly primitives, but whodunnit can be a Proc.
96
- # We cannot use Marshal.dump here because it doesn't support Proc. It is
97
- # unclear exactly how `deep_dup` handles a Proc, but it doesn't complain.
98
- # @api private
99
- def to_h
100
- store.deep_dup
101
- end
102
-
103
78
  # Temporarily set `options` and execute a block.
104
79
  # @api private
105
80
  def with(options)
@@ -136,6 +111,19 @@ module PaperTrail
136
111
 
137
112
  private
138
113
 
114
+ # @api private
115
+ def merge(options)
116
+ options.to_h.each do |k, v|
117
+ store[k] = v
118
+ end
119
+ end
120
+
121
+ # @api private
122
+ def set(options)
123
+ store.clear
124
+ merge(options)
125
+ end
126
+
139
127
  # Returns a Hash, initializing with default values if necessary.
140
128
  # @api private
141
129
  def store
@@ -144,6 +132,15 @@ module PaperTrail
144
132
  }
145
133
  end
146
134
 
135
+ # Returns a deep copy of the internal hash from our RequestStore. Keys are
136
+ # all symbols. Values are mostly primitives, but whodunnit can be a Proc.
137
+ # We cannot use Marshal.dump here because it doesn't support Proc. It is
138
+ # unclear exactly how `deep_dup` handles a Proc, but it doesn't complain.
139
+ # @api private
140
+ def to_h
141
+ store.deep_dup
142
+ end
143
+
147
144
  # Provide a helpful error message if someone has a typo in one of their
148
145
  # option keys. We don't validate option values here. That's traditionally
149
146
  # been handled with casting (`to_s`, `!!`) in the accessor method.
@@ -31,16 +31,6 @@ module PaperTrail
31
31
  arel_field.matches("%\"#{field}\":#{json_value}%")
32
32
  end
33
33
  end
34
-
35
- def where_object_changes_condition(*)
36
- raise <<-STR.squish.freeze
37
- where_object_changes no longer supports reading JSON from a text
38
- column. The old implementation was inaccurate, returning more records
39
- than you wanted. This feature was deprecated in 7.1.0 and removed in
40
- 8.0.0. The json and jsonb datatypes are still supported. See the
41
- discussion at https://github.com/paper-trail-gem/paper_trail/issues/803
42
- STR
43
- end
44
34
  end
45
35
  end
46
36
  end
@@ -9,13 +9,23 @@ module PaperTrail
9
9
  extend self # makes all instance methods become module methods as well
10
10
 
11
11
  def load(string)
12
- ::YAML.load string
12
+ if use_safe_load?
13
+ ::YAML.safe_load(
14
+ string,
15
+ permitted_classes: yaml_column_permitted_classes,
16
+ aliases: true
17
+ )
18
+ elsif ::YAML.respond_to?(:unsafe_load)
19
+ ::YAML.unsafe_load(string)
20
+ else
21
+ ::YAML.load(string)
22
+ end
13
23
  end
14
24
 
15
25
  # @param object (Hash | HashWithIndifferentAccess) - Coming from
16
26
  # `recordable_object` `object` will be a plain `Hash`. However, due to
17
- # recent [memory optimizations](https://git.io/fjeYv), when coming from
18
- # `recordable_object_changes`, it will be a `HashWithIndifferentAccess`.
27
+ # recent [memory optimizations](https://github.com/paper-trail-gem/paper_trail/pull/1189),
28
+ # when coming from `recordable_object_changes`, it will be a `HashWithIndifferentAccess`.
19
29
  def dump(object)
20
30
  object = object.to_hash if object.is_a?(HashWithIndifferentAccess)
21
31
  ::YAML.dump object
@@ -27,16 +37,31 @@ module PaperTrail
27
37
  arel_field.matches("%\n#{field}: #{value}\n%")
28
38
  end
29
39
 
30
- # Returns a SQL LIKE condition to be used to match the given field and
31
- # value in the serialized `object_changes`.
32
- def where_object_changes_condition(*)
33
- raise <<-STR.squish.freeze
34
- where_object_changes no longer supports reading YAML from a text
35
- column. The old implementation was inaccurate, returning more records
36
- than you wanted. This feature was deprecated in 8.1.0 and removed in
37
- 9.0.0. The json and jsonb datatypes are still supported. See
38
- discussion at https://github.com/paper-trail-gem/paper_trail/pull/997
39
- STR
40
+ private
41
+
42
+ def use_safe_load?
43
+ if ::ActiveRecord.gem_version >= Gem::Version.new("7.0.3.1")
44
+ # `use_yaml_unsafe_load` may be removed in the future, at which point
45
+ # safe loading will be the default.
46
+ !defined?(ActiveRecord.use_yaml_unsafe_load) || !ActiveRecord.use_yaml_unsafe_load
47
+ elsif defined?(ActiveRecord::Base.use_yaml_unsafe_load)
48
+ # Rails 5.2.8.1, 6.0.5.1, 6.1.6.1
49
+ !ActiveRecord::Base.use_yaml_unsafe_load
50
+ else
51
+ false
52
+ end
53
+ end
54
+
55
+ def yaml_column_permitted_classes
56
+ if defined?(ActiveRecord.yaml_column_permitted_classes)
57
+ # Rails >= 7.0.3.1
58
+ ActiveRecord.yaml_column_permitted_classes
59
+ elsif defined?(ActiveRecord::Base.yaml_column_permitted_classes)
60
+ # Rails 5.2.8.1, 6.0.5.1, 6.1.6.1
61
+ ActiveRecord::Base.yaml_column_permitted_classes
62
+ else
63
+ []
64
+ end
40
65
  end
41
66
  end
42
67
  end
@@ -11,15 +11,12 @@ module PaperTrail
11
11
  end
12
12
 
13
13
  def serialize(array)
14
- return serialize_with_ar(array) if active_record_pre_502?
15
14
  array
16
15
  end
17
16
 
18
17
  def deserialize(array)
19
- return deserialize_with_ar(array) if active_record_pre_502?
20
-
21
18
  case array
22
- # Needed for legacy reasons. If serialized array is a string
19
+ # Needed for legacy data. If serialized array is a string
23
20
  # then it was serialized with Rails < 5.0.2.
24
21
  when ::String then deserialize_with_ar(array)
25
22
  else array
@@ -28,16 +25,6 @@ module PaperTrail
28
25
 
29
26
  private
30
27
 
31
- def active_record_pre_502?
32
- ::ActiveRecord.gem_version < Gem::Version.new("5.0.2")
33
- end
34
-
35
- def serialize_with_ar(array)
36
- ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.
37
- new(@subtype, @delimiter).
38
- serialize(array)
39
- end
40
-
41
28
  def deserialize_with_ar(array)
42
29
  ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.
43
30
  new(@subtype, @delimiter).