paper_trail 9.2.0 → 12.2.0

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