paper_trail 3.0.6 → 4.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rspec +1 -2
  4. data/.travis.yml +14 -5
  5. data/CHANGELOG.md +215 -8
  6. data/CONTRIBUTING.md +84 -0
  7. data/README.md +922 -502
  8. data/Rakefile +2 -2
  9. data/doc/bug_report_template.rb +65 -0
  10. data/gemfiles/ar3.gemfile +61 -0
  11. data/lib/generators/paper_trail/install_generator.rb +22 -3
  12. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +6 -1
  13. data/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb +11 -0
  14. data/lib/generators/paper_trail/templates/create_version_associations.rb +17 -0
  15. data/lib/generators/paper_trail/templates/create_versions.rb +22 -1
  16. data/lib/paper_trail.rb +52 -22
  17. data/lib/paper_trail/attributes_serialization.rb +89 -0
  18. data/lib/paper_trail/cleaner.rb +32 -15
  19. data/lib/paper_trail/config.rb +35 -2
  20. data/lib/paper_trail/frameworks/active_record.rb +4 -5
  21. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version_association.rb +7 -0
  22. data/lib/paper_trail/frameworks/rails.rb +1 -0
  23. data/lib/paper_trail/frameworks/rails/controller.rb +27 -11
  24. data/lib/paper_trail/frameworks/rspec.rb +5 -0
  25. data/lib/paper_trail/frameworks/sinatra.rb +3 -1
  26. data/lib/paper_trail/has_paper_trail.rb +304 -148
  27. data/lib/paper_trail/record_history.rb +59 -0
  28. data/lib/paper_trail/reifier.rb +270 -0
  29. data/lib/paper_trail/serializers/json.rb +13 -2
  30. data/lib/paper_trail/serializers/yaml.rb +16 -2
  31. data/lib/paper_trail/version_association_concern.rb +15 -0
  32. data/lib/paper_trail/version_concern.rb +160 -122
  33. data/lib/paper_trail/version_number.rb +3 -3
  34. data/paper_trail.gemspec +22 -9
  35. data/spec/generators/install_generator_spec.rb +4 -4
  36. data/spec/models/animal_spec.rb +36 -0
  37. data/spec/models/boolit_spec.rb +48 -0
  38. data/spec/models/callback_modifier_spec.rb +96 -0
  39. data/spec/models/fluxor_spec.rb +19 -0
  40. data/spec/models/gadget_spec.rb +14 -12
  41. data/spec/models/joined_version_spec.rb +9 -9
  42. data/spec/models/json_version_spec.rb +103 -0
  43. data/spec/models/kitchen/banana_spec.rb +14 -0
  44. data/spec/models/not_on_update_spec.rb +19 -0
  45. data/spec/models/post_with_status_spec.rb +3 -3
  46. data/spec/models/skipper_spec.rb +46 -0
  47. data/spec/models/thing_spec.rb +11 -0
  48. data/spec/models/version_spec.rb +195 -44
  49. data/spec/models/widget_spec.rb +136 -76
  50. data/spec/modules/paper_trail_spec.rb +27 -0
  51. data/spec/modules/version_concern_spec.rb +8 -8
  52. data/spec/modules/version_number_spec.rb +16 -16
  53. data/spec/paper_trail/config_spec.rb +52 -0
  54. data/spec/paper_trail_spec.rb +17 -17
  55. data/spec/rails_helper.rb +34 -0
  56. data/spec/requests/articles_spec.rb +10 -14
  57. data/spec/spec_helper.rb +81 -34
  58. data/spec/support/alt_db_init.rb +1 -1
  59. data/test/dummy/app/controllers/application_controller.rb +1 -1
  60. data/test/dummy/app/controllers/articles_controller.rb +4 -1
  61. data/test/dummy/app/models/animal.rb +2 -0
  62. data/test/dummy/app/models/book.rb +4 -0
  63. data/test/dummy/app/models/boolit.rb +4 -0
  64. data/test/dummy/app/models/callback_modifier.rb +45 -0
  65. data/test/dummy/app/models/chapter.rb +9 -0
  66. data/test/dummy/app/models/citation.rb +5 -0
  67. data/test/dummy/app/models/customer.rb +4 -0
  68. data/test/dummy/app/models/editor.rb +4 -0
  69. data/test/dummy/app/models/editorship.rb +5 -0
  70. data/test/dummy/app/models/fruit.rb +5 -0
  71. data/test/dummy/app/models/kitchen/banana.rb +5 -0
  72. data/test/dummy/app/models/line_item.rb +4 -0
  73. data/test/dummy/app/models/not_on_update.rb +4 -0
  74. data/test/dummy/app/models/order.rb +5 -0
  75. data/test/dummy/app/models/paragraph.rb +5 -0
  76. data/test/dummy/app/models/person.rb +13 -3
  77. data/test/dummy/app/models/post.rb +0 -1
  78. data/test/dummy/app/models/quotation.rb +5 -0
  79. data/test/dummy/app/models/section.rb +6 -0
  80. data/test/dummy/app/models/skipper.rb +6 -0
  81. data/test/dummy/app/models/song.rb +20 -0
  82. data/test/dummy/app/models/thing.rb +3 -0
  83. data/test/dummy/app/models/whatchamajigger.rb +4 -0
  84. data/test/dummy/app/models/widget.rb +5 -0
  85. data/test/dummy/app/versions/json_version.rb +3 -0
  86. data/test/dummy/app/versions/kitchen/banana_version.rb +5 -0
  87. data/test/dummy/config/application.rb +6 -0
  88. data/test/dummy/config/database.postgres.yml +1 -1
  89. data/test/dummy/config/environments/test.rb +5 -1
  90. data/test/dummy/config/initializers/paper_trail.rb +6 -1
  91. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +143 -3
  92. data/test/dummy/db/schema.rb +169 -25
  93. data/test/functional/controller_test.rb +4 -2
  94. data/test/functional/modular_sinatra_test.rb +1 -1
  95. data/test/functional/sinatra_test.rb +1 -1
  96. data/test/paper_trail_test.rb +7 -0
  97. data/test/test_helper.rb +38 -2
  98. data/test/time_travel_helper.rb +15 -0
  99. data/test/unit/associations_test.rb +726 -0
  100. data/test/unit/inheritance_column_test.rb +6 -6
  101. data/test/unit/model_test.rb +109 -125
  102. data/test/unit/protected_attrs_test.rb +4 -3
  103. data/test/unit/serializer_test.rb +6 -6
  104. data/test/unit/serializers/json_test.rb +17 -4
  105. data/test/unit/serializers/yaml_test.rb +5 -1
  106. data/test/unit/version_test.rb +87 -69
  107. metadata +172 -75
  108. data/gemfiles/3.0.gemfile +0 -42
  109. data/test/dummy/public/404.html +0 -26
  110. data/test/dummy/public/422.html +0 -26
  111. data/test/dummy/public/500.html +0 -26
  112. data/test/dummy/public/favicon.ico +0 -0
  113. data/test/dummy/public/javascripts/application.js +0 -2
  114. data/test/dummy/public/javascripts/controls.js +0 -965
  115. data/test/dummy/public/javascripts/dragdrop.js +0 -974
  116. data/test/dummy/public/javascripts/effects.js +0 -1123
  117. data/test/dummy/public/javascripts/prototype.js +0 -6001
  118. data/test/dummy/public/javascripts/rails.js +0 -175
  119. data/test/dummy/public/stylesheets/.gitkeep +0 -0
