paper_trail 9.2.0 → 12.2.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/lib/generators/paper_trail/install/USAGE +3 -0
  4. data/lib/generators/paper_trail/{install_generator.rb → install/install_generator.rb} +15 -38
  5. data/lib/generators/paper_trail/{templates → install/templates}/add_object_changes_to_versions.rb.erb +0 -0
  6. data/lib/generators/paper_trail/{templates → install/templates}/create_versions.rb.erb +5 -3
  7. data/lib/generators/paper_trail/migration_generator.rb +38 -0
  8. data/lib/generators/paper_trail/update_item_subtype/USAGE +4 -0
  9. data/lib/generators/paper_trail/update_item_subtype/templates/update_versions_for_item_subtype.rb.erb +85 -0
  10. data/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb +19 -0
  11. data/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +24 -10
  12. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +14 -46
  13. data/lib/paper_trail/compatibility.rb +51 -0
  14. data/lib/paper_trail/config.rb +9 -2
  15. data/lib/paper_trail/errors.rb +33 -0
  16. data/lib/paper_trail/events/base.rb +320 -0
  17. data/lib/paper_trail/events/create.rb +32 -0
  18. data/lib/paper_trail/events/destroy.rb +42 -0
  19. data/lib/paper_trail/events/update.rb +65 -0
  20. data/lib/paper_trail/frameworks/active_record.rb +9 -2
  21. data/lib/paper_trail/frameworks/rails/controller.rb +1 -9
  22. data/lib/paper_trail/frameworks/rails/railtie.rb +30 -0
  23. data/lib/paper_trail/frameworks/rails.rb +1 -2
  24. data/lib/paper_trail/has_paper_trail.rb +20 -17
  25. data/lib/paper_trail/model_config.rb +127 -87
  26. data/lib/paper_trail/queries/versions/where_attribute_changes.rb +50 -0
  27. data/lib/paper_trail/queries/versions/where_object.rb +4 -1
  28. data/lib/paper_trail/queries/versions/where_object_changes.rb +8 -13
  29. data/lib/paper_trail/queries/versions/where_object_changes_from.rb +57 -0
  30. data/lib/paper_trail/queries/versions/where_object_changes_to.rb +57 -0
  31. data/lib/paper_trail/record_trail.rb +94 -411
  32. data/lib/paper_trail/reifier.rb +41 -25
  33. data/lib/paper_trail/request.rb +0 -3
  34. data/lib/paper_trail/serializers/json.rb +0 -10
  35. data/lib/paper_trail/serializers/yaml.rb +6 -13
  36. data/lib/paper_trail/type_serializers/postgres_array_serializer.rb +1 -15
  37. data/lib/paper_trail/version_concern.rb +142 -61
  38. data/lib/paper_trail/version_number.rb +1 -1
  39. data/lib/paper_trail.rb +18 -123
  40. metadata +147 -56
  41. data/lib/generators/paper_trail/USAGE +0 -2
  42. data/lib/paper_trail/frameworks/rails/engine.rb +0 -14
@@ -4,27 +4,6 @@ module PaperTrail
4
4
  # Configures an ActiveRecord model, mostly at application boot time, but also
5
5
  # sometimes mid-request, with methods like enable/disable.
6
6
  class ModelConfig
7
- DPR_DISABLE = <<-STR.squish.freeze
8
- MyModel.paper_trail.disable is deprecated, use
9
- PaperTrail.request.disable_model(MyModel). This new API makes it clear
10
- that only the current request is affected, not all threads. Also, all
11
- other request-variables now go through the same `request` method, so this
12
- new API is more consistent.
13
- STR
14
- DPR_ENABLE = <<-STR.squish.freeze
15
- MyModel.paper_trail.enable is deprecated, use
16
- PaperTrail.request.enable_model(MyModel). This new API makes it clear
17
- that only the current request is affected, not all threads. Also, all
18
- other request-variables now go through the same `request` method, so this
19
- new API is more consistent.
20
- STR
21
- DPR_ENABLED = <<-STR.squish.freeze
22
- MyModel.paper_trail.enabled? is deprecated, use
23
- PaperTrail.request.enabled_for_model?(MyModel). This new API makes it clear
24
- that this is a setting specific to the current request, not all threads.
25
- Also, all other request-variables now go through the same `request`
26
- method, so this new API is more consistent.
27
- STR
28
7
  E_CANNOT_RECORD_AFTER_DESTROY = <<-STR.strip_heredoc.freeze
