paper_trail 6.0.2 → 7.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CONTRIBUTING.md +20 -0
  3. data/.rubocop.yml +30 -2
  4. data/.rubocop_todo.yml +20 -0
  5. data/.travis.yml +3 -5
  6. data/Appraisals +5 -6
  7. data/CHANGELOG.md +33 -0
  8. data/README.md +43 -81
  9. data/Rakefile +1 -1
  10. data/doc/bug_report_template.rb +4 -2
  11. data/gemfiles/ar_4.0.gemfile +7 -0
  12. data/gemfiles/ar_4.2.gemfile +0 -1
  13. data/lib/generators/paper_trail/templates/create_version_associations.rb +1 -1
  14. data/lib/generators/paper_trail/templates/create_versions.rb +1 -1
  15. data/lib/paper_trail.rb +7 -9
  16. data/lib/paper_trail/config.rb +0 -15
  17. data/lib/paper_trail/frameworks/rspec.rb +8 -2
  18. data/lib/paper_trail/model_config.rb +6 -2
  19. data/lib/paper_trail/record_trail.rb +3 -1
  20. data/lib/paper_trail/reifier.rb +43 -354
  21. data/lib/paper_trail/reifiers/belongs_to.rb +48 -0
  22. data/lib/paper_trail/reifiers/has_and_belongs_to_many.rb +50 -0
  23. data/lib/paper_trail/reifiers/has_many.rb +110 -0
  24. data/lib/paper_trail/reifiers/has_many_through.rb +90 -0
  25. data/lib/paper_trail/reifiers/has_one.rb +76 -0
  26. data/lib/paper_trail/serializers/yaml.rb +2 -25
  27. data/lib/paper_trail/version_concern.rb +5 -5
  28. data/lib/paper_trail/version_number.rb +7 -3
  29. data/paper_trail.gemspec +7 -34
  30. data/spec/controllers/articles_controller_spec.rb +1 -1
  31. data/spec/generators/install_generator_spec.rb +40 -34
  32. data/spec/models/animal_spec.rb +50 -25
  33. data/spec/models/boolit_spec.rb +8 -7
  34. data/spec/models/callback_modifier_spec.rb +13 -13
  35. data/spec/models/document_spec.rb +21 -0
  36. data/spec/models/gadget_spec.rb +35 -39
  37. data/spec/models/joined_version_spec.rb +4 -4
  38. data/spec/models/json_version_spec.rb +14 -15
  39. data/spec/models/not_on_update_spec.rb +1 -1
  40. data/spec/models/post_with_status_spec.rb +2 -2
  41. data/spec/models/skipper_spec.rb +4 -4
  42. data/spec/models/thing_spec.rb +1 -1
  43. data/spec/models/truck_spec.rb +1 -1
  44. data/spec/models/vehicle_spec.rb +1 -1
  45. data/spec/models/version_spec.rb +152 -168
  46. data/spec/models/widget_spec.rb +170 -196
  47. data/spec/modules/paper_trail_spec.rb +3 -3
  48. data/spec/modules/version_concern_spec.rb +5 -8
  49. data/spec/modules/version_number_spec.rb +11 -36
  50. data/spec/paper_trail/cleaner_spec.rb +152 -0
  51. data/spec/paper_trail/config_spec.rb +1 -1
  52. data/spec/paper_trail/serializers/custom_yaml_serializer_spec.rb +45 -0
  53. data/spec/paper_trail/serializers/json_spec.rb +57 -0
  54. data/spec/paper_trail/version_limit_spec.rb +55 -0
  55. data/spec/paper_trail_spec.rb +45 -32
  56. data/spec/requests/articles_spec.rb +4 -4
  57. data/test/dummy/app/models/custom_primary_key_record.rb +4 -2
  58. data/test/dummy/app/models/document.rb +1 -1
  59. data/test/dummy/app/models/not_on_update.rb +1 -1
  60. data/test/dummy/app/models/on/create.rb +6 -0
  61. data/test/dummy/app/models/on/destroy.rb +6 -0
  62. data/test/dummy/app/models/on/empty_array.rb +6 -0
  63. data/test/dummy/app/models/on/update.rb +6 -0
  64. data/test/dummy/app/models/person.rb +1 -0
  65. data/test/dummy/app/models/song.rb +19 -28
  66. data/test/dummy/config/application.rb +10 -43
  67. data/test/dummy/config/routes.rb +1 -1
  68. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +25 -51
  69. data/test/dummy/db/schema.rb +29 -19
  70. data/test/test_helper.rb +0 -16
  71. data/test/unit/associations_test.rb +81 -81
  72. data/test/unit/model_test.rb +48 -131
  73. data/test/unit/serializer_test.rb +34 -45
  74. data/test/unit/serializers/mixin_json_test.rb +3 -1
  75. data/test/unit/serializers/yaml_test.rb +1 -5
  76. metadata +44 -19
  77. data/lib/paper_trail/frameworks/sinatra.rb +0 -40
  78. data/test/functional/modular_sinatra_test.rb +0 -46
  79. data/test/functional/sinatra_test.rb +0 -51
  80. data/test/unit/cleaner_test.rb +0 -151
  81. data/test/unit/inheritance_column_test.rb +0 -41
  82. data/test/unit/serializers/json_test.rb +0 -95
  83. data/test/unit/serializers/mixin_yaml_test.rb +0 -53
