paper_trail_diff 0.14.0 → 0.15.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 +4 -4
- data/CHANGELOG.md +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
|
@@ -64,65 +64,92 @@ module PaperTrailDiff
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Performs read-only checks for known PaperTrail/PT-AT reconstruction hazards.
|
|
67
|
+
#
|
|
68
|
+
# The endpoints are a comparison's: two versions, or a version and the
|
|
69
|
+
# current record, in either order. Two current records read no history, so
|
|
70
|
+
# nothing is checked.
|
|
67
71
|
class HistoryDiagnostics
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
STOPPING_CODES = %i[
|
|
73
|
+
unknown_association unsupported_association association_tracking_unavailable
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
#: (untyped, untyped, associations: association_option, ?allow_approximate: bool) -> void
|
|
77
|
+
def initialize(from, to, associations:, allow_approximate: false)
|
|
78
|
+
@versions = validated_versions(from, to)
|
|
72
79
|
@tree = AssociationTree.build(associations)
|
|
80
|
+
@traversal = AssociationTraversal.new(@tree)
|
|
81
|
+
@preparer = TraversalPreparer.new(tree: @tree, traversal: @traversal,
|
|
82
|
+
allow_approximate: allow_approximate)
|
|
83
|
+
@allow_approximate = allow_approximate
|
|
73
84
|
@issues = [] #: Array[DiagnosticIssue]
|
|
74
85
|
end
|
|
75
86
|
|
|
76
87
|
#: () -> DiagnosticReport
|
|
77
88
|
def call
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
unless association_tracking_available?
|
|
86
|
-
add_error(:association_tracking_unavailable, tracking_unavailable_message)
|
|
87
|
-
return DiagnosticReport.new(issues: @issues)
|
|
89
|
+
unless @versions.empty?
|
|
90
|
+
model_class = Endpoint.model_class(@versions.fetch(0))
|
|
91
|
+
# Runs whether or not associations are selected: unorderable versions
|
|
92
|
+
# corrupt a scalar timeline just as surely, and a report that inspected
|
|
93
|
+
# nothing has no business answering `ok?`.
|
|
94
|
+
inspect_version_sequence(model_class)
|
|
95
|
+
inspect_associations(model_class) unless @tree.empty?
|
|
88
96
|
end
|
|
89
|
-
|
|
90
|
-
traversal = AssociationTraversal.new(@tree)
|
|
91
|
-
selected = traversal.selected_reflections(model_class)
|
|
92
|
-
inspect_targets(selected)
|
|
93
|
-
inspect_checkpoint_timestamp(model_class)
|
|
94
|
-
inspect_habtm(model_class, selected)
|
|
95
97
|
DiagnosticReport.new(issues: @issues)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
private
|
|
99
101
|
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
+
# Each refusal a comparison would raise is an error, so `ok?` is false
|
|
103
|
+
# exactly when one would. The checks after them read reflections that must
|
|
104
|
+
# resolve and PT-AT records that must be there.
|
|
105
|
+
#: (untyped) -> void
|
|
106
|
+
def inspect_associations(model_class)
|
|
107
|
+
refusals = @preparer.refusals(model_class, historical: true)
|
|
108
|
+
refusals.each { |refusal| add_error(refusal.code, refusal.message, refusal.path) }
|
|
109
|
+
return if refusals.any? { |refusal| STOPPING_CODES.include?(refusal.code) }
|
|
110
|
+
|
|
111
|
+
selected = @traversal.selected_reflections(model_class)
|
|
112
|
+
inspect_polymorphic_targets(selected)
|
|
113
|
+
inspect_approximate_paths(model_class) if @allow_approximate
|
|
114
|
+
inspect_checkpoint_timestamp(model_class)
|
|
115
|
+
inspect_habtm(model_class, selected)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @rbs @versions: Array[untyped]
|
|
102
119
|
# @rbs @tree: AssociationTree
|
|
120
|
+
# @rbs @traversal: AssociationTraversal
|
|
121
|
+
# @rbs @preparer: TraversalPreparer
|
|
122
|
+
# @rbs @allow_approximate: bool
|
|
103
123
|
# @rbs @issues: Array[DiagnosticIssue]
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
# The endpoints that are versions, in the order given.
|
|
126
|
+
#: (untyped, untyped) -> Array[untyped]
|
|
127
|
+
def validated_versions(from, to)
|
|
128
|
+
Endpoint.validate!(from)
|
|
129
|
+
Endpoint.validate!(to)
|
|
130
|
+
unless Endpoint.identity(from) == Endpoint.identity(to)
|
|
131
|
+
raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
|
|
109
132
|
end
|
|
110
133
|
|
|
111
|
-
|
|
112
|
-
rescue NoMethodError => e
|
|
113
|
-
raise VersionMismatchError, 'expected PaperTrail version endpoints', cause: e
|
|
134
|
+
[from, to].select { |endpoint| Endpoint.version?(endpoint) }
|
|
114
135
|
end
|
|
115
136
|
|
|
116
137
|
#: (Array[Array[untyped]]) -> void
|
|
117
|
-
def
|
|
138
|
+
def inspect_polymorphic_targets(selected)
|
|
118
139
|
selected.each do |path, reflection|
|
|
119
|
-
|
|
120
|
-
add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
|
|
121
|
-
next
|
|
122
|
-
end
|
|
140
|
+
next unless reflection.polymorphic?
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Accepted, but read the way `allow_approximate:` describes.
|
|
147
|
+
#: (untyped) -> void
|
|
148
|
+
def inspect_approximate_paths(model_class)
|
|
149
|
+
ApproximatePaths.call(@traversal, model_class).each do |path, reason|
|
|
150
|
+
add_warning(:approximate_association,
|
|
151
|
+
"association #{path} is read approximately with PT-AT (#{reason}): a " \
|
|
152
|
+
'record that entered or left it since is reported wrongly', path)
|
|
126
153
|
end
|
|
127
154
|
end
|
|
128
155
|
|
|
@@ -139,9 +166,7 @@ module PaperTrailDiff
|
|
|
139
166
|
# transaction that more than one owner recorded members in cannot be read.
|
|
140
167
|
#: (untyped, Array[Array[untyped]]) -> void
|
|
141
168
|
def inspect_shared_transactions(model_class, habtm)
|
|
142
|
-
versions =
|
|
143
|
-
version.respond_to?(:transaction_id) && version.transaction_id
|
|
144
|
-
end
|
|
169
|
+
versions = @versions.select { |version| VersionBoundary.transaction_id(version) }
|
|
145
170
|
habtm.each do |path, reflection|
|
|
146
171
|
owners = HabtmTransactionOwners.new(model_class, reflection)
|
|
147
172
|
versions.each do |version|
|
|
@@ -154,15 +179,29 @@ module PaperTrailDiff
|
|
|
154
179
|
end
|
|
155
180
|
end
|
|
156
181
|
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
# With the current record as one endpoint there is one version, and the
|
|
183
|
+
# sequence runs on from it.
|
|
184
|
+
#: (untyped) -> void
|
|
185
|
+
def inspect_version_sequence(model_class)
|
|
186
|
+
first, last = @versions
|
|
159
187
|
@issues.concat(
|
|
160
|
-
VersionSequenceDiagnostics.new(
|
|
161
|
-
|
|
162
|
-
).call
|
|
188
|
+
VersionSequenceDiagnostics.new(first, last,
|
|
189
|
+
fallback_selected: fallback_selected?(model_class)).call
|
|
163
190
|
)
|
|
164
191
|
end
|
|
165
192
|
|
|
193
|
+
# Only a path PT-AT reads can lose a change between tied versions, and a
|
|
194
|
+
# traversal that does not resolve reads nothing at all.
|
|
195
|
+
#: (untyped) -> bool
|
|
196
|
+
def fallback_selected?(model_class)
|
|
197
|
+
return false if @tree.empty?
|
|
198
|
+
|
|
199
|
+
refusals = @preparer.refusals(model_class, historical: true)
|
|
200
|
+
return false if refusals.any? { |refusal| STOPPING_CODES.include?(refusal.code) }
|
|
201
|
+
|
|
202
|
+
FallbackPaths.any?(@traversal, model_class, approximate: @allow_approximate)
|
|
203
|
+
end
|
|
204
|
+
|
|
166
205
|
#: (untyped) -> void
|
|
167
206
|
def inspect_checkpoint_timestamp(model_class)
|
|
168
207
|
return if synchronized_timestamp_disabled?(model_class)
|
|
@@ -173,7 +212,7 @@ module PaperTrailDiff
|
|
|
173
212
|
|
|
174
213
|
#: (Array[String]) -> void
|
|
175
214
|
def inspect_transaction_metadata(paths)
|
|
176
|
-
|
|
215
|
+
@versions.each do |version|
|
|
177
216
|
if !version.respond_to?(:transaction_id)
|
|
178
217
|
add_error(:transaction_id_column_missing, 'version has no transaction_id column')
|
|
179
218
|
elsif version.transaction_id.nil?
|
|
@@ -189,36 +228,6 @@ module PaperTrailDiff
|
|
|
189
228
|
end
|
|
190
229
|
end
|
|
191
230
|
|
|
192
|
-
#: (untyped, String) -> void
|
|
193
|
-
def inspect_versioned_model(model_class, path)
|
|
194
|
-
return if model_versioned?(model_class)
|
|
195
|
-
|
|
196
|
-
# An error rather than a warning: `ok?` must not stay true for a
|
|
197
|
-
# comparison that cannot work, and this one raises at runtime.
|
|
198
|
-
add_error(
|
|
199
|
-
:unversioned_association_target,
|
|
200
|
-
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
201
|
-
path
|
|
202
|
-
)
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
#: (untyped, String) -> void
|
|
206
|
-
def inspect_through_model(reflection, path)
|
|
207
|
-
model_class = reflection.through_reflection.klass
|
|
208
|
-
return if model_versioned?(model_class)
|
|
209
|
-
|
|
210
|
-
add_warning(
|
|
211
|
-
:unversioned_through_model,
|
|
212
|
-
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
213
|
-
path
|
|
214
|
-
)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
#: (untyped) -> bool
|
|
218
|
-
def model_versioned?(model_class)
|
|
219
|
-
Support.versioned?(model_class)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
231
|
#: (untyped) -> bool
|
|
223
232
|
def synchronized_timestamp_disabled?(model_class)
|
|
224
233
|
options = model_class.paper_trail_options
|
|
@@ -227,30 +236,6 @@ module PaperTrailDiff
|
|
|
227
236
|
false
|
|
228
237
|
end
|
|
229
238
|
|
|
230
|
-
#: () -> bool
|
|
231
|
-
def association_tracking_available?
|
|
232
|
-
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
233
|
-
config = paper_trail.config #: untyped
|
|
234
|
-
!!(defined?(::PaperTrailAssociationTracking) &&
|
|
235
|
-
config.respond_to?(:track_associations?) && config.track_associations?)
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
#: (untyped) -> Array[String]
|
|
239
|
-
def version_identity(version)
|
|
240
|
-
[version.item_type.to_s, version.item_id.to_s]
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
#: (untyped) -> String
|
|
244
|
-
def version_type(version)
|
|
245
|
-
subtype = version.item_subtype if version.respond_to?(:item_subtype)
|
|
246
|
-
subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
#: () -> String
|
|
250
|
-
def tracking_unavailable_message
|
|
251
|
-
'association tracking must be loaded and enabled to diagnose associations'
|
|
252
|
-
end
|
|
253
|
-
|
|
254
239
|
#: (Symbol, String, ?String?, ?untyped) -> void
|
|
255
240
|
def add_error(code, message, path = nil, version_id = nil)
|
|
256
241
|
add_issue(:error, code, message, path, version_id)
|
|
@@ -47,6 +47,29 @@ module PaperTrailDiff
|
|
|
47
47
|
validate_record!(endpoint)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# The record a method reads the history of: a record, saved or destroyed,
|
|
51
|
+
# of a model PaperTrail versions.
|
|
52
|
+
#: (untyped) -> void
|
|
53
|
+
def validate_root!(record)
|
|
54
|
+
unless record?(record)
|
|
55
|
+
raise InvalidEndpointError, "expected a record, got #{described(record)}"
|
|
56
|
+
end
|
|
57
|
+
return if Support.versioned?(record.class)
|
|
58
|
+
|
|
59
|
+
raise InvalidEndpointError,
|
|
60
|
+
"#{record.class.name} is not versioned, so it has no history: add has_paper_trail to it"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A relation is named rather than inspected, which would load it.
|
|
64
|
+
#: (untyped) -> String
|
|
65
|
+
def described(value)
|
|
66
|
+
return 'nil' if value.nil?
|
|
67
|
+
return "the class #{value.name}" if value.is_a?(Module)
|
|
68
|
+
|
|
69
|
+
value.class.name.to_s
|
|
70
|
+
end
|
|
71
|
+
private_class_method :described
|
|
72
|
+
|
|
50
73
|
#: (untyped) -> bool
|
|
51
74
|
def version?(endpoint)
|
|
52
75
|
endpoint.respond_to?(:reify) &&
|
|
@@ -82,11 +105,11 @@ module PaperTrailDiff
|
|
|
82
105
|
raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
|
|
83
106
|
end
|
|
84
107
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
108
|
+
# Every PaperTrail version of the record, whatever scope its versions
|
|
109
|
+
# association has.
|
|
87
110
|
#: (untyped) -> untyped
|
|
88
111
|
def versions(record)
|
|
89
|
-
|
|
112
|
+
VersionQuery.for_items(record.class, record.id)
|
|
90
113
|
rescue NoMethodError => e
|
|
91
114
|
message = 'record does not expose a PaperTrail version history'
|
|
92
115
|
raise InvalidTimelineRangeError, message, cause: e
|
|
@@ -42,7 +42,9 @@ module PaperTrailDiff
|
|
|
42
42
|
# Historical candidate loading exceeded its explicitly bounded work budget.
|
|
43
43
|
class FilterLimitExceededError < Error; end
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# No longer raised: `allow_approximate: true` alone accepts the paths it
|
|
46
|
+
# refused. Kept until `allow_expensive:` is removed, so code that rescues it
|
|
47
|
+
# still loads.
|
|
46
48
|
class UnsupportedBatchPathError < ConfigurationError; end
|
|
47
49
|
|
|
48
50
|
# Raised when a requested ActiveRecord association does not exist.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Whether a request may read a path with PT-AT's reifier rather than from
|
|
6
|
+
# loaded history. PT-AT places association changes by timestamp alone, so
|
|
7
|
+
# only such a path can miss a change between two versions that share one;
|
|
8
|
+
# loaded history orders those versions by id.
|
|
9
|
+
module FallbackPaths
|
|
10
|
+
# A path history reads only approximately counts when it is allowed: a
|
|
11
|
+
# request that does not allow it is refused before anything is read.
|
|
12
|
+
#: (AssociationTraversal, untyped, approximate: bool) -> bool
|
|
13
|
+
def self.any?(traversal, model, approximate:)
|
|
14
|
+
return true if approximate && !ApproximatePaths.call(traversal, model).empty?
|
|
15
|
+
|
|
16
|
+
traversal.selected_reflections(model).any? { |_path, reflection| per_record?(reflection) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Loaded history cannot index a composite foreign key, and a polymorphic
|
|
20
|
+
# belongs_to's target, with its default scope, is known only per record.
|
|
21
|
+
#: (untyped) -> bool
|
|
22
|
+
def self.per_record?(reflection)
|
|
23
|
+
composite_key?(reflection) || (reflection.belongs_to? && reflection.polymorphic?)
|
|
24
|
+
end
|
|
25
|
+
private_class_method :per_record?
|
|
26
|
+
|
|
27
|
+
#: (untyped) -> bool
|
|
28
|
+
def self.composite_key?(reflection)
|
|
29
|
+
return true if reflection.foreign_key.is_a?(Array)
|
|
30
|
+
return false unless reflection.through_reflection?
|
|
31
|
+
|
|
32
|
+
[reflection.through_reflection, reflection.source_reflection].any? do |part|
|
|
33
|
+
composite_key?(part)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_class_method :composite_key?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -6,10 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
class HistoricalAssociationReifier
|
|
7
7
|
#: (untyped, ?habtm_version: untyped) -> void
|
|
8
8
|
def initialize(version, habtm_version: version)
|
|
9
|
-
@transaction_id =
|
|
10
|
-
|
|
11
|
-
@habtm_transaction_id = habtm_version.transaction_id
|
|
12
|
-
end
|
|
9
|
+
@transaction_id = VersionBoundary.transaction_id(version)
|
|
10
|
+
@habtm_transaction_id = VersionBoundary.transaction_id(habtm_version)
|
|
13
11
|
@version_at = version.created_at
|
|
14
12
|
# Keyed on record identity rather than `object_id`, which Ruby may reuse
|
|
15
13
|
# once an earlier record in the same pass has been collected.
|
|
@@ -24,8 +24,10 @@ module PaperTrailDiff
|
|
|
24
24
|
@state_loader = PreparedVersionStateLoader.new
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# More matches than the limit raise, unless the caller does not count
|
|
28
|
+
# them: candidates it only lists, such as deleted roots.
|
|
29
|
+
#: (Array[String], ?counted: bool) -> Array[String]
|
|
30
|
+
def call(candidates, counted: true)
|
|
29
31
|
matched = [] #: Array[String]
|
|
30
32
|
candidates.each_slice(CANDIDATE_BATCH) do |slice|
|
|
31
33
|
versions = candidate_versions(slice)
|
|
@@ -34,7 +36,7 @@ module PaperTrailDiff
|
|
|
34
36
|
next unless held?(versions.fetch(id, []), closing[id])
|
|
35
37
|
|
|
36
38
|
matched << id
|
|
37
|
-
reject_root_limit! if matched.length > @limit
|
|
39
|
+
reject_root_limit! if counted && matched.length > @limit
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
matched
|
|
@@ -64,7 +66,17 @@ module PaperTrailDiff
|
|
|
64
66
|
"unknown historical attributes: #{unknown.join(', ')}"
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
attributes.to_h { |name, value| [name, cast(name, value)] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Expected and recorded values compare as the attribute casts them, as
|
|
73
|
+
# `where` reads its values: an enum's symbol names its stored value, and a
|
|
74
|
+
# `json` or `jsonb` version column, which PaperTrail does not cast, holds
|
|
75
|
+
# dates and times as strings. Casting also copies a mutable value, so the
|
|
76
|
+
# filter keeps its own.
|
|
77
|
+
#: (String, untyped) -> untyped
|
|
78
|
+
def cast(name, value)
|
|
79
|
+
@model.type_for_attribute(name).cast(value)
|
|
68
80
|
end
|
|
69
81
|
|
|
70
82
|
#: (untyped) -> Integer
|
|
@@ -76,8 +88,7 @@ module PaperTrailDiff
|
|
|
76
88
|
|
|
77
89
|
#: (Array[String]) -> Hash[String, Array[untyped]]
|
|
78
90
|
def candidate_versions(ids)
|
|
79
|
-
relation =
|
|
80
|
-
item_id: ids)
|
|
91
|
+
relation = VersionQuery.for_items(@model, ids)
|
|
81
92
|
range = @time_range
|
|
82
93
|
relation = range.scope(relation) if range
|
|
83
94
|
# One past the remaining budget is enough to know it was exceeded.
|
|
@@ -107,9 +118,7 @@ module PaperTrailDiff
|
|
|
107
118
|
range = @time_range
|
|
108
119
|
return {} unless range
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
item_id: ids)
|
|
112
|
-
trailing = range.first_trailing_versions(relation)
|
|
121
|
+
trailing = range.first_trailing_versions(VersionQuery.for_items(@model, ids))
|
|
113
122
|
account_versions!(trailing.length)
|
|
114
123
|
trailing
|
|
115
124
|
end
|
|
@@ -162,7 +171,7 @@ module PaperTrailDiff
|
|
|
162
171
|
"historical_filter: #{name} was not recorded in version #{version.id}"
|
|
163
172
|
end
|
|
164
173
|
|
|
165
|
-
state.attributes.fetch(name) == expected
|
|
174
|
+
cast(name, state.attributes.fetch(name)) == expected
|
|
166
175
|
end
|
|
167
176
|
end
|
|
168
177
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
# Reconstructs and caches immutable historical snapshots for one adapter
|
|
5
|
+
# Reconstructs and caches immutable historical snapshots for one adapter
|
|
6
|
+
# configuration. It keeps the histories batches prepare, by root version;
|
|
7
|
+
# one prepared for a single root goes back to the caller, who passes it to
|
|
8
|
+
# every read for that root.
|
|
6
9
|
class HistoricalSnapshotStore
|
|
7
10
|
#: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
|
|
8
11
|
def initialize(tree:, traversal:, normalizer:, preparer:)
|
|
@@ -13,11 +16,11 @@ module PaperTrailDiff
|
|
|
13
16
|
@prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
|
|
14
17
|
@live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
|
|
15
18
|
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
16
|
-
@prepared_history = nil #: PreparedHistory?
|
|
17
19
|
@prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
|
|
20
|
+
@live_roots = {} #: Hash[identity, untyped]
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) ->
|
|
23
|
+
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> PreparedHistory?
|
|
21
24
|
def prepare(
|
|
22
25
|
record,
|
|
23
26
|
root_versions,
|
|
@@ -28,12 +31,9 @@ module PaperTrailDiff
|
|
|
28
31
|
|
|
29
32
|
# A batched preparation already covers these roots.
|
|
30
33
|
batched = root_versions.find { |version| @prepared_histories.key?(context_key(version)) }
|
|
31
|
-
if batched
|
|
32
|
-
@prepared_history = @prepared_histories.fetch(context_key(batched))
|
|
33
|
-
return
|
|
34
|
-
end
|
|
34
|
+
return @prepared_histories.fetch(context_key(batched)) if batched
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
PreparedHistoryLoader.new(
|
|
37
37
|
record, root_ids: [record.id], root_versions: root_versions, start_at: start_at,
|
|
38
38
|
end_at: end_at, tree: @tree, traversal: @traversal, live_records: []
|
|
39
39
|
).call
|
|
@@ -46,6 +46,9 @@ module PaperTrailDiff
|
|
|
46
46
|
Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
|
|
47
47
|
register_batch(records, root_versions, start_at: start_at, end_at: end_at)
|
|
48
48
|
end
|
|
49
|
+
# A batch passes its roots as it just loaded them, so the current state
|
|
50
|
+
# they close on is read from them rather than once more per root.
|
|
51
|
+
records.each { |record| @live_roots[Endpoint.identity(record)] = record }
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
# Missing roots, and roots compared only between versions, have no live
|
|
@@ -58,50 +61,41 @@ module PaperTrailDiff
|
|
|
58
61
|
register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
# `history` is the one `prepare` returned for this root, if any.
|
|
65
|
+
#: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
|
|
66
|
+
def call(root_endpoint, context_endpoint, history: nil)
|
|
63
67
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
64
68
|
return @snapshots[key] if @snapshots.key?(key)
|
|
65
69
|
|
|
66
|
-
@snapshots[key] = uncached(root_endpoint, context_endpoint)
|
|
70
|
+
@snapshots[key] = uncached(root_endpoint, context_endpoint, history: history)
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
#: (untyped, untyped) -> RecordSnapshot?
|
|
70
|
-
def uncached(root_endpoint, context_endpoint)
|
|
71
|
-
custom(
|
|
72
|
-
|
|
73
|
-
context_endpoint,
|
|
74
|
-
tree: @tree,
|
|
75
|
-
normalizer: @normalizer
|
|
76
|
-
)
|
|
73
|
+
#: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
|
|
74
|
+
def uncached(root_endpoint, context_endpoint, history: nil)
|
|
75
|
+
custom(root_endpoint, context_endpoint, tree: @tree, normalizer: @normalizer,
|
|
76
|
+
history: history)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
80
|
-
def custom(root_endpoint, context_endpoint, tree:, normalizer:)
|
|
79
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, ?history: PreparedHistory?) -> RecordSnapshot?
|
|
80
|
+
def custom(root_endpoint, context_endpoint, tree:, normalizer:, history: nil)
|
|
81
|
+
history = history_for(context_endpoint, history)
|
|
81
82
|
if Endpoint.version?(root_endpoint)
|
|
82
83
|
return snapshot_from_version(
|
|
83
|
-
root_endpoint,
|
|
84
|
-
context_endpoint,
|
|
85
|
-
tree: tree,
|
|
86
|
-
normalizer: normalizer
|
|
84
|
+
root_endpoint, context_endpoint, tree: tree, normalizer: normalizer, history: history
|
|
87
85
|
)
|
|
88
86
|
end
|
|
89
87
|
|
|
90
|
-
snapshot_from_live_root(
|
|
91
|
-
|
|
92
|
-
context_endpoint,
|
|
93
|
-
tree: tree,
|
|
94
|
-
normalizer: normalizer
|
|
95
|
-
)
|
|
88
|
+
snapshot_from_live_root(root_endpoint, context_endpoint, tree: tree, normalizer: normalizer,
|
|
89
|
+
history: history)
|
|
96
90
|
end
|
|
97
91
|
|
|
98
|
-
#: (untyped, habtm_version: untyped) -> untyped
|
|
99
|
-
def association_reader(context_version, habtm_version:)
|
|
92
|
+
#: (untyped, habtm_version: untyped, ?history: PreparedHistory?) -> untyped
|
|
93
|
+
def association_reader(context_version, habtm_version:, history: nil)
|
|
100
94
|
fallback = HistoricalAssociationReifier.new(
|
|
101
95
|
context_version,
|
|
102
96
|
habtm_version: habtm_version
|
|
103
97
|
)
|
|
104
|
-
history =
|
|
98
|
+
history = history_for(context_version, history)
|
|
105
99
|
return fallback unless history
|
|
106
100
|
|
|
107
101
|
PreparedAssociationReifier.new(
|
|
@@ -112,25 +106,6 @@ module PaperTrailDiff
|
|
|
112
106
|
)
|
|
113
107
|
end
|
|
114
108
|
|
|
115
|
-
#: () -> PreparedActivityIndex?
|
|
116
|
-
def activity_index
|
|
117
|
-
@prepared_history&.activity_index
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
#: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
121
|
-
def record_transition(version)
|
|
122
|
-
history = @prepared_history
|
|
123
|
-
return unless history
|
|
124
|
-
|
|
125
|
-
history.record_transition(Endpoint.model_class(version), version.item_id, version)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
#: (untyped, ?direct: bool) -> [bool, untyped]
|
|
129
|
-
def record_after(version, direct: false)
|
|
130
|
-
@prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
|
|
131
|
-
direct: direct) || [false, nil]
|
|
132
|
-
end
|
|
133
|
-
|
|
134
109
|
private
|
|
135
110
|
|
|
136
111
|
# @rbs @tree: AssociationTree
|
|
@@ -140,8 +115,14 @@ module PaperTrailDiff
|
|
|
140
115
|
# @rbs @prepared_snapshotter: PreparedSnapshotBuilder
|
|
141
116
|
# @rbs @live_graph_collector: LiveGraphCollector
|
|
142
117
|
# @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
143
|
-
# @rbs @prepared_history: PreparedHistory?
|
|
144
118
|
# @rbs @prepared_histories: Hash[Array[untyped], PreparedHistory]
|
|
119
|
+
# @rbs @live_roots: Hash[identity, untyped]
|
|
120
|
+
|
|
121
|
+
# A batch's history for the version wins over the one a caller passes.
|
|
122
|
+
#: (untyped, PreparedHistory?) -> PreparedHistory?
|
|
123
|
+
def history_for(context_endpoint, history)
|
|
124
|
+
@prepared_histories[context_key(context_endpoint)] || history
|
|
125
|
+
end
|
|
145
126
|
|
|
146
127
|
#: (untyped) -> Array[untyped]
|
|
147
128
|
def context_key(endpoint)
|
|
@@ -170,34 +151,43 @@ module PaperTrailDiff
|
|
|
170
151
|
root_versions.each { |version| @prepared_histories[context_key(version)] = history }
|
|
171
152
|
end
|
|
172
153
|
|
|
173
|
-
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
174
|
-
def snapshot_from_version(root_version, context_version, tree:, normalizer:)
|
|
154
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> RecordSnapshot?
|
|
155
|
+
def snapshot_from_version(root_version, context_version, tree:, normalizer:, history:)
|
|
175
156
|
model_class = RecordedClass.for(root_version)
|
|
176
157
|
@preparer.call(model_class, historical: true)
|
|
177
158
|
@traversal.ensure_habtm_history!(model_class, root_version)
|
|
178
159
|
handled, snapshot = @prepared_snapshotter.call(
|
|
179
|
-
root_version, context_version, tree: tree, normalizer: normalizer,
|
|
180
|
-
history: @prepared_histories[context_key(context_version)] ||
|
|
181
|
-
@prepared_history
|
|
160
|
+
root_version, context_version, tree: tree, normalizer: normalizer, history: history
|
|
182
161
|
)
|
|
183
162
|
return snapshot if handled
|
|
184
163
|
|
|
185
164
|
record = root_version.reify(dup: true)
|
|
186
165
|
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
187
|
-
habtm_version: root_version)
|
|
166
|
+
habtm_version: root_version, history: history)
|
|
188
167
|
end
|
|
189
168
|
|
|
190
|
-
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
191
|
-
def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
|
|
192
|
-
record =
|
|
169
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> RecordSnapshot?
|
|
170
|
+
def snapshot_from_live_root(root_record, context_version, tree:, normalizer:, history:)
|
|
171
|
+
record = live_root(root_record)
|
|
193
172
|
@preparer.call(record.class, historical: true)
|
|
194
173
|
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
195
|
-
habtm_version: context_version)
|
|
174
|
+
habtm_version: context_version, history: history)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Rebuilding a version's associations writes them onto the record, so each
|
|
178
|
+
# snapshot gets its own copy of a batch's loaded row, built from the row's
|
|
179
|
+
# stored values without a query.
|
|
180
|
+
#: (untyped) -> untyped
|
|
181
|
+
def live_root(root_record)
|
|
182
|
+
loaded = @live_roots[Endpoint.identity(root_record)]
|
|
183
|
+
return Endpoint.reload_record(root_record) unless loaded
|
|
184
|
+
|
|
185
|
+
loaded.class.instantiate(loaded.attributes_before_type_cast)
|
|
196
186
|
end
|
|
197
187
|
|
|
198
|
-
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
|
|
199
|
-
def normalize(record, context_version, tree:, normalizer:, habtm_version:)
|
|
200
|
-
reifier = association_reader(context_version, habtm_version: habtm_version)
|
|
188
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped, history: PreparedHistory?) -> RecordSnapshot?
|
|
189
|
+
def normalize(record, context_version, tree:, normalizer:, habtm_version:, history:) # rubocop:disable Metrics/ParameterLists
|
|
190
|
+
reifier = association_reader(context_version, habtm_version: habtm_version, history: history)
|
|
201
191
|
reflections = [] #: Array[untyped]
|
|
202
192
|
reflections = @traversal.reflections_for(record.class, tree, path: '') if record
|
|
203
193
|
reifier.reify(record, reflections) if record && !reflections.empty?
|