paper_trail 4.0.2 → 4.1.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/.travis.yml +2 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +78 -5
- data/README.md +328 -268
- data/doc/bug_report_template.rb +65 -0
- data/gemfiles/3.0.gemfile +7 -4
- data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +1 -1
- data/lib/generators/paper_trail/templates/create_versions.rb +14 -0
- data/lib/paper_trail.rb +11 -9
- data/lib/paper_trail/attributes_serialization.rb +89 -0
- data/lib/paper_trail/cleaner.rb +8 -1
- data/lib/paper_trail/config.rb +15 -18
- data/lib/paper_trail/frameworks/rails/controller.rb +16 -2
- data/lib/paper_trail/has_paper_trail.rb +102 -99
- data/lib/paper_trail/record_history.rb +59 -0
- data/lib/paper_trail/reifier.rb +270 -0
- data/lib/paper_trail/version_association_concern.rb +3 -1
- data/lib/paper_trail/version_concern.rb +60 -226
- data/lib/paper_trail/version_number.rb +2 -2
- data/paper_trail.gemspec +7 -10
- data/spec/models/animal_spec.rb +17 -0
- data/spec/models/callback_modifier_spec.rb +96 -0
- data/spec/models/json_version_spec.rb +20 -17
- data/spec/paper_trail/config_spec.rb +52 -0
- data/spec/spec_helper.rb +6 -0
- data/test/dummy/app/models/callback_modifier.rb +45 -0
- data/test/dummy/app/models/chapter.rb +9 -0
- data/test/dummy/app/models/citation.rb +5 -0
- data/test/dummy/app/models/paragraph.rb +5 -0
- data/test/dummy/app/models/quotation.rb +5 -0
- data/test/dummy/app/models/section.rb +6 -0
- data/test/dummy/config/database.postgres.yml +1 -1
- data/test/dummy/config/initializers/paper_trail.rb +3 -1
- data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +33 -0
- data/test/dummy/db/schema.rb +27 -0
- data/test/test_helper.rb +36 -0
- data/test/unit/associations_test.rb +726 -0
- data/test/unit/inheritance_column_test.rb +6 -6
- data/test/unit/model_test.rb +62 -594
- data/test/unit/protected_attrs_test.rb +3 -2
- data/test/unit/version_test.rb +87 -69
- metadata +38 -2
@@ -0,0 +1,59 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
|
3
|
+
# Represents the history of a single record.
|
4
|
+
# @api private
|
5
|
+
class RecordHistory
|
6
|
+
|
7
|
+
# @param versions - ActiveRecord::Relation - All versions of the record.
|
8
|
+
# @param version_class - Class - Usually PaperTrail::Version,
|
9
|
+
# but it could also be a custom version class.
|
10
|
+
# @api private
|
11
|
+
def initialize(versions, version_class)
|
12
|
+
@versions = versions
|
13
|
+
@version_class = version_class
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns ordinal position of `version` in `sequence`.
|
17
|
+
# @api private
|
18
|
+
def index(version)
|
19
|
+
sequence.index(version)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# Returns `@versions` in chronological order.
|
25
|
+
# @api private
|
26
|
+
def sequence
|
27
|
+
if @version_class.primary_key_is_int?
|
28
|
+
@versions.select(primary_key).order(primary_key.asc)
|
29
|
+
else
|
30
|
+
@versions.
|
31
|
+
select([timestamp, primary_key]).
|
32
|
+
order(@version_class.timestamp_sort_order)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return - Arel::Attribute - Attribute representing the primary key
|
37
|
+
# of the version table. The column's data type is usually a serial
|
38
|
+
# integer (the rails convention) but not always.
|
39
|
+
# @api private
|
40
|
+
def primary_key
|
41
|
+
table[@version_class.primary_key]
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return - Arel::Table - The version table, usually named `versions`, but
|
45
|
+
# not always.
|
46
|
+
# @api private
|
47
|
+
def table
|
48
|
+
@version_class.arel_table
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return - Arel::Attribute - Attribute representing the timestamp column
|
52
|
+
# of the version table, usually named `created_at` (the rails convention)
|
53
|
+
# but not always.
|
54
|
+
# @api private
|
55
|
+
def timestamp
|
56
|
+
table[PaperTrail.timestamp_field]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,270 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
|
3
|
+
# Given a version record and some options, builds a new model object.
|
4
|
+
# @api private
|
5
|
+
module Reifier
|
6
|
+
class << self
|
7
|
+
|
8
|
+
# See `VersionConcern#reify` for documentation.
|
9
|
+
# @api private
|
10
|
+
def reify(version, options)
|
11
|
+
options = options.dup
|
12
|
+
|
13
|
+
options.reverse_merge!(
|
14
|
+
:version_at => version.created_at,
|
15
|
+
:mark_for_destruction => false,
|
16
|
+
:has_one => false,
|
17
|
+
:has_many => false,
|
18
|
+
:unversioned_attributes => :nil
|
19
|
+
)
|
20
|
+
|
21
|
+
attrs = version.class.object_col_is_json? ?
|
22
|
+
version.object :
|
23
|
+
PaperTrail.serializer.load(version.object)
|
24
|
+
|
25
|
+
# Normally a polymorphic belongs_to relationship allows us to get the
|
26
|
+
# object we belong to by calling, in this case, `item`. However this
|
27
|
+
# returns nil if `item` has been destroyed, and we need to be able to
|
28
|
+
# retrieve destroyed objects.
|
29
|
+
#
|
30
|
+
# In this situation we constantize the `item_type` to get hold of the
|
31
|
+
# class...except when the stored object's attributes include a `type`
|
32
|
+
# key. If this is the case, the object we belong to is using single
|
33
|
+
# table inheritance and the `item_type` will be the base class, not the
|
34
|
+
# actual subclass. If `type` is present but empty, the class is the base
|
35
|
+
# class.
|
36
|
+
|
37
|
+
if options[:dup] != true && version.item
|
38
|
+
model = version.item
|
39
|
+
# Look for attributes that exist in the model and not in this
|
40
|
+
# version. These attributes should be set to nil.
|
41
|
+
if options[:unversioned_attributes] == :nil
|
42
|
+
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
43
|
+
end
|
44
|
+
else
|
45
|
+
inheritance_column_name = version.item_type.constantize.inheritance_column
|
46
|
+
class_name = attrs[inheritance_column_name].blank? ?
|
47
|
+
version.item_type :
|
48
|
+
attrs[inheritance_column_name]
|
49
|
+
klass = class_name.constantize
|
50
|
+
# The `dup` option always returns a new object, otherwise we should
|
51
|
+
# attempt to look for the item outside of default scope(s).
|
52
|
+
if options[:dup] || (_item = klass.unscoped.find_by_id(version.item_id)).nil?
|
53
|
+
model = klass.new
|
54
|
+
elsif options[:unversioned_attributes] == :nil
|
55
|
+
model = _item
|
56
|
+
# Look for attributes that exist in the model and not in this
|
57
|
+
# version. These attributes should be set to nil.
|
58
|
+
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
model.class.unserialize_attributes_for_paper_trail! attrs
|
63
|
+
|
64
|
+
# Set all the attributes in this version on the model.
|
65
|
+
attrs.each do |k, v|
|
66
|
+
if model.has_attribute?(k)
|
67
|
+
model[k.to_sym] = v
|
68
|
+
elsif model.respond_to?("#{k}=")
|
69
|
+
model.send("#{k}=", v)
|
70
|
+
else
|
71
|
+
version.logger.warn(
|
72
|
+
"Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
|
73
|
+
)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
model.send "#{model.class.version_association_name}=", version
|
78
|
+
|
79
|
+
unless options[:has_one] == false
|
80
|
+
reify_has_ones version.transaction_id, model, options
|
81
|
+
end
|
82
|
+
|
83
|
+
unless options[:has_many] == false
|
84
|
+
reify_has_manys version.transaction_id, model, options
|
85
|
+
end
|
86
|
+
|
87
|
+
model
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# Replaces each record in `array` with its reified version, if present
|
93
|
+
# in `versions`.
|
94
|
+
#
|
95
|
+
# @api private
|
96
|
+
# @param array - The collection to be modified.
|
97
|
+
# @param options
|
98
|
+
# @param versions - A `Hash` mapping IDs to `Version`s
|
99
|
+
# @return nil - Always returns `nil`
|
100
|
+
#
|
101
|
+
# Once modified by this method, `array` will be assigned to the
|
102
|
+
# AR association currently being reified.
|
103
|
+
#
|
104
|
+
def prepare_array_for_has_many(array, options, versions)
|
105
|
+
# Iterate each child to replace it with the previous value if there is
|
106
|
+
# a version after the timestamp.
|
107
|
+
array.map! do |record|
|
108
|
+
if (version = versions.delete(record.id)).nil?
|
109
|
+
record
|
110
|
+
elsif version.event == 'create'
|
111
|
+
options[:mark_for_destruction] ? record.tap { |r| r.mark_for_destruction } : nil
|
112
|
+
else
|
113
|
+
version.reify(options.merge(:has_many => false, :has_one => false))
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Reify the rest of the versions and add them to the collection, these
|
118
|
+
# versions are for those that have been removed from the live
|
119
|
+
# associations.
|
120
|
+
array.concat(
|
121
|
+
versions.values.map { |v|
|
122
|
+
v.reify(options.merge(:has_many => false, :has_one => false))
|
123
|
+
}
|
124
|
+
)
|
125
|
+
|
126
|
+
array.compact!
|
127
|
+
|
128
|
+
nil
|
129
|
+
end
|
130
|
+
|
131
|
+
# Restore the `model`'s has_one associations as they were when this
|
132
|
+
# version was superseded by the next (because that's what the user was
|
133
|
+
# looking at when they made the change).
|
134
|
+
def reify_has_ones(transaction_id, model, options = {})
|
135
|
+
version_table_name = model.class.paper_trail_version_class.table_name
|
136
|
+
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
137
|
+
if assoc.klass.paper_trail_enabled_for_model?
|
138
|
+
version = model.class.paper_trail_version_class.joins(:version_associations).
|
139
|
+
where("version_associations.foreign_key_name = ?", assoc.foreign_key).
|
140
|
+
where("version_associations.foreign_key_id = ?", model.id).
|
141
|
+
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
142
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
143
|
+
order("#{version_table_name}.id ASC").
|
144
|
+
first
|
145
|
+
if version
|
146
|
+
if version.event == 'create'
|
147
|
+
if options[:mark_for_destruction]
|
148
|
+
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
|
149
|
+
else
|
150
|
+
model.appear_as_new_record do
|
151
|
+
model.send "#{assoc.name}=", nil
|
152
|
+
end
|
153
|
+
end
|
154
|
+
else
|
155
|
+
child = version.reify(options.merge(:has_many => false, :has_one => false))
|
156
|
+
model.appear_as_new_record do
|
157
|
+
model.send "#{assoc.name}=", child
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# Restore the `model`'s has_many associations as they were at version_at
|
166
|
+
# timestamp We lookup the first child versions after version_at timestamp or
|
167
|
+
# in same transaction.
|
168
|
+
def reify_has_manys(transaction_id, model, options = {})
|
169
|
+
assoc_has_many_through, assoc_has_many_directly =
|
170
|
+
model.class.reflect_on_all_associations(:has_many).
|
171
|
+
partition { |assoc| assoc.options[:through] }
|
172
|
+
reify_has_many_directly(transaction_id, assoc_has_many_directly, model, options)
|
173
|
+
reify_has_many_through(transaction_id, assoc_has_many_through, model, options)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Restore the `model`'s has_many associations not associated through
|
177
|
+
# another association.
|
178
|
+
def reify_has_many_directly(transaction_id, associations, model, options = {})
|
179
|
+
version_table_name = model.class.paper_trail_version_class.table_name
|
180
|
+
associations.each do |assoc|
|
181
|
+
next unless assoc.klass.paper_trail_enabled_for_model?
|
182
|
+
version_id_subquery = PaperTrail::VersionAssociation.
|
183
|
+
joins(model.class.version_association_name).
|
184
|
+
select("MIN(version_id)").
|
185
|
+
where("foreign_key_name = ?", assoc.foreign_key).
|
186
|
+
where("foreign_key_id = ?", model.id).
|
187
|
+
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
188
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
189
|
+
group("item_id").
|
190
|
+
to_sql
|
191
|
+
versions = versions_by_id(model.class, version_id_subquery)
|
192
|
+
collection = Array.new model.send(assoc.name, true) # pass true to avoid cache
|
193
|
+
prepare_array_for_has_many(collection, options, versions)
|
194
|
+
model.send(assoc.name).proxy_association.target = collection
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Restore the `model`'s has_many associations through another association.
|
199
|
+
# This must be called after the direct has_manys have been reified
|
200
|
+
# (reify_has_many_directly).
|
201
|
+
def reify_has_many_through(transaction_id, associations, model, options = {})
|
202
|
+
associations.each do |assoc|
|
203
|
+
next unless assoc.klass.paper_trail_enabled_for_model?
|
204
|
+
|
205
|
+
# Load the collection of through-models. For example, if `model` is a
|
206
|
+
# Chapter, having many Paragraphs through Sections, then
|
207
|
+
# `through_collection` will contain Sections.
|
208
|
+
through_collection = model.send(assoc.options[:through])
|
209
|
+
|
210
|
+
# Examine the `source_reflection`, i.e. the "source" of `assoc` the
|
211
|
+
# `ThroughReflection`. The source can be a `BelongsToReflection`
|
212
|
+
# or a `HasManyReflection`.
|
213
|
+
#
|
214
|
+
# If the association is a has_many association again, then call
|
215
|
+
# reify_has_manys for each record in `through_collection`.
|
216
|
+
if !assoc.source_reflection.belongs_to? && through_collection.present?
|
217
|
+
through_collection.each do |through_model|
|
218
|
+
reify_has_manys(transaction_id, through_model, options)
|
219
|
+
end
|
220
|
+
|
221
|
+
# At this point, the "through" part of the association chain has
|
222
|
+
# been reified, but not the final, "target" part. To continue our
|
223
|
+
# example, `model.sections` (including `model.sections.paragraphs`)
|
224
|
+
# has been loaded. However, the final "target" part of the
|
225
|
+
# association, that is, `model.paragraphs`, has not been loaded. So,
|
226
|
+
# we do that now.
|
227
|
+
collection = through_collection.map { |through_model|
|
228
|
+
through_model.send(assoc.name.to_sym).to_a
|
229
|
+
}.flatten
|
230
|
+
else
|
231
|
+
collection_keys = through_collection.map { |through_model|
|
232
|
+
through_model.send(assoc.association_foreign_key)
|
233
|
+
}
|
234
|
+
|
235
|
+
version_id_subquery = assoc.klass.paper_trail_version_class.
|
236
|
+
select("MIN(id)").
|
237
|
+
where("item_type = ?", assoc.class_name).
|
238
|
+
where("item_id IN (?)", collection_keys).
|
239
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
240
|
+
group("item_id").
|
241
|
+
to_sql
|
242
|
+
versions = versions_by_id(assoc.klass, version_id_subquery)
|
243
|
+
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
|
244
|
+
prepare_array_for_has_many(collection, options, versions)
|
245
|
+
end
|
246
|
+
|
247
|
+
# To continue our example above, assign to `model.paragraphs` the
|
248
|
+
# `collection` (an array of `Paragraph`s).
|
249
|
+
model.send(assoc.name).proxy_association.target = collection
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Given a SQL fragment that identifies the IDs of version records,
|
254
|
+
# returns a `Hash` mapping those IDs to `Version`s.
|
255
|
+
#
|
256
|
+
# @api private
|
257
|
+
# @param klass - An ActiveRecord class.
|
258
|
+
# @param version_id_subquery - String. A SQL subquery that selects
|
259
|
+
# the IDs of version records.
|
260
|
+
# @return A `Hash` mapping IDs to `Version`s
|
261
|
+
#
|
262
|
+
def versions_by_id(klass, version_id_subquery)
|
263
|
+
klass.
|
264
|
+
paper_trail_version_class.
|
265
|
+
where("id IN (#{version_id_subquery})").
|
266
|
+
inject({}) { |acc, v| acc.merge!(v.item_id => v) }
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
@@ -7,7 +7,9 @@ module PaperTrail
|
|
7
7
|
included do
|
8
8
|
belongs_to :version
|
9
9
|
|
10
|
-
|
10
|
+
if PaperTrail.active_record_protected_attributes?
|
11
|
+
attr_accessible :version_id, :foreign_key_name, :foreign_key_id
|
12
|
+
end
|
11
13
|
end
|
12
14
|
end
|
13
15
|
end
|
@@ -18,7 +18,16 @@ module PaperTrail
|
|
18
18
|
validates_presence_of :event
|
19
19
|
|
20
20
|
if PaperTrail.active_record_protected_attributes?
|
21
|
-
attr_accessible
|
21
|
+
attr_accessible(
|
22
|
+
:item_type,
|
23
|
+
:item_id,
|
24
|
+
:event,
|
25
|
+
:whodunnit,
|
26
|
+
:object,
|
27
|
+
:object_changes,
|
28
|
+
:transaction_id,
|
29
|
+
:created_at
|
30
|
+
)
|
22
31
|
end
|
23
32
|
|
24
33
|
after_create :enforce_version_limit!
|
@@ -47,8 +56,13 @@ module PaperTrail
|
|
47
56
|
where 'event <> ?', 'create'
|
48
57
|
end
|
49
58
|
|
50
|
-
#
|
51
|
-
#
|
59
|
+
# Returns versions after `obj`.
|
60
|
+
#
|
61
|
+
# @param obj - a `Version` or a timestamp
|
62
|
+
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
63
|
+
# Default: false.
|
64
|
+
# @return `ActiveRecord::Relation`
|
65
|
+
# @api public
|
52
66
|
def subsequent(obj, timestamp_arg = false)
|
53
67
|
if timestamp_arg != true && self.primary_key_is_int?
|
54
68
|
return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
|
@@ -58,6 +72,13 @@ module PaperTrail
|
|
58
72
|
where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(self.timestamp_sort_order)
|
59
73
|
end
|
60
74
|
|
75
|
+
# Returns versions before `obj`.
|
76
|
+
#
|
77
|
+
# @param obj - a `Version` or a timestamp
|
78
|
+
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
79
|
+
# Default: false.
|
80
|
+
# @return `ActiveRecord::Relation`
|
81
|
+
# @api public
|
61
82
|
def preceding(obj, timestamp_arg = false)
|
62
83
|
if timestamp_arg != true && self.primary_key_is_int?
|
63
84
|
return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
|
@@ -67,7 +88,6 @@ module PaperTrail
|
|
67
88
|
where(arel_table[PaperTrail.timestamp_field].lt(obj)).order(self.timestamp_sort_order('desc'))
|
68
89
|
end
|
69
90
|
|
70
|
-
|
71
91
|
def between(start_time, end_time)
|
72
92
|
where(
|
73
93
|
arel_table[PaperTrail.timestamp_field].gt(start_time).
|
@@ -182,79 +202,8 @@ module PaperTrail
|
|
182
202
|
#
|
183
203
|
def reify(options = {})
|
184
204
|
return nil if object.nil?
|
185
|
-
|
186
205
|
without_identity_map do
|
187
|
-
|
188
|
-
:version_at => created_at,
|
189
|
-
:mark_for_destruction => false,
|
190
|
-
:has_one => false,
|
191
|
-
:has_many => false,
|
192
|
-
:unversioned_attributes => :nil
|
193
|
-
)
|
194
|
-
|
195
|
-
attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
|
196
|
-
|
197
|
-
# Normally a polymorphic belongs_to relationship allows us to get the
|
198
|
-
# object we belong to by calling, in this case, `item`. However this
|
199
|
-
# returns nil if `item` has been destroyed, and we need to be able to
|
200
|
-
# retrieve destroyed objects.
|
201
|
-
#
|
202
|
-
# In this situation we constantize the `item_type` to get hold of the
|
203
|
-
# class...except when the stored object's attributes include a `type`
|
204
|
-
# key. If this is the case, the object we belong to is using single
|
205
|
-
# table inheritance and the `item_type` will be the base class, not the
|
206
|
-
# actual subclass. If `type` is present but empty, the class is the base
|
207
|
-
# class.
|
208
|
-
|
209
|
-
if options[:dup] != true && item
|
210
|
-
model = item
|
211
|
-
# Look for attributes that exist in the model and not in this
|
212
|
-
# version. These attributes should be set to nil.
|
213
|
-
if options[:unversioned_attributes] == :nil
|
214
|
-
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
215
|
-
end
|
216
|
-
else
|
217
|
-
inheritance_column_name = item_type.constantize.inheritance_column
|
218
|
-
class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name]
|
219
|
-
klass = class_name.constantize
|
220
|
-
# The `dup` option always returns a new object, otherwise we should
|
221
|
-
# attempt to look for the item outside of default scope(s).
|
222
|
-
if options[:dup] || (_item = klass.unscoped.find_by_id(item_id)).nil?
|
223
|
-
model = klass.new
|
224
|
-
elsif options[:unversioned_attributes] == :nil
|
225
|
-
model = _item
|
226
|
-
# Look for attributes that exist in the model and not in this
|
227
|
-
# version. These attributes should be set to nil.
|
228
|
-
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
229
|
-
end
|
230
|
-
end
|
231
|
-
|
232
|
-
if PaperTrail.serialized_attributes?
|
233
|
-
model.class.unserialize_attributes_for_paper_trail! attrs
|
234
|
-
end
|
235
|
-
|
236
|
-
# Set all the attributes in this version on the model.
|
237
|
-
attrs.each do |k, v|
|
238
|
-
if model.has_attribute?(k)
|
239
|
-
model[k.to_sym] = v
|
240
|
-
elsif model.respond_to?("#{k}=")
|
241
|
-
model.send("#{k}=", v)
|
242
|
-
else
|
243
|
-
logger.warn "Attribute #{k} does not exist on #{item_type} (Version id: #{id})."
|
244
|
-
end
|
245
|
-
end
|
246
|
-
|
247
|
-
model.send "#{model.class.version_association_name}=", self
|
248
|
-
|
249
|
-
unless options[:has_one] == false
|
250
|
-
reify_has_ones model, options
|
251
|
-
end
|
252
|
-
|
253
|
-
unless options[:has_many] == false
|
254
|
-
reify_has_manys model, options
|
255
|
-
end
|
256
|
-
|
257
|
-
model
|
206
|
+
::PaperTrail::Reifier.reify(self, options)
|
258
207
|
end
|
259
208
|
end
|
260
209
|
|
@@ -263,15 +212,7 @@ module PaperTrail
|
|
263
212
|
# not have an `object_changes` text column.
|
264
213
|
def changeset
|
265
214
|
return nil unless self.class.column_names.include? 'object_changes'
|
266
|
-
|
267
|
-
_changes = self.class.object_changes_col_is_json? ? object_changes : PaperTrail.serializer.load(object_changes)
|
268
|
-
@changeset ||= HashWithIndifferentAccess.new(_changes).tap do |changes|
|
269
|
-
if PaperTrail.serialized_attributes?
|
270
|
-
item_type.constantize.unserialize_attribute_changes_for_paper_trail!(changes)
|
271
|
-
end
|
272
|
-
end
|
273
|
-
rescue
|
274
|
-
{}
|
215
|
+
@changeset ||= load_changeset
|
275
216
|
end
|
276
217
|
|
277
218
|
# Returns who put the item into the state stored in this version.
|
@@ -292,8 +233,10 @@ module PaperTrail
|
|
292
233
|
alias_method :version_author, :terminator
|
293
234
|
|
294
235
|
def sibling_versions(reload = false)
|
295
|
-
|
296
|
-
|
236
|
+
if reload || @sibling_versions.nil?
|
237
|
+
@sibling_versions = self.class.with_item_keys(item_type, item_id)
|
238
|
+
end
|
239
|
+
@sibling_versions
|
297
240
|
end
|
298
241
|
|
299
242
|
def next
|
@@ -304,21 +247,37 @@ module PaperTrail
|
|
304
247
|
@previous ||= sibling_versions.preceding(self).first
|
305
248
|
end
|
306
249
|
|
250
|
+
# Returns an integer representing the chronological position of the
|
251
|
+
# version among its siblings (see `sibling_versions`). The "create" event,
|
252
|
+
# for example, has an index of 0.
|
253
|
+
# @api public
|
307
254
|
def index
|
308
|
-
|
309
|
-
@index ||=
|
310
|
-
if self.class.primary_key_is_int?
|
311
|
-
sibling_versions.select(table[self.class.primary_key]).order(table[self.class.primary_key].asc).index(self)
|
312
|
-
else
|
313
|
-
sibling_versions.select([table[PaperTrail.timestamp_field], table[self.class.primary_key]]).
|
314
|
-
order(self.class.timestamp_sort_order).index(self)
|
315
|
-
end
|
255
|
+
@index ||= RecordHistory.new(sibling_versions, self.class).index(self)
|
316
256
|
end
|
317
257
|
|
318
258
|
private
|
319
259
|
|
260
|
+
# @api private
|
261
|
+
def load_changeset
|
262
|
+
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
|
263
|
+
item_type.constantize.unserialize_attribute_changes_for_paper_trail!(changes)
|
264
|
+
changes
|
265
|
+
rescue # TODO: Rescue something specific
|
266
|
+
{}
|
267
|
+
end
|
268
|
+
|
269
|
+
# @api private
|
270
|
+
def object_changes_deserialized
|
271
|
+
if self.class.object_changes_col_is_json?
|
272
|
+
object_changes
|
273
|
+
else
|
274
|
+
PaperTrail.serializer.load(object_changes)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
320
278
|
# In Rails 3.1+, calling reify on a previous version confuses the
|
321
279
|
# IdentityMap, if enabled. This prevents insertion into the map.
|
280
|
+
# @api private
|
322
281
|
def without_identity_map(&block)
|
323
282
|
if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
|
324
283
|
::ActiveRecord::IdentityMap.without(&block)
|
@@ -327,141 +286,16 @@ module PaperTrail
|
|
327
286
|
end
|
328
287
|
end
|
329
288
|
|
330
|
-
# Restore the `model`'s has_one associations as they were when this
|
331
|
-
# version was superseded by the next (because that's what the user was
|
332
|
-
# looking at when they made the change).
|
333
|
-
def reify_has_ones(model, options = {})
|
334
|
-
version_table_name = model.class.paper_trail_version_class.table_name
|
335
|
-
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
336
|
-
if assoc.klass.paper_trail_enabled_for_model?
|
337
|
-
version = model.class.paper_trail_version_class.joins(:version_associations).
|
338
|
-
where("version_associations.foreign_key_name = ?", assoc.foreign_key).
|
339
|
-
where("version_associations.foreign_key_id = ?", model.id).
|
340
|
-
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
341
|
-
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
342
|
-
order("#{version_table_name}.id ASC").first
|
343
|
-
if version
|
344
|
-
if version.event == 'create'
|
345
|
-
if options[:mark_for_destruction]
|
346
|
-
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
|
347
|
-
else
|
348
|
-
model.appear_as_new_record do
|
349
|
-
model.send "#{assoc.name}=", nil
|
350
|
-
end
|
351
|
-
end
|
352
|
-
else
|
353
|
-
child = version.reify(options.merge(:has_many => false, :has_one => false))
|
354
|
-
model.appear_as_new_record do
|
355
|
-
model.send "#{assoc.name}=", child
|
356
|
-
end
|
357
|
-
end
|
358
|
-
end
|
359
|
-
end
|
360
|
-
end
|
361
|
-
end
|
362
|
-
|
363
|
-
# Restore the `model`'s has_many associations as they were at version_at
|
364
|
-
# timestamp We lookup the first child versions after version_at timestamp or
|
365
|
-
# in same transaction.
|
366
|
-
def reify_has_manys(model, options = {})
|
367
|
-
assoc_has_many_through, assoc_has_many_directly =
|
368
|
-
model.class.reflect_on_all_associations(:has_many).
|
369
|
-
partition { |assoc| assoc.options[:through] }
|
370
|
-
reify_has_many_directly(assoc_has_many_directly, model, options)
|
371
|
-
reify_has_many_through(assoc_has_many_through, model, options)
|
372
|
-
end
|
373
|
-
|
374
|
-
# Restore the `model`'s has_many associations not associated through
|
375
|
-
# another association.
|
376
|
-
def reify_has_many_directly(associations, model, options = {})
|
377
|
-
version_table_name = model.class.paper_trail_version_class.table_name
|
378
|
-
associations.each do |assoc|
|
379
|
-
next unless assoc.klass.paper_trail_enabled_for_model?
|
380
|
-
version_id_subquery = PaperTrail::VersionAssociation.joins(model.class.version_association_name).
|
381
|
-
select("MIN(version_id)").
|
382
|
-
where("foreign_key_name = ?", assoc.foreign_key).
|
383
|
-
where("foreign_key_id = ?", model.id).
|
384
|
-
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
385
|
-
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
386
|
-
group("item_id").to_sql
|
387
|
-
versions = model.class.paper_trail_version_class.where("id IN (#{version_id_subquery})").inject({}) do |acc, v|
|
388
|
-
acc.merge!(v.item_id => v)
|
389
|
-
end
|
390
|
-
|
391
|
-
# Pass true to force the model to load.
|
392
|
-
collection = Array.new model.send(assoc.name, true)
|
393
|
-
|
394
|
-
# Iterate each child to replace it with the previous value if there is
|
395
|
-
# a version after the timestamp.
|
396
|
-
collection.map! do |c|
|
397
|
-
if (version = versions.delete(c.id)).nil?
|
398
|
-
c
|
399
|
-
elsif version.event == 'create'
|
400
|
-
options[:mark_for_destruction] ? c.tap { |r| r.mark_for_destruction } : nil
|
401
|
-
else
|
402
|
-
version.reify(options.merge(:has_many => false, :has_one => false))
|
403
|
-
end
|
404
|
-
end
|
405
|
-
|
406
|
-
# Reify the rest of the versions and add them to the collection, these
|
407
|
-
# versions are for those that have been removed from the live
|
408
|
-
# associations.
|
409
|
-
collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) }
|
410
|
-
|
411
|
-
model.send(assoc.name).proxy_association.target = collection.compact
|
412
|
-
end
|
413
|
-
end
|
414
|
-
|
415
|
-
# Restore the `model`'s has_many associations through another association.
|
416
|
-
# This must be called after the direct has_manys have been reified
|
417
|
-
# (reify_has_many_directly).
|
418
|
-
def reify_has_many_through(associations, model, options = {})
|
419
|
-
associations.each do |assoc|
|
420
|
-
next unless assoc.klass.paper_trail_enabled_for_model?
|
421
|
-
through_collection = model.send(assoc.options[:through])
|
422
|
-
collection_keys = through_collection.map { |through_model| through_model.send(assoc.foreign_key) }
|
423
|
-
|
424
|
-
version_id_subquery = assoc.klass.paper_trail_version_class.
|
425
|
-
select("MIN(id)").
|
426
|
-
where("item_type = ?", assoc.class_name).
|
427
|
-
where("item_id IN (?)", collection_keys).
|
428
|
-
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
429
|
-
group("item_id").to_sql
|
430
|
-
versions = assoc.klass.paper_trail_version_class.where("id IN (#{version_id_subquery})").inject({}) do |acc, v|
|
431
|
-
acc.merge!(v.item_id => v)
|
432
|
-
end
|
433
|
-
|
434
|
-
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
|
435
|
-
|
436
|
-
# Iterate each child to replace it with the previous value if there is
|
437
|
-
# a version after the timestamp.
|
438
|
-
collection.map! do |c|
|
439
|
-
if (version = versions.delete(c.id)).nil?
|
440
|
-
c
|
441
|
-
elsif version.event == 'create'
|
442
|
-
options[:mark_for_destruction] ? c.tap { |r| r.mark_for_destruction } : nil
|
443
|
-
else
|
444
|
-
version.reify(options.merge(:has_many => false, :has_one => false))
|
445
|
-
end
|
446
|
-
end
|
447
|
-
|
448
|
-
# Reify the rest of the versions and add them to the collection, these
|
449
|
-
# versions are for those that have been removed from the live
|
450
|
-
# associations.
|
451
|
-
collection += versions.values.map { |version| version.reify(options.merge(:has_many => false, :has_one => false)) }
|
452
|
-
|
453
|
-
model.send(assoc.name).proxy_association.target = collection.compact
|
454
|
-
end
|
455
|
-
end
|
456
|
-
|
457
289
|
# Checks that a value has been set for the `version_limit` config
|
458
290
|
# option, and if so enforces it.
|
291
|
+
# @api private
|
459
292
|
def enforce_version_limit!
|
460
|
-
|
293
|
+
limit = PaperTrail.config.version_limit
|
294
|
+
return unless limit.is_a? Numeric
|
461
295
|
previous_versions = sibling_versions.not_creates
|
462
|
-
return unless previous_versions.size >
|
463
|
-
|
464
|
-
|
296
|
+
return unless previous_versions.size > limit
|
297
|
+
excess_versions = previous_versions - previous_versions.last(limit)
|
298
|
+
excess_versions.map(&:destroy)
|
465
299
|
end
|
466
300
|
end
|
467
301
|
end
|