29
8
  paper_trail.on_destroy(:after) is incompatible with ActiveRecord's
30
9
  belongs_to_required_by_default. Use on_destroy(:before)
@@ -39,29 +18,21 @@ module PaperTrail
39
18
  `abstract_class`. This is fine, but all application models must be
40
19
  configured to use concrete (not abstract) version models.
41
20
  STR
21
+ DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION = <<~STR.squish
22
+ Passing versions association name as `has_paper_trail versions: %{versions_name}`
23
+ is deprecated. Use `has_paper_trail versions: {name: %{versions_name}}` instead.
24
+ The hash you pass to `versions:` is now passed directly to `has_many`.
25
+ STR
26
+ DPR_CLASS_NAME_OPTION = <<~STR.squish
27
+ Passing Version class name as `has_paper_trail class_name: %{class_name}`
28
+ is deprecated. Use `has_paper_trail versions: {class_name: %{class_name}}`
29
+ instead. The hash you pass to `versions:` is now passed directly to `has_many`.
30
+ STR
42
31
 
43
32
  def initialize(model_class)
44
33
  @model_class = model_class
45
34
  end
46
35
 
47
- # @deprecated
48
- def disable
49
- ::ActiveSupport::Deprecation.warn(DPR_DISABLE, caller(1))
50
- ::PaperTrail.request.disable_model(@model_class)
51
- end
52
-
53
- # @deprecated
54
- def enable
55
- ::ActiveSupport::Deprecation.warn(DPR_ENABLE, caller(1))
56
- ::PaperTrail.request.enable_model(@model_class)
57
- end
58
-
59
- # @deprecated
60
- def enabled?
61
- ::ActiveSupport::Deprecation.warn(DPR_ENABLED, caller(1))
62
- ::PaperTrail.request.enabled_for_model?(@model_class)
63
- end
64
-
65
36
  # Adds a callback that records a version after a "create" event.
66
37
  #
67
38
  # @api public
@@ -69,22 +40,14 @@ module PaperTrail
69
40
  @model_class.after_create { |r|
70
41
  r.paper_trail.record_create if r.paper_trail.save_version?
71
42
  }
72
- return if @model_class.paper_trail_options[:on].include?(:create)
73
- @model_class.paper_trail_options[:on] << :create
43
+ append_option_uniquely(:on, :create)
74
44
  end
75
45
 
76
46
  # Adds a callback that records a version before or after a "destroy" event.
77
47
  #
78
48
  # @api public
79
49
  def on_destroy(recording_order = "before")
80
- unless %w[after before].include?(recording_order.to_s)
81
- raise ArgumentError, 'recording order can only be "after" or "before"'
82
- end
83
-
84
- if recording_order.to_s == "after" && cannot_record_after_destroy?
85
- raise E_CANNOT_RECORD_AFTER_DESTROY
86
- end
87
-
50
+ assert_valid_recording_order_for_on_destroy(recording_order)
88
51
  @model_class.send(
89
52
  "#{recording_order}_destroy",
90
53
  lambda do |r|
@@ -92,9 +55,7 @@ module PaperTrail
92
55
  r.paper_trail.record_destroy(recording_order)
93
56
  end
94
57
  )
95
-
96
- return if @model_class.paper_trail_options[:on].include?(:destroy)
97
- @model_class.paper_trail_options[:on] << :destroy
58
+ append_option_uniquely(:on, :destroy)
98
59
  end
99
60
 
100
61
  # Adds a callback that records a version after an "update" event.
@@ -116,19 +77,27 @@ module PaperTrail
116
77
  @model_class.after_update { |r|
117
78
  r.paper_trail.clear_version_instance
118
79
  }
119
- return if @model_class.paper_trail_options[:on].include?(:update)
120
- @model_class.paper_trail_options[:on] << :update
80
+ append_option_uniquely(:on, :update)
121
81
  end
122
82
 
123
83
  # Adds a callback that records a version after a "touch" event.
