paper_trail 4.0.0 → 5.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CONTRIBUTING.md +105 -0
  3. data/.github/ISSUE_TEMPLATE.md +13 -0
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +100 -0
  6. data/.rubocop_todo.yml +14 -0
  7. data/.travis.yml +11 -10
  8. data/Appraisals +37 -0
  9. data/CHANGELOG.md +173 -8
  10. data/Gemfile +1 -1
  11. data/README.md +641 -470
  12. data/Rakefile +19 -19
  13. data/doc/bug_report_template.rb +71 -0
  14. data/doc/warning_about_not_setting_whodunnit.md +32 -0
  15. data/gemfiles/ar3.gemfile +18 -0
  16. data/gemfiles/ar4.gemfile +7 -0
  17. data/gemfiles/ar5.gemfile +13 -0
  18. data/lib/generators/paper_trail/install_generator.rb +26 -18
  19. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +3 -1
  20. data/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb +2 -0
  21. data/lib/generators/paper_trail/templates/create_version_associations.rb +9 -4
  22. data/lib/generators/paper_trail/templates/create_versions.rb +53 -5
  23. data/lib/paper_trail/attribute_serializers/README.md +10 -0
  24. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +58 -0
  25. data/lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb +48 -0
  26. data/lib/paper_trail/attribute_serializers/object_attribute.rb +39 -0
  27. data/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +42 -0
  28. data/lib/paper_trail/cleaner.rb +41 -18
  29. data/lib/paper_trail/config.rb +42 -26
  30. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version.rb +5 -1
  31. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version_association.rb +6 -2
  32. data/lib/paper_trail/frameworks/active_record.rb +2 -2
  33. data/lib/paper_trail/frameworks/cucumber.rb +1 -0
  34. data/lib/paper_trail/frameworks/rails/controller.rb +50 -14
  35. data/lib/paper_trail/frameworks/rails/engine.rb +6 -1
  36. data/lib/paper_trail/frameworks/rails.rb +2 -7
  37. data/lib/paper_trail/frameworks/rspec/helpers.rb +3 -1
  38. data/lib/paper_trail/frameworks/rspec.rb +5 -5
  39. data/lib/paper_trail/frameworks/sinatra.rb +8 -5
  40. data/lib/paper_trail/has_paper_trail.rb +381 -221
  41. data/lib/paper_trail/record_history.rb +57 -0
  42. data/lib/paper_trail/reifier.rb +450 -0
  43. data/lib/paper_trail/serializers/json.rb +7 -7
  44. data/lib/paper_trail/serializers/yaml.rb +31 -12
  45. data/lib/paper_trail/version_association_concern.rb +6 -2
  46. data/lib/paper_trail/version_concern.rb +200 -287
  47. data/lib/paper_trail/version_number.rb +6 -9
  48. data/lib/paper_trail.rb +169 -137
  49. data/paper_trail.gemspec +41 -43
  50. data/spec/generators/install_generator_spec.rb +24 -25
  51. data/spec/generators/paper_trail/templates/create_versions_spec.rb +51 -0
  52. data/spec/models/animal_spec.rb +23 -6
  53. data/spec/models/boolit_spec.rb +8 -8
  54. data/spec/models/callback_modifier_spec.rb +96 -0
  55. data/spec/models/car_spec.rb +13 -0
  56. data/spec/models/fluxor_spec.rb +3 -3
  57. data/spec/models/gadget_spec.rb +19 -19
  58. data/spec/models/joined_version_spec.rb +3 -3
  59. data/spec/models/json_version_spec.rb +50 -28
  60. data/spec/models/kitchen/banana_spec.rb +3 -3
  61. data/spec/models/not_on_update_spec.rb +7 -4
  62. data/spec/models/post_with_status_spec.rb +13 -3
  63. data/spec/models/skipper_spec.rb +40 -11
  64. data/spec/models/thing_spec.rb +4 -4
  65. data/spec/models/truck_spec.rb +5 -0
  66. data/spec/models/vehicle_spec.rb +5 -0
  67. data/spec/models/version_spec.rb +103 -59
  68. data/spec/models/widget_spec.rb +86 -55
  69. data/spec/modules/paper_trail_spec.rb +2 -2
  70. data/spec/modules/version_concern_spec.rb +11 -12
  71. data/spec/modules/version_number_spec.rb +3 -4
  72. data/spec/paper_trail/config_spec.rb +33 -0
  73. data/spec/paper_trail_spec.rb +16 -14
  74. data/spec/rails_helper.rb +10 -9
  75. data/spec/requests/articles_spec.rb +11 -7
  76. data/spec/spec_helper.rb +42 -17
  77. data/spec/support/alt_db_init.rb +8 -13
  78. data/test/custom_json_serializer.rb +3 -3
  79. data/test/dummy/Rakefile +2 -2
  80. data/test/dummy/app/controllers/application_controller.rb +21 -8
  81. data/test/dummy/app/controllers/articles_controller.rb +11 -8
  82. data/test/dummy/app/controllers/widgets_controller.rb +13 -12
  83. data/test/dummy/app/models/animal.rb +1 -1
  84. data/test/dummy/app/models/article.rb +19 -11
  85. data/test/dummy/app/models/authorship.rb +1 -1
  86. data/test/dummy/app/models/bar_habtm.rb +4 -0
  87. data/test/dummy/app/models/book.rb +4 -4
  88. data/test/dummy/app/models/boolit.rb +1 -1
  89. data/test/dummy/app/models/callback_modifier.rb +45 -0
  90. data/test/dummy/app/models/car.rb +3 -0
  91. data/test/dummy/app/models/chapter.rb +9 -0
  92. data/test/dummy/app/models/citation.rb +5 -0
  93. data/test/dummy/app/models/customer.rb +1 -1
  94. data/test/dummy/app/models/document.rb +2 -2
  95. data/test/dummy/app/models/editor.rb +1 -1
  96. data/test/dummy/app/models/foo_habtm.rb +5 -0
  97. data/test/dummy/app/models/fruit.rb +2 -2
  98. data/test/dummy/app/models/gadget.rb +1 -1
  99. data/test/dummy/app/models/kitchen/banana.rb +1 -1
  100. data/test/dummy/app/models/legacy_widget.rb +2 -2
  101. data/test/dummy/app/models/line_item.rb +1 -1
  102. data/test/dummy/app/models/not_on_update.rb +1 -1
  103. data/test/dummy/app/models/paragraph.rb +5 -0
  104. data/test/dummy/app/models/person.rb +6 -6
  105. data/test/dummy/app/models/post.rb +1 -1
  106. data/test/dummy/app/models/post_with_status.rb +1 -1
  107. data/test/dummy/app/models/quotation.rb +5 -0
  108. data/test/dummy/app/models/section.rb +6 -0
  109. data/test/dummy/app/models/skipper.rb +2 -2
  110. data/test/dummy/app/models/song.rb +13 -4
  111. data/test/dummy/app/models/thing.rb +2 -2
  112. data/test/dummy/app/models/translation.rb +2 -2
  113. data/test/dummy/app/models/truck.rb +4 -0
  114. data/test/dummy/app/models/vehicle.rb +4 -0
  115. data/test/dummy/app/models/whatchamajigger.rb +1 -1
  116. data/test/dummy/app/models/widget.rb +7 -6
  117. data/test/dummy/app/versions/joined_version.rb +4 -3
  118. data/test/dummy/app/versions/json_version.rb +1 -1
  119. data/test/dummy/app/versions/kitchen/banana_version.rb +1 -1
  120. data/test/dummy/app/versions/post_version.rb +2 -2
  121. data/test/dummy/config/application.rb +20 -9
  122. data/test/dummy/config/boot.rb +5 -5
  123. data/test/dummy/config/database.postgres.yml +1 -1
  124. data/test/dummy/config/environment.rb +1 -1
  125. data/test/dummy/config/environments/development.rb +4 -3
  126. data/test/dummy/config/environments/production.rb +3 -2
  127. data/test/dummy/config/environments/test.rb +15 -5
  128. data/test/dummy/config/initializers/backtrace_silencers.rb +4 -2
  129. data/test/dummy/config/initializers/paper_trail.rb +4 -3
  130. data/test/dummy/config/initializers/secret_token.rb +3 -1
  131. data/test/dummy/config/initializers/session_store.rb +1 -1
  132. data/test/dummy/config/routes.rb +2 -2
  133. data/test/dummy/config.ru +1 -1
  134. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +148 -68
  135. data/test/dummy/db/schema.rb +119 -31
  136. data/test/dummy/script/rails +6 -4
  137. data/test/functional/controller_test.rb +34 -35
  138. data/test/functional/enabled_for_controller_test.rb +6 -7
  139. data/test/functional/modular_sinatra_test.rb +43 -38
  140. data/test/functional/sinatra_test.rb +49 -40
  141. data/test/functional/thread_safety_test.rb +4 -6
  142. data/test/paper_trail_test.rb +15 -14
  143. data/test/test_helper.rb +78 -18
  144. data/test/time_travel_helper.rb +1 -15
  145. data/test/unit/associations_test.rb +1016 -0
  146. data/test/unit/cleaner_test.rb +66 -60
  147. data/test/unit/inheritance_column_test.rb +19 -19
  148. data/test/unit/model_test.rb +646 -1071
  149. data/test/unit/protected_attrs_test.rb +19 -14
  150. data/test/unit/serializer_test.rb +44 -43
  151. data/test/unit/serializers/json_test.rb +28 -21
  152. data/test/unit/serializers/mixin_json_test.rb +15 -14
  153. data/test/unit/serializers/mixin_yaml_test.rb +20 -16
  154. data/test/unit/serializers/yaml_test.rb +16 -14
  155. data/test/unit/timestamp_test.rb +10 -12
  156. data/test/unit/version_test.rb +88 -70
  157. metadata +166 -72
  158. data/gemfiles/3.0.gemfile +0 -52
