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.
- 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
|