@@ -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
@@ -13,8 +13,8 @@ module PaperTrail
13
13
  ActiveSupport::JSON.encode object
14
14
  end
15
15
 
16
- # Returns a SQL condition to be used to match the given field and value in
17
- # the serialized object.
16
+ # Returns a SQL condition to be used to match the given field and value
17
+ # in the serialized object
18
18
  def where_object_condition(arel_field, field, value)
19
19
  # Convert to JSON to handle strings and nulls correctly.
20
20
  json_value = value.to_json
@@ -31,6 +31,17 @@ module PaperTrail
31
31
  arel_field.matches("%\"#{field}\":#{json_value}%")
32
32
  end
33
33
  end
34
+
35
+ # Returns a SQL condition to be used to match the given field and value
36
+ # in the serialized object_changes
37
+ def where_object_changes_condition(arel_field, field, value)
38
+ # Convert to JSON to handle strings and nulls correctly.
39
+ json_value = value.to_json
40
+
41
+ # Need to check first (before) and secondary (after) fields
42
+ arel_field.matches("%\"#{field}\":[#{json_value},%").
43
+ or(arel_field.matches("%\"#{field}\":[%,#{json_value}]%"))
44
+ end
34
45
  end
35
46
  end
36
47
  end
@@ -13,11 +13,25 @@ module PaperTrail
13
13
  ::YAML.dump object
