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
|
@@ -46,9 +46,7 @@ module PaperTrailDiff
|
|
|
46
46
|
def preload_model_tree(model_class, records, tree)
|
|
47
47
|
reflections = selected_reflections(model_class, tree)
|
|
48
48
|
resolve_polymorphic_targets(records, reflections)
|
|
49
|
-
batch, individual = reflections.partition
|
|
50
|
-
batch_safe?(records.fetch(0), reflection)
|
|
51
|
-
end
|
|
49
|
+
batch, individual = reflections.partition { |reflection, _| BatchPreload.safe?(reflection) }
|
|
52
50
|
preload_batch(records, batch.map { |reflection, _| reflection.name })
|
|
53
51
|
preload_subtrees(records, batch, individually: false)
|
|
54
52
|
preload_subtrees(records, individual, individually: true)
|
|
@@ -101,38 +99,6 @@ module PaperTrailDiff
|
|
|
101
99
|
active_record.const_get(:Associations).const_get(:Preloader)
|
|
102
100
|
end
|
|
103
101
|
|
|
104
|
-
# The preloader applies a limit or offset to all owners' records at once,
|
|
105
|
-
# not to each owner's, so such an association is loaded per record. A
|
|
106
|
-
# has_one is no exception. Through a part with a limit, an offset or a
|
|
107
|
-
# has_one cut, the preloader keeps what the part alone would load, while
|
|
108
|
-
# the through's own query keeps the part's conditions only.
|
|
109
|
-
#: (untyped, untyped) -> bool
|
|
110
|
-
def batch_safe?(record, reflection)
|
|
111
|
-
scope = reflection.scope
|
|
112
|
-
return false if scope && !scope.arity.zero?
|
|
113
|
-
return true unless reflection.collection? || reflection.has_one?
|
|
114
|
-
return false if cut_part?(reflection)
|
|
115
|
-
|
|
116
|
-
relation = record.association(reflection.name).scope
|
|
117
|
-
relation.limit_value.nil? && relation.offset_value.nil?
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
#: (untyped) -> bool
|
|
121
|
-
def cut_part?(reflection)
|
|
122
|
-
reflection.collection? &&
|
|
123
|
-
(ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Every part a through passes, at any depth.
|
|
127
|
-
#: (untyped) -> Array[untyped]
|
|
128
|
-
def through_parts(reflection)
|
|
129
|
-
return [] unless reflection.through_reflection?
|
|
130
|
-
|
|
131
|
-
[reflection.through_reflection, reflection.source_reflection].flat_map do |part|
|
|
132
|
-
[part, *through_parts(part)]
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
102
|
#: (Array[untyped], untyped) -> Array[untyped]
|
|
137
103
|
def load_individually(records, reflection)
|
|
138
104
|
unique_records(records.flat_map do |record|
|
|
@@ -52,13 +52,17 @@ module PaperTrailDiff
|
|
|
52
52
|
# Returns the changed paths, or an empty hash when the pair is not two
|
|
53
53
|
# structures this can look inside. An empty result therefore means "nothing
|
|
54
54
|
# to report at this depth", and the caller still has the whole-value change.
|
|
55
|
+
# Two arrays at the top are one membership change, at the empty path.
|
|
55
56
|
#: () -> nested_changes
|
|
56
57
|
def call
|
|
57
|
-
|
|
58
|
+
from, to = structures
|
|
58
59
|
changes = {} #: nested_changes
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
if from.is_a?(Hash) && to.is_a?(Hash)
|
|
61
|
+
walk(from, to, [], changes)
|
|
62
|
+
elsif from.is_a?(Array) && to.is_a?(Array)
|
|
63
|
+
whole = [] #: Array[String]
|
|
64
|
+
changes[whole.freeze] = change_for(from, to) if from != to
|
|
65
|
+
end
|
|
62
66
|
changes.freeze
|
|
63
67
|
end
|
|
64
68
|
|
|
@@ -67,26 +71,23 @@ module PaperTrailDiff
|
|
|
67
71
|
# @rbs @from_value: untyped
|
|
68
72
|
# @rbs @to_value: untyped
|
|
69
73
|
|
|
70
|
-
# Both sides have to be readable as
|
|
71
|
-
# anything. A column that held text on one side and
|
|
72
|
-
# changed wholesale, and saying so is the accurate report.
|
|
73
|
-
#: () -> [
|
|
74
|
+
# Both sides have to be readable as the same kind of structure for a
|
|
75
|
+
# nested answer to mean anything. A column that held text on one side and
|
|
76
|
+
# JSON on the other changed wholesale, and saying so is the accurate report.
|
|
77
|
+
#: () -> [untyped, untyped]
|
|
74
78
|
def structures
|
|
75
79
|
[structure(@from_value), structure(@to_value)]
|
|
76
80
|
end
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
# A string is read as JSON; anything else, or JSON that is neither an
|
|
83
|
+
# object nor an array, is left for #call to decline.
|
|
84
|
+
#: (untyped) -> untyped
|
|
79
85
|
def structure(value)
|
|
80
|
-
return value
|
|
81
|
-
return unless value.is_a?(String)
|
|
86
|
+
return value unless value.is_a?(String)
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
nil
|
|
87
|
-
end
|
|
88
|
-
# JSON parses objects into plain Hashes, never a subclass.
|
|
89
|
-
parsed if parsed.instance_of?(Hash)
|
|
88
|
+
JSON.parse(value)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
nil
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
#: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
|
|
@@ -7,16 +7,16 @@ module PaperTrailDiff
|
|
|
7
7
|
# the only place allowed to know about both PaperTrail and the pure engine.
|
|
8
8
|
# Reconstruction logic lives in the collaborators it wires together.
|
|
9
9
|
class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def initialize(associations:, ignore:, reload_live_endpoints: true, allow_expensive: false)
|
|
10
|
+
# `recorded_changes:` reads what each activity boundary's version recorded.
|
|
11
|
+
# `allow_approximate:` accepts association paths read with PT-AT.
|
|
12
|
+
#: (associations: association_option, ignore: ignore_option, ?reload_live_endpoints: bool, ?recorded_changes: bool, ?allow_approximate: bool) -> void
|
|
13
|
+
def initialize(associations:, ignore:, reload_live_endpoints: true, recorded_changes: false, allow_approximate: false) # rubocop:disable Metrics/MethodLength, Layout/LineLength
|
|
15
14
|
@association_tree = AssociationTree.build(associations)
|
|
16
15
|
@ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
|
|
16
|
+
@recorded = (@ignore_policy if recorded_changes)
|
|
17
17
|
@traversal = AssociationTraversal.new(@association_tree)
|
|
18
|
-
@
|
|
19
|
-
|
|
18
|
+
@traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal,
|
|
19
|
+
allow_approximate: allow_approximate)
|
|
20
20
|
@live_endpoints = LiveEndpointProvider.new(
|
|
21
21
|
tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
|
|
22
22
|
)
|
|
@@ -53,12 +53,13 @@ module PaperTrailDiff
|
|
|
53
53
|
count = comparisons.is_a?(Array) ? comparisons.length : 0
|
|
54
54
|
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
55
55
|
Instrumentation.instrument('compare_many', payload) do
|
|
56
|
-
comparison_batch(comparisons, preparer: method(:
|
|
56
|
+
comparison_batch(comparisons, preparer: @traversal_preparer.method(:call)).call
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped
|
|
61
|
-
def timeline(record, from:, to:, within:, version_scope: nil
|
|
60
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
|
|
61
|
+
def timeline(record, from:, to:, within:, version_scope: nil)
|
|
62
|
+
Endpoint.validate_root!(record)
|
|
62
63
|
@traversal_preparer.call(record.class, historical: true)
|
|
63
64
|
from, to = default_range(record, from, to, within)
|
|
64
65
|
TimelineBuilder.new(
|
|
@@ -67,38 +68,40 @@ module PaperTrailDiff
|
|
|
67
68
|
to: to,
|
|
68
69
|
within: within,
|
|
69
70
|
version_scope: version_scope,
|
|
70
|
-
live_endpoint: live_endpoint_for(record,
|
|
71
|
+
live_endpoint: live_endpoint_for(record, within),
|
|
71
72
|
snapshotter: @timeline_snapshotter
|
|
72
73
|
).build
|
|
73
74
|
end
|
|
74
75
|
|
|
75
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?
|
|
76
|
-
def activity_timeline(record, from:, to:, within:, version_scope: nil,
|
|
77
|
-
|
|
76
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
77
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
78
|
+
Endpoint.validate_root!(record)
|
|
79
|
+
payload = model_payload(record)
|
|
78
80
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
79
81
|
@traversal_preparer.call(record.class, historical: true)
|
|
80
82
|
from, to = default_range(record, from, to, within)
|
|
81
|
-
live = live_endpoint_for(record,
|
|
83
|
+
live = live_endpoint_for(record, within)
|
|
82
84
|
steps = activity_builder(
|
|
83
85
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
84
|
-
live_endpoint: live,
|
|
86
|
+
live_endpoint: live, view: activity_view(true, snapshots, group)
|
|
85
87
|
).build
|
|
86
88
|
payload[:step_count] = steps.length
|
|
87
89
|
steps
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
91
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?
|
|
92
|
-
def analyze(record, from:, to:, within:, activity: false, version_scope: nil,
|
|
93
|
-
|
|
93
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
94
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
95
|
+
view = activity_view(activity, snapshots, group)
|
|
96
|
+
Endpoint.validate_root!(record)
|
|
94
97
|
@traversal_preparer.call(record.class, historical: true)
|
|
95
98
|
from, to = default_range(record, from, to, within)
|
|
96
|
-
live = live_endpoint_for(record,
|
|
99
|
+
live = live_endpoint_for(record, within)
|
|
97
100
|
if activity
|
|
98
|
-
return
|
|
101
|
+
return activity_builder(
|
|
99
102
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
100
|
-
live_endpoint: live,
|
|
101
|
-
)
|
|
103
|
+
live_endpoint: live, view: view
|
|
104
|
+
).analyze
|
|
102
105
|
end
|
|
103
106
|
|
|
104
107
|
TimelineBuilder.new(
|
|
@@ -108,40 +111,29 @@ module PaperTrailDiff
|
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
111
|
-
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?
|
|
112
|
-
def analyze_many(records, within:, activity: false, version_scope: nil,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
116
|
-
Instrumentation.instrument('analyze_many', payload) do
|
|
117
|
-
AnalysisBatch.new(
|
|
118
|
-
records,
|
|
119
|
-
time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
|
|
120
|
-
close_on_current: close_on_current?(close_on, within) || within.nil?,
|
|
121
|
-
context_required: activity && !@association_tree.empty?,
|
|
122
|
-
live_loader: method(:load_batch_endpoints),
|
|
123
|
-
history_preparer: @historical_store.method(:prepare_batch),
|
|
124
|
-
analyzer: batched_root_analyzer(activity, within: within, snapshots: snapshots,
|
|
125
|
-
group: group)
|
|
126
|
-
).call
|
|
127
|
-
end
|
|
114
|
+
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
|
|
115
|
+
def analyze_many(records, within:, activity: false, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
116
|
+
analyze_batch(records, within: within, activity: activity, version_scope: version_scope,
|
|
117
|
+
view: activity_view(activity, snapshots, group))
|
|
128
118
|
end
|
|
129
119
|
|
|
130
120
|
# Selects the roots from a relation before running the ordinary batch, so a
|
|
131
121
|
# caller reporting on a population does not have to rediscover which of its
|
|
132
122
|
# members changed. The roots the relation could not reach come back named
|
|
133
123
|
# rather than dropped -- see `PaperTrailDiff.analyze_scope`.
|
|
134
|
-
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?
|
|
135
|
-
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil,
|
|
136
|
-
|
|
137
|
-
check_scope(scope
|
|
124
|
+
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
|
|
125
|
+
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
126
|
+
view = activity_view(activity, snapshots, group)
|
|
127
|
+
check_scope(scope)
|
|
138
128
|
# @type var options: report_options
|
|
139
|
-
options = { within: within, activity: activity, version_scope: version_scope,
|
|
140
|
-
close_on: close_on, snapshots: snapshots, group: group }
|
|
129
|
+
options = { within: within, activity: activity, version_scope: version_scope, view: view }
|
|
141
130
|
ScopedAnalysisBuilder.new(
|
|
142
131
|
scope, limit: limit, within: within, historical_filter: historical_filter,
|
|
143
132
|
max_filter_versions: max_filter_versions, include_deleted: include_deleted,
|
|
144
|
-
analyzer:
|
|
133
|
+
analyzer: lambda { |records|
|
|
134
|
+
analyze_batch(records, within: within, activity: activity,
|
|
135
|
+
version_scope: version_scope, view: view)
|
|
136
|
+
},
|
|
145
137
|
deleted_analyzer: lambda { |identities|
|
|
146
138
|
analyze_deleted(scope.all.model, identities, options)
|
|
147
139
|
},
|
|
@@ -153,14 +145,40 @@ module PaperTrailDiff
|
|
|
153
145
|
|
|
154
146
|
private
|
|
155
147
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
#: (bool, bool, Symbol?) -> ActivityView
|
|
149
|
+
def activity_view(activity, snapshots, group)
|
|
150
|
+
ActivityView.build(activity: activity, snapshots: snapshots, group: group,
|
|
151
|
+
recorded: @recorded)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
155
|
+
def model_payload(record)
|
|
156
|
+
@instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: (Array[untyped], within: untyped, activity: bool, version_scope: untyped, view: ActivityView) -> Hash[identity, Analysis]
|
|
160
|
+
def analyze_batch(records, within:, activity:, version_scope:, view:)
|
|
161
|
+
count = records.is_a?(Array) ? records.length : 0
|
|
162
|
+
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
163
|
+
Instrumentation.instrument('analyze_many', payload) do
|
|
164
|
+
AnalysisBatch.new(
|
|
165
|
+
records,
|
|
166
|
+
time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
|
|
167
|
+
context_required: activity && !@association_tree.empty?,
|
|
168
|
+
live_loader: method(:load_batch_endpoints),
|
|
169
|
+
history_preparer: @historical_store.method(:prepare_batch),
|
|
170
|
+
analyzer: batched_root_analyzer(activity, within: within, view: view)
|
|
171
|
+
).call
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Analysis reads association history, so a request it would refuse is
|
|
176
|
+
# refused before the first selection query rather than once analysis starts.
|
|
177
|
+
#: (untyped) -> void
|
|
178
|
+
def check_scope(scope)
|
|
160
179
|
return unless scope.respond_to?(:all) && scope.respond_to?(:where)
|
|
161
180
|
|
|
162
|
-
@
|
|
163
|
-
@traversal_preparer.call(scope.all.model, historical: true) if activity
|
|
181
|
+
@traversal_preparer.call(scope.all.model, historical: true)
|
|
164
182
|
end
|
|
165
183
|
|
|
166
184
|
#: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
|
|
@@ -175,27 +193,18 @@ module PaperTrailDiff
|
|
|
175
193
|
)
|
|
176
194
|
end
|
|
177
195
|
|
|
196
|
+
# Refused before the current records load, rather than once analysis starts.
|
|
178
197
|
#: (Array[untyped]) -> Hash[identity, untyped]
|
|
179
198
|
def load_batch_endpoints(records)
|
|
180
|
-
records.map(&:class).uniq.each { |model| @
|
|
199
|
+
records.map(&:class).uniq.each { |model| @traversal_preparer.call(model, historical: true) }
|
|
181
200
|
@live_endpoints.call(records)
|
|
182
201
|
end
|
|
183
202
|
|
|
184
|
-
#: (untyped, historical: bool) -> void
|
|
185
|
-
def prepare_batch_model(model, historical:)
|
|
186
|
-
@batch_safety.call(model)
|
|
187
|
-
@traversal_preparer.call(model, historical: historical)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# @rbs @batch_safety: BatchSafety
|
|
191
|
-
|
|
192
203
|
#: (untyped, Array[Array[String]], Hash[Symbol, untyped]) -> Hash[Array[String], Analysis?]
|
|
193
204
|
def analyze_deleted(model, identities, options)
|
|
194
205
|
within = options.fetch(:within)
|
|
195
|
-
analyzer = batched_root_analyzer(
|
|
196
|
-
|
|
197
|
-
group: options.fetch(:group)
|
|
198
|
-
)
|
|
206
|
+
analyzer = batched_root_analyzer(options.fetch(:activity), within: within,
|
|
207
|
+
view: options.fetch(:view))
|
|
199
208
|
DeletedAnalysisBatch.new(
|
|
200
209
|
model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
|
|
201
210
|
version_scope: options.fetch(:version_scope),
|
|
@@ -209,6 +218,7 @@ module PaperTrailDiff
|
|
|
209
218
|
# @rbs @live_endpoints: LiveEndpointProvider
|
|
210
219
|
# @rbs @instrumentation_payload: Hash[Symbol, untyped]
|
|
211
220
|
# @rbs @ignore_policy: IgnorePolicy
|
|
221
|
+
# @rbs @recorded: IgnorePolicy?
|
|
212
222
|
# @rbs @traversal: AssociationTraversal
|
|
213
223
|
# @rbs @traversal_preparer: TraversalPreparer
|
|
214
224
|
# @rbs @snapshot_pool: SnapshotPool
|
|
@@ -217,24 +227,13 @@ module PaperTrailDiff
|
|
|
217
227
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
218
228
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
219
229
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
live_endpoint: live_endpoint, snapshots: snapshots, group: group
|
|
225
|
-
).analyze
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Association membership is resolved by timestamp, so endpoints sharing one
|
|
229
|
-
# cannot be told apart and any association change between them is invisible.
|
|
230
|
-
# The result may still be correct -- nothing associated may have changed --
|
|
231
|
-
# and the gem cannot tell which, since not seeing the change is the symptom.
|
|
232
|
-
# So it reports the condition and leaves the judgement to the application.
|
|
230
|
+
# The result may still be correct -- nothing the path reaches may have
|
|
231
|
+
# changed -- and the gem cannot tell which, since not seeing the change is
|
|
232
|
+
# the symptom. So it reports the condition and leaves the judgement to the
|
|
233
|
+
# application.
|
|
233
234
|
#: (untyped, untyped) -> void
|
|
234
235
|
def notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
|
|
235
|
-
return
|
|
236
|
-
return unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
|
|
237
|
-
return unless from_endpoint.created_at == to_endpoint.created_at
|
|
236
|
+
return unless ambiguous_association_boundary?(from_endpoint, to_endpoint)
|
|
238
237
|
|
|
239
238
|
Instrumentation.notify(
|
|
240
239
|
'ambiguous_association_boundary',
|
|
@@ -246,19 +245,19 @@ module PaperTrailDiff
|
|
|
246
245
|
)
|
|
247
246
|
end
|
|
248
247
|
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
unless
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
248
|
+
# Loaded history orders two versions that share a timestamp by id, but
|
|
249
|
+
# PT-AT places association changes by timestamp alone, so only a path it
|
|
250
|
+
# reads can lose a change between them. A request the preparer refuses
|
|
251
|
+
# raises here, before anything is reported.
|
|
252
|
+
#: (untyped, untyped) -> bool
|
|
253
|
+
def ambiguous_association_boundary?(from_endpoint, to_endpoint)
|
|
254
|
+
return false if @association_tree.empty?
|
|
255
|
+
return false unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
|
|
256
|
+
return false unless from_endpoint.created_at == to_endpoint.created_at
|
|
257
|
+
|
|
258
|
+
model_class = RecordedClass.for(from_endpoint)
|
|
259
|
+
@traversal_preparer.call(model_class, historical: true)
|
|
260
|
+
FallbackPaths.any?(@traversal, model_class, approximate: true)
|
|
262
261
|
end
|
|
263
262
|
|
|
264
263
|
# With neither a window nor endpoints, a range is the record's whole
|
|
@@ -271,12 +270,15 @@ module PaperTrailDiff
|
|
|
271
270
|
[from.nil? ? :first : from, to.nil? ? record : to]
|
|
272
271
|
end
|
|
273
272
|
|
|
273
|
+
# A window with nothing recorded after its last change ends at the live
|
|
274
|
+
# record, which holds the state that change produced -- whether the window
|
|
275
|
+
# ended a moment ago or long before. Explicit endpoints name their own end.
|
|
274
276
|
# Only a saved row has current state to close on. A destroyed root's own
|
|
275
277
|
# destroy version already terminates its history, and a reified or unsaved
|
|
276
278
|
# instance stands for a state that is not in the table.
|
|
277
|
-
#: (untyped,
|
|
278
|
-
def live_endpoint_for(record,
|
|
279
|
-
return
|
|
279
|
+
#: (untyped, untyped) -> untyped
|
|
280
|
+
def live_endpoint_for(record, within)
|
|
281
|
+
return if within.nil?
|
|
280
282
|
return unless Endpoint.record?(record) && record.persisted?
|
|
281
283
|
|
|
282
284
|
record
|
|
@@ -288,17 +290,20 @@ module PaperTrailDiff
|
|
|
288
290
|
@timeline_snapshotter = TimelineSnapshotProvider.new(
|
|
289
291
|
@historical_store, live_snapshotter: method(:live_snapshot)
|
|
290
292
|
)
|
|
291
|
-
@activity_snapshotter =
|
|
293
|
+
@activity_snapshotter = ActivitySnapshotProvider.new(
|
|
294
|
+
store: @historical_store, snapshotter: method(:snapshot_at),
|
|
295
|
+
refresher: method(:branch_refresher)
|
|
296
|
+
)
|
|
292
297
|
end
|
|
293
298
|
|
|
294
|
-
#: (bool, within: untyped,
|
|
295
|
-
def batched_root_analyzer(activity, within:,
|
|
299
|
+
#: (bool, within: untyped, view: ActivityView) -> BatchedRootAnalyzer
|
|
300
|
+
def batched_root_analyzer(activity, within:, view:)
|
|
296
301
|
BatchedRootAnalyzer.new(
|
|
297
302
|
tree: @association_tree,
|
|
298
303
|
timeline_snapshotter: @timeline_snapshotter,
|
|
299
304
|
activity_snapshotter: @activity_snapshotter,
|
|
300
305
|
preparer: @traversal_preparer.method(:call),
|
|
301
|
-
activity: activity, within: within,
|
|
306
|
+
activity: activity, within: within, view: view
|
|
302
307
|
)
|
|
303
308
|
end
|
|
304
309
|
|
|
@@ -312,27 +317,23 @@ module PaperTrailDiff
|
|
|
312
317
|
)
|
|
313
318
|
end
|
|
314
319
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
320
|
+
# Refreshes one root's snapshots, reading with the history prepared for it.
|
|
321
|
+
#: (PreparedActivitySnapshots) -> BranchSnapshotRefresher
|
|
322
|
+
def branch_refresher(snapshots)
|
|
323
|
+
BranchSnapshotRefresher.new(
|
|
318
324
|
tree: @association_tree, ignore_policy: @ignore_policy,
|
|
319
325
|
traversal: @traversal, pool: @snapshot_pool,
|
|
320
326
|
normalizer: @normalizer,
|
|
321
|
-
full_snapshotter: method(:
|
|
322
|
-
partial_snapshotter:
|
|
323
|
-
association_reader:
|
|
324
|
-
record_transition:
|
|
325
|
-
prepared_record_after:
|
|
326
|
-
)
|
|
327
|
-
ActivitySnapshotProvider.new(
|
|
328
|
-
snapshotter: method(:snapshot_at), refresher: refresher,
|
|
329
|
-
preparer: @historical_store.method(:prepare),
|
|
330
|
-
activity_index: @historical_store.method(:activity_index)
|
|
327
|
+
full_snapshotter: snapshots.method(:call),
|
|
328
|
+
partial_snapshotter: snapshots.method(:custom),
|
|
329
|
+
association_reader: snapshots.method(:association_reader),
|
|
330
|
+
record_transition: snapshots.method(:record_transition),
|
|
331
|
+
prepared_record_after: snapshots.method(:record_after)
|
|
331
332
|
)
|
|
332
333
|
end
|
|
333
334
|
|
|
334
|
-
#: (untyped, from: untyped, to: untyped, within: untyped,
|
|
335
|
-
def activity_builder(record, from:, to:, within:, version_scope
|
|
335
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, view: ActivityView) -> ActivityTimelineBuilder
|
|
336
|
+
def activity_builder(record, from:, to:, within:, version_scope:, live_endpoint:, view:) # rubocop:disable Metrics/ParameterLists
|
|
336
337
|
ActivityTimelineBuilder.new(
|
|
337
338
|
record,
|
|
338
339
|
range: TimelineRange.new(
|
|
@@ -341,8 +342,7 @@ module PaperTrailDiff
|
|
|
341
342
|
),
|
|
342
343
|
tree: @association_tree,
|
|
343
344
|
snapshotter: @activity_snapshotter,
|
|
344
|
-
|
|
345
|
-
group: group
|
|
345
|
+
view: view
|
|
346
346
|
)
|
|
347
347
|
end
|
|
348
348
|
|
|
@@ -356,16 +356,11 @@ module PaperTrailDiff
|
|
|
356
356
|
snapshot_at(version, version)
|
|
357
357
|
end
|
|
358
358
|
|
|
359
|
-
#: (untyped) -> RecordSnapshot?
|
|
360
|
-
def
|
|
361
|
-
@historical_store.uncached(version, version)
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
#: (untyped, untyped) -> RecordSnapshot?
|
|
365
|
-
def snapshot_at(root_endpoint, context_endpoint)
|
|
359
|
+
#: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
|
|
360
|
+
def snapshot_at(root_endpoint, context_endpoint, history: nil)
|
|
366
361
|
return live_snapshot(root_endpoint) if Endpoint.record?(context_endpoint)
|
|
367
362
|
|
|
368
|
-
@historical_store.call(root_endpoint, context_endpoint)
|
|
363
|
+
@historical_store.call(root_endpoint, context_endpoint, history: history)
|
|
369
364
|
end
|
|
370
365
|
|
|
371
366
|
#: (untyped) -> RecordSnapshot
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
# Validates and reuses caller-owned live records
|
|
5
|
+
# Validates and reuses caller-owned live records, reading again only the
|
|
6
|
+
# associations a preload of many records cannot have loaded correctly.
|
|
6
7
|
class PreloadedEndpointBatchLoader
|
|
7
8
|
#: (tree: AssociationTree, traversal: AssociationTraversal) -> void
|
|
8
9
|
def initialize(tree:, traversal:)
|
|
@@ -36,6 +37,7 @@ module PaperTrailDiff
|
|
|
36
37
|
message = "current endpoint association is not preloaded: #{association_path}"
|
|
37
38
|
raise UnloadedAssociationError, message
|
|
38
39
|
end
|
|
40
|
+
read_alone(association) unless BatchPreload.safe?(reflection)
|
|
39
41
|
|
|
40
42
|
subtree = tree.child(reflection.name)
|
|
41
43
|
return unless subtree
|
|
@@ -44,5 +46,15 @@ module PaperTrailDiff
|
|
|
44
46
|
validate_record(child, subtree, path: association_path)
|
|
45
47
|
end
|
|
46
48
|
end
|
|
49
|
+
|
|
50
|
+
# Preloading many records applies a limit or offset to all of them
|
|
51
|
+
# together, so what a caller preloaded cannot be trusted: one record keeps
|
|
52
|
+
# another's share, or none. Such an association is read again for each
|
|
53
|
+
# record, as reloading reads it.
|
|
54
|
+
#: (untyped) -> void
|
|
55
|
+
def read_alone(association)
|
|
56
|
+
association.reset
|
|
57
|
+
association.load_target
|
|
58
|
+
end
|
|
47
59
|
end
|
|
48
60
|
end
|
|
@@ -189,7 +189,7 @@ module PaperTrailDiff
|
|
|
189
189
|
# A version without a transaction id names no rows, so it can stay in.
|
|
190
190
|
#: (Array[untyped]) -> Array[untyped]
|
|
191
191
|
def transaction_ids(versions)
|
|
192
|
-
versions.map { |version|
|
|
192
|
+
versions.map { |version| VersionBoundary.transaction_id(version) }.uniq
|
|
193
193
|
end
|
|
194
194
|
end
|
|
195
195
|
end
|
|
@@ -111,7 +111,8 @@ module PaperTrailDiff
|
|
|
111
111
|
#: (untyped, Array[untyped], untyped, String) -> untyped
|
|
112
112
|
def historical_child_relation(owner_class, owner_ids, reflection, foreign_key)
|
|
113
113
|
version_class = owner_class.paper_trail.version_class
|
|
114
|
-
owner_class.paper_trail.version_association_class
|
|
114
|
+
associations = owner_class.paper_trail.version_association_class
|
|
115
|
+
VersionTable.joined(associations, version_class).where(
|
|
115
116
|
foreign_key_name: foreign_key,
|
|
116
117
|
foreign_key_id: owner_ids,
|
|
117
118
|
foreign_type: parent_types(owner_class)
|
|
@@ -146,6 +146,11 @@ module PaperTrailDiff
|
|
|
146
146
|
owners ? owners.fetch(owner.id.to_s, empty) : group.fetch(:ids)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
# PT-AT records a checkpoint's membership as it is when the checkpoint is
|
|
150
|
+
# saved, so a tag attached earlier in the checkpoint's own transaction is a
|
|
151
|
+
# member there, unlike other associations, which a version reads as they
|
|
152
|
+
# were before its transaction. A member created in that transaction has no
|
|
153
|
+
# earlier state, so it is read as it was created.
|
|
149
154
|
#: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
|
|
150
155
|
def habtm_records(record, reflection, boundary, habtm_boundary, ranked)
|
|
151
156
|
transaction_id = transaction_id_for(habtm_boundary)
|
|
@@ -155,7 +160,10 @@ module PaperTrailDiff
|
|
|
155
160
|
ids = @habtm.fetch(edge_key(record.class, reflection), empty)[transaction_id.to_s]
|
|
156
161
|
return unless ids
|
|
157
162
|
|
|
158
|
-
members = ids.filter_map
|
|
163
|
+
members = ids.filter_map do |id|
|
|
164
|
+
@records.record_before(reflection.klass, id, boundary) ||
|
|
165
|
+
@records.record_created_in(reflection.klass, id, habtm_boundary)
|
|
166
|
+
end
|
|
159
167
|
@conditions.select(reflection, members, ranked: ranked)
|
|
160
168
|
end
|
|
161
169
|
|
|
@@ -21,6 +21,7 @@ module PaperTrailDiff
|
|
|
21
21
|
@root_versions = root_versions
|
|
22
22
|
@tree = tree
|
|
23
23
|
@traversal = traversal
|
|
24
|
+
start_at = VersionBoundary.transaction_start(root_versions, start_at)
|
|
24
25
|
@records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
|
|
25
26
|
@activity = PreparedActivityIndex.new(
|
|
26
27
|
@records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at
|