paper_trail 6.0.2 → 7.0.0

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