14
14
  end
15
15
 
16
- # Returns a SQL condition to be used to match the given field and value in
17
- # the serialized object.
16
+ # Returns a SQL condition to be used to match the given field and value
17
+ # in the serialized object
18
18
  def where_object_condition(arel_field, field, value)
19
19
  arel_field.matches("%\n#{field}: #{value}\n%")
20
20
  end
21
+
22
+ # Returns a SQL condition to be used to match the given field and value
23
+ # in the serialized object_changes
24
+ def where_object_changes_condition(arel_field, field, value)
25
+ # Need to check first (before) and secondary (after) fields
26
+ if (defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == 'psych') ||
27
+ (defined?(::Psych) && ::YAML == ::Psych)
28
+ arel_field.matches("%\n#{field}:\n- #{value}\n%").
29
+ or(arel_field.matches("%\n#{field}:\n-%\n- #{value}\n%"))
30
+ else # Syck adds extra spaces into array dumps
31
+ arel_field.matches("%\n#{field}: \n%- #{value}\n%").
32
+ or(arel_field.matches("%\n#{field}: \n-%\n- #{value}\n%"))
33
+ end
34
+ end
21
35
  end
22
36
  end
23
37
  end
@@ -0,0 +1,15 @@
1
+ require 'active_support/concern'
2
+
3
+ module PaperTrail
4
+ module VersionAssociationConcern
5
+ extend ::ActiveSupport::Concern
6
+
7
+ included do
8
+ belongs_to :version
9
+
10
+ if PaperTrail.active_record_protected_attributes?
11
+ attr_accessible :version_id, :foreign_key_name, :foreign_key_id
12
+ end
13
+ end
14
+ end
15
+ end
@@ -6,10 +6,33 @@ module PaperTrail
6
6
 
7
7
  included do
8
8
  belongs_to :item, :polymorphic => true
9
+
10
+ # Since the test suite has test coverage for this, we want to declare
11
+ # the association when the test suite is running. This makes it pass when
12
+ # DB is not initialized prior to test runs such as when we run on Travis
13
+ # CI (there won't be a db in `test/dummy/db/`).
14
+ if PaperTrail.config.track_associations?
15
+ has_many :version_associations, :dependent => :destroy
16
+ end
17
+
9
18
  validates_presence_of :event
10
- attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes if PaperTrail.active_record_protected_attributes?
19
+
20
+ if PaperTrail.active_record_protected_attributes?
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
+ )
31
+ end
11
32
 
12
33
  after_create :enforce_version_limit!
34
+
35
+ scope :within_transaction, lambda { |id| where :transaction_id => id }
13
36
  end
14
37
 
15
38
  module ClassMethods
@@ -33,8 +56,13 @@ module PaperTrail
33
56
  where 'event <> ?', 'create'
34
57
  end
35
58
 
36
- # Expects `obj` to be an instance of `PaperTrail::Version` by default, but can accept a timestamp if
37
- # `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
38
66
  def subsequent(obj, timestamp_arg = false)
39
67
  if timestamp_arg != true && self.primary_key_is_int?
40
68
  return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
@@ -44,6 +72,13 @@ module PaperTrail
44
72
  where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(self.timestamp_sort_order)
