paper_trail 9.2.0 → 14.0.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} +27 -38
  5. data/lib/generators/paper_trail/{templates → install/templates}/create_versions.rb.erb +5 -3
  6. data/lib/generators/paper_trail/migration_generator.rb +38 -0
  7. data/lib/generators/paper_trail/update_item_subtype/USAGE +4 -0
  8. data/lib/generators/paper_trail/update_item_subtype/templates/update_versions_for_item_subtype.rb.erb +85 -0
  9. data/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb +19 -0
  10. data/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +24 -10
  11. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +17 -45
  12. data/lib/paper_trail/compatibility.rb +51 -0
  13. data/lib/paper_trail/config.rb +9 -2
  14. data/lib/paper_trail/errors.rb +33 -0
  15. data/lib/paper_trail/events/base.rb +343 -0
  16. data/lib/paper_trail/events/create.rb +32 -0
  17. data/lib/paper_trail/events/destroy.rb +42 -0
  18. data/lib/paper_trail/events/update.rb +76 -0
  19. data/lib/paper_trail/frameworks/active_record.rb +9 -2
  20. data/lib/paper_trail/frameworks/rails/controller.rb +1 -9
  21. data/lib/paper_trail/frameworks/rails/railtie.rb +30 -0
  22. data/lib/paper_trail/frameworks/rails.rb +1 -2
  23. data/lib/paper_trail/has_paper_trail.rb +20 -17
  24. data/lib/paper_trail/model_config.rb +124 -87
  25. data/lib/paper_trail/queries/versions/where_attribute_changes.rb +50 -0
  26. data/lib/paper_trail/queries/versions/where_object.rb +4 -1
  27. data/lib/paper_trail/queries/versions/where_object_changes.rb +9 -14
  28. data/lib/paper_trail/queries/versions/where_object_changes_from.rb +57 -0
  29. data/lib/paper_trail/queries/versions/where_object_changes_to.rb +57 -0
  30. data/lib/paper_trail/record_trail.rb +137 -436
  31. data/lib/paper_trail/reifier.rb +41 -25
  32. data/lib/paper_trail/request.rb +22 -25
  33. data/lib/paper_trail/serializers/json.rb +0 -10
  34. data/lib/paper_trail/serializers/yaml.rb +41 -11
  35. data/lib/paper_trail/type_serializers/postgres_array_serializer.rb +1 -15
  36. data/lib/paper_trail/version_concern.rb +152 -62
  37. data/lib/paper_trail/version_number.rb +2 -2
  38. data/lib/paper_trail.rb +23 -123
  39. metadata +152 -61
  40. data/lib/generators/paper_trail/USAGE +0 -2
  41. data/lib/paper_trail/frameworks/rails/engine.rb +0 -14
  42. /data/lib/generators/paper_trail/{templates → install/templates}/add_object_changes_to_versions.rb.erb +0 -0
@@ -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: false,
97
+ in_after_callback: true,
98
+ is_touch: true
99
+ )
100
+ end
132
101
  }
133
102
  end
134
103
 
@@ -145,52 +114,123 @@ 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
+ # @api private
125
+ def append_option_uniquely(option, value)
126
+ collection = @model_class.paper_trail_options.fetch(option)
127
+ return if collection.include?(value)
128
+ collection << value
156
129
  end
157
130
 
158
131
  # Raises an error if the provided class is an `abstract_class`.
159
132
  # @api private
160
133
  def assert_concrete_activerecord_class(class_name)
161
134
  if class_name.constantize.abstract_class?
162
- raise format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
135
+ raise Error, format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
136
+ end
137
+ end
138
+
139
+ # @api private
140
+ def assert_valid_recording_order_for_on_destroy(recording_order)
141
+ unless %w[after before].include?(recording_order.to_s)
142
+ raise ArgumentError, 'recording order can only be "after" or "before"'
143
+ end
144
+
145
+ if recording_order.to_s == "after" && cannot_record_after_destroy?
146
+ raise Error, E_CANNOT_RECORD_AFTER_DESTROY
163
147
  end
164
148
  end
165
149
 
166
150
  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
