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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'paper_trail'
4
+ require "paper_trail_association_tracking/config"
5
+ require "paper_trail_association_tracking/model_config"
6
+ require "paper_trail_association_tracking/reifier"
7
+ require "paper_trail_association_tracking/record_trail"
8
+ require "paper_trail_association_tracking/request"
9
+ require "paper_trail_association_tracking/paper_trail"
10
+ require "paper_trail_association_tracking/version_concern"
11
+
12
+ module PaperTrailAssociationTracking
13
+ def self.version
14
+ VERSION::STRING
15
+ end
16
+
17
+ def self.gem_version
18
+ ::Gem::Version.new(VERSION::STRING)
19
+ end
20
+ end
21
+
22
+ module PaperTrail
23
+ class << self
24
+ prepend ::PaperTrailAssociationTracking::PaperTrail::ClassMethods
25
+ end
26
+
27
+ class Config
28
+ prepend ::PaperTrailAssociationTracking::Config
29
+ end
30
+
31
+ class ModelConfig
32
+ prepend ::PaperTrailAssociationTracking::ModelConfig
33
+ end
34
+
35
+ class RecordTrail
36
+ prepend ::PaperTrailAssociationTracking::RecordTrail
37
+ end
38
+
39
+ module Reifier
40
+ class << self
41
+ prepend ::PaperTrailAssociationTracking::Reifier::ClassMethods
42
+ end
43
+ end
44
+
45
+ module Request
46
+ class << self
47
+ prepend ::PaperTrailAssociationTracking::Request::ClassMethods
48
+ end
49
+ end
50
+
51
+ module VersionConcern
52
+ include ::PaperTrailAssociationTracking::VersionConcern
53
+ end
54
+ end
55
+
56
+
57
+ # Require frameworks
58
+ if defined?(::Rails)
59
+ # Rails module is sometimes defined by gems like rails-html-sanitizer
60
+ # so we check for presence of Rails.application.
61
+ if defined?(::Rails.application)
62
+ require "paper_trail_association_tracking/frameworks/rails"
63
+ else
64
+ ::Kernel.warn('PaperTrail has been loaded too early, before rails is loaded. This can happen when another gem defines the ::Rails namespace, then PT is loaded, all before rails is loaded. You may want to reorder your Gemfile, or defer the loading of PT by using `require: false` and a manual require elsewhere.')
65
+ end
66
+ else
67
+ require "paper_trail_association_tracking/frameworks/active_record"
68
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Config
5
+ def association_reify_error_behaviour=(val)
6
+ val = val.to_s
7
+ if ['error', 'warn', 'ignore'].include?(val.to_s)
8
+ @association_reify_error_behaviour = val.to_s
9
+ else
10
+ raise ArgumentError.new('Incorrect value passed to `association_reify_error_behaviour`')
11
+ end
12
+ end
13
+
14
+ def association_reify_error_behaviour
15
+ @association_reify_error_behaviour ||= "error"
16
+ end
17
+
18
+ def track_associations=(val)
19
+ @track_associations = !!val
20
+ end
21
+
22
+ def track_associations?
23
+ !!@track_associations
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file only needs to be loaded if the gem is being used outside of Rails,
4
+ # since otherwise the model(s) will get loaded in via the `Rails::Engine`.
5
+ require "paper_trail_association_tracking/frameworks/active_record/models/paper_trail/version_association"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail_association_tracking/version_association_concern"
4
+
5
+ module ::PaperTrail
6
+ # This is the default ActiveRecord model provided by PaperTrail. Most simple
7
+ # applications will only use this and its partner, `Version`, but it is
8
+ # possible to sub-class, extend, or even do without this model entirely.
9
+ # See the readme for details.
10
+ class VersionAssociation < ::ActiveRecord::Base
11
+ include PaperTrailAssociationTracking::VersionAssociationConcern
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail_association_tracking/frameworks/rails/engine"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module Rails
5
+ # See http://guides.rubyonrails.org/engines.html
6
+ class Engine < ::Rails::Engine
7
+ paths["app/models"] << "lib/paper_trail_association_tracking/frameworks/active_record/models"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "rspec/matchers"
5
+
6
+ RSpec::Matchers.define :have_a_version_with do |attributes|
7
+ # check if the model has a version with the specified attributes
8
+ match do |actual|
9
+ versions_association = actual.class.versions_association_name
10
+ actual.send(versions_association).where_object(attributes).any?
11
+ end
12
+ end
13
+
14
+ RSpec::Matchers.define :have_a_version_with_changes do |attributes|
15
+ # check if the model has a version changes with the specified attributes
16
+ match do |actual|
17
+ versions_association = actual.class.versions_association_name
18
+ actual.send(versions_association).where_object_changes(attributes).any?
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ # Configures an ActiveRecord model, mostly at application boot time, but also
5
+ # sometimes mid-request, with methods like enable/disable.
6
+ module ModelConfig
7
+ # Set up `@model_class` for PaperTrail. Installs callbacks, associations,
8
+ # "class attributes", instance methods, and more.
9
+ # @api private
10
+ def setup(options = {})
11
+ super
12
+
13
+ setup_transaction_callbacks
14
+ setup_callbacks_for_habtm(options[:join_tables])
15
+ end
16
+
17
+ private
18
+
19
+ # Raises an error if the provided class is an `abstract_class`.
20
+ # @api private
21
+ def assert_concrete_activerecord_class(class_name)
22
+ if class_name.constantize.abstract_class?
23
+ raise format(::PaperTrail::ModelConfig::E_HPT_ABSTRACT_CLASS, @model_class, class_name)
24
+ end
25
+ end
26
+
27
+ def habtm_assocs_not_skipped
28
+ @model_class.reflect_on_all_associations(:has_and_belongs_to_many).
29
+ reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) }
30
+ end
31
+
32
+ # Adds callbacks to record changes to habtm associations such that on save
33
+ # the previous version of the association (if changed) can be reconstructed.
34
+ def setup_callbacks_for_habtm(join_tables)
35
+ @model_class.send :attr_accessor, :paper_trail_habtm
36
+ @model_class.class_attribute :paper_trail_save_join_tables
37
+ @model_class.paper_trail_save_join_tables = Array.wrap(join_tables)
38
+ habtm_assocs_not_skipped.each(&method(:setup_habtm_change_callbacks))
39
+ end
40
+
41
+ def setup_habtm_change_callbacks(assoc)
42
+ assoc_name = assoc.name
43
+ %w[add remove].each do |verb|
44
+ @model_class.send(:"before_#{verb}_for_#{assoc_name}").send(
45
+ :<<,
46
+ lambda do |*args|
47
+ update_habtm_state(assoc_name, :"before_#{verb}", args[-2], args.last)
48
+ end
49
+ )
50
+ end
51
+ end
52
+
53
+ # Reset the transaction id when the transaction is closed.
54
+ def setup_transaction_callbacks
55
+ @model_class.after_commit { ::PaperTrail.request.clear_transaction_id }
56
+ @model_class.after_rollback { ::PaperTrail.request.clear_transaction_id }
57
+ end
58
+
59
+ def update_habtm_state(name, callback, model, assoc)
60
+ model.paper_trail_habtm ||= {}
61
+ model.paper_trail_habtm[name] ||= { removed: [], added: [] }
62
+ state = model.paper_trail_habtm[name]
63
+ assoc_id = assoc.id
64
+ case callback
65
+ when :before_add
66
+ state[:added] |= [assoc_id]
67
+ state[:removed] -= [assoc_id]
68
+ when :before_remove
69
+ state[:removed] |= [assoc_id]
70
+ state[:added] -= [assoc_id]
71
+ else
72
+ raise "Invalid callback: #{callback}"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module PaperTrail
5
+ module ClassMethods
6
+ def transaction?
7
+ ::ActiveRecord::Base.connection.open_transactions.positive?
8
+ end
9
+
10
+ # @deprecated
11
+ def clear_transaction_id
12
+ ::ActiveSupport::Deprecation.warn(
13
+ "PaperTrail.clear_transaction_id is deprecated, use PaperTrail.request.clear_transaction_id",
14
+ caller(1)
15
+ )
16
+ request.clear_transaction_id
17
+ end
18
+
19
+ # @deprecated
20
+ def transaction_id
21
+ ::ActiveSupport::Deprecation.warn(
22
+ "PaperTrail.transaction_id is deprecated without replacement.",
23
+ caller(1)
24
+ )
25
+ request.transaction_id
26
+ end
27
+
28
+ # @deprecated
29
+ def transaction_id=(id)
30
+ ::ActiveSupport::Deprecation.warn(
31
+ "PaperTrail.transaction_id= is deprecated without replacement.",
32
+ caller(1)
33
+ )
34
+ request.transaction_id = id
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrailAssociationTracking
4
+ module RecordTrail
5
+ # Utility method for reifying. Anything executed inside the block will
6
+ # appear like a new record.
7
+ #
8
+ # > .. as best as I can tell, the purpose of
9
+ # > appear_as_new_record was to attempt to prevent the callbacks in
10
+ # > AutosaveAssociation (which is the module responsible for persisting
11
+ # > foreign key changes earlier than most people want most of the time
12
+ # > because backwards compatibility or the maintainer hates himself or
13
+ # > something) from running. By also stubbing out persisted? we can
14
+ # > actually prevent those. A more stable option might be to use suppress
15
+ # > instead, similar to the other branch in reify_has_one.
16
+ # > -Sean Griffin (https://github.com/paper-trail-gem/paper_trail/pull/899)
17
+ #
18
+ # @api private
19
+ def appear_as_new_record
20
+ @record.instance_eval {
21
+ alias :old_new_record? :new_record?
22
+ alias :new_record? :present?
23
+ alias :old_persisted? :persisted?
24
+ alias :persisted? :nil?
25
+ }
26
+ yield
27
+ @record.instance_eval {
28
+ alias :new_record? :old_new_record?
29
+ alias :persisted? :old_persisted?
30
+ }
31
+ end
32
+
33
+ # @api private
34
+ def record_create
35
+ version = super
36
+ if version
37
+ update_transaction_id(version)
38
+ save_associations(version)
39
+ end
40
+ end
41
+
42
+ # @api private
43
+ def data_for_create
44
+ data = super
45
+ add_transaction_id_to(data)
46
+ data
47
+ end
48
+
49
+ # @api private
50
+ def record_destroy(*args)
51
+ version = super
52
+ if version && version.respond_to?(:errors) && version.errors.empty?
53
+ update_transaction_id(version)
54
+ save_associations(version)
55
+ end
56
+ version
57
+ end
58
+
59
+ # @api private
60
+ def data_for_destroy
61
+ data = super
62
+ add_transaction_id_to(data)
63
+ data
64
+ end
65
+
66
+ # Returns a boolean indicating whether to store serialized version diffs
67
+ # in the `object_changes` column of the version record.
68
+ # @api private
69
+ def record_object_changes?
70
+ @record.paper_trail_options[:save_changes] &&
71
+ @record.class.paper_trail.version_class.column_names.include?("object_changes")
72
+ end
73
+
74
+ # @api private
75
+ def record_update(**opts)
76
+ version = super
77
+ if version && version.respond_to?(:errors) && version.errors.empty?
78
+ update_transaction_id(version)
79
+ save_associations(version)
80
+ end
81
+ version
82
+ end
83
+
84
+ # Used during `record_update`, returns a hash of data suitable for an AR
85
+ # `create`. That is, all the attributes of the nascent `Version` record.
86
+ #
87
+ # @api private
88
+ def data_for_update(*args)
89
+ data = super
90
+ add_transaction_id_to(data)
91
+ data
92
+ end
93
+
94
+ # @api private
95
+ def record_update_columns(*args)
96
+ version = super
97
+ if version && version.respond_to?(:errors) && version.errors.empty?
98
+ update_transaction_id(version)
99
+ save_associations(version)
100
+ end
101
+ version
102
+ end
103
+
104
+ # Returns data for record_update_columns
105
+ # @api private
106
+ def data_for_update_columns(*args)
107
+ data = super
108
+ add_transaction_id_to(data)
109
+ data
110
+ end
111
+
112
+ # Saves associations if the join table for `VersionAssociation` exists.
113
+ def save_associations(version)
114
+ return unless ::PaperTrail.config.track_associations?
115
+ save_bt_associations(version)
116
+ save_habtm_associations(version)
117
+ end
118
+
119
+ # Save all `belongs_to` associations.
120
+ # @api private
121
+ def save_bt_associations(version)
122
+ @record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
123
+ save_bt_association(assoc, version)
124
+ end
125
+ end
126
+
127
+ # When a record is created, updated, or destroyed, we determine what the
128
+ # HABTM associations looked like before any changes were made, by using
129
+ # the `paper_trail_habtm` data structure. Then, we create
130
+ # `VersionAssociation` records for each of the associated records.
131
+ # @api private
132
+ def save_habtm_associations(version)
133
+ @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
134
+ next unless save_habtm_association?(a)
135
+ habtm_assoc_ids(a).each do |id|
136
+ ::PaperTrail::VersionAssociation.create(
137
+ version_id: version.transaction_id,
138
+ foreign_key_name: a.name,
139
+ foreign_key_id: id
140
+ )
141
+ end
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def add_transaction_id_to(data)
148
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
149
+ data[:transaction_id] = ::PaperTrail.request.transaction_id
150
+ end
151
+
152
+ # Given a HABTM association, returns an array of ids.
153
+ #
154
+ # @api private
155
+ def habtm_assoc_ids(habtm_assoc)
156
+ current = @record.send(habtm_assoc.name).to_a.map(&:id) # TODO: `pluck` would use less memory
157
+ removed = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :removed) || []
158
+ added = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :added) || []
159
+ current + removed - added
160
+ end
161
+
162
+ # Save a single `belongs_to` association.
163
+ # @api private
164
+ def save_bt_association(assoc, version)
165
+ assoc_version_args = {
166
+ version_id: version.id,
167
+ foreign_key_name: assoc.foreign_key
168
+ }
169
+
170
+ if assoc.options[:polymorphic]
171
+ associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type)
172
+ if associated_record && ::PaperTrail.request.enabled_for_model?(associated_record.class)
173
+ assoc_version_args[:foreign_key_id] = associated_record.id
174
+ end
175
+ elsif ::PaperTrail.request.enabled_for_model?(assoc.klass)
176
+ assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key)
177
+ end
178
+
179
+ if assoc_version_args.key?(:foreign_key_id)
180
+ ::PaperTrail::VersionAssociation.create(assoc_version_args)
181
+ end
182
+ end
183
+
184
+ # Returns true if the given HABTM association should be saved.
185
+ # @api private
186
+ def save_habtm_association?(assoc)
187
+ @record.class.paper_trail_save_join_tables.include?(assoc.name) ||
188
+ ::PaperTrail.request.enabled_for_model?(assoc.klass)
189
+ end
190
+
191
+ def update_transaction_id(version)
192
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
193
+ if ::PaperTrail.transaction? && ::PaperTrail.request.transaction_id.nil?
194
+ ::PaperTrail.request.transaction_id = version.id
195
+ version.transaction_id = version.id
196
+ version.save
197
+ end
198
+ end
199
+ end
200
+ end