paper_trail_diff 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -8,6 +8,7 @@ module PaperTrailDiff
|
|
|
8
8
|
|
|
9
9
|
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
10
10
|
def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
|
|
11
|
+
ActivityGrouping.validate!(group)
|
|
11
12
|
@snapshots = snapshots
|
|
12
13
|
@group = group
|
|
13
14
|
# Merging a group compares its outer states, so the snapshots must survive
|
|
@@ -36,10 +37,7 @@ module PaperTrailDiff
|
|
|
36
37
|
def analyze
|
|
37
38
|
return time_builder.analyze if @range.time?
|
|
38
39
|
return Analysis.empty if @range.unresolved?
|
|
39
|
-
|
|
40
|
-
unless Endpoint.version?(@to)
|
|
41
|
-
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
42
|
-
end
|
|
40
|
+
return analyze_current if Endpoint.record?(@to)
|
|
43
41
|
|
|
44
42
|
plan = @range.select_plan
|
|
45
43
|
root_versions = plan.reconstruction_versions
|
|
@@ -73,27 +71,48 @@ module PaperTrailDiff
|
|
|
73
71
|
root_versions = @range.select_plan.reconstruction_versions
|
|
74
72
|
prepare_history(root_versions)
|
|
75
73
|
events = collect_events(root_versions)
|
|
76
|
-
|
|
74
|
+
history = event_history(root_versions, events)
|
|
75
|
+
activity_steps(history, events, destroyed_boundary(root_versions), nil)
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
#: () -> Array[ActivityStep]
|
|
80
79
|
def build_to_current
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
events
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
_plan, events, history, snapshot, boundary = current_history
|
|
81
|
+
activity_steps(history, events, boundary, snapshot)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: () -> Analysis
|
|
85
|
+
def analyze_current
|
|
86
|
+
plan, events, history, snapshot, boundary = current_history
|
|
87
|
+
final = boundary ? snapshot : history.last_snapshot
|
|
88
|
+
Analysis.new(
|
|
89
|
+
diff: Engine.compare(history.first_snapshot, final),
|
|
90
|
+
timeline: ActivityRootSteps.call(
|
|
91
|
+
plan, history.root_snapshots, closing_snapshot: snapshot,
|
|
92
|
+
captured_at: boundary&.recorded_at
|
|
93
|
+
),
|
|
94
|
+
activity_timeline: activity_steps(history, events, boundary, snapshot),
|
|
95
|
+
from_snapshot: history.first_snapshot, to_snapshot: final
|
|
94
96
|
)
|
|
95
97
|
end
|
|
96
98
|
|
|
99
|
+
# The explicit live endpoint and a batch's live closing endpoint use the
|
|
100
|
+
# same selected plan. A version filter can close earlier, on its successor.
|
|
101
|
+
#: () -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
|
|
102
|
+
def current_history
|
|
103
|
+
validate_current_range!
|
|
104
|
+
plan = @range.select_plan
|
|
105
|
+
return [plan, [], ActivityHistory.empty, nil, nil] if plan.empty?
|
|
106
|
+
|
|
107
|
+
snapshot, captured_at = capture_current if plan.closing_record
|
|
108
|
+
versions = plan.reconstruction_versions
|
|
109
|
+
prepare_history(versions, end_at: captured_at)
|
|
110
|
+
events = collect_events(versions, range_end: captured_at || versions.last)
|
|
111
|
+
history = event_history(versions, events, current: plan.closing_record)
|
|
112
|
+
boundary = ActivityBoundary.current(@to, captured_at: captured_at) if plan.closing_record
|
|
113
|
+
[plan, events, history, snapshot, boundary]
|
|
114
|
+
end
|
|
115
|
+
|
|
97
116
|
#: () -> [RecordSnapshot?, untyped]
|
|
98
117
|
def capture_current
|
|
99
118
|
[@snapshotter.call(@to, @to), Time.now.utc]
|
|
@@ -107,6 +126,7 @@ module PaperTrailDiff
|
|
|
107
126
|
|
|
108
127
|
Endpoint.validate_pair!(@from, @to)
|
|
109
128
|
Endpoint.validate_pair!(@record, @to)
|
|
129
|
+
AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
|
|
110
130
|
end
|
|
111
131
|
|
|
112
132
|
#: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
|
|
@@ -120,6 +140,7 @@ module PaperTrailDiff
|
|
|
120
140
|
root_versions: root_versions,
|
|
121
141
|
tree: @tree,
|
|
122
142
|
traversal: AssociationTraversal.new(@tree),
|
|
143
|
+
prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
|
|
123
144
|
range_start: range_start,
|
|
124
145
|
range_end: range_end
|
|
125
146
|
).call
|
|
@@ -133,23 +154,6 @@ module PaperTrailDiff
|
|
|
133
154
|
@snapshotter.prepare(@record, root_versions, end_at: end_at)
|
|
134
155
|
end
|
|
135
156
|
|
|
136
|
-
#: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
137
|
-
def build_event_steps(
|
|
138
|
-
root_versions,
|
|
139
|
-
events,
|
|
140
|
-
current: nil,
|
|
141
|
-
final_boundary: nil,
|
|
142
|
-
final_snapshot: nil
|
|
143
|
-
)
|
|
144
|
-
history = event_history(root_versions, events, current: current)
|
|
145
|
-
activity_steps(
|
|
146
|
-
history,
|
|
147
|
-
events,
|
|
148
|
-
final_boundary || destroyed_boundary(root_versions),
|
|
149
|
-
final_snapshot
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
157
|
# Appends the transition into an explicit closing boundary, which is either
|
|
154
158
|
# a requested current record or the absence a final root destroy leaves.
|
|
155
159
|
#: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Collects root and selected-descendant versions relevant to one root history range.
|
|
6
6
|
class ActivityVersionCollector
|
|
7
|
-
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped) -> void
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped, ?prepared: PreparedActivityIndex?) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
9
|
record,
|
|
10
10
|
root_versions:,
|
|
11
11
|
tree:,
|
|
12
12
|
traversal:,
|
|
13
13
|
range_start: root_versions.first,
|
|
14
|
-
range_end: root_versions.last
|
|
14
|
+
range_end: root_versions.last,
|
|
15
|
+
prepared: nil
|
|
15
16
|
)
|
|
17
|
+
@prepared = prepared
|
|
16
18
|
@record = record
|
|
17
19
|
@root_versions = root_versions
|
|
18
20
|
@tree = tree
|
|
@@ -32,6 +34,7 @@ module PaperTrailDiff
|
|
|
32
34
|
|
|
33
35
|
private
|
|
34
36
|
|
|
37
|
+
# @rbs @prepared: PreparedActivityIndex?
|
|
35
38
|
# @rbs @record: untyped
|
|
36
39
|
# @rbs @root_versions: Array[untyped]
|
|
37
40
|
# @rbs @tree: AssociationTree
|
|
@@ -59,7 +62,7 @@ module PaperTrailDiff
|
|
|
59
62
|
@registry.add(group.fetch(:versions), branch: branch)
|
|
60
63
|
next if subtree.empty?
|
|
61
64
|
|
|
62
|
-
child_path =
|
|
65
|
+
child_path = Support.association_path(path, reflection.name.to_s)
|
|
63
66
|
collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
|
|
64
67
|
path: child_path, branch: branch)
|
|
65
68
|
end
|
|
@@ -97,6 +100,10 @@ module PaperTrailDiff
|
|
|
97
100
|
|
|
98
101
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
99
102
|
def belongs_to_groups(parent_class, parent_versions, reflection)
|
|
103
|
+
if (prepared = @prepared)
|
|
104
|
+
rows = prepared.association_rows(parent_class, parent_versions, reflection)
|
|
105
|
+
return identity_groups(rows, reflection)
|
|
106
|
+
end
|
|
100
107
|
rows = parent_class.paper_trail.version_association_class.where(
|
|
101
108
|
version_id: parent_versions.map(&:id),
|
|
102
109
|
foreign_key_name: reflection.foreign_key.to_s
|
|
@@ -106,9 +113,12 @@ module PaperTrailDiff
|
|
|
106
113
|
|
|
107
114
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
108
115
|
def direct_child_groups(parent_class, parent_ids, reflection)
|
|
109
|
-
item_ids =
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
item_ids = if (prepared = @prepared)
|
|
117
|
+
prepared.child_ids(parent_class, parent_ids, reflection, @range)
|
|
118
|
+
else
|
|
119
|
+
ActivityChildCandidateLoader.new(parent_class, parent_ids, reflection,
|
|
120
|
+
@range).call
|
|
121
|
+
end
|
|
112
122
|
return {} if item_ids.empty?
|
|
113
123
|
|
|
114
124
|
{ reflection.klass.name => group(reflection.klass, item_ids) }
|
|
@@ -116,6 +126,11 @@ module PaperTrailDiff
|
|
|
116
126
|
|
|
117
127
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
118
128
|
def habtm_groups(parent_class, parent_versions, reflection)
|
|
129
|
+
if (prepared = @prepared)
|
|
130
|
+
rows = prepared.association_rows(parent_class, parent_versions, reflection,
|
|
131
|
+
transactions: true)
|
|
132
|
+
return identity_groups(rows, reflection)
|
|
133
|
+
end
|
|
119
134
|
transaction_ids = parent_versions.filter_map do |version|
|
|
120
135
|
version.transaction_id if version.respond_to?(:transaction_id)
|
|
121
136
|
end
|
|
@@ -133,7 +148,7 @@ module PaperTrailDiff
|
|
|
133
148
|
identities.each do |model_class, class_rows|
|
|
134
149
|
next unless model_class
|
|
135
150
|
|
|
136
|
-
ids = class_rows.
|
|
151
|
+
ids = class_rows.filter_map(&:foreign_key_id).uniq
|
|
137
152
|
groups[model_class.name] = group(model_class, ids)
|
|
138
153
|
end
|
|
139
154
|
groups
|
|
@@ -162,13 +177,17 @@ module PaperTrailDiff
|
|
|
162
177
|
def versions_for(model_class, ids)
|
|
163
178
|
return [] if ids.empty?
|
|
164
179
|
|
|
180
|
+
if (prepared = @prepared)
|
|
181
|
+
return prepared.versions(model_class, ids).select { |version| @range.include?(version) }
|
|
182
|
+
end
|
|
183
|
+
|
|
165
184
|
version_class = model_class.paper_trail.version_class
|
|
166
185
|
@range.scope(
|
|
167
186
|
version_class.where(
|
|
168
187
|
item_type: model_class.base_class.name,
|
|
169
188
|
item_id: ids
|
|
170
189
|
)
|
|
171
|
-
).to_a.select { |version|
|
|
190
|
+
).to_a.select { |version| @range.include?(version) }
|
|
172
191
|
end
|
|
173
192
|
|
|
174
193
|
#: (untyped, untyped) -> untyped
|
|
@@ -179,15 +198,5 @@ module PaperTrailDiff
|
|
|
179
198
|
rescue NameError
|
|
180
199
|
nil
|
|
181
200
|
end
|
|
182
|
-
|
|
183
|
-
#: (untyped) -> bool
|
|
184
|
-
def in_range?(version)
|
|
185
|
-
@range.include?(version)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
#: (String, String) -> String
|
|
189
|
-
def join_path(parent, name)
|
|
190
|
-
parent.empty? ? name : "#{parent}.#{name}"
|
|
191
|
-
end
|
|
192
201
|
end
|
|
193
202
|
end
|
|
@@ -34,10 +34,15 @@ module PaperTrailDiff
|
|
|
34
34
|
# Reconstructed endpoint states are opt-in, because they carry the whole
|
|
35
35
|
# selected graph whether or not anything changed, which dwarfs the rest of
|
|
36
36
|
# the payload for a wide graph.
|
|
37
|
-
#: (?snapshots: bool) -> Hash[Symbol, untyped]
|
|
38
|
-
def to_h(snapshots: false)
|
|
39
|
-
value = { diff: diff.to_h, timeline:
|
|
40
|
-
|
|
37
|
+
#: (?snapshots: bool, ?metadata: bool) -> Hash[Symbol, untyped]
|
|
38
|
+
def to_h(snapshots: false, metadata: false)
|
|
39
|
+
value = { diff: diff.to_h, timeline: timeline.map { |step| step.to_h(metadata: metadata) } }
|
|
40
|
+
activity = activity_timeline
|
|
41
|
+
if activity
|
|
42
|
+
value[:activity_timeline] = activity.map do |step|
|
|
43
|
+
step.to_h(metadata: metadata, snapshots: snapshots)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
41
46
|
return value unless snapshots
|
|
42
47
|
|
|
43
48
|
value.merge(
|
|
@@ -6,12 +6,13 @@ module PaperTrailDiff
|
|
|
6
6
|
# versions and preparing their association history once for the whole batch
|
|
7
7
|
# rather than once per root.
|
|
8
8
|
class AnalysisBatch
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool, ?context_required: bool) -> void
|
|
10
10
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
11
11
|
records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
|
|
12
|
-
close_on_current: false
|
|
12
|
+
close_on_current: false, context_required: false
|
|
13
13
|
)
|
|
14
14
|
@records = records
|
|
15
|
+
@context_required = context_required
|
|
15
16
|
@time_range = time_range
|
|
16
17
|
@version_scope = validated_scope(version_scope)
|
|
17
18
|
@close_on_current = close_on_current
|
|
@@ -26,7 +27,8 @@ module PaperTrailDiff
|
|
|
26
27
|
loaded = @live_loader.call(records)
|
|
27
28
|
selected = BatchedRootVersions.new(
|
|
28
29
|
records, time_range: @time_range, version_scope: @version_scope,
|
|
29
|
-
live_endpoints: (loaded if @close_on_current)
|
|
30
|
+
live_endpoints: (loaded if @close_on_current),
|
|
31
|
+
context_required: @context_required
|
|
30
32
|
).call
|
|
31
33
|
prepare(records, selected, loaded)
|
|
32
34
|
records.to_h do |record|
|
|
@@ -42,6 +44,7 @@ module PaperTrailDiff
|
|
|
42
44
|
# @rbs @time_range: TimeRange?
|
|
43
45
|
# @rbs @version_scope: untyped
|
|
44
46
|
# @rbs @close_on_current: bool
|
|
47
|
+
# @rbs @context_required: bool
|
|
45
48
|
# @rbs @live_loader: untyped
|
|
46
49
|
# @rbs @history_preparer: untyped
|
|
47
50
|
# @rbs @analyzer: untyped
|
|
@@ -75,20 +78,38 @@ module PaperTrailDiff
|
|
|
75
78
|
def prepare(records, selected, loaded)
|
|
76
79
|
records.group_by(&:class).each_value do |grouped|
|
|
77
80
|
versions = grouped.flat_map do |record|
|
|
78
|
-
selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).
|
|
81
|
+
selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).reconstruction_versions
|
|
79
82
|
end
|
|
80
83
|
next if versions.empty?
|
|
81
84
|
|
|
82
85
|
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
83
|
-
|
|
86
|
+
live = closes_live?(grouped, selected)
|
|
87
|
+
@history_preparer.call(roots, versions, **preparation_range(versions, live: live))
|
|
84
88
|
end
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> bool
|
|
92
|
+
def closes_live?(records, selected)
|
|
93
|
+
records.any? do |record|
|
|
94
|
+
!selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).closing_record.nil?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A plan closing on the live record reads history up to now; the others
|
|
99
|
+
# stop at the window, so a past window does not load everything since.
|
|
100
|
+
#: (Array[untyped], ?live: bool) -> Hash[Symbol, untyped]
|
|
101
|
+
def preparation_range(versions, live: @close_on_current)
|
|
102
|
+
last = versions.map(&:created_at).max
|
|
103
|
+
end_at = [last, @time_range&.end_time, (Time.now.utc if live)].compact.max
|
|
104
|
+
{ start_at: @time_range&.begin_time || versions.map(&:created_at).min, end_at: end_at }
|
|
105
|
+
end
|
|
106
|
+
|
|
87
107
|
# A root with no versions in range has nothing to report, which is an empty
|
|
88
|
-
# result rather than a failed request
|
|
108
|
+
# result rather than a failed request -- unless an activity view closes it
|
|
109
|
+
# on current state, where its descendants may still have moved.
|
|
89
110
|
#: (untyped, RootVersionPlan) -> Analysis
|
|
90
111
|
def analysis_for(record, plan)
|
|
91
|
-
return Analysis.empty if plan.empty?
|
|
112
|
+
return Analysis.empty if plan.empty? && !plan.closing_record
|
|
92
113
|
|
|
93
114
|
@analyzer.call(record, plan)
|
|
94
115
|
end
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Resolves and validates ActiveRecord reflections for a bounded association tree.
|
|
6
6
|
class AssociationTraversal
|
|
7
|
+
# The class a `belongs_to` points at for this record, reading a polymorphic
|
|
8
|
+
# type column when there is one. Nil when that type names no class.
|
|
9
|
+
#: (untyped, untyped) -> untyped
|
|
10
|
+
def self.belongs_to_target(reflection, record)
|
|
11
|
+
return reflection.klass unless reflection.polymorphic?
|
|
12
|
+
|
|
13
|
+
Object.const_get(record.public_send(reflection.foreign_type).to_s)
|
|
14
|
+
rescue NameError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
7
18
|
#: (AssociationTree) -> void
|
|
8
19
|
def initialize(tree)
|
|
9
20
|
@tree = tree
|
|
@@ -37,6 +48,18 @@ module PaperTrailDiff
|
|
|
37
48
|
end.freeze
|
|
38
49
|
end
|
|
39
50
|
|
|
51
|
+
# HABTM join rows have no versions of their own, so membership changes
|
|
52
|
+
# cannot be split into events once a range runs past the last root version
|
|
53
|
+
# into live state.
|
|
54
|
+
#: (untyped) -> void
|
|
55
|
+
def reject_live_habtm_activity!(model_class)
|
|
56
|
+
paths = habtm_paths(model_class)
|
|
57
|
+
return if paths.empty?
|
|
58
|
+
|
|
59
|
+
message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
|
|
60
|
+
raise UnsupportedLiveActivityError, message
|
|
61
|
+
end
|
|
62
|
+
|
|
40
63
|
#: (untyped, AssociationTree, path: String) -> Array[untyped]
|
|
41
64
|
def reflections_for(model_class, tree, path:)
|
|
42
65
|
key = [model_class.name.to_s, path, tree.object_id]
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
# Resolves `:first`
|
|
5
|
+
# Resolves `:first` endpoints for a whole batch in a fixed number of queries per
|
|
6
6
|
# model class. Left to the caller this is a per-root lookup, which reintroduces
|
|
7
7
|
# exactly the queries a batched comparison exists to remove.
|
|
8
8
|
class BatchBoundaryResolver
|
|
9
|
-
BOUNDARIES = %i[first
|
|
9
|
+
BOUNDARIES = %i[first].freeze
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
#: (untyped) -> bool
|
|
@@ -45,16 +45,17 @@ module PaperTrailDiff
|
|
|
45
45
|
#: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
|
|
46
46
|
def resolve(endpoint, other, index)
|
|
47
47
|
return endpoint unless symbolic?(endpoint)
|
|
48
|
+
raise ConfigurationError, TimelineRange::LAST_REMOVED if endpoint == :last
|
|
48
49
|
|
|
49
50
|
unless BOUNDARIES.include?(endpoint)
|
|
50
51
|
raise ConfigurationError,
|
|
51
|
-
"unsupported boundary: #{endpoint.inspect}; use :first,
|
|
52
|
+
"unsupported boundary: #{endpoint.inspect}; use :first, a version, or a record"
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
identity = anchor_identity(other)
|
|
55
56
|
unless identity
|
|
56
57
|
raise ConfigurationError,
|
|
57
|
-
'a :first
|
|
58
|
+
'a :first endpoint needs the other endpoint to name a record'
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
index.dig(identity, endpoint) || endpoint
|
|
@@ -100,33 +101,24 @@ module PaperTrailDiff
|
|
|
100
101
|
|
|
101
102
|
#: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
|
|
102
103
|
def add_model_boundaries(index, model_class, ids)
|
|
103
|
-
|
|
104
|
+
firsts = first_versions(model_class, ids)
|
|
104
105
|
ids.each do |id|
|
|
105
|
-
index[identity_key(model_class, id)] = {
|
|
106
|
-
first: versions[boundary_key(first_ids, id)],
|
|
107
|
-
last: versions[boundary_key(last_ids, id)]
|
|
108
|
-
}
|
|
106
|
+
index[identity_key(model_class, id)] = { first: firsts[id.to_s] }
|
|
109
107
|
end
|
|
110
108
|
end
|
|
111
109
|
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
# Each root's earliest version by time, in one query whatever the size of
|
|
111
|
+
# the batch. The lowest id is not the earliest version when ids are not
|
|
112
|
+
# sequential, as with UUIDs, so time decides and the usual ordering breaks
|
|
113
|
+
# a tie -- refusing one that ids cannot order.
|
|
114
|
+
#: (untyped, Array[untyped]) -> Hash[String, untyped]
|
|
115
|
+
def first_versions(model_class, ids)
|
|
116
116
|
version_class = model_class.paper_trail.version_class
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Grouped keys come back with whatever type the column uses, so match on the
|
|
125
|
-
# string form rather than assuming integers.
|
|
126
|
-
#: (Hash[untyped, untyped], untyped) -> untyped
|
|
127
|
-
def boundary_key(grouped, id)
|
|
128
|
-
key = grouped.keys.find { |candidate| candidate.to_s == id.to_s }
|
|
129
|
-
grouped[key]
|
|
117
|
+
table = version_class.arel_table
|
|
118
|
+
candidates = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
|
|
119
|
+
.where(VersionQuery.no_earlier_version(table)).to_a
|
|
120
|
+
candidates.group_by { |version| version.item_id.to_s }
|
|
121
|
+
.transform_values { |versions| Support.chronological_sort(versions).first }
|
|
130
122
|
end
|
|
131
123
|
|
|
132
124
|
#: (untyped, untyped) -> Array[String]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Refuses known per-record association loading before a batch reconstructs it.
|
|
6
|
+
class BatchSafety
|
|
7
|
+
#: (AssociationTraversal, allow_expensive: bool) -> void
|
|
8
|
+
def initialize(traversal, allow_expensive:)
|
|
9
|
+
unless [true, false].include?(allow_expensive)
|
|
10
|
+
raise ConfigurationError, 'allow_expensive: must be true or false'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@traversal = traversal
|
|
14
|
+
@allow_expensive = allow_expensive
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, ?historical: bool) -> void
|
|
18
|
+
def call(model, historical: true)
|
|
19
|
+
return if @allow_expensive
|
|
20
|
+
|
|
21
|
+
@traversal.selected_reflections(model).each do |path, reflection|
|
|
22
|
+
check(reflection, path, historical)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# @rbs @traversal: AssociationTraversal
|
|
29
|
+
# @rbs @allow_expensive: bool
|
|
30
|
+
|
|
31
|
+
#: (untyped, String, bool) -> void
|
|
32
|
+
def check(reflection, path, historical)
|
|
33
|
+
reason = reason_for(reflection, historical)
|
|
34
|
+
if reason
|
|
35
|
+
raise UnsupportedBatchPathError,
|
|
36
|
+
"association #{path} requires per-record reconstruction (#{reason}); " \
|
|
37
|
+
'remove this path or explicitly pass allow_expensive: true'
|
|
38
|
+
end
|
|
39
|
+
return unless reflection.options[:through]
|
|
40
|
+
|
|
41
|
+
check(reflection.through_reflection, path, historical)
|
|
42
|
+
check(reflection.source_reflection, path, historical)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (untyped, bool) -> String?
|
|
46
|
+
def reason_for(reflection, historical)
|
|
47
|
+
return 'association scope' if reflection.scope
|
|
48
|
+
return 'default-scoped target' if !reflection.polymorphic? && reflection.klass.default_scopes?
|
|
49
|
+
return unless historical && reflection.options[:through]
|
|
50
|
+
return if reflection.source_reflection.belongs_to?
|
|
51
|
+
|
|
52
|
+
'through source is not belongs_to'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -5,13 +5,16 @@ module PaperTrailDiff
|
|
|
5
5
|
# Builds one root's Analysis inside a batch, from versions the batch already
|
|
6
6
|
# selected and history it already prepared.
|
|
7
7
|
class BatchedRootAnalyzer
|
|
8
|
-
#: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
|
|
9
|
-
def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:)
|
|
8
|
+
#: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool, ?within: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, within: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
10
10
|
@tree = tree
|
|
11
11
|
@timeline_snapshotter = timeline_snapshotter
|
|
12
12
|
@activity_snapshotter = activity_snapshotter
|
|
13
13
|
@preparer = preparer
|
|
14
14
|
@activity = activity
|
|
15
|
+
@within = within
|
|
16
|
+
@snapshots = snapshots
|
|
17
|
+
@group = group
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
#: (untyped, RootVersionPlan) -> Analysis
|
|
@@ -22,7 +25,7 @@ module PaperTrailDiff
|
|
|
22
25
|
TimelineBuilder.new(
|
|
23
26
|
record,
|
|
24
27
|
from: plan.versions.first,
|
|
25
|
-
to: plan.versions.last,
|
|
28
|
+
to: plan.closing_record || plan.versions.last,
|
|
26
29
|
within: nil,
|
|
27
30
|
plan: plan,
|
|
28
31
|
snapshotter: @timeline_snapshotter
|
|
@@ -36,14 +39,20 @@ module PaperTrailDiff
|
|
|
36
39
|
# @rbs @activity_snapshotter: untyped
|
|
37
40
|
# @rbs @preparer: untyped
|
|
38
41
|
# @rbs @activity: bool
|
|
42
|
+
# @rbs @within: untyped
|
|
43
|
+
# @rbs @snapshots: bool
|
|
44
|
+
# @rbs @group: Symbol?
|
|
39
45
|
|
|
40
46
|
#: (untyped, RootVersionPlan) -> Analysis
|
|
41
47
|
def activity_analysis(record, plan)
|
|
42
48
|
range = TimelineRange.new(
|
|
43
|
-
record, from:
|
|
49
|
+
record, from: (@within ? nil : plan.versions.first),
|
|
50
|
+
to: (@within ? nil : plan.closing_record || plan.versions.last),
|
|
51
|
+
within: @within, plan: plan
|
|
44
52
|
)
|
|
45
53
|
ActivityTimelineBuilder.new(
|
|
46
|
-
record, range: range, tree: @tree, snapshotter: @activity_snapshotter
|
|
54
|
+
record, range: range, tree: @tree, snapshotter: @activity_snapshotter,
|
|
55
|
+
snapshots: @snapshots, group: @group
|
|
47
56
|
).analyze
|
|
48
57
|
end
|
|
49
58
|
end
|
|
@@ -6,9 +6,13 @@ module PaperTrailDiff
|
|
|
6
6
|
# queries. Only the range forms that mean the same thing for every root are
|
|
7
7
|
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
8
|
class BatchedRootVersions
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?) -> void
|
|
10
|
-
def initialize(records, time_range:, version_scope: nil, live_endpoints: nil
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?, ?context_required: bool) -> void
|
|
10
|
+
def initialize(records, time_range:, version_scope: nil, live_endpoints: nil,
|
|
11
|
+
context_required: false)
|
|
11
12
|
@records = records
|
|
13
|
+
# An activity view needs a root to reconstruct from even in a window with
|
|
14
|
+
# no root version, exactly as a single-record activity timeline does.
|
|
15
|
+
@context_required = context_required
|
|
12
16
|
@time_range = time_range
|
|
13
17
|
@version_scope = version_scope
|
|
14
18
|
@live_endpoints = live_endpoints
|
|
@@ -32,6 +36,7 @@ module PaperTrailDiff
|
|
|
32
36
|
# @rbs @time_range: TimeRange?
|
|
33
37
|
# @rbs @version_scope: untyped
|
|
34
38
|
# @rbs @live_endpoints: Hash[Array[String], untyped]?
|
|
39
|
+
# @rbs @context_required: bool
|
|
35
40
|
|
|
36
41
|
#: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
37
42
|
def select_model(model_class, records, selected)
|
|
@@ -64,6 +69,7 @@ module PaperTrailDiff
|
|
|
64
69
|
selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
|
|
65
70
|
after_range: after_range,
|
|
66
71
|
windowed: !@time_range.nil?,
|
|
72
|
+
context_required: @context_required,
|
|
67
73
|
filtered: !@version_scope.nil?,
|
|
68
74
|
live_endpoint: live_endpoint
|
|
69
75
|
).call
|
|
@@ -104,43 +110,9 @@ module PaperTrailDiff
|
|
|
104
110
|
range ? range.scope(scope) : scope
|
|
105
111
|
end
|
|
106
112
|
|
|
107
|
-
# One row per root: the earliest version after the window, found without a
|
|
108
|
-
# window function so the query stays portable.
|
|
109
113
|
#: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
|
|
110
114
|
def trailing_versions(model_class, ids, range)
|
|
111
|
-
|
|
112
|
-
relation = range.trailing_scope(base_scope(model_class, ids))
|
|
113
|
-
ordered(relation.where(no_earlier_trailing(table, range)))
|
|
114
|
-
.to_h { |version| [version.item_id.to_s, version] }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
#: (untyped, TimeRange) -> untyped
|
|
118
|
-
def no_earlier_trailing(table, range)
|
|
119
|
-
arel = Object.const_get(:Arel) #: untyped
|
|
120
|
-
later = table.alias('paper_trail_diff_later_roots')
|
|
121
|
-
arel.const_get(:SelectManager).new
|
|
122
|
-
.from(later)
|
|
123
|
-
.project(arel.sql('1'))
|
|
124
|
-
.where(preceding(table, later, range))
|
|
125
|
-
.exists
|
|
126
|
-
.not
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Mirrors the window's own end handling, so a version sitting exactly on an
|
|
130
|
-
# inclusive boundary stays inside the window instead of masking the trailing
|
|
131
|
-
# version that follows it.
|
|
132
|
-
#: (untyped, untyped, TimeRange) -> untyped
|
|
133
|
-
def preceding(table, later, range)
|
|
134
|
-
same_item = later[:item_type].eq(table[:item_type])
|
|
135
|
-
.and(later[:item_id].eq(table[:item_id]))
|
|
136
|
-
same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
#: (untyped, TimeRange) -> untyped
|
|
140
|
-
def after_window(later, range)
|
|
141
|
-
return later[:created_at].gteq(range.end_time) if range.exclude_end?
|
|
142
|
-
|
|
143
|
-
later[:created_at].gt(range.end_time)
|
|
115
|
+
range.first_trailing_versions(base_scope(model_class, ids))
|
|
144
116
|
end
|
|
145
117
|
|
|
146
118
|
#: (untyped, Array[untyped]) -> untyped
|
|
@@ -153,7 +125,7 @@ module PaperTrailDiff
|
|
|
153
125
|
|
|
154
126
|
#: (untyped) -> Array[untyped]
|
|
155
127
|
def ordered(relation)
|
|
156
|
-
|
|
128
|
+
VersionQuery.chronological(relation).to_a
|
|
157
129
|
end
|
|
158
130
|
|
|
159
131
|
#: (untyped, untyped) -> Array[String]
|