84
+ #
85
+ # Rails < 6.0 has a bug where dirty-tracking does not occur during
86
+ # a `touch`. (https://github.com/rails/rails/issues/33429) See also:
87
+ # https://github.com/paper-trail-gem/paper_trail/issues/1121
88
+ # https://github.com/paper-trail-gem/paper_trail/issues/1161
89
+ # https://github.com/paper-trail-gem/paper_trail/pull/1285
90
+ #
124
91
  # @api public
125
92
  def on_touch
126
93
  @model_class.after_touch { |r|
127
- r.paper_trail.record_update(
128
- force: true,
129
- in_after_callback: true,
130
- is_touch: true
131
- )
94
+ if r.paper_trail.save_version?
95
+ r.paper_trail.record_update(
96
+ force: RAILS_LT_6_0,
97
+ in_after_callback: true,
98
+ is_touch: true
99
+ )
100
+ end
132
101
  }
133
102
  end
134
103
 
@@ -145,52 +114,126 @@ module PaperTrail
145
114
  setup_callbacks_from_options options[:on]
146
115
  end
147
116
 
117
+ # @api private
148
118
  def version_class
149
- @_version_class ||= @model_class.version_class_name.constantize
119
+ @version_class ||= @model_class.version_class_name.constantize
150
120
  end
151
121
 
152
122
  private
153
123
 
154
- def active_record_gem_version
155
- Gem::Version.new(ActiveRecord::VERSION::STRING)
124
+ RAILS_LT_6_0 = ::ActiveRecord.gem_version < ::Gem::Version.new("6.0.0")
125
+ private_constant :RAILS_LT_6_0
126
+
127
+ # @api private
128
+ def append_option_uniquely(option, value)
129
+ collection = @model_class.paper_trail_options.fetch(option)
130
+ return if collection.include?(value)
131
+ collection << value
156
132
  end
157
133
 
158
134
  # Raises an error if the provided class is an `abstract_class`.
159
135
  # @api private
160
136
  def assert_concrete_activerecord_class(class_name)
161
137
  if class_name.constantize.abstract_class?
162
- raise format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
138
+ raise Error, format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
139
+ end
140
+ end
141
+
142
+ # @api private
143
+ def assert_valid_recording_order_for_on_destroy(recording_order)
144
+ unless %w[after before].include?(recording_order.to_s)
145
+ raise ArgumentError, 'recording order can only be "after" or "before"'
146
+ end
147
+
148
+ if recording_order.to_s == "after" && cannot_record_after_destroy?
149
+ raise Error, E_CANNOT_RECORD_AFTER_DESTROY
163
150
  end
164
151
  end
165
152
 
166
153
  def cannot_record_after_destroy?
167
- Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") &&
168
- ::ActiveRecord::Base.belongs_to_required_by_default
154
+ ::ActiveRecord::Base.belongs_to_required_by_default
155
+ end
156
+
157
+ def check_version_class_name(options)
158
+ # @api private - `version_class_name`
159
+ @model_class.class_attribute :version_class_name
160
+ if options[:class_name]
161
+ ::ActiveSupport::Deprecation.warn(
162
+ format(
163
+ DPR_CLASS_NAME_OPTION,
164
+ class_name: options[:class_name].inspect
165
+ ),
166
+ caller(1)
167
+ )
168
+ options[:versions][:class_name] = options[:class_name]
169
+ end
170
+ @model_class.version_class_name = options[:versions][:class_name] || "PaperTrail::Version"
171
+ assert_concrete_activerecord_class(@model_class.version_class_name)
172
+ end
173
+
174
+ def check_versions_association_name(options)
175
+ # @api private - versions_association_name
176
+ @model_class.class_attribute :versions_association_name
177
+ @model_class.versions_association_name = options[:versions][:name] || :versions
178
+ end
179
+
180
+ def define_has_many_versions(options)
181
+ options = ensure_versions_option_is_hash(options)
182
+ check_version_class_name(options)
183
+ check_versions_association_name(options)
184
+ scope = get_versions_scope(options)
185
+ @model_class.has_many(
186
+ @model_class.versions_association_name,
187
+ scope,
188
+ class_name: @model_class.version_class_name,
189
+ as: :item,
190
+ **options[:versions].except(:name, :scope)
191
+ )
192
+ end
193
+
194
+ def ensure_versions_option_is_hash(options)
195
+ unless options[:versions].is_a?(Hash)
196
+ if options[:versions]
197
+ ::ActiveSupport::Deprecation.warn(
198
+ format(
199
+ DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION,
200
+ versions_name: options[:versions].inspect
201
+ ),
202
+ caller(1)
203
+ )
204
+ end
205
+ options[:versions] = {
206
+ name: options[:versions]
207
+ }
208
+ end
209
+ options
210
+ end
211
+
212
+ # Process an `ignore`, `skip`, or `only` option.
213
+ def event_attribute_option(option_name)
214
+ [@model_class.paper_trail_options[option_name]].
215
+ flatten.
216
+ compact.
217
+ map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
218
+ end
219
+
220
+ def get_versions_scope(options)
221
+ options[:versions][:scope] || -> { order(model.timestamp_sort_order) }
169
222
  end
