paper_trail 11.1.0 → 15.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/paper_trail/install/install_generator.rb +29 -5
  3. data/lib/generators/paper_trail/install/templates/create_versions.rb.erb +11 -6
  4. data/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb +4 -2
  5. data/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +24 -10
  6. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +10 -10
  7. data/lib/paper_trail/attribute_serializers/object_attribute.rb +13 -3
  8. data/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +13 -3
  9. data/lib/paper_trail/compatibility.rb +3 -3
  10. data/lib/paper_trail/errors.rb +33 -0
  11. data/lib/paper_trail/events/base.rb +84 -64
  12. data/lib/paper_trail/events/destroy.rb +1 -1
  13. data/lib/paper_trail/events/update.rb +23 -7
  14. data/lib/paper_trail/frameworks/active_record.rb +9 -2
  15. data/lib/paper_trail/frameworks/rails/controller.rb +0 -6
  16. data/lib/paper_trail/frameworks/rails/railtie.rb +34 -0
  17. data/lib/paper_trail/frameworks/rails.rb +1 -2
  18. data/lib/paper_trail/has_paper_trail.rb +4 -0
  19. data/lib/paper_trail/model_config.rb +51 -45
  20. data/lib/paper_trail/queries/versions/where_attribute_changes.rb +50 -0
  21. data/lib/paper_trail/queries/versions/where_object.rb +1 -1
  22. data/lib/paper_trail/queries/versions/where_object_changes.rb +9 -14
  23. data/lib/paper_trail/queries/versions/where_object_changes_from.rb +57 -0
  24. data/lib/paper_trail/queries/versions/where_object_changes_to.rb +57 -0
  25. data/lib/paper_trail/record_trail.rb +81 -64
  26. data/lib/paper_trail/reifier.rb +27 -10
  27. data/lib/paper_trail/request.rb +22 -25
  28. data/lib/paper_trail/serializers/json.rb +0 -10
  29. data/lib/paper_trail/serializers/yaml.rb +38 -13
  30. data/lib/paper_trail/type_serializers/postgres_array_serializer.rb +1 -14
  31. data/lib/paper_trail/version_concern.rb +74 -22
  32. data/lib/paper_trail/version_number.rb +2 -2
  33. data/lib/paper_trail.rb +30 -40
  34. metadata +98 -45
  35. data/Gemfile +0 -4
  36. data/lib/paper_trail/frameworks/rails/engine.rb +0 -45
  37. data/paper_trail.gemspec +0 -69
@@ -0,0 +1,34 @@
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 |app|
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
+
29
+ if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("7.1")
30
+ app.deprecators[:paper_trail] = PaperTrail.deprecator
31
+ end
32
+ end
33
+ end
34
+ 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"
@@ -53,6 +53,10 @@ module PaperTrail
53
53
  # - A Hash - options passed to `has_many`, plus `name:` and `scope:`.
54
54
  # - :version - The name to use for the method which returns the version
55
55
  # the instance was reified from. Default is `:version`.
56
+ # - :synchronize_version_creation_timestamp - By default, paper trail
57
+ # sets the `created_at` field for a new Version equal to the `updated_at`
58
+ # column of the model being updated. If you instead want `created_at` to
59
+ # populate with the current timestamp, set this option to `false`.
56
60
  #
57
61
  # Plugins like the experimental `paper_trail-association_tracking` gem
58
62
  # may accept additional options.
@@ -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,28 @@ 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 (no longer supported by PT) had a bug where dirty-tracking
86
+ # did not occur during a `touch`.
87
+ # (https://github.com/rails/rails/issues/33429) See also:
88
+ # https://github.com/paper-trail-gem/paper_trail/issues/1121
89
+ # https://github.com/paper-trail-gem/paper_trail/issues/1161
90
+ # https://github.com/paper-trail-gem/paper_trail/pull/1285
91
+ #
100
92
  # @api public
101
93
  def on_touch
102
94
  @model_class.after_touch { |r|
103
- r.paper_trail.record_update(
104
- force: true,
105
- in_after_callback: true,
106
- is_touch: true
107
- )
95
+ if r.paper_trail.save_version?
96
+ r.paper_trail.record_update(
97
+ force: false,
98
+ in_after_callback: true,
99
+ is_touch: true
100
+ )
101
+ end
108
102
  }
109
103
  end
110
104
 
@@ -117,44 +111,52 @@ module PaperTrail
117
111
  @model_class.send :include, ::PaperTrail::Model::InstanceMethods
118
112
  setup_options(options)
119
113
  setup_associations(options)
120
- check_presence_of_item_subtype_column(options)
121
114
  @model_class.after_rollback { paper_trail.clear_rolled_back_versions }
122
115
  setup_callbacks_from_options options[:on]
123
116
  end
124
117
 
118
+ # @api private
125
119
  def version_class
126
- @_version_class ||= @model_class.version_class_name.constantize
120
+ @version_class ||= @model_class.version_class_name.constantize
127
121
  end
128
122
 
129
123
  private
130
124
 
125
+ # @api private
126
+ def append_option_uniquely(option, value)
127
+ collection = @model_class.paper_trail_options.fetch(option)
128
+ return if collection.include?(value)
129
+ collection << value
130
+ end
131
+
131
132
  # Raises an error if the provided class is an `abstract_class`.
132
133
  # @api private
133
134
  def assert_concrete_activerecord_class(class_name)
134
135
  if class_name.constantize.abstract_class?
135
- raise format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
136
+ raise Error, format(E_HPT_ABSTRACT_CLASS, @model_class, class_name)
136
137
  end
137
138
  end
138
139
 
