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
@@ -29,22 +29,38 @@ module PaperTrail
29
29
  event: @record.paper_trail_event || "update",
30
30
  whodunnit: PaperTrail.request.whodunnit
31
31
  }
32
- if @record.respond_to?(:updated_at)
33
- data[:created_at] = @record.updated_at
34
- end
35
32
  if record_object?
36
33
  data[:object] = recordable_object(@is_touch)
37
34
  end
38
- if record_object_changes?
39
- changes = @force_changes.nil? ? notable_changes : @force_changes
40
- data[:object_changes] = prepare_object_changes(changes)
41
- end
35
+ merge_object_changes_into(data)
42
36
  merge_item_subtype_into(data)
43
37
  merge_metadata_into(data)
44
38
  end
45
39
 
40
+ # If it is a touch event, and changed are empty, it is assumed to be
41
+ # implicit `touch` mutation, and will a version is created.
42
+ #
43
+ # See https://github.com/rails/rails/commit/dcb825902d79d0f6baba956f7c6ec5767611353e
44
+ #
45
+ # @api private
46
+ def changed_notably?
47
+ if @is_touch && changes_in_latest_version.empty?
48
+ true
49
+ else
50
+ super
51
+ end
52
+ end
53
+
46
54
  private
47
55
 
56
+ # @api private
57
+ def merge_object_changes_into(data)
58
+ if record_object_changes?
59
+ changes = @force_changes.nil? ? notable_changes : @force_changes
60
+ data[:object_changes] = prepare_object_changes(changes)
61
+ end
62
+ end
63
+
48
64
  # `touch` cannot record `object_changes` because rails' `touch` does not
49
65
  # perform dirty-tracking. Specifically, methods from `Dirty`, like
50
66
  # `saved_changes`, return the same values before and after `touch`.
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This file only needs to be loaded if the gem is being used outside of Rails,
4
- # since otherwise the model(s) will get loaded in via the `Rails::Engine`.
3
+ # Either ActiveRecord has already been loaded by the Lazy Load Hook in our
4
+ # Railtie, or else we load it now.
5
+ require "active_record"
6
+ ::PaperTrail::Compatibility.check_activerecord(::ActiveRecord.gem_version)
7
+
8
+ # Now we can load the parts of PT that depend on AR.
9
+ require "paper_trail/has_paper_trail"
10
+ require "paper_trail/reifier"
5
11
  require "paper_trail/frameworks/active_record/models/paper_trail/version"
12
+ ActiveRecord::Base.include PaperTrail::Model
@@ -25,9 +25,7 @@ module PaperTrail
25
25
  # @api public
26
26
  def user_for_paper_trail
27
27
  return unless defined?(current_user)
28
- ActiveSupport::VERSION::MAJOR >= 4 ? current_user.try!(:id) : current_user.try(:id)
29
- rescue NoMethodError
30
- current_user
28
+ current_user.try(:id) || current_user
31
29
  end
32
30
 
33
31
  # Returns any information about the controller or request that you
@@ -103,9 +101,3 @@ module PaperTrail
103
101
  end
104
102
  end
105
103
  end
106
-
107
- if defined?(::ActionController)
108
- ::ActiveSupport.on_load(:action_controller) do
109
- include ::PaperTrail::Rails::Controller
110
- end
111
- end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ # Represents code to load within Rails framework. See documentation in
5
+ # `railties/lib/rails/railtie.rb`.
6
+ # @api private
7
+ class Railtie < ::Rails::Railtie
8
+ # PaperTrail only has one initializer.
9
+ #
10
+ # We specify `before: "load_config_initializers"` to ensure that the PT
11
+ # initializer happens before "app initializers" (those defined in
12
+ # the app's `config/initalizers`).
13
+ initializer "paper_trail", before: "load_config_initializers" do
14
+ # `on_load` is a "lazy load hook". It "declares a block that will be
15
+ # executed when a Rails component is fully loaded". (See
16
+ # `active_support/lazy_load_hooks.rb`)
17
+ ActiveSupport.on_load(:action_controller) do
18
+ require "paper_trail/frameworks/rails/controller"
19
+
20
+ # Mix our extensions into `ActionController::Base`, which is `self`
21
+ # because of the `class_eval` in `lazy_load_hooks.rb`.
22
+ include PaperTrail::Rails::Controller
23
+ end
24
+
25
+ ActiveSupport.on_load(:active_record) do
26
+ require "paper_trail/frameworks/active_record"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,4 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "paper_trail/frameworks/rails/controller"
4
- require "paper_trail/frameworks/rails/engine"
3
+ require "paper_trail/frameworks/rails/railtie"
@@ -12,7 +12,7 @@ module PaperTrail
12
12
  # `.paper_trail` and `#paper_trail`.
13
13
  module Model
14
14
  def self.included(base)
15
- base.send :extend, ClassMethods
15
+ base.extend ClassMethods
16
16
  end
17
17
 
18
18
  # :nodoc:
@@ -18,11 +18,6 @@ module PaperTrail
18
18
  `abstract_class`. This is fine, but all application models must be
19
19
  configured to use concrete (not abstract) version models.
20
20
  STR
21
- E_MODEL_LIMIT_REQUIRES_ITEM_SUBTYPE = <<~STR.squish.freeze
22
- To use PaperTrail's per-model limit in your %s model, you must have an
23
- item_subtype column in your versions table. See documentation sections
24
- 2.e.1 Per-model limit, and 4.b.1 The optional item_subtype column.
25
- STR
26
21
  DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION = <<~STR.squish