170
223
 
171
224
  def setup_associations(options)
225
+ # @api private - version_association_name
172
226
  @model_class.class_attribute :version_association_name
173
227
  @model_class.version_association_name = options[:version] || :version
174
228
 
175
229
  # The version this instance was reified from.
230
+ # @api public
176
231
  @model_class.send :attr_accessor, @model_class.version_association_name
177
232
 
178
- @model_class.class_attribute :version_class_name
179
- @model_class.version_class_name = options[:class_name] || "PaperTrail::Version"
180
-
181
- @model_class.class_attribute :versions_association_name
182
- @model_class.versions_association_name = options[:versions] || :versions
183
-
233
+ # @api public - paper_trail_event
184
234
  @model_class.send :attr_accessor, :paper_trail_event
185
235
 
186
- assert_concrete_activerecord_class(@model_class.version_class_name)
187
-
188
- @model_class.has_many(
189
- @model_class.versions_association_name,
190
- -> { order(model.timestamp_sort_order) },
191
- class_name: @model_class.version_class_name,
192
- as: :item
193
- )
236
+ define_has_many_versions(options)
194
237
  end
195
238
 
196
239
  def setup_callbacks_from_options(options_on = [])
@@ -200,20 +243,17 @@ module PaperTrail
200
243
  end
201
244
 
202
245
  def setup_options(options)
246
+ # @api public - paper_trail_options - Let's encourage plugins to use eg.
247
+ # `paper_trail_options[:versions][:class_name]` rather than
248
+ # `version_class_name` because the former is documented and the latter is
249
+ # not.
203
250
  @model_class.class_attribute :paper_trail_options
204
251
  @model_class.paper_trail_options = options.dup
205
252
 
206
253
  %i[ignore skip only].each do |k|
207
- @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]].
208
- flatten.
209
- compact.
210
- map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
254
+ @model_class.paper_trail_options[k] = event_attribute_option(k)
211
255
  end
212
-
213
256
  @model_class.paper_trail_options[:meta] ||= {}
214
- if @model_class.paper_trail_options[:save_changes].nil?
215
- @model_class.paper_trail_options[:save_changes] = true
216
- end
217
257
  end
218
258
  end