151
+ ::ActiveRecord::Base.belongs_to_required_by_default
152
+ end
153
+
154
+ def check_version_class_name(options)
155
+ # @api private - `version_class_name`
156
+ @model_class.class_attribute :version_class_name
157
+ if options[:class_name]
158
+ ::ActiveSupport::Deprecation.warn(
159
+ format(
160
+ DPR_CLASS_NAME_OPTION,
161
+ class_name: options[:class_name].inspect
162
+ ),
163
+ caller(1)
164
+ )
165
+ options[:versions][:class_name] = options[:class_name]
166
+ end
167
+ @model_class.version_class_name = options[:versions][:class_name] || "PaperTrail::Version"
168
+ assert_concrete_activerecord_class(@model_class.version_class_name)
169
+ end
170
+
171
+ def check_versions_association_name(options)
172
+ # @api private - versions_association_name
173
+ @model_class.class_attribute :versions_association_name
174
+ @model_class.versions_association_name = options[:versions][:name] || :versions
175
+ end
176
+
177
+ def define_has_many_versions(options)
178
+ options = ensure_versions_option_is_hash(options)
179
+ check_version_class_name(options)
180
+ check_versions_association_name(options)
181
+ scope = get_versions_scope(options)
182
+ @model_class.has_many(
183
+ @model_class.versions_association_name,
184
+ scope,
185
+ class_name: @model_class.version_class_name,
186
+ as: :item,
187
+ **options[:versions].except(:name, :scope)
188
+ )
189
+ end
190
+
191
+ def ensure_versions_option_is_hash(options)
192
+ unless options[:versions].is_a?(Hash)
193
+ if options[:versions]
194
+ ::ActiveSupport::Deprecation.warn(
195
+ format(
196
+ DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION,
197
+ versions_name: options[:versions].inspect
198
+ ),
199
+ caller(1)
200
+ )
201
+ end
202
+ options[:versions] = {
203
+ name: options[:versions]
204
+ }
205
+ end
206
+ options
207
+ end
208
+
209
+ # Process an `ignore`, `skip`, or `only` option.
210
+ def event_attribute_option(option_name)
211
+ [@model_class.paper_trail_options[option_name]].
212
+ flatten.
213
+ compact.
214
+ map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
215
+ end
216
+
217
+ def get_versions_scope(options)
218
+ options[:versions][:scope] || -> { order(model.timestamp_sort_order) }
169
219
  end
170
220
 
171
221
  def setup_associations(options)
222
+ # @api private - version_association_name
172
223
  @model_class.class_attribute :version_association_name
173
224
  @model_class.version_association_name = options[:version] || :version
174
225
 
175
226
  # The version this instance was reified from.
227
+ # @api public
176
228
  @model_class.send :attr_accessor, @model_class.version_association_name
177
229
 
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
-
230
+ # @api public - paper_trail_event
184
231
  @model_class.send :attr_accessor, :paper_trail_event
185
232
 
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
- )
233
+ define_has_many_versions(options)
194
234
  end
195
235
 
196
236
  def setup_callbacks_from_options(options_on = [])
@@ -200,20 +240,17 @@ module PaperTrail
200
240
  end
201
241
 
202
242
  def setup_options(options)
243
+ # @api public - paper_trail_options - Let's encourage plugins to use eg.
244
+ # `paper_trail_options[:versions][:class_name]` rather than
245
+ # `version_class_name` because the former is documented and the latter is
246
+ # not.
203
247
  @model_class.class_attribute :paper_trail_options
204
248
  @model_class.paper_trail_options = options.dup
205
249
 
206
250
  %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 }
251
+ @model_class.paper_trail_options[k] = event_attribute_option(k)
211
252
  end
212
-
213
253
  @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
254
  end
218
255
  end
219
256
  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
@@ -15,7 +15,7 @@ module PaperTrail
15
15
  @version_model_class = version_model_class
16
16
 
17
17
  # Currently, this `deep_dup` is necessary because the `jsonb` branch
18
- # modifies `@attributes`, and that would be a nasty suprise for
18
+ # modifies `@attributes`, and that would be a nasty surprise for
19
19
  # consumers of this class.
20
20
  # TODO: Stop modifying `@attributes`, then remove `deep_dup`.
21
21
  @attributes = attributes.deep_dup
@@ -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