paper_trail-association_tracking 0.0.1

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