27
22
  Passing versions association name as `has_paper_trail versions: %{versions_name}`
28
23
  is deprecated. Use `has_paper_trail versions: {name: %{versions_name}}` instead.
@@ -45,22 +40,14 @@ module PaperTrail
45
40
  @model_class.after_create { |r|
46
41
  r.paper_trail.record_create if r.paper_trail.save_version?
47
42
  }
48
- return if @model_class.paper_trail_options[:on].include?(:create)
49
- @model_class.paper_trail_options[:on] << :create
43
+ append_option_uniquely(:on, :create)
50
44
  end
51
45
 
52
46
  # Adds a callback that records a version before or after a "destroy" event.
53
47
  #
54
48
  # @api public
55
49
  def on_destroy(recording_order = "before")
56
- unless %w[after before].include?(recording_order.to_s)
57
- raise ArgumentError, 'recording order can only be "after" or "before"'
58
- end
59
-
60
- if recording_order.to_s == "after" && cannot_record_after_destroy?
61
- raise E_CANNOT_RECORD_AFTER_DESTROY
62
- end
63
-
50
+ assert_valid_recording_order_for_on_destroy(recording_order)
64
51
  @model_class.send(
65
52
  "#{recording_order}_destroy",
66
53
  lambda do |r|
@@ -68,9 +55,7 @@ module PaperTrail
68
55
  r.paper_trail.record_destroy(recording_order)
69
56
  end
70
57
  )
71
-
72
- return if @model_class.paper_trail_options[:on].include?(:destroy)
73
- @model_class.paper_trail_options[:on] << :destroy
58
+ append_option_uniquely(:on, :destroy)
74
59
  end
75
60
 
76
61
  # Adds a callback that records a version after an "update" event.
@@ -92,19 +77,27 @@ module PaperTrail
92
77
  @model_class.after_update { |r|
93
78
  r.paper_trail.clear_version_instance
94
79
  }
95
- return if @model_class.paper_trail_options[:on].include?(:update)
96
- @model_class.paper_trail_options[:on] << :update
80
+ append_option_uniquely(:on, :update)
97
81
  end
98
82
 
99
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
+ #
100
91
  # @api public
101
92
  def on_touch
102
93
  @model_class.after_touch { |r|
103
- r.paper_trail.record_update(
104
- force: true,
105
- in_after_callback: true,
106
- is_touch: true
107
- )
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
108
101
  }
109
102
  end
110
103
 
@@ -117,42 +110,45 @@ module PaperTrail
117
110
  @model_class.send :include, ::PaperTrail::Model::InstanceMethods
118
111
  setup_options(options)
119
112
  setup_associations(options)
120
- check_presence_of_item_subtype_column(options)
121
113
  @model_class.after_rollback { paper_trail.clear_rolled_back_versions }
122
114
  setup_callbacks_from_options options[:on]
123
115
  end
124
116
 
117
+ # @api private
125
118
  def version_class
126
- @_version_class ||= @model_class.version_class_name.constantize
119
+ @version_class ||= @model_class.version_class_name.constantize
127
120
  end
128
121
 
129
122
  private
130
123
 
131
- def active_record_gem_version
132
- 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
133
129
  end
134
130
 
135
131
  # Raises an error if the provided class is an `abstract_class`.
136
132
  # @api private
137
133
  def assert_concrete_activerecord_class(class_name)
138
134
  if class_name.constantize.abstract_class?
139
- raise format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
135
+ raise Error, format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
140
136
  end
141
137
  end
142
138
 
143
- def cannot_record_after_destroy?
144
- Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") &&
145
- ::ActiveRecord::Base.belongs_to_required_by_default
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
147
+ end
146
148
  end
147
149
 
148
- # Some options require the presence of the `item_subtype` column. Currently
149
- # only `limit`, but in the future there may be others.
150
- #
151
- # @api private
152
- def check_presence_of_item_subtype_column(options)
153
- return unless options.key?(:limit)
154
- return if version_class.item_subtype_column_present?
155
- raise format(E_MODEL_LIMIT_REQUIRES_ITEM_SUBTYPE, @model_class.name)
150
+ def cannot_record_after_destroy?
151
+ ::ActiveRecord::Base.belongs_to_required_by_default
156
152
  end
157
153
 
158
154
  def check_version_class_name(options)
@@ -210,6 +206,14 @@ module PaperTrail
210
206
  options
211
207
  end
212
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
+
213
217
  def get_versions_scope(options)
214
218
  options[:versions][:scope] || -> { order(model.timestamp_sort_order) }
215
219
  end
@@ -244,12 +248,8 @@ module PaperTrail
244
248
  @model_class.paper_trail_options = options.dup
245
249
 
246
250
  %i[ignore skip only].each do |k|
247
- @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]].
248
- flatten.
249
- compact.
250
- map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
251
+ @model_class.paper_trail_options[k] = event_attribute_option(k)
251
252
  end
252
-
253
253
  @model_class.paper_trail_options[:meta] ||= {}
254
254
  end
255
255
  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
@@ -19,7 +19,7 @@ module PaperTrail
19
19
  # @api private
20
20
  def execute
21
21
  column = @version_model_class.columns_hash["object"]
22
- raise "where_object can't be called without an object column" unless column
22
+ raise Error, "where_object requires an object column" unless column
23
23
 
24
24
  case column.type
25
25
  when :jsonb
@@ -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&.respond_to?(:where_object_changes)
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