paper_trail 5.1.1 → 5.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.
- checksums.yaml +4 -4
- data/.github/CONTRIBUTING.md +9 -7
- data/.rubocop.yml +0 -4
- data/.rubocop_todo.yml +6 -1
- data/CHANGELOG.md +41 -1
- data/README.md +45 -43
- data/lib/paper_trail.rb +5 -0
- data/lib/paper_trail/attribute_serializers/object_attribute.rb +1 -1
- data/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +1 -1
- data/lib/paper_trail/frameworks/rails/controller.rb +7 -2
- data/lib/paper_trail/has_paper_trail.rb +196 -495
- data/lib/paper_trail/model_config.rb +195 -0
- data/lib/paper_trail/record_trail.rb +450 -0
- data/lib/paper_trail/reifier.rb +11 -11
- data/lib/paper_trail/version_number.rb +2 -2
- data/spec/models/boolit_spec.rb +2 -2
- data/spec/models/fluxor_spec.rb +4 -6
- data/spec/models/gadget_spec.rb +5 -7
- data/spec/models/not_on_update_spec.rb +2 -2
- data/spec/models/post_with_status_spec.rb +1 -1
- data/spec/models/widget_spec.rb +36 -66
- data/test/dummy/app/models/callback_modifier.rb +5 -5
- data/test/dummy/app/models/elephant.rb +1 -1
- data/test/functional/thread_safety_test.rb +4 -4
- data/test/unit/cleaner_test.rb +1 -1
- data/test/unit/model_test.rb +58 -48
- data/test/unit/protected_attrs_test.rb +4 -3
- data/test/unit/serializer_test.rb +4 -3
- metadata +4 -2
@@ -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
|