paper_trail_diff 0.1.0
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 +45 -0
- data/LICENSE +22 -0
- data/README.md +473 -0
- data/lib/paper_trail_diff/activity_boundary.rb +82 -0
- data/lib/paper_trail_diff/activity_event.rb +128 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
- data/lib/paper_trail_diff/activity_range.rb +58 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
- data/lib/paper_trail_diff/analysis.rb +26 -0
- data/lib/paper_trail_diff/association_discovery.rb +132 -0
- data/lib/paper_trail_diff/association_traversal.rb +131 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
- data/lib/paper_trail_diff/configuration.rb +170 -0
- data/lib/paper_trail_diff/diagnostics.rb +242 -0
- data/lib/paper_trail_diff/endpoint.rb +87 -0
- data/lib/paper_trail_diff/engine.rb +180 -0
- data/lib/paper_trail_diff/errors.rb +34 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
- data/lib/paper_trail_diff/live_association_reader.rb +10 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
- data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
- data/lib/paper_trail_diff/prepared_history.rb +217 -0
- data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
- data/lib/paper_trail_diff/snapshot.rb +120 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
- data/lib/paper_trail_diff/step.rb +28 -0
- data/lib/paper_trail_diff/support.rb +80 -0
- data/lib/paper_trail_diff/timeline_builder.rb +60 -0
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
- data/lib/paper_trail_diff/value_objects.rb +134 -0
- data/lib/paper_trail_diff/version.rb +6 -0
- data/lib/paper_trail_diff/version_range.rb +81 -0
- data/lib/paper_trail_diff.rb +131 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
- data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
- data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
- data/sig/generated/paper_trail_diff/engine.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +43 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
- data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
- data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
- data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
- data/sig/generated/paper_trail_diff/step.rbs +18 -0
- data/sig/generated/paper_trail_diff/support.rbs +30 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
- data/sig/generated/paper_trail_diff/version.rbs +5 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
- data/sig/generated/paper_trail_diff.rbs +56 -0
- metadata +144 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One immutable historical-reconstruction setup finding.
|
|
6
|
+
class DiagnosticIssue
|
|
7
|
+
attr_reader :severity #: Symbol
|
|
8
|
+
attr_reader :code #: Symbol
|
|
9
|
+
attr_reader :message #: String
|
|
10
|
+
attr_reader :path #: String?
|
|
11
|
+
attr_reader :version_id #: untyped
|
|
12
|
+
|
|
13
|
+
#: (severity: Symbol, code: Symbol, message: String, ?path: String?, ?version_id: untyped) -> void
|
|
14
|
+
def initialize(severity:, code:, message:, path: nil, version_id: nil)
|
|
15
|
+
@severity = severity
|
|
16
|
+
@code = code
|
|
17
|
+
@message = Support.immutable_copy(message)
|
|
18
|
+
@path = Support.immutable_copy(path)
|
|
19
|
+
@version_id = Support.immutable_copy(version_id)
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: () -> Hash[Symbol, untyped]
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
severity: severity,
|
|
27
|
+
code: code,
|
|
28
|
+
message: message,
|
|
29
|
+
path: path,
|
|
30
|
+
version_id: version_id
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Immutable diagnostics for a requested historical association comparison.
|
|
36
|
+
class DiagnosticReport
|
|
37
|
+
attr_reader :issues #: Array[DiagnosticIssue]
|
|
38
|
+
|
|
39
|
+
#: (issues: Array[DiagnosticIssue]) -> void
|
|
40
|
+
def initialize(issues:)
|
|
41
|
+
@issues = issues.dup.freeze
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: () -> Array[DiagnosticIssue]
|
|
46
|
+
def errors
|
|
47
|
+
issues.select { |issue| issue.severity == :error }.freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> Array[DiagnosticIssue]
|
|
51
|
+
def warnings
|
|
52
|
+
issues.select { |issue| issue.severity == :warning }.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> bool
|
|
56
|
+
def ok?
|
|
57
|
+
errors.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: () -> Hash[Symbol, untyped]
|
|
61
|
+
def to_h
|
|
62
|
+
{ ok: ok?, issues: Support.serialize(issues) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Performs read-only checks for known PaperTrail/PT-AT reconstruction hazards.
|
|
67
|
+
class HistoryDiagnostics
|
|
68
|
+
#: (untyped, untyped, associations: Array[String | Symbol]) -> void
|
|
69
|
+
def initialize(from_version, to_version, associations:)
|
|
70
|
+
@from_version = from_version
|
|
71
|
+
@to_version = to_version
|
|
72
|
+
@tree = AssociationTree.build(associations)
|
|
73
|
+
@issues = [] #: Array[DiagnosticIssue]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: () -> DiagnosticReport
|
|
77
|
+
def call
|
|
78
|
+
model_class = validated_model_class
|
|
79
|
+
return DiagnosticReport.new(issues: []) if @tree.empty?
|
|
80
|
+
|
|
81
|
+
unless association_tracking_available?
|
|
82
|
+
add_error(:association_tracking_unavailable, tracking_unavailable_message)
|
|
83
|
+
return DiagnosticReport.new(issues: @issues)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
traversal = AssociationTraversal.new(@tree)
|
|
87
|
+
selected = traversal.selected_reflections(model_class)
|
|
88
|
+
inspect_targets(selected)
|
|
89
|
+
inspect_checkpoint_timestamp(model_class)
|
|
90
|
+
inspect_habtm(selected)
|
|
91
|
+
DiagnosticReport.new(issues: @issues)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# @rbs @from_version: untyped
|
|
97
|
+
# @rbs @to_version: untyped
|
|
98
|
+
# @rbs @tree: AssociationTree
|
|
99
|
+
# @rbs @issues: Array[DiagnosticIssue]
|
|
100
|
+
|
|
101
|
+
#: () -> untyped
|
|
102
|
+
def validated_model_class
|
|
103
|
+
unless version_identity(@from_version) == version_identity(@to_version)
|
|
104
|
+
raise VersionMismatchError, 'versions must belong to the same PaperTrail item'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Object.const_get(version_type(@from_version))
|
|
108
|
+
rescue NoMethodError => e
|
|
109
|
+
raise VersionMismatchError, 'expected PaperTrail version endpoints', cause: e
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: (Array[Array[untyped]]) -> void
|
|
113
|
+
def inspect_targets(selected)
|
|
114
|
+
selected.each do |path, reflection|
|
|
115
|
+
if reflection.polymorphic?
|
|
116
|
+
add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
inspect_versioned_model(reflection.klass, path)
|
|
121
|
+
inspect_through_model(reflection, path) if reflection.options[:through]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
#: (Array[Array[untyped]]) -> void
|
|
126
|
+
def inspect_habtm(selected)
|
|
127
|
+
paths = selected.filter_map do |path, reflection|
|
|
128
|
+
path if reflection.macro == :has_and_belongs_to_many
|
|
129
|
+
end
|
|
130
|
+
return if paths.empty?
|
|
131
|
+
|
|
132
|
+
inspect_transaction_metadata(paths)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (untyped) -> void
|
|
136
|
+
def inspect_checkpoint_timestamp(model_class)
|
|
137
|
+
return if synchronized_timestamp_disabled?(model_class)
|
|
138
|
+
|
|
139
|
+
message = 'manual checkpoints should set synchronize_version_creation_timestamp: false'
|
|
140
|
+
add_warning(:synchronized_version_timestamp, message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
#: (Array[String]) -> void
|
|
144
|
+
def inspect_transaction_metadata(paths)
|
|
145
|
+
[@from_version, @to_version].each do |version|
|
|
146
|
+
if !version.respond_to?(:transaction_id)
|
|
147
|
+
add_error(:transaction_id_column_missing, 'version has no transaction_id column')
|
|
148
|
+
elsif version.transaction_id.nil?
|
|
149
|
+
paths.each do |path|
|
|
150
|
+
add_error(
|
|
151
|
+
:transaction_id_missing,
|
|
152
|
+
'HABTM endpoint has no transaction-backed association snapshot',
|
|
153
|
+
path,
|
|
154
|
+
version.id
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
#: (untyped, String) -> void
|
|
162
|
+
def inspect_versioned_model(model_class, path)
|
|
163
|
+
return if model_versioned?(model_class)
|
|
164
|
+
|
|
165
|
+
add_warning(
|
|
166
|
+
:unversioned_association_target,
|
|
167
|
+
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
168
|
+
path
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
#: (untyped, String) -> void
|
|
173
|
+
def inspect_through_model(reflection, path)
|
|
174
|
+
model_class = reflection.through_reflection.klass
|
|
175
|
+
return if model_versioned?(model_class)
|
|
176
|
+
|
|
177
|
+
add_warning(
|
|
178
|
+
:unversioned_through_model,
|
|
179
|
+
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
180
|
+
path
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
#: (untyped) -> bool
|
|
185
|
+
def model_versioned?(model_class)
|
|
186
|
+
model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
#: (untyped) -> bool
|
|
190
|
+
def synchronized_timestamp_disabled?(model_class)
|
|
191
|
+
options = model_class.paper_trail_options
|
|
192
|
+
options[:synchronize_version_creation_timestamp] == false
|
|
193
|
+
rescue NoMethodError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
#: () -> bool
|
|
198
|
+
def association_tracking_available?
|
|
199
|
+
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
200
|
+
config = paper_trail.config #: untyped
|
|
201
|
+
!!(defined?(::PaperTrailAssociationTracking) &&
|
|
202
|
+
config.respond_to?(:track_associations?) && config.track_associations?)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
#: (untyped) -> Array[String]
|
|
206
|
+
def version_identity(version)
|
|
207
|
+
[version.item_type.to_s, version.item_id.to_s]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
#: (untyped) -> String
|
|
211
|
+
def version_type(version)
|
|
212
|
+
subtype = version.item_subtype if version.respond_to?(:item_subtype)
|
|
213
|
+
subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
#: () -> String
|
|
217
|
+
def tracking_unavailable_message
|
|
218
|
+
'association tracking must be loaded and enabled to diagnose associations'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
#: (Symbol, String, ?String?, ?untyped) -> void
|
|
222
|
+
def add_error(code, message, path = nil, version_id = nil)
|
|
223
|
+
add_issue(:error, code, message, path, version_id)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
#: (Symbol, String, ?String?) -> void
|
|
227
|
+
def add_warning(code, message, path = nil)
|
|
228
|
+
add_issue(:warning, code, message, path, nil)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
#: (Symbol, Symbol, String, String?, untyped) -> void
|
|
232
|
+
def add_issue(severity, code, message, path, version_id)
|
|
233
|
+
@issues << DiagnosticIssue.new(
|
|
234
|
+
severity: severity,
|
|
235
|
+
code: code,
|
|
236
|
+
message: message,
|
|
237
|
+
path: path,
|
|
238
|
+
version_id: version_id
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Validates and describes explicit PaperTrail-version and live-record endpoints.
|
|
6
|
+
module Endpoint
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
#: (untyped, untyped) -> void
|
|
10
|
+
def validate_pair!(from_endpoint, to_endpoint)
|
|
11
|
+
validate!(from_endpoint)
|
|
12
|
+
validate!(to_endpoint)
|
|
13
|
+
return if identity(from_endpoint) == identity(to_endpoint)
|
|
14
|
+
|
|
15
|
+
raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (untyped) -> void
|
|
19
|
+
def validate!(endpoint)
|
|
20
|
+
return if version?(endpoint)
|
|
21
|
+
|
|
22
|
+
validate_record!(endpoint)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: (untyped) -> bool
|
|
26
|
+
def version?(endpoint)
|
|
27
|
+
endpoint.respond_to?(:reify) &&
|
|
28
|
+
endpoint.respond_to?(:item_type) &&
|
|
29
|
+
endpoint.respond_to?(:item_id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: (untyped) -> bool
|
|
33
|
+
def record?(endpoint)
|
|
34
|
+
!version?(endpoint) &&
|
|
35
|
+
endpoint.respond_to?(:persisted?) &&
|
|
36
|
+
endpoint.respond_to?(:destroyed?) &&
|
|
37
|
+
endpoint.class.respond_to?(:base_class)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped) -> Array[String]
|
|
41
|
+
def identity(endpoint)
|
|
42
|
+
if version?(endpoint)
|
|
43
|
+
[endpoint.item_type.to_s, endpoint.item_id.to_s]
|
|
44
|
+
elsif record?(endpoint)
|
|
45
|
+
[endpoint.class.base_class.name.to_s, endpoint.id.to_s]
|
|
46
|
+
else
|
|
47
|
+
raise InvalidEndpointError, 'expected a PaperTrail version or persisted record endpoint'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (untyped) -> untyped
|
|
52
|
+
def model_class(version)
|
|
53
|
+
subtype = version.item_subtype if version.respond_to?(:item_subtype)
|
|
54
|
+
model_type = subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
|
|
55
|
+
Object.const_get(model_type)
|
|
56
|
+
rescue NameError => e
|
|
57
|
+
raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (untyped) -> untyped
|
|
61
|
+
def reload_record(record)
|
|
62
|
+
validate_record!(record)
|
|
63
|
+
record.class.unscoped.find(record.id)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
active_record = Object.const_get(:ActiveRecord) #: untyped
|
|
66
|
+
record_not_found = active_record.const_get(:RecordNotFound) #: untyped
|
|
67
|
+
raise unless e.is_a?(record_not_found)
|
|
68
|
+
|
|
69
|
+
message = 'current record endpoint could not be reloaded from the database'
|
|
70
|
+
raise InvalidEndpointError, message, cause: e
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#: (untyped) -> void
|
|
74
|
+
def validate_record!(record)
|
|
75
|
+
unless record?(record)
|
|
76
|
+
raise InvalidEndpointError, 'expected a PaperTrail version or persisted record endpoint'
|
|
77
|
+
end
|
|
78
|
+
unless record.persisted? && !record.destroyed?
|
|
79
|
+
raise InvalidEndpointError, 'current record endpoint must be persisted and not destroyed'
|
|
80
|
+
end
|
|
81
|
+
return unless record.respond_to?(:has_changes_to_save?) && record.has_changes_to_save?
|
|
82
|
+
|
|
83
|
+
raise InvalidEndpointError, 'current record endpoint must not have unsaved changes'
|
|
84
|
+
end
|
|
85
|
+
private_class_method :validate_record!
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Pure snapshot comparison. This class has no ActiveRecord or PaperTrail dependencies.
|
|
6
|
+
class Engine
|
|
7
|
+
class << self
|
|
8
|
+
#: (RecordSnapshot?, RecordSnapshot?) -> Diff
|
|
9
|
+
def compare(from_snapshot, to_snapshot)
|
|
10
|
+
return Diff.new if from_snapshot.equal?(to_snapshot)
|
|
11
|
+
|
|
12
|
+
if from_snapshot.nil? || to_snapshot.nil?
|
|
13
|
+
change = ValueChange.new(from: from_snapshot, to: to_snapshot)
|
|
14
|
+
return Diff.new(record_presence_change: change)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Diff.new(
|
|
18
|
+
attributes: compare_attributes(from_snapshot.attributes, to_snapshot.attributes),
|
|
19
|
+
associations: compare_associations(from_snapshot.associations, to_snapshot.associations)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
#: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, ValueChange]
|
|
26
|
+
def compare_attributes(from_attributes, to_attributes)
|
|
27
|
+
changes = {} #: attribute_changes
|
|
28
|
+
(from_attributes.keys | to_attributes.keys).sort.each do |name|
|
|
29
|
+
from_value = from_attributes[name]
|
|
30
|
+
to_value = to_attributes[name]
|
|
31
|
+
next if from_value == to_value
|
|
32
|
+
|
|
33
|
+
changes[name] = ValueChange.new(from: from_value, to: to_value)
|
|
34
|
+
end
|
|
35
|
+
changes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: (association_snapshots, association_snapshots) -> association_diffs
|
|
39
|
+
def compare_associations(from_associations, to_associations)
|
|
40
|
+
changes = {} #: association_diffs
|
|
41
|
+
(from_associations.keys | to_associations.keys).sort.each do |name|
|
|
42
|
+
next if from_associations[name].equal?(to_associations[name])
|
|
43
|
+
|
|
44
|
+
from_association, to_association = association_pair(
|
|
45
|
+
from_associations[name],
|
|
46
|
+
to_associations[name]
|
|
47
|
+
)
|
|
48
|
+
difference = compare_association(from_association, to_association)
|
|
49
|
+
changes[name] = difference unless difference.empty?
|
|
50
|
+
end
|
|
51
|
+
changes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (AssociationSnapshot?, AssociationSnapshot?) -> [AssociationSnapshot, AssociationSnapshot]
|
|
55
|
+
def association_pair(from_association, to_association)
|
|
56
|
+
validate_association_kinds!(from_association, to_association)
|
|
57
|
+
association = from_association || to_association
|
|
58
|
+
# @type var association: AssociationSnapshot
|
|
59
|
+
kind = association.kind
|
|
60
|
+
|
|
61
|
+
[
|
|
62
|
+
from_association || AssociationSnapshot.new(kind: kind, records: []),
|
|
63
|
+
to_association || AssociationSnapshot.new(kind: kind, records: [])
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (AssociationSnapshot?, AssociationSnapshot?) -> void
|
|
68
|
+
def validate_association_kinds!(from_association, to_association)
|
|
69
|
+
return unless kinds_differ?(from_association, to_association)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, 'association kind changed between snapshots'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: (AssociationSnapshot?, AssociationSnapshot?) -> bool
|
|
75
|
+
def kinds_differ?(from_association, to_association)
|
|
76
|
+
return false unless from_association && to_association
|
|
77
|
+
|
|
78
|
+
from_association.kind != to_association.kind
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (AssociationSnapshot, AssociationSnapshot) -> association_diff
|
|
82
|
+
def compare_association(from_association, to_association)
|
|
83
|
+
if AssociationSnapshot.collection_kind?(from_association.kind)
|
|
84
|
+
return compare_collection(from_association, to_association)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
compare_to_one(from_association, to_association)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (AssociationSnapshot, AssociationSnapshot) -> ToOneAssociationDiff
|
|
91
|
+
def compare_to_one(from_association, to_association)
|
|
92
|
+
from_record = from_association.records.first
|
|
93
|
+
to_record = to_association.records.first
|
|
94
|
+
if same_identity?(from_record, to_record)
|
|
95
|
+
return ToOneAssociationDiff.new(
|
|
96
|
+
kind: from_association.kind,
|
|
97
|
+
relationship: nil,
|
|
98
|
+
changed: compare_record(from_record, to_record)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
ToOneAssociationDiff.new(
|
|
103
|
+
kind: from_association.kind,
|
|
104
|
+
relationship: relationship_change(from_record, to_record),
|
|
105
|
+
changed: nil
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (RecordSnapshot?, RecordSnapshot?) -> ValueChange?
|
|
110
|
+
def relationship_change(from_record, to_record)
|
|
111
|
+
return unless from_record || to_record
|
|
112
|
+
|
|
113
|
+
ValueChange.new(from: from_record, to: to_record)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
#: (RecordSnapshot, RecordSnapshot) -> RecordChange?
|
|
117
|
+
def compare_record(from_record, to_record)
|
|
118
|
+
attributes = compare_attributes(from_record.attributes, to_record.attributes)
|
|
119
|
+
associations = compare_associations(from_record.associations, to_record.associations)
|
|
120
|
+
return if attributes.empty? && associations.empty?
|
|
121
|
+
|
|
122
|
+
RecordChange.new(record: to_record, attributes: attributes, associations: associations)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
#: (AssociationSnapshot, AssociationSnapshot) -> CollectionAssociationDiff
|
|
126
|
+
def compare_collection(from_association, to_association)
|
|
127
|
+
from_records = index_records(from_association.records)
|
|
128
|
+
to_records = index_records(to_association.records)
|
|
129
|
+
|
|
130
|
+
CollectionAssociationDiff.new(
|
|
131
|
+
kind: from_association.kind,
|
|
132
|
+
added: records_missing_from(to_records, from_records),
|
|
133
|
+
removed: records_missing_from(from_records, to_records),
|
|
134
|
+
changed: changed_records(from_records, to_records)
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
|
|
139
|
+
def records_missing_from(records, other_records)
|
|
140
|
+
identities = sorted_identities(records.keys - other_records.keys)
|
|
141
|
+
identities.map { |identity| records.fetch(identity) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
|
|
145
|
+
def changed_records(from_records, to_records)
|
|
146
|
+
sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
|
|
147
|
+
from_record = from_records.fetch(identity)
|
|
148
|
+
to_record = to_records.fetch(identity)
|
|
149
|
+
next if from_record.equal?(to_record)
|
|
150
|
+
|
|
151
|
+
compare_record(from_record, to_record)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: (Array[RecordSnapshot]) -> Hash[Array[untyped], RecordSnapshot]
|
|
156
|
+
def index_records(records)
|
|
157
|
+
index = {} #: Hash[identity, RecordSnapshot]
|
|
158
|
+
records.each do |record|
|
|
159
|
+
identity = record.identity
|
|
160
|
+
if index.key?(identity)
|
|
161
|
+
raise ArgumentError, "duplicate record identity: #{identity.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
index[identity] = record
|
|
165
|
+
end
|
|
166
|
+
index
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
170
|
+
def sorted_identities(identities)
|
|
171
|
+
identities.sort_by { |type, id| [type, id.inspect] }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
#: (RecordSnapshot?, RecordSnapshot?) -> bool
|
|
175
|
+
def same_identity?(from_record, to_record)
|
|
176
|
+
from_record && to_record ? from_record.identity == to_record.identity : false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Base error for failures detected by paper_trail_diff.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when a public option has an invalid type, key, value, or path.
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when compare endpoints do not belong to the same PaperTrail item.
|
|
12
|
+
class VersionMismatchError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a live endpoint is not a clean, persisted ActiveRecord record.
|
|
15
|
+
class InvalidEndpointError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when a timeline boundary is absent or the requested range is reversed.
|
|
18
|
+
class InvalidTimelineRangeError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when a requested ActiveRecord association does not exist.
|
|
21
|
+
class UnknownAssociationError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when a requested association macro is not supported.
|
|
24
|
+
class UnsupportedAssociationError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when association comparison is requested without an available PT-AT setup.
|
|
27
|
+
class AssociationTrackingUnavailableError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association.
|
|
30
|
+
class IncompleteAssociationHistoryError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when current-ended activity cannot be reconstructed for a selected association.
|
|
33
|
+
class UnsupportedLiveActivityError < Error; end
|
|
34
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reconstructs selected nested edges at one root PaperTrail endpoint.
|
|
6
|
+
class HistoricalAssociationReifier
|
|
7
|
+
#: (untyped, ?habtm_version: untyped) -> void
|
|
8
|
+
def initialize(version, habtm_version: version)
|
|
9
|
+
@transaction_id = version.transaction_id if version.respond_to?(:transaction_id)
|
|
10
|
+
if habtm_version.respond_to?(:transaction_id)
|
|
11
|
+
@habtm_transaction_id = habtm_version.transaction_id
|
|
12
|
+
end
|
|
13
|
+
@version_at = version.created_at
|
|
14
|
+
@reified_associations = {} #: Hash[Array[untyped], bool]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, Array[untyped]) -> void
|
|
18
|
+
def reify(record, reflections)
|
|
19
|
+
reflections.each { |reflection| reify_association(record, reflection) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs @transaction_id: untyped
|
|
25
|
+
# @rbs @habtm_transaction_id: untyped
|
|
26
|
+
# @rbs @version_at: untyped
|
|
27
|
+
# @rbs @reified_associations: Hash[Array[untyped], bool]
|
|
28
|
+
|
|
29
|
+
#: (untyped, untyped) -> void
|
|
30
|
+
def reify_association(record, reflection)
|
|
31
|
+
key = [record.object_id, reflection.name]
|
|
32
|
+
return if @reified_associations[key]
|
|
33
|
+
|
|
34
|
+
@reified_associations[key] = true
|
|
35
|
+
case reflection.macro
|
|
36
|
+
when :belongs_to
|
|
37
|
+
reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
|
|
38
|
+
when :has_one
|
|
39
|
+
reifier(:HasOne).reify(reflection, record, options, @transaction_id)
|
|
40
|
+
when :has_many
|
|
41
|
+
reify_has_many(record, reflection)
|
|
42
|
+
when :has_and_belongs_to_many
|
|
43
|
+
reify_habtm(record, reflection)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: (untyped, untyped) -> void
|
|
48
|
+
def reify_has_many(record, reflection)
|
|
49
|
+
if reflection.options[:through]
|
|
50
|
+
reify_association(record, reflection.through_reflection)
|
|
51
|
+
reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
version_table = record.class.paper_trail.version_class.table_name
|
|
56
|
+
reifier(:HasMany).reify(
|
|
57
|
+
reflection,
|
|
58
|
+
record,
|
|
59
|
+
options,
|
|
60
|
+
@transaction_id,
|
|
61
|
+
version_table
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: (untyped, untyped) -> void
|
|
66
|
+
def reify_habtm(record, reflection)
|
|
67
|
+
unless @habtm_transaction_id
|
|
68
|
+
message = 'HABTM reconstruction requires a transaction-backed endpoint version'
|
|
69
|
+
raise IncompleteAssociationHistoryError, message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
73
|
+
target_versioned = paper_trail.request.enabled_for_model?(reflection.klass)
|
|
74
|
+
reifier(:HasAndBelongsToMany).reify(
|
|
75
|
+
target_versioned,
|
|
76
|
+
reflection,
|
|
77
|
+
record,
|
|
78
|
+
options,
|
|
79
|
+
@habtm_transaction_id
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
#: () -> Hash[Symbol, untyped]
|
|
84
|
+
def options
|
|
85
|
+
{
|
|
86
|
+
dup: true,
|
|
87
|
+
mark_for_destruction: false,
|
|
88
|
+
unversioned_attributes: :nil,
|
|
89
|
+
version_at: @version_at
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
#: (Symbol) -> untyped
|
|
94
|
+
def reifier(name)
|
|
95
|
+
tracking = Object.const_get(:PaperTrailAssociationTracking) #: untyped
|
|
96
|
+
reifiers = tracking.const_get(:Reifiers) #: untyped
|
|
97
|
+
reifiers.const_get(name)
|
|
98
|
+
rescue NameError => e
|
|
99
|
+
message = 'loaded association tracking does not support nested reconstruction'
|
|
100
|
+
raise AssociationTrackingUnavailableError, message, cause: e
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|