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