Class: PaperTrail::RecordTrail

Inherits:
Object
  • Object
show all
Defined in:
lib/paper_trail/record_trail.rb

Overview

Represents the “paper trail” for a single record.

Instance Method Summary collapse

Constructor Details

#initialize(record) ⇒ RecordTrail

Returns a new instance of RecordTrail.



4
5
6
# File 'lib/paper_trail/record_trail.rb', line 4

def initialize(record)
  @record = record
end

Instance Method Details

#appear_as_new_recordObject

Utility method for reifying. Anything executed inside the block will appear like a new record.



10
11
12
13
14
15
16
17
# File 'lib/paper_trail/record_trail.rb', line 10

def appear_as_new_record
  @record.instance_eval {
    alias :old_new_record? :new_record?
    alias :new_record? :present?
  }
  yield
  @record.instance_eval { alias :new_record? :old_new_record? }
end

#attributes_before_changeObject



19
20
21
22
23
24
# File 'lib/paper_trail/record_trail.rb', line 19

def attributes_before_change
  changed = @record.changed_attributes.select { |k, _v|
    @record.class.column_names.include?(k)
  }
  @record.attributes.merge(changed)
end

#changed_and_not_ignoredObject



26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/paper_trail/record_trail.rb', line 26

def changed_and_not_ignored
  ignore = @record.paper_trail_options[:ignore].dup
  # Remove Hash arguments and then evaluate whether the attributes (the
  # keys of the hash) should also get pushed into the collection.
  ignore.delete_if do |obj|
    obj.is_a?(Hash) &&
      obj.each { |attr, condition|
        ignore << attr if condition.respond_to?(:call) && condition.call(@record)
      }
  end
  skip = @record.paper_trail_options[:skip]
  @record.changed - ignore - skip
end

#changed_notably?Boolean

