paper_trail 4.2.0 → 5.0.0

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