139
- def cannot_record_after_destroy?
140
- ::ActiveRecord::Base.belongs_to_required_by_default
140
+ # @api private
141
+ def assert_valid_recording_order_for_on_destroy(recording_order)
142
+ unless %w[after before].include?(recording_order.to_s)
143
+ raise ArgumentError, 'recording order can only be "after" or "before"'
144
+ end
145
+
146
+ if recording_order.to_s == "after" && cannot_record_after_destroy?
147
+ raise Error, E_CANNOT_RECORD_AFTER_DESTROY
148
+ end
141
149
  end
142
150
 
143
- # Some options require the presence of the `item_subtype` column. Currently
144
- # only `limit`, but in the future there may be others.
145
- #
146
- # @api private
147
- def check_presence_of_item_subtype_column(options)
148
- return unless options.key?(:limit)
149
- return if version_class.item_subtype_column_present?
150
- raise format(E_MODEL_LIMIT_REQUIRES_ITEM_SUBTYPE, @model_class.name)
151
+ def cannot_record_after_destroy?
152
+ ::ActiveRecord::Base.belongs_to_required_by_default
151
153
  end
152
154
 
153
155
  def check_version_class_name(options)
154
156
  # @api private - `version_class_name`
155
157
  @model_class.class_attribute :version_class_name
156
158
  if options[:class_name]
157
- ::ActiveSupport::Deprecation.warn(
159
+ PaperTrail.deprecator.warn(
158
160
  format(
159
161
  DPR_CLASS_NAME_OPTION,
160
162
  class_name: options[:class_name].inspect
@@ -190,7 +192,7 @@ module PaperTrail
190
192
  def ensure_versions_option_is_hash(options)
191
193
  unless options[:versions].is_a?(Hash)
192
194
  if options[:versions]
193
- ::ActiveSupport::Deprecation.warn(
195
+ PaperTrail.deprecator.warn(
194
196
  format(
195
197
  DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION,
196
198
  versions_name: options[:versions].inspect
@@ -205,6 +207,14 @@ module PaperTrail
205
207
  options
206
208
  end
207
209
 
210
+ # Process an `ignore`, `skip`, or `only` option.
211
+ def event_attribute_option(option_name)
212
+ [@model_class.paper_trail_options[option_name]].
213
+ flatten.
214
+ compact.
215
+ map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
216
+ end
217
+
208
218
  def get_versions_scope(options)
209
219
  options[:versions][:scope] || -> { order(model.timestamp_sort_order) }
210
220
  end
@@ -239,12 +249,8 @@ module PaperTrail
239
249
  @model_class.paper_trail_options = options.dup
240
250
 
241
251
  %i[ignore skip only].each do |k|
242
- @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]].
243
- flatten.
244
- compact.
245
- map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
252
+ @model_class.paper_trail_options[k] = event_attribute_option(k)
246
253
  end
247
-
248
254
  @model_class.paper_trail_options[:meta] ||= {}
249
255
  end
250
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
@@ -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
@@ -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,23 @@ 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
+ @record.paper_trail_options[:synchronize_version_creation_timestamp] != false
231
+ data[:created_at] = @record.updated_at
232
+ end
272
233
 
273
234
  # Merge data from `Event` with data from PT-AT. We no longer use
274
235
  # `data_for_update` but PT-AT still does. To save memory, we use `merge!`
275
236
  # instead of `merge`.
276
- data = event.data.merge!(data_for_update)
237
+ data.merge!(data_for_update)
277
238
 
278
239
  # Using `version_class.new` reduces memory usage compared to
279
240
  # `versions_assoc.build`. It's a trade-off though. We have to clear
@@ -282,13 +243,69 @@ module PaperTrail
282
243
  @record.class.paper_trail.version_class.new(data)
283
244
  end
284
245
 
246
+ # PT-AT extends this method to add its transaction id.
247
+ #
248
+ # @api public
249
+ def data_for_create
250
+ {}
251
+ end
252
+
253
+ # PT-AT extends this method to add its transaction id.
254
+ #
255
+ # @api public
256
+ def data_for_destroy
257
+ {}
258
+ end
259
+
260
+ # PT-AT extends this method to add its transaction id.
261
+ #
262
+ # @api public
263
+ def data_for_update
264
+ {}
265
+ end
266
+
267
+ # PT-AT extends this method to add its transaction id.
268
+ #
269
+ # @api public
270
+ def data_for_update_columns
271
+ {}
272
+ end
273
+
274
+ # Is PT enabled for this particular record?
275
+ # @api private
276
+ def enabled?
277
+ PaperTrail.enabled? &&
278
+ PaperTrail.request.enabled? &&
279
+ PaperTrail.request.enabled_for_model?(@record.class)
280
+ end
281
+
285
282
  def log_version_errors(version, action)
286
283
  version.logger&.warn(
287
284
  "Unable to create version for #{action} of #{@record.class.name}" \
288
- "##{@record.id}: " + version.errors.full_messages.join(", ")
285
+ "##{@record.id}: " + version.errors.full_messages.join(", ")
289
286
  )
290
287
  end
291
288
 
289
+ # @api private
290
+ # @return - The created version object, so that plugins can use it, e.g.
291
+ # paper_trail-association_tracking
292
+ def record_update_columns(changes)
293
+ return unless enabled?
294
+ data = Events::Update.new(@record, false, false, changes).data
295
+
296
+ # Merge data from `Event` with data from PT-AT. We no longer use
297
+ # `data_for_update_columns` but PT-AT still does.
298
+ data.merge!(data_for_update_columns)
299
+
300
+ versions_assoc = @record.send(@record.class.versions_association_name)
301
+ version = versions_assoc.create(data)
302
+ if version.errors.any?
303
+ log_version_errors(version, :update)
304
+ else
305
+ version
306
+ end
307
+ end
308
+
292
309
  def version
293
310
  @record.public_send(@record.class.version_association_name)
294
311
  end