@@ -0,0 +1,48 @@
1
+ module PaperTrail
2
+ module Reifiers
3
+ # Reify a single `belongs_to` association of `model`.
4
+ # @api private
5
+ module BelongsTo
6
+ class << self
7
+ # @api private
8
+ def reify(assoc, model, options, transaction_id)
9
+ id = model.send(assoc.foreign_key)
10
+ version = load_version(assoc, id, transaction_id, options[:version_at])
11
+ record = load_record(assoc, id, options, version)
12
+ model.send("#{assoc.name}=".to_sym, record)
13
+ end
14
+
15
+ private
16
+
17
+ # Given a `belongs_to` association and a `version`, return a record that
18
+ # can be assigned in order to reify that association.
19
+ # @api private
20
+ def load_record(assoc, id, options, version)
21
+ if version.nil?
22
+ assoc.klass.where(assoc.klass.primary_key => id).first
23
+ else
24
+ version.reify(
25
+ options.merge(
26
+ has_many: false,
27
+ has_one: false,
28
+ belongs_to: false,
29
+ has_and_belongs_to_many: false
30
+ )
31
+ )
32
+ end
33
+ end
34
+
35
+ # Given a `belongs_to` association and an `id`, return a version record
36
+ # from the point in time identified by `transaction_id` or `version_at`.
37
+ # @api private
38
+ def load_version(assoc, id, transaction_id, version_at)
39
+ assoc.klass.paper_trail.version_class.
40
+ where("item_type = ?", assoc.class_name).
41
+ where("item_id = ?", id).
42
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
43
+ order("id").limit(1).first
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ module PaperTrail
2
+ module Reifiers
3
+ # Reify a single HABTM association of `model`.
4
+ # @api private
5
+ module HasAndBelongsToMany
6
+ class << self
7
+ # @api private
8
+ def reify(pt_enabled, assoc, model, options, transaction_id)
9
+ version_ids = ::PaperTrail::VersionAssociation.
10
+ where("foreign_key_name = ?", assoc.name).
11
+ where("version_id = ?", transaction_id).
12
+ pluck(:foreign_key_id)
13
+
14
+ model.send(assoc.name).proxy_association.target =
15
+ version_ids.map do |id|
16
+ if pt_enabled
17
+ version = load_version(assoc, id, transaction_id, options[:version_at])
18
+ if version
19
+ next version.reify(
20
+ options.merge(
21
+ has_many: false,
22
+ has_one: false,
23
+ belongs_to: false,
24
+ has_and_belongs_to_many: false
25
+ )
26
+ )
27
+ end
28
+ end
29
+ assoc.klass.where(assoc.klass.primary_key => id).first
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Given a HABTM association `assoc` and an `id`, return a version record
36
+ # from the point in time identified by `transaction_id` or `version_at`.
37
+ # @api private
38
+ def load_version(assoc, id, transaction_id, version_at)
39
+ assoc.klass.paper_trail.version_class.
40
+ where("item_type = ?", assoc.klass.name).
41
+ where("item_id = ?", id).
42
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
43
+ order("id").
44
+ limit(1).
45
+ first
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,110 @@
1
+ module PaperTrail
2
+ module Reifiers
3
+ # Reify a single, direct (not `through`) `has_many` association of `model`.
4
+ # @api private
5
+ module HasMany
6
+ class << self
7
+ # @api private
8
+ def reify(assoc, model, options, transaction_id, version_table_name)
9
+ versions = load_versions_for_hm_association(
10
+ assoc,
11
+ model,
12
+ version_table_name,
13
+ transaction_id,
14
+ options[:version_at]
15
+ )
16
+ collection = Array.new model.send(assoc.name).reload # to avoid cache
17
+ prepare_array(collection, options, versions)
18
+ model.send(assoc.name).proxy_association.target = collection
19
+ end
20
+
21
+ # Replaces each record in `array` with its reified version, if present
22
+ # in `versions`.
23
+ #
24
+ # @api private
25
+ # @param array - The collection to be modified.
26
+ # @param options
27
+ # @param versions - A `Hash` mapping IDs to `Version`s
28
+ # @return nil - Always returns `nil`
29
+ #
30
+ # Once modified by this method, `array` will be assigned to the
31
+ # AR association currently being reified.
32
+ #
33
+ def prepare_array(array, options, versions)
34
+ # Iterate each child to replace it with the previous value if there is
35
+ # a version after the timestamp.
36
+ array.map! do |record|
37
+ if (version = versions.delete(record.id)).nil?
38
+ record
39
+ elsif version.event == "create"
40
+ options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
41
+ else
42
+ version.reify(
43
+ options.merge(
44
+ has_many: false,
45
+ has_one: false,
46
+ belongs_to: false,
47
+ has_and_belongs_to_many: false
48
+ )
49
+ )
50
+ end
51
+ end
52
+
53
+ # Reify the rest of the versions and add them to the collection, these
54
+ # versions are for those that have been removed from the live
55
+ # associations.
56
+ array.concat(
57
+ versions.values.map { |v|
58
+ v.reify(
59
+ options.merge(
60
+ has_many: false,
61
+ has_one: false,
62
+ belongs_to: false,
63
+ has_and_belongs_to_many: false
64
+ )
65
+ )
66
+ }
67
+ )
68
+
69
+ array.compact!
70
+
71
+ nil
72
+ end
73
+
74
+ # Given a SQL fragment that identifies the IDs of version records,
75
+ # returns a `Hash` mapping those IDs to `Version`s.
76
+ #
77
+ # @api private
78
+ # @param klass - An ActiveRecord class.
79
+ # @param version_id_subquery - String. A SQL subquery that selects
80
+ # the IDs of version records.
81
+ # @return A `Hash` mapping IDs to `Version`s
82
+ #
83
+ def versions_by_id(klass, version_id_subquery)
84
+ klass.
85
+ paper_trail.version_class.
86
+ where("id IN (#{version_id_subquery})").
87
+ inject({}) { |a, e| a.merge!(e.item_id => e) }
88
+ end
89
+
90
+ private
91
+
92
+ # Given a `has_many` association on `model`, return the version records
93
+ # from the point in time identified by `tx_id` or `version_at`.
94
+ # @api private
95
+ def load_versions_for_hm_association(assoc, model, version_table, tx_id, version_at)
96
+ version_id_subquery = ::PaperTrail::VersionAssociation.
97
+ joins(model.class.version_association_name).
98
+ select("MIN(version_id)").
99
+ where("foreign_key_name = ?", assoc.foreign_key).
100
+ where("foreign_key_id = ?", model.id).
101
+ where("#{version_table}.item_type = ?", assoc.class_name).
102
+ where("created_at >= ? OR transaction_id = ?", version_at, tx_id).
103
+ group("item_id").
104
+ to_sql
105
+ versions_by_id(model.class, version_id_subquery)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,90 @@
1
+ module PaperTrail
2
+ module Reifiers
3
+ # Reify a single HMT association of `model`.
4
+ # @api private
5
+ module HasManyThrough
6
+ class << self
7
+ # @api private
8
+ def reify(assoc, model, options, transaction_id)
9
+ # Load the collection of through-models. For example, if `model` is a
10
+ # Chapter, having many Paragraphs through Sections, then
11
+ # `through_collection` will contain Sections.
12
+ through_collection = model.send(assoc.options[:through])
13
+
14
+ # Now, given the collection of "through" models (e.g. sections), load
15
+ # the collection of "target" models (e.g. paragraphs)
16
+ collection = collection(through_collection, assoc, options, transaction_id)
17
+
18
+ # Finally, assign the `collection` of "target" models, e.g. to
19
+ # `model.paragraphs`.
20
+ model.send(assoc.name).proxy_association.target = collection
21
+ end
22
+
23
+ private
24
+
25
+ # Examine the `source_reflection`, i.e. the "source" of `assoc` the
26
+ # `ThroughReflection`. The source can be a `BelongsToReflection`
27
+ # or a `HasManyReflection`.
28
+ #
29
+ # If the association is a has_many association again, then call
30
+ # reify_has_manys for each record in `through_collection`.
31
+ #
32
+ # @api private
33
+ def collection(through_collection, assoc, options, transaction_id)
34
+ if !assoc.source_reflection.belongs_to? && through_collection.present?
35
+ collection_through_has_many(through_collection, assoc, options, transaction_id)
36
+ else
37
+ collection_through_belongs_to(through_collection, assoc, options, transaction_id)
38
+ end
39
+ end
40
+
41
+ # @api private
42
+ def collection_through_has_many(through_collection, assoc, options, transaction_id)
43
+ through_collection.each do |through_model|
44
+ ::PaperTrail::Reifier.reify_has_manys(transaction_id, through_model, options)
45
+ end
46
+
47
+ # At this point, the "through" part of the association chain has
48
+ # been reified, but not the final, "target" part. To continue our
49
+ # example, `model.sections` (including `model.sections.paragraphs`)
50
+ # has been loaded. However, the final "target" part of the
51
+ # association, that is, `model.paragraphs`, has not been loaded. So,
52
+ # we do that now.
53
+ through_collection.flat_map { |through_model|
54
+ through_model.public_send(assoc.name.to_sym).to_a
55
+ }
56
+ end
57
+
58
+ # @api private
59
+ def collection_through_belongs_to(through_collection, assoc, options, tx_id)
60
+ ids = through_collection.map { |through_model|
61
+ through_model.send(assoc.source_reflection.foreign_key)
62
+ }
63
+ versions = load_versions_for_hmt_association(assoc, ids, tx_id, options[:version_at])
64
+ collection = Array.new assoc.klass.where(assoc.klass.primary_key => ids)
65
+ Reifiers::HasMany.prepare_array(collection, options, versions)
66
+ collection
67
+ end
68
+
69
+ # Given a `has_many(through:)` association and an array of `ids`, return
70
+ # the version records from the point in time identified by `tx_id` or
71
+ # `version_at`.
72
+ # @api private
73
+ def load_versions_for_hmt_association(assoc, ids, tx_id, version_at)
74
+ version_id_subquery = assoc.klass.paper_trail.version_class.
75
+ select("MIN(id)").
76
+ where("item_type = ?", assoc.class_name).
77
+ where("item_id IN (?)", ids).
78
+ where(
79
+ "created_at >= ? OR transaction_id = ?",
80
+ version_at,
81
+ tx_id
82
+ ).
83
+ group("item_id").
84
+ to_sql
85
+ Reifiers::HasMany.versions_by_id(assoc.klass, version_id_subquery)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ module PaperTrail
2
+ module Reifiers
3
+ # Reify a single `has_one` association of `model`.
4
+ # @api private
5
+ module HasOne
6
+ class << self
7
+ # @api private
8
+ def reify(assoc, model, options, transaction_id)
9
+ version = load_version_for_has_one(assoc, model, transaction_id, options[:version_at])
10
+ return unless version
11
+ if version.event == "create"
12
+ create_event(assoc, model, options)
13
+ else
14
+ noncreate_event(assoc, model, options, version)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ # @api private
21
+ def create_event(assoc, model, options)
22
+ if options[:mark_for_destruction]
23
+ model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
24
+ else
25
+ model.paper_trail.appear_as_new_record do
26
+ model.send "#{assoc.name}=", nil
27
+ end
28
+ end
29
+ end
30
+
31
+ # Given a has-one association `assoc` on `model`, return the version
32
+ # record from the point in time identified by `transaction_id` or `version_at`.
33
+ # @api private
34
+ def load_version_for_has_one(assoc, model, transaction_id, version_at)
35
+ version_table_name = model.class.paper_trail.version_class.table_name
36
+ model.class.paper_trail.version_class.joins(:version_associations).
37
+ where("version_associations.foreign_key_name = ?", assoc.foreign_key).
38
+ where("version_associations.foreign_key_id = ?", model.id).
39
+ where("#{version_table_name}.item_type = ?", assoc.class_name).
40
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
41
+ order("#{version_table_name}.id ASC").
42
+ first
43
+ end
44
+
45
+ # @api private
46
+ def noncreate_event(assoc, model, options, version)
47
+ child = version.reify(
48
+ options.merge(
49
+ has_many: false,
50
+ has_one: false,
51
+ belongs_to: false,
52
+ has_and_belongs_to_many: false
53
+ )
54
+ )
55
+ model.paper_trail.appear_as_new_record do
56
+ without_persisting(child) do
57
+ model.send "#{assoc.name}=", child
58
+ end
59
+ end
60
+ end
61
+
62
+ # Temporarily suppress #save so we can reassociate with the reified
63
+ # master of a has_one relationship. Since ActiveRecord 5 the related
64
+ # object is saved when it is assigned to the association. ActiveRecord
65
+ # 5 also happens to be the first version that provides #suppress.
66
+ def without_persisting(record)
67
+ if record.class.respond_to? :suppress
68
+ record.class.suppress { yield }
69
+ else
70
+ yield
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -24,33 +24,10 @@ module PaperTrail
24
24
  # value in the serialized `object_changes`.
