paper_trail 4.0.2 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -0
  3. data/CHANGELOG.md +27 -0
  4. data/CONTRIBUTING.md +78 -5
  5. data/README.md +328 -268
  6. data/doc/bug_report_template.rb +65 -0
  7. data/gemfiles/3.0.gemfile +7 -4
  8. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +1 -1
  9. data/lib/generators/paper_trail/templates/create_versions.rb +14 -0
  10. data/lib/paper_trail.rb +11 -9
  11. data/lib/paper_trail/attributes_serialization.rb +89 -0
  12. data/lib/paper_trail/cleaner.rb +8 -1
  13. data/lib/paper_trail/config.rb +15 -18
  14. data/lib/paper_trail/frameworks/rails/controller.rb +16 -2
  15. data/lib/paper_trail/has_paper_trail.rb +102 -99
  16. data/lib/paper_trail/record_history.rb +59 -0
  17. data/lib/paper_trail/reifier.rb +270 -0
  18. data/lib/paper_trail/version_association_concern.rb +3 -1
  19. data/lib/paper_trail/version_concern.rb +60 -226
  20. data/lib/paper_trail/version_number.rb +2 -2
  21. data/paper_trail.gemspec +7 -10
  22. data/spec/models/animal_spec.rb +17 -0
  23. data/spec/models/callback_modifier_spec.rb +96 -0
  24. data/spec/models/json_version_spec.rb +20 -17
  25. data/spec/paper_trail/config_spec.rb +52 -0
  26. data/spec/spec_helper.rb +6 -0
  27. data/test/dummy/app/models/callback_modifier.rb +45 -0
  28. data/test/dummy/app/models/chapter.rb +9 -0
  29. data/test/dummy/app/models/citation.rb +5 -0
  30. data/test/dummy/app/models/paragraph.rb +5 -0
  31. data/test/dummy/app/models/quotation.rb +5 -0
  32. data/test/dummy/app/models/section.rb +6 -0
  33. data/test/dummy/config/database.postgres.yml +1 -1
  34. data/test/dummy/config/initializers/paper_trail.rb +3 -1
  35. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +33 -0
  36. data/test/dummy/db/schema.rb +27 -0
  37. data/test/test_helper.rb +36 -0
  38. data/test/unit/associations_test.rb +726 -0
  39. data/test/unit/inheritance_column_test.rb +6 -6
  40. data/test/unit/model_test.rb +62 -594
  41. data/test/unit/protected_attrs_test.rb +3 -2
  42. data/test/unit/version_test.rb +87 -69
  43. 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
- attr_accessible :version_id, :foreign_key_name, :foreign_key_id if PaperTrail.active_record_protected_attributes?
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 :item_type, :item_id, :event, :whodunnit, :object, :object_changes, :transaction_id, :created_at
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
- # Expects `obj` to be an instance of `PaperTrail::Version` by default,
51
- # but can accept a timestamp if `timestamp_arg` receives `true`
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
- options.reverse_merge!(
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
- @sibling_versions = nil if reload == true
296
- @sibling_versions ||= self.class.with_item_keys(item_type, item_id)
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
- table = self.class.arel_table unless @index
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
- return unless PaperTrail.config.version_limit.is_a? Numeric
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 > PaperTrail.config.version_limit
463
- excess_previous_versions = previous_versions - previous_versions.last(PaperTrail.config.version_limit)
464
- excess_previous_versions.map(&:destroy)
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