45
73
  end
46
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
47
82
  def preceding(obj, timestamp_arg = false)
48
83
  if timestamp_arg != true && self.primary_key_is_int?
49
84
  return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
@@ -53,7 +88,6 @@ module PaperTrail
53
88
  where(arel_table[PaperTrail.timestamp_field].lt(obj)).order(self.timestamp_sort_order('desc'))
54
89
  end
55
90
 
56
-
57
91
  def between(start_time, end_time)
58
92
  where(
59
93
  arel_table[PaperTrail.timestamp_field].gt(start_time).
@@ -61,7 +95,8 @@ module PaperTrail
61
95
  ).order(self.timestamp_sort_order)
62
96
  end
63
97
 
64
- # defaults to using the primary key as the secondary sort order if possible
98
+ # Defaults to using the primary key as the secondary sort order if
99
+ # possible.
65
100
  def timestamp_sort_order(direction = 'asc')
66
101
  [arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
67
102
  array << arel_table[primary_key].send(direction.downcase) if self.primary_key_is_int?
@@ -72,15 +107,51 @@ module PaperTrail
72
107
  # identically-named method in the serializer being used.
73
108
  def where_object(args = {})
74
109
  raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
75
- arel_field = arel_table[:object]
76
110
 
77
- where_conditions = args.map do |field, value|
78
- PaperTrail.serializer.where_object_condition(arel_field, field, value)
79
- end.reduce do |condition1, condition2|
80
- condition1.and(condition2)
111
+ if columns_hash['object'].type == :jsonb
112
+ where("object @> ?", args.to_json)
113
+ elsif columns_hash['object'].type == :json
114
+ predicates = []
115
+ values = []
116
+ args.each do |field, value|
117
+ predicates.push "object->>? = ?"
118
+ values.concat([field, value.to_s])
119
+ end
120
+ sql = predicates.join(" and ")
121
+ where(sql, *values)
122
+ else
123
+ arel_field = arel_table[:object]
124
+ where_conditions = args.map { |field, value|
125
+ PaperTrail.serializer.where_object_condition(arel_field, field, value)
126
+ }.reduce { |a, e| a.and(e) }
127
+ where(where_conditions)
81
128
  end
129
+ end
82
130
 
83
- where(where_conditions)
131
+ def where_object_changes(args = {})
132
+ raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
133
+
134
+ if columns_hash['object_changes'].type == :jsonb
135
+ args.each { |field, value| args[field] = [value] }
136
+ where("object_changes @> ?", args.to_json)
137
+ elsif columns_hash['object'].type == :json
138
+ predicates = []
139
+ values = []
140
+ args.each do |field, value|
141
+ predicates.push(
142
+ "((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
143
+ )
144
+ values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
145
+ end
146
+ sql = predicates.join(" and ")
147
+ where(sql, *values)
148
+ else
149
+ arel_field = arel_table[:object_changes]
150
+ where_conditions = args.map { |field, value|
151
+ PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
152
+ }.reduce { |a, e| a.and(e) }
153
+ where(where_conditions)
154
+ end
84
155
  end
85
156
 
86
157
  def primary_key_is_int?
@@ -89,110 +160,83 @@ module PaperTrail
89
160
  true
90
161
  end
91
162
 
92
- # Returns whether the `object` column is using the `json` type supported by PostgreSQL
163
+ # Returns whether the `object` column is using the `json` type supported
164
+ # by PostgreSQL.
93
165
  def object_col_is_json?
94
- @object_col_is_json ||= columns_hash['object'].type == :json
166
+ [:json, :jsonb].include?(columns_hash['object'].type)
95
167
  end
96
168
 
97
- # Returns whether the `object_changes` column is using the `json` type supported by PostgreSQL
169
+ # Returns whether the `object_changes` column is using the `json` type
170
+ # supported by PostgreSQL.
98
171
  def object_changes_col_is_json?
99
- @object_changes_col_is_json ||= columns_hash['object_changes'].type == :json
172
+ [:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
100
173
  end
101
174
  end
102
175
 
103
176
  # Restore the item from this version.
104
177
  #
105
- # This will automatically restore all :has_one associations as they were "at the time",
106
- # if they are also being versioned by PaperTrail. NOTE: this isn't always guaranteed
107
- # to work so you can either change the lookback period (from the default 3 seconds) or
108
- # opt out.
178
+ # Optionally this can also restore all :has_one and :has_many (including
179
+ # has_many :through) associations as they were "at the time", if they are
180
+ # also being versioned by PaperTrail.
109
181
  #
110
182
  # Options:
111
- # :has_one set to `false` to opt out of has_one reification.
112
- # set to a float to change the lookback time (check whether your db supports
113
- # sub-second datetimes if you want them).
183
+ #
184
+ # - :has_one
185
+ # - `true` - Also reify has_one associations.
186
+ # - `false - Default.
187
+ # - :has_many
188
+ # - `true` - Also reify has_many and has_many :through associations.
189
+ # - `false` - Default.
190
+ # - :mark_for_destruction
191
+ # - `true` - Mark the has_one/has_many associations that did not exist in
192
+ # the reified version for destruction, instead of removing them.
193
+ # - `false` - Default. Useful for persisting the reified version.
194
+ # - :dup
195
+ # - `false` - Default.
196
+ # - `true` - Always create a new object instance. Useful for
197
+ # comparing two versions of the same object.
198
+ # - :unversioned_attributes
199
+ # - `:nil` - Default. Attributes undefined in version record are set to
200
+ # nil in reified record.
201
+ # - `:preserve` - Attributes undefined in version record are not modified.
202
+ #
114
203
  def reify(options = {})
115
204
  return nil if object.nil?
116
-
117
205
  without_identity_map do
118
- options[:has_one] = 3 if options[:has_one] == true
119
- options.reverse_merge! :has_one => false
120
-
121
- attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
122
-
123
- # Normally a polymorphic belongs_to relationship allows us
124
- # to get the object we belong to by calling, in this case,
125
- # `item`. However this returns nil if `item` has been
126
- # destroyed, and we need to be able to retrieve destroyed
127
- # objects.
128
- #
129
- # In this situation we constantize the `item_type` to get hold of
130
- # the class...except when the stored object's attributes
131
- # include a `type` key. If this is the case, the object
132
- # we belong to is using single table inheritance and the
133
- # `item_type` will be the base class, not the actual subclass.
134
- # If `type` is present but empty, the class is the base class.
135
-
136
- if item
137
- model = item
138
- # Look for attributes that exist in the model and not in this version. These attributes should be set to nil.
139
- (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
140
- else
141
- inheritance_column_name = item_type.constantize.inheritance_column
142
- class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name]
143
- klass = class_name.constantize
144
- model = klass.new
145
- end
146
-
147
- model.class.unserialize_attributes_for_paper_trail attrs
148
-
149
- # Set all the attributes in this version on the model
150
- attrs.each do |k, v|
151
- if model.respond_to?("#{k}=")
152
- model[k.to_sym] = v
153
- else
154
- logger.warn "Attribute #{k} does not exist on #{item_type} (Version id: #{id})."
155
- end
156
- end
157
-
158
- model.send "#{model.class.version_association_name}=", self
159
-
160
- unless options[:has_one] == false
161
- reify_has_ones model, options[:has_one]
162
- end
163
-
164
- model
206
+ ::PaperTrail::Reifier.reify(self, options)
165
207
  end
166
208
  end
167
209
 
168
- # Returns what changed in this version of the item. Cf. `ActiveModel::Dirty#changes`.
169
- # Returns `nil` if your `versions` table does not have an `object_changes` text column.
210
+ # Returns what changed in this version of the item.
211
+ # `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
212
+ # not have an `object_changes` text column.
170
213
  def changeset
171
214
  return nil unless self.class.column_names.include? 'object_changes'
172
-
173
- _changes = self.class.object_changes_col_is_json? ? object_changes : PaperTrail.serializer.load(object_changes)
174
- @changeset ||= HashWithIndifferentAccess.new(_changes).tap do |changes|
175
- item_type.constantize.unserialize_attribute_changes(changes)
176
- end
177
- rescue
178
- {}
215
+ @changeset ||= load_changeset
179
216
  end
180
217
 
181
218
  # Returns who put the item into the state stored in this version.
219
+ def paper_trail_originator
220
+ @paper_trail_originator ||= previous.whodunnit rescue nil
221
+ end
222
+
182
223
  def originator
183
- @originator ||= previous.whodunnit rescue nil
224
+ ::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
225
+ self.paper_trail_originator
184
226
  end
185
227
 
186
- # Returns who changed the item from the state it had in this version.
187
- # This is an alias for `whodunnit`.
228
+ # Returns who changed the item from the state it had in this version. This
229
+ # is an alias for `whodunnit`.
188
230
  def terminator
189
231
  @terminator ||= whodunnit
190
232
  end
191
233
  alias_method :version_author, :terminator
192
234
 
193
235
  def sibling_versions(reload = false)
194
- @sibling_versions = nil if reload == true
195
- @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
196
240
  end
197
241
 
198
242
  def next
@@ -203,21 +247,37 @@ module PaperTrail
203
247
  @previous ||= sibling_versions.preceding(self).first
204
248
  end
205
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
206
254
  def index
207
- table = self.class.arel_table unless @index
208
- @index ||=
209
- if self.class.primary_key_is_int?
210
- sibling_versions.select(table[self.class.primary_key]).order(table[self.class.primary_key].asc).index(self)
211
- else
212
- sibling_versions.select([table[PaperTrail.timestamp_field], table[self.class.primary_key]]).
213
- order(self.class.timestamp_sort_order).index(self)
214
- end
255
+ @index ||= RecordHistory.new(sibling_versions, self.class).index(self)
215
256
  end
216
257
 
217
258
  private
218
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
+
219
278
  # In Rails 3.1+, calling reify on a previous version confuses the
220
279
  # IdentityMap, if enabled. This prevents insertion into the map.
280
+ # @api private
221
281
  def without_identity_map(&block)
222
282
  if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
223
283
  ::ActiveRecord::IdentityMap.without(&block)
@@ -226,38 +286,16 @@ module PaperTrail
226
286
  end
227
287
  end
228
288
 
229
- # Restore the `model`'s has_one associations as they were when this version was
230
- # superseded by the next (because that's what the user was looking at when they
231
- # made the change).
232
- #
233
- # The `lookback` sets how many seconds before the model's change we go.
234
- def reify_has_ones(model, lookback)
235
- model.class.reflect_on_all_associations(:has_one).each do |assoc|
236
- child = model.send assoc.name
237
- if child.respond_to? :version_at
238
- # N.B. we use version of the child as it was `lookback` seconds before the parent was updated.
239
- # Ideally we want the version of the child as it was just before the parent was updated...
240
- # but until PaperTrail knows which updates are "together" (e.g. parent and child being
241
- # updated on the same form), it's impossible to tell when the overall update started;
242
- # and therefore impossible to know when "just before" was.
243
- if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds))
244
- child_as_it_was.attributes.each do |k,v|
245
- model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil
246
- end
247
- else
248
- model.send "#{assoc.name}=", nil
249
- end
250
- end
251
- end
252
- end
253
-
254
- # checks to see if a value has been set for the `version_limit` config option, and if so enforces it
289
+ # Checks that a value has been set for the `version_limit` config
290
+ # option, and if so enforces it.
291
+ # @api private
255
292
  def enforce_version_limit!
256
- return unless PaperTrail.config.version_limit.is_a? Numeric
293
+ limit = PaperTrail.config.version_limit
294
+ return unless limit.is_a? Numeric
257
295
  previous_versions = sibling_versions.not_creates
258
- return unless previous_versions.size > PaperTrail.config.version_limit
259
- excess_previous_versions = previous_versions - previous_versions.last(PaperTrail.config.version_limit)
260
- 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)
261
299
  end
262
300
  end
263
301
  end