paper_trail 3.0.6 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
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