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