paper_trail-association_tracking 0.0.1

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 (29) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/LICENSE +20 -0
  4. data/README.md +252 -0
  5. data/Rakefile +61 -0
  6. data/lib/generators/paper_trail_association_tracking/install_generator.rb +64 -0
  7. data/lib/generators/paper_trail_association_tracking/templates/add_transaction_id_column_to_versions.rb.erb +13 -0
  8. data/lib/generators/paper_trail_association_tracking/templates/create_version_associations.rb.erb +22 -0
  9. data/lib/paper_trail-association_tracking.rb +68 -0
  10. data/lib/paper_trail_association_tracking/config.rb +26 -0
  11. data/lib/paper_trail_association_tracking/frameworks/active_record.rb +5 -0
  12. data/lib/paper_trail_association_tracking/frameworks/active_record/models/paper_trail/version_association.rb +13 -0
  13. data/lib/paper_trail_association_tracking/frameworks/rails.rb +3 -0
  14. data/lib/paper_trail_association_tracking/frameworks/rails/engine.rb +10 -0
  15. data/lib/paper_trail_association_tracking/frameworks/rspec.rb +20 -0
  16. data/lib/paper_trail_association_tracking/model_config.rb +76 -0
  17. data/lib/paper_trail_association_tracking/paper_trail.rb +38 -0
  18. data/lib/paper_trail_association_tracking/record_trail.rb +200 -0
  19. data/lib/paper_trail_association_tracking/reifier.rb +125 -0
  20. data/lib/paper_trail_association_tracking/reifiers/belongs_to.rb +50 -0
  21. data/lib/paper_trail_association_tracking/reifiers/has_and_belongs_to_many.rb +52 -0
  22. data/lib/paper_trail_association_tracking/reifiers/has_many.rb +112 -0
  23. data/lib/paper_trail_association_tracking/reifiers/has_many_through.rb +92 -0
  24. data/lib/paper_trail_association_tracking/reifiers/has_one.rb +135 -0
  25. data/lib/paper_trail_association_tracking/request.rb +32 -0
  26. data/lib/paper_trail_association_tracking/version.rb +5 -0
  27. data/lib/paper_trail_association_tracking/version_association_concern.rb +13 -0
  28. data/lib/paper_trail_association_tracking/version_concern.rb +37 -0
  29. metadata +260 -0
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail_association_tracking/reifiers/belongs_to"
4
+ require "paper_trail_association_tracking/reifiers/has_and_belongs_to_many"
5
+ require "paper_trail_association_tracking/reifiers/has_many"
6
+ require "paper_trail_association_tracking/reifiers/has_many_through"
7
+ require "paper_trail_association_tracking/reifiers/has_one"
8
+
9
+ module PaperTrailAssociationTracking
10
+ # Given a version record and some options, builds a new model object.
11
+ # @api private
12
+ module Reifier
13
+ module ClassMethods
14
+ # See `VersionConcern#reify` for documentation.
15
+ # @api private
16
+ def reify(version, options)
17
+ options = apply_defaults_to(options, version)
18
+ model = super
19
+ reify_associations(model, options, version)
20
+ model
21
+ end
22
+
23
+ # Restore the `model`'s has_many associations as they were at version_at
24
+ # timestamp We lookup the first child versions after version_at timestamp or
25
+ # in same transaction.
26
+ # @api private
27
+ def reify_has_manys(transaction_id, model, options = {})
28
+ assoc_has_many_through, assoc_has_many_directly =
29
+ model.class.reflect_on_all_associations(:has_many).
30
+ partition { |assoc| assoc.options[:through] }
31
+ reify_has_many_associations(transaction_id, assoc_has_many_directly, model, options)
32
+ reify_has_many_through_associations(transaction_id, assoc_has_many_through, model, options)
33
+ end
34
+
35
+ private
36
+
37
+ # Given a hash of `options` for `.reify`, return a new hash with default
38
+ # values applied.
39
+ # @api private
40
+ def apply_defaults_to(options, version)
41
+ {
42
+ version_at: version.created_at,
43
+ mark_for_destruction: false,
44
+ has_one: false,
45
+ has_many: false,
46
+ belongs_to: false,
47
+ has_and_belongs_to_many: false,
48
+ unversioned_attributes: :nil
49
+ }.merge(options)
50
+ end
51
+
52
+ # @api private
53
+ def each_enabled_association(associations)
54
+ associations.each do |assoc|
55
+ next unless ::PaperTrail.request.enabled_for_model?(assoc.klass)
56
+ yield assoc
57
+ end
58
+ end
59
+
60
+ # @api private
61
+ def reify_associations(model, options, version)
62
+ if options[:has_one]
63
+ reify_has_one_associations(version.transaction_id, model, options)
64
+ end
65
+ if options[:belongs_to]
66
+ reify_belongs_to_associations(version.transaction_id, model, options)
67
+ end
68
+ if options[:has_many]
69
+ reify_has_manys(version.transaction_id, model, options)
70
+ end
71
+ if options[:has_and_belongs_to_many]
72
+ reify_habtm_associations version.transaction_id, model, options
73
+ end
74
+ end
75
+
76
+ # Restore the `model`'s has_one associations as they were when this
77
+ # version was superseded by the next (because that's what the user was
78
+ # looking at when they made the change).
79
+ # @api private
80
+ def reify_has_one_associations(transaction_id, model, options = {})
81
+ associations = model.class.reflect_on_all_associations(:has_one)
82
+ each_enabled_association(associations) do |assoc|
83
+ ::PaperTrailAssociationTracking::Reifiers::HasOne.reify(assoc, model, options, transaction_id)
84
+ end
85
+ end
86
+
87
+ # Reify all `belongs_to` associations of `model`.
88
+ # @api private
89
+ def reify_belongs_to_associations(transaction_id, model, options = {})
90
+ associations = model.class.reflect_on_all_associations(:belongs_to)
91
+ each_enabled_association(associations) do |assoc|
92
+ ::PaperTrailAssociationTracking::Reifiers::BelongsTo.reify(assoc, model, options, transaction_id)
93
+ end
94
+ end
95
+
96
+ # Reify all direct (not `through`) `has_many` associations of `model`.
97
+ # @api private
98
+ def reify_has_many_associations(transaction_id, associations, model, options = {})
99
+ version_table_name = model.class.paper_trail.version_class.table_name
100
+ each_enabled_association(associations) do |assoc|
101
+ ::PaperTrailAssociationTracking::Reifiers::HasMany.reify(assoc, model, options, transaction_id, version_table_name)
102
+ end
103
+ end
104
+
105
+ # Reify all HMT associations of `model`. This must be called after the
106
+ # direct (non-`through`) has_manys have been reified.
107
+ # @api private
108
+ def reify_has_many_through_associations(transaction_id, associations, model, options = {})
109
+ each_enabled_association(associations) do |assoc|
110
+ ::PaperTrailAssociationTracking::Reifiers::HasManyThrough.reify(assoc, model, options, transaction_id)
111
+ end
112
+ end
113
+
114
+ # Reify all HABTM associations of `model`.
115
+ # @api private
116
+ def reify_habtm_associations(transaction_id, model, options = {})
117
+ model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc|
118
+ pt_enabled = ::PaperTrail.request.enabled_for_model?(assoc.klass)
119
+ next unless model.class.paper_trail_save_join_tables.include?(assoc.name) || pt_enabled
120
+ ::PaperTrailAssociationTracking::Reifiers::HasAndBelongsToMany.reify(pt_enabled, assoc, model, options, transaction_id)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Reifiers
5
+ # Reify a single `belongs_to` association of `model`.
6
+ # @api private
7
+ module BelongsTo
8
+ class << self
9
+ # @api private
10
+ def reify(assoc, model, options, transaction_id)
11
+ id = model.send(assoc.foreign_key)
12
+ version = load_version(assoc, id, transaction_id, options[:version_at])
13
+ record = load_record(assoc, id, options, version)
14
+ model.send("#{assoc.name}=".to_sym, record)
15
+ end
16
+
17
+ private
18
+
19
+ # Given a `belongs_to` association and a `version`, return a record that
20
+ # can be assigned in order to reify that association.
21
+ # @api private
22
+ def load_record(assoc, id, options, version)
23
+ if version.nil?
24
+ assoc.klass.where(assoc.klass.primary_key => id).first
25
+ else
26
+ version.reify(
27
+ options.merge(
28
+ has_many: false,
29
+ has_one: false,
30
+ belongs_to: false,
31
+ has_and_belongs_to_many: false
32
+ )
33
+ )
34
+ end
35
+ end
36
+
37
+ # Given a `belongs_to` association and an `id`, return a version record
38
+ # from the point in time identified by `transaction_id` or `version_at`.
39
+ # @api private
40
+ def load_version(assoc, id, transaction_id, version_at)
41
+ assoc.klass.paper_trail.version_class.
42
+ where("item_type = ?", assoc.klass.base_class.name).
43
+ where("item_id = ?", id).
44
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
45
+ order("id").limit(1).first
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Reifiers
5
+ # Reify a single HABTM association of `model`.
6
+ # @api private
7
+ module HasAndBelongsToMany
8
+ class << self
9
+ # @api private
10
+ def reify(pt_enabled, assoc, model, options, transaction_id)
11
+ version_ids = ::PaperTrail::VersionAssociation.
12
+ where("foreign_key_name = ?", assoc.name).
13
+ where("version_id = ?", transaction_id).
14
+ pluck(:foreign_key_id)
15
+
16
+ model.send(assoc.name).proxy_association.target =
17
+ version_ids.map do |id|
18
+ if pt_enabled
19
+ version = load_version(assoc, id, transaction_id, options[:version_at])
20
+ if version
21
+ next version.reify(
22
+ options.merge(
23
+ has_many: false,
24
+ has_one: false,
25
+ belongs_to: false,
26
+ has_and_belongs_to_many: false
27
+ )
28
+ )
29
+ end
30
+ end
31
+ assoc.klass.where(assoc.klass.primary_key => id).first
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Given a HABTM association `assoc` and an `id`, return a version record
38
+ # from the point in time identified by `transaction_id` or `version_at`.
39
+ # @api private
40
+ def load_version(assoc, id, transaction_id, version_at)
41
+ assoc.klass.paper_trail.version_class.
42
+ where("item_type = ?", assoc.klass.base_class.name).
43
+ where("item_id = ?", id).
44
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
45
+ order("id").
46
+ limit(1).
47
+ first
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Reifiers
5
+ # Reify a single, direct (not `through`) `has_many` association of `model`.
6
+ # @api private
7
+ module HasMany
8
+ class << self
9
+ # @api private
10
+ def reify(assoc, model, options, transaction_id, version_table_name)
11
+ versions = load_versions_for_hm_association(
12
+ assoc,
13
+ model,
14
+ version_table_name,
15
+ transaction_id,
16
+ options[:version_at]
17
+ )
18
+ collection = Array.new model.send(assoc.name).reload # to avoid cache
19
+ prepare_array(collection, options, versions)
20
+ model.send(assoc.name).proxy_association.target = collection
21
+ end
22
+
23
+ # Replaces each record in `array` with its reified version, if present
24
+ # in `versions`.
25
+ #
26
+ # @api private
27
+ # @param array - The collection to be modified.
28
+ # @param options
29
+ # @param versions - A `Hash` mapping IDs to `Version`s
30
+ # @return nil - Always returns `nil`
31
+ #
32
+ # Once modified by this method, `array` will be assigned to the
33
+ # AR association currently being reified.
34
+ #
35
+ def prepare_array(array, options, versions)
36
+ # Iterate each child to replace it with the previous value if there is
37
+ # a version after the timestamp.
38
+ array.map! do |record|
39
+ if (version = versions.delete(record.id)).nil?
40
+ record
41
+ elsif version.event == "create"
42
+ options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
43
+ else
44
+ version.reify(
45
+ options.merge(
46
+ has_many: false,
47
+ has_one: false,
48
+ belongs_to: false,
49
+ has_and_belongs_to_many: false
50
+ )
51
+ )
52
+ end
53
+ end
54
+
55
+ # Reify the rest of the versions and add them to the collection, these
56
+ # versions are for those that have been removed from the live
57
+ # associations.
58
+ array.concat(
59
+ versions.values.map { |v|
60
+ v.reify(
61
+ options.merge(
62
+ has_many: false,
63
+ has_one: false,
64
+ belongs_to: false,
65
+ has_and_belongs_to_many: false
66
+ )
67
+ )
68
+ }
69
+ )
70
+
71
+ array.compact!
72
+
73
+ nil
74
+ end
75
+
76
+ # Given a SQL fragment that identifies the IDs of version records,
77
+ # returns a `Hash` mapping those IDs to `Version`s.
78
+ #
79
+ # @api private
80
+ # @param klass - An ActiveRecord class.
81
+ # @param version_id_subquery - String. A SQL subquery that selects
82
+ # the IDs of version records.
83
+ # @return A `Hash` mapping IDs to `Version`s
84
+ #
85
+ def versions_by_id(klass, version_id_subquery)
86
+ klass.
87
+ paper_trail.version_class.
88
+ where("id IN (#{version_id_subquery})").
89
+ inject({}) { |a, e| a.merge!(e.item_id => e) }
90
+ end
91
+
92
+ private
93
+
94
+ # Given a `has_many` association on `model`, return the version records
95
+ # from the point in time identified by `tx_id` or `version_at`.
96
+ # @api private
97
+ def load_versions_for_hm_association(assoc, model, version_table, tx_id, version_at)
98
+ version_id_subquery = ::PaperTrail::VersionAssociation.
99
+ joins(model.class.version_association_name).
100
+ select("MIN(version_id)").
101
+ where("foreign_key_name = ?", assoc.foreign_key).
102
+ where("foreign_key_id = ?", model.id).
103
+ where("#{version_table}.item_type = ?", assoc.klass.base_class.name).
104
+ where("created_at >= ? OR transaction_id = ?", version_at, tx_id).
105
+ group("item_id").
106
+ to_sql
107
+ versions_by_id(model.class, version_id_subquery)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Reifiers
5
+ # Reify a single HMT association of `model`.
6
+ # @api private
7
+ module HasManyThrough
8
+ class << self
9
+ # @api private
10
+ def reify(assoc, model, options, transaction_id)
11
+ # Load the collection of through-models. For example, if `model` is a
12
+ # Chapter, having many Paragraphs through Sections, then
13
+ # `through_collection` will contain Sections.
14
+ through_collection = model.send(assoc.options[:through])
15
+
16
+ # Now, given the collection of "through" models (e.g. sections), load
17
+ # the collection of "target" models (e.g. paragraphs)
18
+ collection = collection(through_collection, assoc, options, transaction_id)
19
+
20
+ # Finally, assign the `collection` of "target" models, e.g. to
21
+ # `model.paragraphs`.
22
+ model.send(assoc.name).proxy_association.target = collection
23
+ end
24
+
25
+ private
26
+
27
+ # Examine the `source_reflection`, i.e. the "source" of `assoc` the
28
+ # `ThroughReflection`. The source can be a `BelongsToReflection`
29
+ # or a `HasManyReflection`.
30
+ #
31
+ # If the association is a has_many association again, then call
32
+ # reify_has_manys for each record in `through_collection`.
33
+ #
34
+ # @api private
35
+ def collection(through_collection, assoc, options, transaction_id)
36
+ if !assoc.source_reflection.belongs_to? && through_collection.present?
37
+ collection_through_has_many(through_collection, assoc, options, transaction_id)
38
+ else
39
+ collection_through_belongs_to(through_collection, assoc, options, transaction_id)
40
+ end
41
+ end
42
+
43
+ # @api private
44
+ def collection_through_has_many(through_collection, assoc, options, transaction_id)
45
+ through_collection.each do |through_model|
46
+ ::PaperTrail::Reifier.reify_has_manys(transaction_id, through_model, options)
47
+ end
48
+
49
+ # At this point, the "through" part of the association chain has
50
+ # been reified, but not the final, "target" part. To continue our
51
+ # example, `model.sections` (including `model.sections.paragraphs`)
52
+ # has been loaded. However, the final "target" part of the
53
+ # association, that is, `model.paragraphs`, has not been loaded. So,
54
+ # we do that now.
55
+ through_collection.flat_map { |through_model|
56
+ through_model.public_send(assoc.name.to_sym).to_a
57
+ }
58
+ end
59
+
60
+ # @api private
61
+ def collection_through_belongs_to(through_collection, assoc, options, tx_id)
62
+ ids = through_collection.map { |through_model|
63
+ through_model.send(assoc.source_reflection.foreign_key)
64
+ }
65
+ versions = load_versions_for_hmt_association(assoc, ids, tx_id, options[:version_at])
66
+ collection = Array.new assoc.klass.where(assoc.klass.primary_key => ids)
67
+ ::PaperTrailAssociationTracking::Reifiers::HasMany.prepare_array(collection, options, versions)
68
+ collection
69
+ end
70
+
71
+ # Given a `has_many(through:)` association and an array of `ids`, return
72
+ # the version records from the point in time identified by `tx_id` or
73
+ # `version_at`.
74
+ # @api private
75
+ def load_versions_for_hmt_association(assoc, ids, tx_id, version_at)
76
+ version_id_subquery = assoc.klass.paper_trail.version_class.
77
+ select("MIN(id)").
78
+ where("item_type = ?", assoc.klass.base_class.name).
79
+ where("item_id IN (?)", ids).
80
+ where(
81
+ "created_at >= ? OR transaction_id = ?",
82
+ version_at,
83
+ tx_id
84
+ ).
85
+ group("item_id").
86
+ to_sql
87
+ ::PaperTrailAssociationTracking::Reifiers::HasMany.versions_by_id(assoc.klass, version_id_subquery)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Reifiers
5
+ # Reify a single `has_one` association of `model`.
6
+ # @api private
7
+ module HasOne
8
+ # A more helpful error message, instead of the AssociationTypeMismatch
9
+ # you would get if, eg. we were to try to assign a Bicycle to the :car
10
+ # association (before, if there were multiple records we would just take
11
+ # the first and hope for the best).
12
+ # @api private
13
+ class FoundMoreThanOne < RuntimeError
14
+ MESSAGE_FMT = <<~STR
15
+ Unable to reify has_one association. Expected to find one %s,
16
+ but found %d.
17
+
18
+ This is a known issue, and a good example of why association tracking
19
+ is an experimental feature that should not be used in production.
20
+
21
+ That said, this is a rare error. In spec/models/person_spec.rb we
22
+ reproduce it by having two STI models with the same foreign_key (Car
23
+ and Bicycle are both Vehicles and the FK for both is owner_id)
24
+
25
+ If you'd like to help fix this error, please read
26
+ https://github.com/airblade/paper_trail/issues/594
27
+ and see spec/models/person_spec.rb
28
+ STR
29
+
30
+ def initialize(base_class_name, num_records_found)
31
+ @base_class_name = base_class_name.to_s
32
+ @num_records_found = num_records_found.to_i
33
+ end
34
+
35
+ def message
36
+ format(MESSAGE_FMT, @base_class_name, @num_records_found)
37
+ end
38
+ end
39
+
40
+ class << self
41
+ # @api private
42
+ def reify(assoc, model, options, transaction_id)
43
+ version = load_version(assoc, model, transaction_id, options[:version_at])
44
+ return unless version
45
+ if version.event == "create"
46
+ create_event(assoc, model, options)
47
+ else
48
+ noncreate_event(assoc, model, options, version)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # @api private
55
+ def create_event(assoc, model, options)
56
+ if options[:mark_for_destruction]
57
+ model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
58
+ else
59
+ model.paper_trail.appear_as_new_record do
60
+ model.send "#{assoc.name}=", nil
61
+ end
62
+ end
63
+ end
64
+
65
+ # Given a has-one association `assoc` on `model`, return the version
66
+ # record from the point in time identified by `transaction_id` or `version_at`.
67
+ # @api private
68
+ def load_version(assoc, model, transaction_id, version_at)
69
+ base_class_name = assoc.klass.base_class.name
70
+ versions = load_versions(assoc, model, transaction_id, version_at, base_class_name)
71
+ case versions.length
72
+ when 0
73
+ nil
74
+ when 1
75
+ versions.first
76
+ else
77
+ case ::PaperTrail.config.association_reify_error_behaviour
78
+ when "warn"
79
+ version = versions.first
80
+ version.logger&.warn(
81
+ FoundMoreThanOne.new(base_class_name, versions.length).message
82
+ )
83
+ version
84
+ when "ignore"
85
+ versions.first
86
+ else # "error"
87
+ raise FoundMoreThanOne.new(base_class_name, versions.length)
88
+ end
89
+ end
90
+ end
91
+
92
+ # @api private
93
+ def load_versions(assoc, model, transaction_id, version_at, base_class_name)
94
+ version_table_name = model.class.paper_trail.version_class.table_name
95
+ model.class.paper_trail.version_class.joins(:version_associations).
96
+ where("version_associations.foreign_key_name = ?", assoc.foreign_key).
97
+ where("version_associations.foreign_key_id = ?", model.id).
98
+ where("#{version_table_name}.item_type = ?", base_class_name).
99
+ where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
100
+ order("#{version_table_name}.id ASC").
101
+ load
102
+ end
103
+
104
+ # @api private
105
+ def noncreate_event(assoc, model, options, version)
106
+ child = version.reify(
107
+ options.merge(
108
+ has_many: false,
109
+ has_one: false,
110
+ belongs_to: false,
111
+ has_and_belongs_to_many: false
112
+ )
113
+ )
114
+ model.paper_trail.appear_as_new_record do
115
+ without_persisting(child) do
116
+ model.send "#{assoc.name}=", child
117
+ end
118
+ end
119
+ end
120
+
121
+ # Temporarily suppress #save so we can reassociate with the reified
122
+ # master of a has_one relationship. Since ActiveRecord 5 the related
123
+ # object is saved when it is assigned to the association. ActiveRecord
124
+ # 5 also happens to be the first version that provides #suppress.
125
+ def without_persisting(record)
126
+ if record.class.respond_to? :suppress
127
+ record.class.suppress { yield }
128
+ else
129
+ yield
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end