Determines whether it is appropriate to generate a new version instance. A timestamp-only update (e.g. only ‘updated_at` changed) is considered notable unless an ignored attribute was also changed.

Returns:

  • (Boolean)


57
58
59
60
61
62
63
64
# File 'lib/paper_trail/record_trail.rb', line 57

def changed_notably?
  if ignored_attr_has_changed?
    timestamps = @record.send(:timestamp_attributes_for_update_in_model).map(&:to_s)
    (notably_changed - timestamps).any?
  else
    notably_changed.any?
  end
end

#changesObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



67
68
69
70
71
72
73
74
75
# File 'lib/paper_trail/record_trail.rb', line 67

def changes
  notable_changes = @record.changes.delete_if { |k, _v|
    !notably_changed.include?(k)
  }
  AttributeSerializers::ObjectChangesAttribute.
    new(@record.class).
    serialize(notable_changes)
  notable_changes.to_hash
end

#clear_rolled_back_versionsObject

Invoked after rollbacks to ensure versions records are not created for changes that never actually took place. Optimization: Use lazy ‘reset` instead of eager `reload` because, in many use cases, the association will not be used.



44
45
46
# File 'lib/paper_trail/record_trail.rb', line 44

def clear_rolled_back_versions
  versions.reset
end

#clear_version_instanceObject

Invoked via`after_update` callback for when a previous version is reified and then saved.



50
51
52
# File 'lib/paper_trail/record_trail.rb', line 50

def clear_version_instance
  @record.send("#{@record.class.version_association_name}=", nil)
end

#enabled?Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/paper_trail/record_trail.rb', line 77

def enabled?
  PaperTrail.enabled? && PaperTrail.enabled_for_controller? && enabled_for_model?
end

#enabled_for_model?Boolean

Returns:

  • (Boolean)


81
82
83
# File 'lib/paper_trail/record_trail.rb', line 81

def enabled_for_model?
  @record.class.paper_trail.enabled?
end

#ignored_attr_has_changed?Boolean

An attributed is “ignored” if it is listed in the ‘:ignore` option and/or the `:skip` option. Returns true if an ignored attribute has changed.

Returns:

  • (Boolean)


88
89
90
91
# File 'lib/paper_trail/record_trail.rb', line 88

def ignored_attr_has_changed?
  ignored = @record.paper_trail_options[:ignore] + @record.paper_trail_options[:skip]
  ignored.any? && (@record.changed & ignored).any?
end

#live?Boolean

Returns true if this instance is the current, live one; returns false if this instance came from a previous version.

Returns:

  • (Boolean)


95
96
97
# File 'lib/paper_trail/record_trail.rb', line 95

def live?
  source_version.nil?
end

#merge_metadata(data) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/paper_trail/record_trail.rb', line 100

def (data)
  # First we merge the model-level metadata in `meta`.
  @record.paper_trail_options[:meta].each do |k, v|
    data[k] =
      if v.respond_to?(:call)
        v.call(@record)
      elsif v.is_a?(Symbol) && @record.respond_to?(v, true)
        # If it is an attribute that is changing in an existing object,
        # be sure to grab the current version.
        if @record.has_attribute?(v) &&
            @record.send("#{v}_changed?".to_sym) &&
            data[:event] != "create"
          @record.send("#{v}_was".to_sym)
        else
          @record.send(v)
        end
      else
        v
      end
  end

  # Second we merge any extra data from the controller (if available).
  data.merge(PaperTrail.controller_info || {})
end

#next_versionObject

Returns the object (not a Version) as it became next. NOTE: if self (the item) was not reified from a version, i.e. it is the

"live" item, we return nil.  Perhaps we should return self instead?


128
129
130
131
132
133
# File 'lib/paper_trail/record_trail.rb', line 128

def next_version
  subsequent_version = source_version.next
  subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
rescue # TODO: Rescue something more specific
  nil
end

#notably_changedObject



135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/paper_trail/record_trail.rb', line 135

def notably_changed
  only = @record.paper_trail_options[:only].dup
  # Remove Hash arguments and then evaluate whether the attributes (the
  # keys of the hash) should also get pushed into the collection.
  only.delete_if do |obj|
    obj.is_a?(Hash) &&
      obj.each { |attr, condition|
        only << attr if condition.respond_to?(:call) && condition.call(@record)
      }
  end
  only.empty? ? changed_and_not_ignored : (changed_and_not_ignored & only)
end

#object_attrs_for_paper_trailObject

Returns hash of attributes (with appropriate attributes serialized), omitting attributes to be skipped.



150
151
152
153
154
# File 'lib/paper_trail/record_trail.rb', line 150

def object_attrs_for_paper_trail
  attrs = attributes_before_change.except(*@record.paper_trail_options[:skip])
  AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs)
  attrs
end

#originatorObject

Returns who put ‘@record` into its current state.



157
158
159
# File 'lib/paper_trail/record_trail.rb', line 157

def originator
  (source_version || versions.last).try(:whodunnit)
end

#previous_versionObject

Returns the object (not a Version) as it was most recently.



162
163
164
# File 'lib/paper_trail/record_trail.rb', line 162

def previous_version
  (source_version ? source_version.previous : versions.last).try(:reify)
end

#record_createObject



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/paper_trail/record_trail.rb', line 166

def record_create
  return unless enabled?
  data = {
    event: @record.paper_trail_event || "create",
    whodunnit: PaperTrail.whodunnit
  }
  if @record.respond_to?(:updated_at)
    data[PaperTrail.timestamp_field] = @record.updated_at
  end
  if record_object_changes? && changed_notably?
    data[:object_changes] = recordable_object_changes
  end
  add_transaction_id_to(data)
  versions_assoc = @record.send(@record.class.versions_association_name)
  version = versions_assoc.create! (data)
  update_transaction_id(version)
  save_associations(version)
end

#record_destroyObject



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/paper_trail/record_trail.rb', line 185

def record_destroy
  if enabled? && !@record.new_record?
    data = {
      item_id: @record.id,
      item_type: @record.class.base_class.name,
      event: @record.paper_trail_event || "destroy",
      object: recordable_object,
      whodunnit: PaperTrail.whodunnit
    }
    add_transaction_id_to(data)
    version = @record.class.paper_trail.version_class.create((data))
    if version.errors.any?
      log_version_errors(version, :destroy)
    else
      @record.send("#{@record.class.version_association_name}=", version)
      @record.send(@record.class.versions_association_name).reset
      update_transaction_id(version)
      save_associations(version)
    end
  end
end

#record_object_changes?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a boolean indicating whether to store serialized version diffs in the ‘object_changes` column of the version record.

Returns:

  • (Boolean)


210
211
212
213
# File 'lib/paper_trail/record_trail.rb', line 210

def record_object_changes?
  @record.paper_trail_options[:save_changes] &&
    @record.class.paper_trail.version_class.column_names.include?("object_changes")
end

#record_update(force) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/paper_trail/record_trail.rb', line 215

def record_update(force)
  if enabled? && (force || changed_notably?)
    data = {
      event: @record.paper_trail_event || "update",
      object: recordable_object,
      whodunnit: PaperTrail.whodunnit
    }
    if @record.respond_to?(:updated_at)
      data[PaperTrail.timestamp_field] = @record.updated_at
    end
    if record_object_changes?
      data[:object_changes] = recordable_object_changes
    end
    add_transaction_id_to(data)
    versions_assoc = @record.send(@record.class.versions_association_name)
    version = versions_assoc.create((data))
    if version.errors.any?
      log_version_errors(version, :update)
    else
      update_transaction_id(version)
      save_associations(version)
    end
  end
end

#recordable_objectObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns an object which can be assigned to the ‘object` attribute of a nascent version record. If the `object` column is a postgres `json` column, then a hash can be used in the assignment, otherwise the column is a `text` column, and we must perform the serialization here, using `PaperTrail.serializer`.



246
247
248
249
250
251
252
# File 'lib/paper_trail/record_trail.rb', line 246

def recordable_object
  if @record.class.paper_trail.version_class.object_col_is_json?
    object_attrs_for_paper_trail
  else
    PaperTrail.serializer.dump(object_attrs_for_paper_trail)
  end
end

#recordable_object_changesObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns an object which can be assigned to the ‘object_changes` attribute of a nascent version record. If the `object_changes` column is a postgres `json` column, then a hash can be used in the assignment, otherwise the column is a `text` column, and we must perform the serialization here, using `PaperTrail.serializer`.



260
261
262
263
264
265
266
# File 'lib/paper_trail/record_trail.rb', line 260

def recordable_object_changes
  if @record.class.paper_trail.version_class.object_changes_col_is_json?
    changes
  else
    PaperTrail.serializer.dump(changes)
  end
end

#reset_timestamp_attrs_for_update_if_neededObject

Invoked via callback when a user attempts to persist a reified ‘Version`.



270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/paper_trail/record_trail.rb', line 270

def reset_timestamp_attrs_for_update_if_needed
  return if live?
  @record.send(:timestamp_attributes_for_update_in_model).each do |column|
    # ActiveRecord 4.2 deprecated `reset_column!` in favor of
    # `restore_column!`.
    if @record.respond_to?("restore_#{column}!")
      @record.send("restore_#{column}!")
    else
      @record.send("reset_#{column}!")
    end
  end
end

#save_associations(version) ⇒ Object

Saves associations if the join table for ‘VersionAssociation` exists.



284
285
286
287
288
# File 'lib/paper_trail/record_trail.rb', line 284

def save_associations(version)
  return unless PaperTrail.config.track_associations?
  save_associations_belongs_to(version)
  save_associations_habtm(version)
end

#save_associations_belongs_to(version) ⇒ Object



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/paper_trail/record_trail.rb', line 290

def save_associations_belongs_to(version)
  @record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
    assoc_version_args = {
      version_id: version.id,
      foreign_key_name: assoc.foreign_key
    }

    if assoc.options[:polymorphic]
      associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type)
      if associated_record && associated_record.class.paper_trail.enabled?
        assoc_version_args[:foreign_key_id] = associated_record.id
      end
    elsif assoc.klass.paper_trail.enabled?
      assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key)
    end

    if assoc_version_args.key?(:foreign_key_id)
      PaperTrail::VersionAssociation.create(assoc_version_args)
    end
  end
