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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +20 -0
- data/README.md +252 -0
- data/Rakefile +61 -0
- data/lib/generators/paper_trail_association_tracking/install_generator.rb +64 -0
- data/lib/generators/paper_trail_association_tracking/templates/add_transaction_id_column_to_versions.rb.erb +13 -0
- data/lib/generators/paper_trail_association_tracking/templates/create_version_associations.rb.erb +22 -0
- data/lib/paper_trail-association_tracking.rb +68 -0
- data/lib/paper_trail_association_tracking/config.rb +26 -0
- data/lib/paper_trail_association_tracking/frameworks/active_record.rb +5 -0
- data/lib/paper_trail_association_tracking/frameworks/active_record/models/paper_trail/version_association.rb +13 -0
- data/lib/paper_trail_association_tracking/frameworks/rails.rb +3 -0
- data/lib/paper_trail_association_tracking/frameworks/rails/engine.rb +10 -0
- data/lib/paper_trail_association_tracking/frameworks/rspec.rb +20 -0
- data/lib/paper_trail_association_tracking/model_config.rb +76 -0
- data/lib/paper_trail_association_tracking/paper_trail.rb +38 -0
- data/lib/paper_trail_association_tracking/record_trail.rb +200 -0
- data/lib/paper_trail_association_tracking/reifier.rb +125 -0
- data/lib/paper_trail_association_tracking/reifiers/belongs_to.rb +50 -0
- data/lib/paper_trail_association_tracking/reifiers/has_and_belongs_to_many.rb +52 -0
- data/lib/paper_trail_association_tracking/reifiers/has_many.rb +112 -0
- data/lib/paper_trail_association_tracking/reifiers/has_many_through.rb +92 -0
- data/lib/paper_trail_association_tracking/reifiers/has_one.rb +135 -0
- data/lib/paper_trail_association_tracking/request.rb +32 -0
- data/lib/paper_trail_association_tracking/version.rb +5 -0
- data/lib/paper_trail_association_tracking/version_association_concern.rb +13 -0
- data/lib/paper_trail_association_tracking/version_concern.rb +37 -0
- 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,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
|