219
259
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_attribute_changes` in
7
+ # `paper_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereAttributeChanges
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attribute - An attribute that changed. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attribute)
15
+ @version_model_class = version_model_class
16
+ @attribute = attribute
17
+ end
18
+
19
+ # @api private
20
+ def execute
21
+ if PaperTrail.config.object_changes_adapter.respond_to?(:where_attribute_changes)
22
+ return PaperTrail.config.object_changes_adapter.where_attribute_changes(
23
+ @version_model_class, @attribute
24
+ )
25
+ end
26
+ column_type = @version_model_class.columns_hash["object_changes"].type
27
+ case column_type
28
+ when :jsonb, :json
29
+ json
30
+ else
31
+ raise UnsupportedColumnType.new(
32
+ method: "where_attribute_changes",
33
+ expected: "json or jsonb",
34
+ actual: column_type
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @api private
42
+ def json
43
+ sql = "object_changes -> ? IS NOT NULL"
44
+
45
+ @version_model_class.where(sql, @attribute)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -18,7 +18,10 @@ module PaperTrail
18
18
 
19
19
  # @api private
20
20
  def execute
21
- case @version_model_class.columns_hash["object"].type
21
+ column = @version_model_class.columns_hash["object"]
22
+ raise Error, "where_object requires an object column" unless column
23
+
24
+ case column.type
22
25
  when :jsonb
23
26
  jsonb
24
27
  when :json
@@ -23,18 +23,23 @@ module PaperTrail
23
23
 
24
24
  # @api private
25
25
  def execute
26
- if PaperTrail.config.object_changes_adapter
26
+ if PaperTrail.config.object_changes_adapter.respond_to?(:where_object_changes)
27
27
  return PaperTrail.config.object_changes_adapter.where_object_changes(
28
28
  @version_model_class, @attributes
29
29
  )
30
30
  end
31
- case @version_model_class.columns_hash["object_changes"].type
31
+ column_type = @version_model_class.columns_hash["object_changes"].type
32
+ case column_type
32
33
  when :jsonb
33
34
  jsonb
34
35
  when :json
35
36
  json
36
37
  else
37
- text
38
+ raise UnsupportedColumnType.new(
39
+ method: "where_object_changes",
40
+ expected: "json or jsonb",
41
+ actual: column_type
42
+ )
38
43
  end
39
44
  end
40
45
 
@@ -59,16 +64,6 @@ module PaperTrail
59
64
  @attributes.each { |field, value| @attributes[field] = [value] }
60
65
  @version_model_class.where("object_changes @> ?", @attributes.to_json)
61
66
  end
62
-
63
- # @api private
64
- def text
65
- arel_field = @version_model_class.arel_table[:object_changes]
66
- where_conditions = @attributes.map { |field, value|
67
- ::PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
68
- }
69
- where_conditions = where_conditions.reduce { |a, e| a.and(e) }
70
- @version_model_class.where(where_conditions)
71
- end
72
67
  end
73
68
  end
74
69
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_object_changes_from` in
7
+ # `paper_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereObjectChangesFrom
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attributes - A `Hash` of attributes and values. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attributes)
15
+ @version_model_class = version_model_class
16
+ @attributes = attributes
17
+ end
18
+
19
+ # @api private
20
+ def execute
21
+ if PaperTrail.config.object_changes_adapter.respond_to?(:where_object_changes_from)
22
+ return PaperTrail.config.object_changes_adapter.where_object_changes_from(
23
+ @version_model_class, @attributes
24
+ )
25
+ end
26
+ column_type = @version_model_class.columns_hash["object_changes"].type
27
+ case column_type
28
+ when :jsonb, :json
29
+ json
30
+ else
31
+ raise UnsupportedColumnType.new(
32
+ method: "where_object_changes_from",
33
+ expected: "json or jsonb",
34
+ actual: column_type
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @api private
42
+ def json
43
+ predicates = []
44
+ values = []
45
+ @attributes.each do |field, value|
46
+ predicates.push(
47
+ "(object_changes->>? ILIKE ?)"
48
+ )
49
+ values.concat([field, "[#{value.to_json},%"])
50
+ end
51
+ sql = predicates.join(" and ")
52
+ @version_model_class.where(sql, *values)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_object_changes_to` in
7
+ # `paper_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereObjectChangesTo
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attributes - A `Hash` of attributes and values. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attributes)
15
+ @version_model_class = version_model_class
16
+ @attributes = attributes
17
+ end
18
+
19
+ # @api private
20
+ def execute
21
+ if PaperTrail.config.object_changes_adapter.respond_to?(:where_object_changes_to)
22
+ return PaperTrail.config.object_changes_adapter.where_object_changes_to(
23
+ @version_model_class, @attributes
24
+ )
25
+ end
26
+ column_type = @version_model_class.columns_hash["object_changes"].type
27
+ case column_type
28
+ when :jsonb, :json
29
+ json
30
+ else
31
+ raise UnsupportedColumnType.new(
32
+ method: "where_object_changes_to",
33
+ expected: "json or jsonb",
34
+ actual: column_type
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @api private
42
+ def json
43
+ predicates = []
44
+ values = []
45
+ @attributes.each do |field, value|
46
+ predicates.push(
47
+ "(object_changes->>? ILIKE ?)"
48
+ )
49
+ values.concat([field, "[%#{value.to_json}]"])
50
+ end
51
+ sql = predicates.join(" and ")
52
+ @version_model_class.where(sql, *values)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end