end

#save_associations_habtm(version) ⇒ Object



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/paper_trail/record_trail.rb', line 312

def save_associations_habtm(version)
  # Use the :added and :removed keys to extrapolate the HABTM associations
  # to before any changes were made
  @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
    next unless
      @record.class.paper_trail_save_join_tables.include?(a.name) ||
          a.klass.paper_trail.enabled?
    assoc_version_args = {
      version_id: version.transaction_id,
      foreign_key_name: a.name
    }
    assoc_ids =
      @record.send(a.name).to_a.map(&:id) +
      (@record.paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) -
      (@record.paper_trail_habtm.try(:[], a.name).try(:[], :added) || [])
    assoc_ids.each do |id|
      PaperTrail::VersionAssociation.create(assoc_version_args.merge(foreign_key_id: id))
    end
  end
end

#save_version?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

AR callback.

Returns:

  • (Boolean)


335
336
337
338
339
# File 'lib/paper_trail/record_trail.rb', line 335

def save_version?
  if_condition = @record.paper_trail_options[:if]
  unless_condition = @record.paper_trail_options[:unless]
  (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
end

#source_versionObject



341
342
343
# File 'lib/paper_trail/record_trail.rb', line 341

def source_version
  version
end

#touch_with_version(name = nil) ⇒ Object

Mimics the ‘touch` method from `ActiveRecord::Persistence`, but also creates a version. A version is created regardless of options such as `:on`, `:if`, or `:unless`.

TODO: look into leveraging the ‘after_touch` callback from `ActiveRecord` to allow the regular `touch` method to generate a version as normal. May make sense to switch the `record_update` method to leverage an `after_update` callback anyways (likely for v4.0.0)



353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/paper_trail/record_trail.rb', line 353

def touch_with_version(name = nil)
  unless @record.persisted?
    raise ActiveRecordError, "can not touch on a new record object"
  end
  attributes = @record.send :timestamp_attributes_for_update_in_model
  attributes << name if name
  current_time = @record.send :current_time_from_proper_timezone
  attributes.each { |column|
    @record.send(:write_attribute, column, current_time)
  }
  @record.record_update(true) unless will_record_after_update?
  @record.save!(validate: false)
end

#version_at(timestamp, reify_options = {}) ⇒ Object

Returns the object (not a Version) as it was at the given timestamp.



368
369
370
371
372
373
374
# File 'lib/paper_trail/record_trail.rb', line 368

def version_at(timestamp, reify_options = {})
  # Because a version stores how its object looked *before* the change,
  # we need to look for the first version created *after* the timestamp.
  v = versions.subsequent(timestamp, true).first
  return v.reify(reify_options) if v
  @record unless @record.destroyed?
end

#versions_between(start_time, end_time) ⇒ Object

Returns the objects (not Versions) as they were between the given times.



377
378
379
380
381
382
# File 'lib/paper_trail/record_trail.rb', line 377

def versions_between(start_time, end_time)
  versions = send(@record.class.versions_association_name).between(start_time, end_time)
  versions.collect { |version|
    version_at(version.send(PaperTrail.timestamp_field))
  }
end

#whodunnit(value) ⇒ Object

Temporarily overwrites the value of whodunnit and then executes the provided block.



403
404
405
406
407
408
409
410
# File 'lib/paper_trail/record_trail.rb', line 403

def whodunnit(value)
  raise ArgumentError, "expected to receive a block" unless block_given?
  current_whodunnit = PaperTrail.whodunnit
  PaperTrail.whodunnit = value
  yield @record
ensure
  PaperTrail.whodunnit = current_whodunnit
end

#without_versioning(method = nil) ⇒ Object

Executes the given method or block without creating a new version.



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/paper_trail/record_trail.rb', line 385

def without_versioning(method = nil)
  paper_trail_was_enabled = enabled_for_model?
  @record.class.paper_trail.disable
  if method
    if respond_to?(method)
      public_send(method)
    else
      @record.send(method)
    end
  else
    yield @record
  end
ensure
  @record.class.paper_trail.enable if paper_trail_was_enabled
end