@@ -0,0 +1,57 @@
1
+ module PaperTrail
2
+ # Represents the history of a single record.
3
+ # @api private
4
+ class RecordHistory
5
+ # @param versions - ActiveRecord::Relation - All versions of the record.
6
+ # @param version_class - Class - Usually PaperTrail::Version,
7
+ # but it could also be a custom version class.
8
+ # @api private
9
+ def initialize(versions, version_class)
10
+ @versions = versions
11
+ @version_class = version_class
12
+ end
13
+
14
+ # Returns ordinal position of `version` in `sequence`.
15
+ # @api private
16
+ def index(version)
17
+ sequence.to_a.index(version)
18
+ end
19
+
20
+ private
21
+
22
+ # Returns `@versions` in chronological order.
23
+ # @api private
24
+ def sequence
25
+ if @version_class.primary_key_is_int?
26
+ @versions.select(primary_key).order(primary_key.asc)
27
+ else
28
+ @versions.
29
+ select([timestamp, primary_key]).
30
+ order(@version_class.timestamp_sort_order)
31
+ end
32
+ end
33
+
34
+ # @return - Arel::Attribute - Attribute representing the primary key
35
+ # of the version table. The column's data type is usually a serial
36
+ # integer (the rails convention) but not always.
37
+ # @api private
38
+ def primary_key
39
+ table[@version_class.primary_key]
40
+ end
41
+
42
+ # @return - Arel::Table - The version table, usually named `versions`, but
43
+ # not always.
44
+ # @api private
45
+ def table
46
+ @version_class.arel_table
47
+ end
48
+
49
+ # @return - Arel::Attribute - Attribute representing the timestamp column
50
+ # of the version table, usually named `created_at` (the rails convention)
51
+ # but not always.
52
+ # @api private
53
+ def timestamp
54
+ table[PaperTrail.timestamp_field]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,450 @@
1
+ require "paper_trail/attribute_serializers/object_attribute"
2
+
3
+ module PaperTrail
4
+ # Given a version record and some options, builds a new model object.
5
+ # @api private
6
+ module Reifier
7
+ class << self
8
+ # See `VersionConcern#reify` for documentation.
9
+ # @api private
10
+ def reify(version, options)
11
+ options = apply_defaults_to(options, version)
12
+ attrs = version.object_deserialized
13
+
14
+ # Normally a polymorphic belongs_to relationship allows us to get the
15
+ # object we belong to by calling, in this case, `item`. However this
16
+ # returns nil if `item` has been destroyed, and we need to be able to
17
+ # retrieve destroyed objects.
18
+ #
19
+ # In this situation we constantize the `item_type` to get hold of the
20
+ # class...except when the stored object's attributes include a `type`
21
+ # key. If this is the case, the object we belong to is using single
22
+ # table inheritance and the `item_type` will be the base class, not the
23
+ # actual subclass. If `type` is present but empty, the class is the base
24
+ # class.
25
+ if options[:dup] != true && version.item
26
+ model = version.item
27
+ if options[:unversioned_attributes] == :nil
28
+ init_unversioned_attrs(attrs, model)
29
+ end
30
+ else
31
+ klass = version_reification_class(version, attrs)
32
+ # The `dup` option always returns a new object, otherwise we should
33
+ # attempt to look for the item outside of default scope(s).
34
+ if options[:dup] || (item_found = klass.unscoped.find_by_id(version.item_id)).nil?
35
+ model = klass.new
36
+ elsif options[:unversioned_attributes] == :nil
37
+ model = item_found
38
+ init_unversioned_attrs(attrs, model)
39
+ end
40
+ end
41
+
42
+ reify_attributes(model, version, attrs)
43
+ model.send "#{model.class.version_association_name}=", version
44
+ reify_associations(model, options, version)
45
+ model
46
+ end
47
+
48
+ private
49
+
50
+ # Given a hash of `options` for `.reify`, return a new hash with default
51
+ # values applied.
52
+ # @api private
53
+ def apply_defaults_to(options, version)
54
+ {
55
+ version_at: version.created_at,
56
+ mark_for_destruction: false,
57
+ has_one: false,
58
+ has_many: false,
59
+ belongs_to: false,
60
+ has_and_belongs_to_many: false,
61
+ unversioned_attributes: :nil
62
+ }.merge(options)
63
+ end
64
+
65
+ # @api private
66
+ def each_enabled_association(associations)
67
+ associations.each do |assoc|
68
+ next unless assoc.klass.paper_trail_enabled_for_model?
69
+ yield assoc
70
+ end
71
+ end
72
+
73
+ # Examine the `source_reflection`, i.e. the "source" of `assoc` the
74
+ # `ThroughReflection`. The source can be a `BelongsToReflection`
75
+ # or a `HasManyReflection`.
76
+ #
77
+ # If the association is a has_many association again, then call
78
+ # reify_has_manys for each record in `through_collection`.
79
+ #
80
+ # @api private
81
+ def hmt_collection(through_collection, assoc, options, transaction_id)
82
+ if !assoc.source_reflection.belongs_to? && through_collection.present?
83
+ hmt_collection_through_has_many(
84
+ through_collection, assoc, options, transaction_id
85
+ )
86
+ else
87
+ hmt_collection_through_belongs_to(
88
+ through_collection, assoc, options, transaction_id
89
+ )
90
+ end
91
+ end
92
+
93
+ # @api private
94
+ def hmt_collection_through_has_many(through_collection, assoc, options, transaction_id)
95
+ through_collection.each do |through_model|
96
+ reify_has_manys(transaction_id, through_model, options)
97
+ end
98
+
99
+ # At this point, the "through" part of the association chain has
100
+ # been reified, but not the final, "target" part. To continue our
101
+ # example, `model.sections` (including `model.sections.paragraphs`)
102
+ # has been loaded. However, the final "target" part of the
103
+ # association, that is, `model.paragraphs`, has not been loaded. So,
104
+ # we do that now.
105
+ through_collection.flat_map { |through_model|
106
+ through_model.public_send(assoc.name.to_sym).to_a
107
+ }
108
+ end
109
+
110
+ # @api private
111
+ def hmt_collection_through_belongs_to(through_collection, assoc, options, transaction_id)
112
+ collection_keys = through_collection.map { |through_model|
113
+ through_model.send(assoc.source_reflection.foreign_key)
114
+ }
115
+ version_id_subquery = assoc.klass.paper_trail_version_class.
116
+ select("MIN(id)").
117
+ where("item_type = ?", assoc.class_name).
118
+ where("item_id IN (?)", collection_keys).
119
+ where(
120
+ "created_at >= ? OR transaction_id = ?",
121
+ options[:version_at],
122
+ transaction_id
123
+ ).
124
+ group("item_id").
125
+ to_sql
126
+ versions = versions_by_id(assoc.klass, version_id_subquery)
127
+ collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
128
+ prepare_array_for_has_many(collection, options, versions)
129
+ collection
130
+ end
131
+
132
+ # Look for attributes that exist in `model` and not in this version.
133
+ # These attributes should be set to nil. Modifies `attrs`.
134
+ # @api private
135
+ def init_unversioned_attrs(attrs, model)
136
+ (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
137
+ end
138
+
139
+ # Given a HABTM association `assoc` and an `id`, return a version record
140
+ # from the point in time identified by `transaction_id` or `version_at`.
141
+ # @api private
142
+ def load_version_for_habtm(assoc, id, transaction_id, version_at)
143
+ assoc.klass.paper_trail_version_class.
144
+ where("item_type = ?", assoc.klass.name).
145
+ where("item_id = ?", id).
146
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
147
+ order("id").
148
+ limit(1).
149
+ first
150
+ end
151
+
152
+ # Given a has-one association `assoc` on `model`, return the version
153
+ # record from the point in time identified by `transaction_id` or `version_at`.
154
+ # @api private
155
+ def load_version_for_has_one(assoc, model, transaction_id, version_at)
156
+ version_table_name = model.class.paper_trail_version_class.table_name
157
+ model.class.paper_trail_version_class.joins(:version_associations).
158
+ where("version_associations.foreign_key_name = ?", assoc.foreign_key).
159
+ where("version_associations.foreign_key_id = ?", model.id).
160
+ where("#{version_table_name}.item_type = ?", assoc.class_name).
161
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
162
+ order("#{version_table_name}.id ASC").
163
+ first
164
+ end
165
+
166
+ # Set all the attributes in this version on the model.
167
+ def reify_attributes(model, version, attrs)
168
+ enums = model.class.respond_to?(:defined_enums) ? model.class.defined_enums : {}
169
+ AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs)
170
+ attrs.each do |k, v|
171
+ # `ObjectAttribute#deserialize` will return the mapped enum value
172
+ # and in Rails < 5, the []= uses the integer type caster from the column
173
+ # definition (in general) and thus will turn a (usually) string to 0 instead
174
+ # of the correct value
175
+ is_enum_without_type_caster = ::ActiveRecord::VERSION::MAJOR < 5 && enums.key?(k)
176
+
177
+ if model.has_attribute?(k) && !is_enum_without_type_caster
178
+ model[k.to_sym] = v
179
+ elsif model.respond_to?("#{k}=")
180
+ model.send("#{k}=", v)
181
+ else
182
+ version.logger.warn(
183
+ "Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
184
+ )
185
+ end
186
+ end
187
+ end
188
+
189
+ # Replaces each record in `array` with its reified version, if present
190
+ # in `versions`.
191
+ #
192
+ # @api private
193
+ # @param array - The collection to be modified.
194
+ # @param options
195
+ # @param versions - A `Hash` mapping IDs to `Version`s
196
+ # @return nil - Always returns `nil`
197
+ #
198
+ # Once modified by this method, `array` will be assigned to the
199
+ # AR association currently being reified.
200
+ #
201
+ def prepare_array_for_has_many(array, options, versions)
202
+ # Iterate each child to replace it with the previous value if there is
203
+ # a version after the timestamp.
204
+ array.map! do |record|
205
+ if (version = versions.delete(record.id)).nil?
206
+ record
207
+ elsif version.event == "create"
208
+ options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
209
+ else
210
+ version.reify(
211
+ options.merge(
212
+ has_many: false,
213
+ has_one: false,
214
+ belongs_to: false,
215
+ has_and_belongs_to_many: false
216
+ )
217
+ )
218
+ end
219
+ end
220
+
221
+ # Reify the rest of the versions and add them to the collection, these
222
+ # versions are for those that have been removed from the live
223
+ # associations.
224
+ array.concat(
225
+ versions.values.map { |v|
226
+ v.reify(
227
+ options.merge(
228
+ has_many: false,
229
+ has_one: false,
230
+ belongs_to: false,
231
+ has_and_belongs_to_many: false
232
+ )
233
+ )
234
+ }
235
+ )
236
+
237
+ array.compact!
238
+
239
+ nil
240
+ end
241
+
242
+ def reify_associations(model, options, version)
243
+ reify_has_ones version.transaction_id, model, options if options[:has_one]
244
+
245
+ reify_belongs_tos version.transaction_id, model, options if options[:belongs_to]
246
+
247
+ reify_has_manys version.transaction_id, model, options if options[:has_many]
248
+
249
+ if options[:has_and_belongs_to_many]
250
+ reify_has_and_belongs_to_many version.transaction_id, model, options
251
+ end
252
+ end
253
+
254
+ # Restore the `model`'s has_one associations as they were when this
255
+ # version was superseded by the next (because that's what the user was
256
+ # looking at when they made the change).
257
+ def reify_has_ones(transaction_id, model, options = {})
258
+ associations = model.class.reflect_on_all_associations(:has_one)
259
+ each_enabled_association(associations) do |assoc|
260
+ version = load_version_for_has_one(assoc, model, transaction_id, options[:version_at])
261
+ next unless version
262
+ if version.event == "create"
263
+ if options[:mark_for_destruction]
264
+ model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
265
+ else
266
+ model.appear_as_new_record do
267
+ model.send "#{assoc.name}=", nil
268
+ end
269
+ end
270
+ else
271
+ child = version.reify(
272
+ options.merge(
273
+ has_many: false,
274
+ has_one: false,
275
+ belongs_to: false,
276
+ has_and_belongs_to_many: false
277
+ )
278
+ )
279
+ model.appear_as_new_record do
280
+ without_persisting(child) do
281
+ model.send "#{assoc.name}=", child
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ def reify_belongs_tos(transaction_id, model, options = {})
289
+ associations = model.class.reflect_on_all_associations(:belongs_to)
290
+ each_enabled_association(associations) do |assoc|
291
+ collection_key = model.send(assoc.association_foreign_key)
292
+ version = assoc.klass.paper_trail_version_class.
293
+ where("item_type = ?", assoc.class_name).
294
+ where("item_id = ?", collection_key).
295
+ where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
296
+ order("id").limit(1).first
297
+
298
+ collection = if version.nil?
299
+ assoc.klass.where(assoc.klass.primary_key => collection_key).first
300
+ else
301
+ version.reify(
302
+ options.merge(
303
+ has_many: false,
304
+ has_one: false,
305
+ belongs_to: false,
306
+ has_and_belongs_to_many: false
307
+ )
308
+ )
309
+ end
310
+
311
+ model.send("#{assoc.name}=".to_sym, collection)
312
+ end
313
+ end
314
+
315
+ # Restore the `model`'s has_many associations as they were at version_at
316
+ # timestamp We lookup the first child versions after version_at timestamp or
317
+ # in same transaction.
318
+ def reify_has_manys(transaction_id, model, options = {})
319
+ assoc_has_many_through, assoc_has_many_directly =
320
+ model.class.reflect_on_all_associations(:has_many).
321
+ partition { |assoc| assoc.options[:through] }
322
+ reify_has_many_directly(transaction_id, assoc_has_many_directly, model, options)
323
+ reify_has_many_through(transaction_id, assoc_has_many_through, model, options)
324
+ end
325
+
326
+ # Restore the `model`'s has_many associations not associated through
327
+ # another association.
328
+ def reify_has_many_directly(transaction_id, associations, model, options = {})
329
+ version_table_name = model.class.paper_trail_version_class.table_name
330
+ each_enabled_association(associations) do |assoc|
331
+ version_id_subquery = PaperTrail::VersionAssociation.
332
+ joins(model.class.version_association_name).
333
+ select("MIN(version_id)").
334
+ where("foreign_key_name = ?", assoc.foreign_key).
335
+ where("foreign_key_id = ?", model.id).
336
+ where("#{version_table_name}.item_type = ?", assoc.class_name).
337
+ where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
338
+ group("item_id").
339
+ to_sql
340
+ versions = versions_by_id(model.class, version_id_subquery)
341
+ collection = Array.new model.send(assoc.name).reload # to avoid cache
342
+ prepare_array_for_has_many(collection, options, versions)
343
+ model.send(assoc.name).proxy_association.target = collection
344
+ end
345
+ end
346
+
347
+ # Restore the `model`'s has_many associations through another association.
348
+ # This must be called after the direct has_manys have been reified
349
+ # (reify_has_many_directly).
350
+ def reify_has_many_through(transaction_id, associations, model, options = {})
351
+ each_enabled_association(associations) do |assoc|
352
+ # Load the collection of through-models. For example, if `model` is a
353
+ # Chapter, having many Paragraphs through Sections, then
354
+ # `through_collection` will contain Sections.
355
+ through_collection = model.send(assoc.options[:through])
356
+
357
+ # Now, given the collection of "through" models (e.g. sections), load
358
+ # the collection of "target" models (e.g. paragraphs)
359
+ collection = hmt_collection(through_collection, assoc, options, transaction_id)
360
+
361
+ # Finally, assign the `collection` of "target" models, e.g. to
362
+ # `model.paragraphs`.
363
+ model.send(assoc.name).proxy_association.target = collection
364
+ end
365
+ end
366
+
367
+ def reify_has_and_belongs_to_many(transaction_id, model, options = {})
368
+ model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc|
369
+ papertrail_enabled = assoc.klass.paper_trail_enabled_for_model?
370
+ next unless
371
+ model.class.paper_trail_save_join_tables.include?(assoc.name) ||
372
+ papertrail_enabled
373
+
374
+ version_ids = PaperTrail::VersionAssociation.
375
+ where("foreign_key_name = ?", assoc.name).
376
+ where("version_id = ?", transaction_id).
377
+ pluck(:foreign_key_id)
378
+
379
+ model.send(assoc.name).proxy_association.target =
380
+ version_ids.map do |id|
381
+ if papertrail_enabled
382
+ version = load_version_for_habtm(
383
+ assoc,
384
+ id,
385
+ transaction_id,
386
+ options[:version_at]
387
+ )
388
+ if version
389
+ next version.reify(
390
+ options.merge(
391
+ has_many: false,
392
+ has_one: false,
393
+ belongs_to: false,
394
+ has_and_belongs_to_many: false
395
+ )
396
+ )
397
+ end
398
+ end
399
+ assoc.klass.where(assoc.klass.primary_key => id).first
400
+ end
401
+ end
402
+ end
403
+
404
+ # Given a `version`, return the class to reify. This method supports
405
+ # Single Table Inheritance (STI) with custom inheritance columns.
406
+ #
407
+ # For example, imagine a `version` whose `item_type` is "Animal". The
408
+ # `animals` table is an STI table (it has cats and dogs) and it has a
409
+ # custom inheritance column, `species`. If `attrs["species"]` is "Dog",
410
+ # this method returns the constant `Dog`. If `attrs["species"]` is blank,
411
+ # this method returns the constant `Animal`. You can see this particular
412
+ # example in action in `spec/models/animal_spec.rb`.
413
+ #
414
+ def version_reification_class(version, attrs)
415
+ inheritance_column_name = version.item_type.constantize.inheritance_column
416
+ inher_col_value = attrs[inheritance_column_name]
417
+ class_name = inher_col_value.blank? ? version.item_type : inher_col_value
418
+ class_name.constantize
419
+ end
420
+
421
+ # Given a SQL fragment that identifies the IDs of version records,
422
+ # returns a `Hash` mapping those IDs to `Version`s.
423
+ #
424
+ # @api private
425
+ # @param klass - An ActiveRecord class.
426
+ # @param version_id_subquery - String. A SQL subquery that selects
427
+ # the IDs of version records.
428
+ # @return A `Hash` mapping IDs to `Version`s
429
+ #
430
+ def versions_by_id(klass, version_id_subquery)
431
+ klass.
432
+ paper_trail_version_class.
433
+ where("id IN (#{version_id_subquery})").
434
+ inject({}) { |a, e| a.merge!(e.item_id => e) }
435
+ end
436
+
437
+ # Temporarily suppress #save so we can reassociate with the reified
438
+ # master of a has_one relationship. Since ActiveRecord 5 the related
439
+ # object is saved when it is assigned to the association. ActiveRecord
440
+ # 5 also happens to be the first version that provides #suppress.
441
+ def without_persisting(record)
442
+ if record.class.respond_to? :suppress
443
+ record.class.suppress { yield }
444
+ else
445
+ yield
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
@@ -1,7 +1,8 @@
1
- require 'active_support/json'
1
+ require "active_support/json"
2
2
 
