paper_trail 5.1.1 → 5.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,195 @@
1
+ require "active_support/core_ext"
2
+
3
+ module PaperTrail
4
+ # Configures an ActiveRecord model, mostly at application boot time, but also
5
+ # sometimes mid-request, with methods like enable/disable.
6
+ class ModelConfig
7
+ E_CANNOT_RECORD_AFTER_DESTROY = <<-STR.strip_heredoc.freeze
8
+ paper_trail.on_destroy(:after) is incompatible with ActiveRecord's
9
+ belongs_to_required_by_default and has no effect. Please use :before
10
+ or disable belongs_to_required_by_default.
11
+ STR
12
+
13
+ def initialize(model_class)
14
+ @model_class = model_class
15
+ end
16
+
17
+ # Switches PaperTrail off for this class.
18
+ def disable
19
+ ::PaperTrail.enabled_for_model(@model_class, false)
20
+ end
21
+
22
+ # Switches PaperTrail on for this class.
23
+ def enable
24
+ ::PaperTrail.enabled_for_model(@model_class, true)
25
+ end
26
+
27
+ def enabled?
28
+ return false unless @model_class.include?(::PaperTrail::Model::InstanceMethods)
29
+ ::PaperTrail.enabled_for_model?(@model_class)
30
+ end
31
+
32
+ # Adds a callback that records a version after a "create" event.
33
+ def on_create
34
+ @model_class.after_create :record_create, if: ->(m) { m.paper_trail.save_version? }
35
+ return if @model_class.paper_trail_options[:on].include?(:create)
36
+ @model_class.paper_trail_options[:on] << :create
37
+ end
38
+
39
+ # Adds a callback that records a version before or after a "destroy" event.
40
+ def on_destroy(recording_order = "before")
41
+ unless %w(after before).include?(recording_order.to_s)
42
+ raise ArgumentError, 'recording order can only be "after" or "before"'
43
+ end
44
+
45
+ if recording_order.to_s == "after" && cannot_record_after_destroy?
46
+ ::ActiveSupport::Deprecation.warn(E_CANNOT_RECORD_AFTER_DESTROY)
47
+ end
48
+
49
+ @model_class.send "#{recording_order}_destroy", :record_destroy,
50
+ if: ->(m) { m.paper_trail.save_version? }
51
+
52
+ return if @model_class.paper_trail_options[:on].include?(:destroy)
53
+ @model_class.paper_trail_options[:on] << :destroy
54
+ end
55
+
56
+ # Adds a callback that records a version after an "update" event.
57
+ def on_update
58
+ @model_class.before_save :reset_timestamp_attrs_for_update_if_needed!, on: :update
59
+ @model_class.after_update :record_update, if: ->(m) { m.paper_trail.save_version? }
60
+ @model_class.after_update :clear_version_instance!
61
+ return if @model_class.paper_trail_options[:on].include?(:update)
62
+ @model_class.paper_trail_options[:on] << :update
63
+ end
64
+
65
+ # Set up `@model_class` for PaperTrail. Installs callbacks, associations,
66
+ # "class attributes", instance methods, and more.
67
+ # @api private
68
+ def setup(options = {})
69
+ options[:on] ||= [:create, :update, :destroy]
70
+ options[:on] = Array(options[:on]) # Support single symbol
71
+ @model_class.send :include, ::PaperTrail::Model::InstanceMethods
72
+ if ::ActiveRecord::VERSION::STRING < "4.2"
73
+ @model_class.send :extend, AttributeSerializers::LegacyActiveRecordShim
74
+ end
75
+ setup_options(options)
76
+ setup_associations(options)
77
+ setup_transaction_callbacks
78
+ setup_callbacks_from_options options[:on]
79
+ setup_callbacks_for_habtm options[:join_tables]
80
+ end
81
+
82
+ def version_class
83
+ @_version_class ||= @model_class.version_class_name.constantize
84
+ end
85
+
86
+ private
87
+
88
+ def active_record_gem_version
89
+ Gem::Version.new(ActiveRecord::VERSION::STRING)
90
+ end
91
+
92
+ def cannot_record_after_destroy?
93
+ Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") &&
94
+ ::ActiveRecord::Base.belongs_to_required_by_default
95
+ end
96
+
97
+ def setup_associations(options)
98
+ @model_class.class_attribute :version_association_name
99
+ @model_class.version_association_name = options[:version] || :version
100
+
101
+ # The version this instance was reified from.
102
+ @model_class.send :attr_accessor, @model_class.version_association_name
103
+
104
+ @model_class.class_attribute :version_class_name
105
+ @model_class.version_class_name = options[:class_name] || "PaperTrail::Version"
106
+
107
+ @model_class.class_attribute :versions_association_name
108
+ @model_class.versions_association_name = options[:versions] || :versions
109
+
110
+ @model_class.send :attr_accessor, :paper_trail_event
111
+
112
+ # In rails 4, the `has_many` syntax for specifying order uses a lambda.
113
+ if ::ActiveRecord::VERSION::MAJOR >= 4
114
+ @model_class.has_many(
115
+ @model_class.versions_association_name,
116
+ -> { order(model.timestamp_sort_order) },
117
+ class_name: @model_class.version_class_name,
118
+ as: :item
119
+ )
120
+ else
121
+ @model_class.has_many(
122
+ @model_class.versions_association_name,
123
+ class_name: @model_class.version_class_name,
124
+ as: :item,
125
+ order: @model_class.paper_trail_version_class.timestamp_sort_order
126
+ )
127
+ end
128
+ end
129
+
130
+ # Adds callbacks to record changes to habtm associations such that on save
131
+ # the previous version of the association (if changed) can be interpreted.
132
+ def setup_callbacks_for_habtm(join_tables)
133
+ @model_class.send :attr_accessor, :paper_trail_habtm
134
+ @model_class.class_attribute :paper_trail_save_join_tables
135
+ @model_class.paper_trail_save_join_tables = Array.wrap(join_tables)
136
+ @model_class.reflect_on_all_associations(:has_and_belongs_to_many).
137
+ reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) }.
138
+ each { |a|
139
+ added_callback = lambda do |*args|
140
+ update_habtm_state(a.name, :before_add, args[-2], args.last)
141
+ end
142
+ removed_callback = lambda do |*args|
143
+ update_habtm_state(a.name, :before_remove, args[-2], args.last)
144
+ end
145
+ @model_class.send(:"before_add_for_#{a.name}").send(:<<, added_callback)
146
+ @model_class.send(:"before_remove_for_#{a.name}").send(:<<, removed_callback)
147
+ }
148
+ end
149
+
150
+ def setup_callbacks_from_options(options_on = [])
151
+ options_on.each do |event|
152
+ public_send("on_#{event}")
153
+ end
154
+ end
155
+
156
+ def setup_options(options)
157
+ @model_class.class_attribute :paper_trail_options
158
+ @model_class.paper_trail_options = options.dup
159
+
160
+ [:ignore, :skip, :only].each do |k|
161
+ @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]].
162
+ flatten.
163
+ compact.
164
+ map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
165
+ end
166
+
167
+ @model_class.paper_trail_options[:meta] ||= {}
168
+ if @model_class.paper_trail_options[:save_changes].nil?
169
+ @model_class.paper_trail_options[:save_changes] = true
170
+ end
171
+ end
172
+
173
+ # Reset the transaction id when the transaction is closed.
174
+ def setup_transaction_callbacks
175
+ @model_class.after_commit { PaperTrail.clear_transaction_id }
176
+ @model_class.after_rollback { PaperTrail.clear_transaction_id }
177
+ @model_class.after_rollback { paper_trail.clear_rolled_back_versions }
178
+ end
179
+
180
+ def update_habtm_state(name, callback, model, assoc)
181
+ model.paper_trail_habtm ||= {}
182
+ model.paper_trail_habtm.reverse_merge!(name => { removed: [], added: [] })
183
+ case callback
184
+ when :before_add
185
+ model.paper_trail_habtm[name][:added] |= [assoc.id]
186
+ model.paper_trail_habtm[name][:removed] -= [assoc.id]
187
+ when :before_remove
188
+ model.paper_trail_habtm[name][:removed] |= [assoc.id]
189
+ model.paper_trail_habtm[name][:added] -= [assoc.id]
190
+ else
191
+ raise "Invalid callback: #{callback}"
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,450 @@
1
+ module PaperTrail
2
+ # Represents the "paper trail" for a single record.
3
+ class RecordTrail
4
+ def initialize(record)
5
+ @record = record
6
+ end
7
+
8
+ # Utility method for reifying. Anything executed inside the block will
9
+ # appear like a new record.
10
+ def appear_as_new_record
11
+ @record.instance_eval {
12
+ alias :old_new_record? :new_record?
13
+ alias :new_record? :present?
14
+ }
15
+ yield
16
+ @record.instance_eval { alias :new_record? :old_new_record? }
17
+ end
18
+
19
+ def attributes_before_change
20
+ changed = @record.changed_attributes.select { |k, _v|
21
+ @record.class.column_names.include?(k)
22
+ }
23
+ @record.attributes.merge(changed)
24
+ end
25
+
26
+ def changed_and_not_ignored
27
+ ignore = @record.paper_trail_options[:ignore].dup
28
+ # Remove Hash arguments and then evaluate whether the attributes (the
29
+ # keys of the hash) should also get pushed into the collection.
30
+ ignore.delete_if do |obj|
31
+ obj.is_a?(Hash) &&
32
+ obj.each { |attr, condition|
33
+ ignore << attr if condition.respond_to?(:call) && condition.call(@record)
34
+ }
35
+ end
36
+ skip = @record.paper_trail_options[:skip]
37
+ @record.changed - ignore - skip
38
+ end
39
+
40
+ # Invoked after rollbacks to ensure versions records are not created for
41
+ # changes that never actually took place. Optimization: Use lazy `reset`
42
+ # instead of eager `reload` because, in many use cases, the association will
43
+ # not be used.
44
+ def clear_rolled_back_versions
45
+ versions.reset
46
+ end
47
+
48
+ # Invoked via`after_update` callback for when a previous version is
49
+ # reified and then saved.
50
+ def clear_version_instance
51
+ @record.send("#{@record.class.version_association_name}=", nil)
52
+ end
53
+
54
+ # Determines whether it is appropriate to generate a new version
55
+ # instance. A timestamp-only update (e.g. only `updated_at` changed) is
56
+ # considered notable unless an ignored attribute was also changed.
57
+ def changed_notably?
58
+ if ignored_attr_has_changed?
59
+ timestamps = @record.send(:timestamp_attributes_for_update_in_model).map(&:to_s)
60
+ (notably_changed - timestamps).any?
61
+ else
62
+ notably_changed.any?
63
+ end
64
+ end
65
+
66
+ # @api private
67
+ def changes
68
+ notable_changes = @record.changes.delete_if { |k, _v|
69
+ !notably_changed.include?(k)
70
+ }
71
+ AttributeSerializers::ObjectChangesAttribute.
72
+ new(@record.class).
73
+ serialize(notable_changes)
74
+ notable_changes.to_hash
75
+ end
76
+
77
+ def enabled?
78
+ PaperTrail.enabled? && PaperTrail.enabled_for_controller? && enabled_for_model?
79
+ end
80
+
81
+ def enabled_for_model?
82
+ @record.class.paper_trail.enabled?
83
+ end
84
+
85
+ # An attributed is "ignored" if it is listed in the `:ignore` option
86
+ # and/or the `:skip` option. Returns true if an ignored attribute has
87
+ # changed.
88
+ def ignored_attr_has_changed?
89
+ ignored = @record.paper_trail_options[:ignore] + @record.paper_trail_options[:skip]
90
+ ignored.any? && (@record.changed & ignored).any?
91
+ end
92
+
93
+ # Returns true if this instance is the current, live one;
94
+ # returns false if this instance came from a previous version.
95
+ def live?
96
+ source_version.nil?
97
+ end
98
+
99
+ # @api private
100
+ def merge_metadata(data)
101
+ # First we merge the model-level metadata in `meta`.
102
+ @record.paper_trail_options[:meta].each do |k, v|
103
+ data[k] =
104
+ if v.respond_to?(:call)
105
+ v.call(@record)
106
+ elsif v.is_a?(Symbol) && @record.respond_to?(v, true)
107
+ # If it is an attribute that is changing in an existing object,
108
+ # be sure to grab the current version.
109
+ if @record.has_attribute?(v) &&
110
+ @record.send("#{v}_changed?".to_sym) &&
111
+ data[:event] != "create"
112
+ @record.send("#{v}_was".to_sym)
113
+ else
114
+ @record.send(v)
115
+ end
116
+ else
117
+ v
118
+ end
119
+ end
120
+
121
+ # Second we merge any extra data from the controller (if available).
122
+ data.merge(PaperTrail.controller_info || {})
123
+ end
124
+
125
+ # Returns the object (not a Version) as it became next.
126
+ # NOTE: if self (the item) was not reified from a version, i.e. it is the
127
+ # "live" item, we return nil. Perhaps we should return self instead?
128
+ def next_version
129
+ subsequent_version = source_version.next
130
+ subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
131
+ rescue # TODO: Rescue something more specific
132
+ nil
133
+ end
134
+
135
+ def notably_changed
136
+ only = @record.paper_trail_options[:only].dup
137
+ # Remove Hash arguments and then evaluate whether the attributes (the
138
+ # keys of the hash) should also get pushed into the collection.
139
+ only.delete_if do |obj|
140
+ obj.is_a?(Hash) &&
141
+ obj.each { |attr, condition|
142
+ only << attr if condition.respond_to?(:call) && condition.call(@record)
143
+ }
144
+ end
145
+ only.empty? ? changed_and_not_ignored : (changed_and_not_ignored & only)
146
+ end
147
+
148
+ # Returns hash of attributes (with appropriate attributes serialized),
149
+ # omitting attributes to be skipped.
150
+ def object_attrs_for_paper_trail
151
+ attrs = attributes_before_change.except(*@record.paper_trail_options[:skip])
152
+ AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs)
153
+ attrs
154
+ end
155
+
156
+ # Returns who put `@record` into its current state.
157
+ def originator
158
+ (source_version || versions.last).try(:whodunnit)
159
+ end
160
+
161
+ # Returns the object (not a Version) as it was most recently.
162
+ def previous_version
163
+ (source_version ? source_version.previous : versions.last).try(:reify)
164
+ end
165
+
166
+ def record_create
167
+ return unless enabled?
168
+ data = {
169
+ event: @record.paper_trail_event || "create",
170
+ whodunnit: PaperTrail.whodunnit
171
+ }
172
+ if @record.respond_to?(:updated_at)
173
+ data[PaperTrail.timestamp_field] = @record.updated_at
174
+ end
175
+ if record_object_changes? && changed_notably?
176
+ data[:object_changes] = recordable_object_changes
177
+ end
178
+ add_transaction_id_to(data)
179
+ versions_assoc = @record.send(@record.class.versions_association_name)
180
+ version = versions_assoc.create! merge_metadata(data)
181
+ update_transaction_id(version)
182
+ save_associations(version)
183
+ end
184
+
185
+ def record_destroy
186
+ if enabled? && !@record.new_record?
187
+ data = {
188
+ item_id: @record.id,
189
+ item_type: @record.class.base_class.name,
190
+ event: @record.paper_trail_event || "destroy",
191
+ object: recordable_object,
192
+ whodunnit: PaperTrail.whodunnit
193
+ }
194
+ add_transaction_id_to(data)
195
+ version = @record.class.paper_trail.version_class.create(merge_metadata(data))
196
+ if version.errors.any?
197
+ log_version_errors(version, :destroy)
198
+ else
199
+ @record.send("#{@record.class.version_association_name}=", version)
200
+ @record.send(@record.class.versions_association_name).reset
201
+ update_transaction_id(version)
202
+ save_associations(version)
203
+ end
204
+ end
205
+ end
206
+
207
+ # Returns a boolean indicating whether to store serialized version diffs
208
+ # in the `object_changes` column of the version record.
209
+ # @api private
210
+ def record_object_changes?
211
+ @record.paper_trail_options[:save_changes] &&
212
+ @record.class.paper_trail.version_class.column_names.include?("object_changes")
213
+ end
214
+
215
+ def record_update(force)
216
+ if enabled? && (force || changed_notably?)
217
+ data = {
218
+ event: @record.paper_trail_event || "update",
219
+ object: recordable_object,
220
+ whodunnit: PaperTrail.whodunnit
221
+ }
222
+ if @record.respond_to?(:updated_at)
223
+ data[PaperTrail.timestamp_field] = @record.updated_at
224
+ end
225
+ if record_object_changes?
226
+ data[:object_changes] = recordable_object_changes
227
+ end
228
+ add_transaction_id_to(data)
229
+ versions_assoc = @record.send(@record.class.versions_association_name)
230
+ version = versions_assoc.create(merge_metadata(data))
231
+ if version.errors.any?
232
+ log_version_errors(version, :update)
233
+ else
234
+ update_transaction_id(version)
235
+ save_associations(version)
236
+ end
237
+ end
238
+ end
239
+
240
+ # Returns an object which can be assigned to the `object` attribute of a
241
+ # nascent version record. If the `object` column is a postgres `json`
242
+ # column, then a hash can be used in the assignment, otherwise the column
243
+ # is a `text` column, and we must perform the serialization here, using
244
+ # `PaperTrail.serializer`.
245
+ # @api private
246
+ def recordable_object
247
+ if @record.class.paper_trail.version_class.object_col_is_json?
248
+ object_attrs_for_paper_trail
249
+ else
250
+ PaperTrail.serializer.dump(object_attrs_for_paper_trail)
251
+ end
252
+ end
253
+
254
+ # Returns an object which can be assigned to the `object_changes`
255
+ # attribute of a nascent version record. If the `object_changes` column is
256
+ # a postgres `json` column, then a hash can be used in the assignment,
257
+ # otherwise the column is a `text` column, and we must perform the
258
+ # serialization here, using `PaperTrail.serializer`.
259
+ # @api private
260
+ def recordable_object_changes
261
+ if @record.class.paper_trail.version_class.object_changes_col_is_json?
262
+ changes
263
+ else
264
+ PaperTrail.serializer.dump(changes)
265
+ end
266
+ end
267
+
268
+ # Invoked via callback when a user attempts to persist a reified
269
+ # `Version`.
270
+ def reset_timestamp_attrs_for_update_if_needed
271
+ return if live?
272
+ @record.send(:timestamp_attributes_for_update_in_model).each do |column|
273
+ # ActiveRecord 4.2 deprecated `reset_column!` in favor of
274
+ # `restore_column!`.
275
+ if @record.respond_to?("restore_#{column}!")
276
+ @record.send("restore_#{column}!")
277
+ else
278
+ @record.send("reset_#{column}!")
279
+ end
280
+ end
281
+ end
282
+
283
+ # Saves associations if the join table for `VersionAssociation` exists.
284
+ def save_associations(version)
285
+ return unless PaperTrail.config.track_associations?
286
+ save_associations_belongs_to(version)
287
+ save_associations_habtm(version)
288
+ end
289
+
290
+ def save_associations_belongs_to(version)
291
+ @record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
292
+ assoc_version_args = {
293
+ version_id: version.id,
294
+ foreign_key_name: assoc.foreign_key
295
+ }
296
+
297
+ if assoc.options[:polymorphic]
298
+ associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type)
299
+ if associated_record && associated_record.class.paper_trail.enabled?
300
+ assoc_version_args[:foreign_key_id] = associated_record.id
301
+ end
302
+ elsif assoc.klass.paper_trail.enabled?
303
+ assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key)
304
+ end
305
+
306
+ if assoc_version_args.key?(:foreign_key_id)
307
+ PaperTrail::VersionAssociation.create(assoc_version_args)
308
+ end
309
+ end
310
+ end
311
+
312
+ def save_associations_habtm(version)
313
+ # Use the :added and :removed keys to extrapolate the HABTM associations
314
+ # to before any changes were made
315
+ @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
316
+ next unless
317
+ @record.class.paper_trail_save_join_tables.include?(a.name) ||
318
+ a.klass.paper_trail.enabled?
319
+ assoc_version_args = {
320
+ version_id: version.transaction_id,
321
+ foreign_key_name: a.name
322
+ }
323
+ assoc_ids =
324
+ @record.send(a.name).to_a.map(&:id) +
325
+ (@record.paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) -
326
+ (@record.paper_trail_habtm.try(:[], a.name).try(:[], :added) || [])
327
+ assoc_ids.each do |id|
328
+ PaperTrail::VersionAssociation.create(assoc_version_args.merge(foreign_key_id: id))
329
+ end
330
+ end
331
+ end
332
+
333
+ # AR callback.
334
+ # @api private
335
+ def save_version?
336
+ if_condition = @record.paper_trail_options[:if]
337
+ unless_condition = @record.paper_trail_options[:unless]
338
+ (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
339
+ end
340
+
341
+ def source_version
342
+ version
343
+ end
344
+
345
+ # Mimics the `touch` method from `ActiveRecord::Persistence`, but also
346
+ # creates a version. A version is created regardless of options such as
347
+ # `:on`, `:if`, or `:unless`.
348
+ #
349
+ # TODO: look into leveraging the `after_touch` callback from
350
+ # `ActiveRecord` to allow the regular `touch` method to generate a version
351
+ # as normal. May make sense to switch the `record_update` method to
352
+ # leverage an `after_update` callback anyways (likely for v4.0.0)
353
+ def touch_with_version(name = nil)
354
+ unless @record.persisted?
355
+ raise ActiveRecordError, "can not touch on a new record object"
356
+ end
357
+ attributes = @record.send :timestamp_attributes_for_update_in_model
358
+ attributes << name if name
359
+ current_time = @record.send :current_time_from_proper_timezone
360
+ attributes.each { |column|
361
+ @record.send(:write_attribute, column, current_time)
362
+ }
363
+ @record.record_update(true) unless will_record_after_update?
364
+ @record.save!(validate: false)
365
+ end
366
+
367
+ # Returns the object (not a Version) as it was at the given timestamp.
368
+ def version_at(timestamp, reify_options = {})
369
+ # Because a version stores how its object looked *before* the change,
370
+ # we need to look for the first version created *after* the timestamp.
371
+ v = versions.subsequent(timestamp, true).first
372
+ return v.reify(reify_options) if v
373
+ @record unless @record.destroyed?
374
+ end
375
+
376
+ # Returns the objects (not Versions) as they were between the given times.
377
+ def versions_between(start_time, end_time)
378
+ versions = send(@record.class.versions_association_name).between(start_time, end_time)
379
+ versions.collect { |version|
380
+ version_at(version.send(PaperTrail.timestamp_field))
381
+ }
382
+ end
383
+
384
+ # Executes the given method or block without creating a new version.
385
+ def without_versioning(method = nil)
386
+ paper_trail_was_enabled = enabled_for_model?
387
+ @record.class.paper_trail.disable
388
+ if method
389
+ if respond_to?(method)
390
+ public_send(method)
391
+ else
392
+ @record.send(method)
393
+ end
394
+ else
395
+ yield @record
396
+ end
397
+ ensure
398
+ @record.class.paper_trail.enable if paper_trail_was_enabled
399
+ end
400
+
401
+ # Temporarily overwrites the value of whodunnit and then executes the
402
+ # provided block.
403
+ def whodunnit(value)
404
+ raise ArgumentError, "expected to receive a block" unless block_given?
405
+ current_whodunnit = PaperTrail.whodunnit
406
+ PaperTrail.whodunnit = value
407
+ yield @record
408
+ ensure
409
+ PaperTrail.whodunnit = current_whodunnit
410
+ end
411
+
412
+ private
413
+
414
+ def add_transaction_id_to(data)
415
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
416
+ data[:transaction_id] = PaperTrail.transaction_id
417
+ end
418
+
419
+ def log_version_errors(version, action)
420
+ version.logger.warn(
421
+ "Unable to create version for #{action} of #{@record.class.name}##{id}: " +
422
+ version.errors.full_messages.join(", ")
423
+ )
424
+ end
425
+
426
+ # Returns true if `save` will cause `record_update`
427
+ # to be called via the `after_update` callback.
428
+ def will_record_after_update?
429
+ on = @record.paper_trail_options[:on]
430
+ on.nil? || on.include?(:update)
431
+ end
432
+
433
+ def update_transaction_id(version)
434
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
435
+ if PaperTrail.transaction? && PaperTrail.transaction_id.nil?
436
+ PaperTrail.transaction_id = version.id
437
+ version.transaction_id = version.id
438
+ version.save
439
+ end
440
+ end
441
+
442
+ def version
443
+ @record.public_send(@record.class.version_association_name)
444
+ end
445
+
446
+ def versions
447
+ @record.public_send(@record.class.versions_association_name)
448
+ end
449
+ end
450
+ end