25
25
  def where_object_changes_condition(arel_field, field, value)
26
26
  # Need to check first (before) and secondary (after) fields
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
27
+ m1 = "%\n#{field}:\n- #{value}\n%"
28
+ m2 = "%\n#{field}:\n-%\n- #{value}\n%"
40
29
  arel_field.matches(m1).or(arel_field.matches(m2))
41
30
  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
52
- end
53
- end
54
31
  end
55
32
  end
56
33
  end
@@ -162,13 +162,13 @@ module PaperTrail
162
162
  # Returns whether the `object` column is using the `json` type supported
163
163
  # by PostgreSQL.
164
164
  def object_col_is_json?
165
- [:json, :jsonb].include?(columns_hash["object"].type)
165
+ %i(json jsonb).include?(columns_hash["object"].type)
166
166
  end
167
167
 
168
168
  # Returns whether the `object_changes` column is using the `json` type
169
169
  # supported by PostgreSQL.
170
170
  def object_changes_col_is_json?
171
- [:json, :jsonb].include?(columns_hash["object_changes"].try(:type))
171
+ %i(json jsonb).include?(columns_hash["object_changes"].try(:type))
172
172
  end
173
173
  end
174
174
 
@@ -306,13 +306,13 @@ module PaperTrail
306
306
  end
307
307
  end
308
308
 
309
- # Checks that a value has been set for the `version_limit` config
310
- # option, and if so enforces it.
309
+ # Enforces the `version_limit`, if set. Default: no limit.
311
310
  # @api private
312
311
  def enforce_version_limit!
313
312
  limit = PaperTrail.config.version_limit
314
313
  return unless limit.is_a? Numeric
315
- previous_versions = sibling_versions.not_creates
314
+ previous_versions = sibling_versions.not_creates.
315
+ order(self.class.timestamp_sort_order("asc"))
316
316
  return unless previous_versions.size > limit
317
317
  excess_versions = previous_versions - previous_versions.last(limit)
318
318
  excess_versions.map(&:destroy)