3
3
  module PaperTrail
4
4
  module Serializers
5
+ # An alternate serializer for, e.g. `versions.object`.
5
6
  module JSON
6
7
  extend self # makes all instance methods become module methods as well
7
8
 
@@ -13,8 +14,8 @@ module PaperTrail
13
14
  ActiveSupport::JSON.encode object
14
15
  end
15
16
 
16
- # Returns a SQL condition to be used to match the given field and value
17
- # in the serialized object
17
+ # Returns a SQL LIKE condition to be used to match the given field and
18
+ # value in the serialized object.
18
19
  def where_object_condition(arel_field, field, value)
19
20
  # Convert to JSON to handle strings and nulls correctly.
20
21
  json_value = value.to_json
@@ -25,15 +26,14 @@ module PaperTrail
25
26
  # 123.
26
27
  if value.is_a? Numeric
27
28
  arel_field.matches("%\"#{field}\":#{json_value},%").
28
- or(
29
- arel_field.matches("%\"#{field}\":#{json_value}}%"))
29
+ or(arel_field.matches("%\"#{field}\":#{json_value}}%"))
30
30
  else
31
31
  arel_field.matches("%\"#{field}\":#{json_value}%")
32
32
  end
33
33
  end
34
34
 
35
- # Returns a SQL condition to be used to match the given field and value
36
- # in the serialized object_changes
35
+ # Returns a SQL LIKE condition to be used to match the given field and
36
+ # value in the serialized `object_changes`.
37
37
  def where_object_changes_condition(arel_field, field, value)
38
38
  # Convert to JSON to handle strings and nulls correctly.
39
39
  json_value = value.to_json
@@ -1,7 +1,8 @@
1
- require 'yaml'
1
+ require "yaml"
2
2
 
3
3
  module PaperTrail
4
4
  module Serializers
5
+ # The default serializer for, e.g. `versions.object`.
5
6
  module YAML
6
7
  extend self # makes all instance methods become module methods as well
7
8
 
@@ -13,23 +14,41 @@ module PaperTrail
13
14
  ::YAML.dump object
14
15
  end
15
16
 
16
- # Returns a SQL condition to be used to match the given field and value
17
- # in the serialized object
17
+ # Returns a SQL LIKE condition to be used to match the given field and
18
+ # value in the serialized object.
18
19
  def where_object_condition(arel_field, field, value)
19
20
  arel_field.matches("%\n#{field}: #{value}\n%")
20
21
  end
21
22
 
22
- # Returns a SQL condition to be used to match the given field and value
23
- # in the serialized object_changes
23
+ # Returns a SQL LIKE condition to be used to match the given field and
24
+ # value in the serialized `object_changes`.
24
25
  def where_object_changes_condition(arel_field, field, value)
25
26
  # 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%"))
27
+ m1 = nil
28
+ m2 = nil
29
+ case yaml_engine_id
30
+ when :psych
31
+ m1 = "%\n#{field}:\n- #{value}\n%"
32
+ m2 = "%\n#{field}:\n-%\n- #{value}\n%"
33
+ when :syck
34
+ # Syck adds extra spaces into array dumps
35
+ m1 = "%\n#{field}: \n%- #{value}\n%"
36
+ m2 = "%\n#{field}: \n-%\n- #{value}\n%"
37
+ else
38
+ raise "Unknown yaml engine"
39
+ end
40
+ arel_field.matches(m1).or(arel_field.matches(m2))
41
+ end
42
+
43
+ # Returns a symbol identifying the YAML engine. Syck was removed from
44
+ # the ruby stdlib in ruby 2.0, but is still available as a gem.
45
+ # @api private
46
+ def yaml_engine_id
47
+ if (defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == "psych") ||
48
+ (defined?(::Psych) && ::YAML == ::Psych)
49
+ :psych
50
+ else
51
+ :syck
33
52
  end
34
53
  end
35
54
  end
@@ -1,13 +1,17 @@
1
- require 'active_support/concern'
1
+ require "active_support/concern"
2
2
 
3
3
  module PaperTrail
4
+ # Functionality for `PaperTrail::VersionAssociation`. Exists in a module
5
+ # for the same reasons outlined in version_concern.rb.
4
6
  module VersionAssociationConcern
5
7
  extend ::ActiveSupport::Concern
6
8
 
7
9
  included do
8
10
  belongs_to :version
9
11
 
10
- attr_accessible :version_id, :foreign_key_name, :foreign_key_id if PaperTrail.active_record_protected_attributes?
12
+ if PaperTrail.active_record_protected_attributes?
13
+ attr_accessible :version_id, :foreign_key_name, :foreign_key_id
14
+ end
11
15
  end
12
16
  end
13
17
  end