paper_trail_diff 0.3.1 → 0.5.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 +81 -0
- data/README.md +316 -32
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
- data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
- data/lib/paper_trail_diff/analysis.rb +9 -0
- data/lib/paper_trail_diff/analysis_batch.rb +74 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
- data/lib/paper_trail_diff/batched_root_versions.rb +131 -0
- data/lib/paper_trail_diff/collection_comparator.rb +14 -1
- data/lib/paper_trail_diff/collection_transition.rb +6 -3
- data/lib/paper_trail_diff/comparison_batch.rb +37 -13
- data/lib/paper_trail_diff/endpoint.rb +27 -2
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/historical_snapshot_store.rb +12 -9
- data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
- data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
- data/lib/paper_trail_diff/snapshot.rb +26 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
- data/lib/paper_trail_diff/time_version_range.rb +11 -0
- data/lib/paper_trail_diff/timeline_builder.rb +3 -3
- data/lib/paper_trail_diff/timeline_range.rb +66 -6
- data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
- data/lib/paper_trail_diff.rb +37 -5
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
- data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +42 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -3
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- data/sig/generated/paper_trail_diff.rbs +13 -3
- metadata +16 -4
|
@@ -7,63 +7,43 @@ module PaperTrailDiff
|
|
|
7
7
|
#: (AssociationTraversal) -> void
|
|
8
8
|
def initialize(traversal)
|
|
9
9
|
@traversal = traversal
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
@collection_type = nil #: String?
|
|
13
|
-
@collection_path = nil #: String?
|
|
14
|
-
@collection_routes = nil #: Array[Array[untyped]]?
|
|
15
|
-
@belongs_to_model = nil
|
|
16
|
-
@belongs_to_tree = nil #: AssociationTree?
|
|
17
|
-
@belongs_to_type = nil #: String?
|
|
18
|
-
@belongs_to_path = nil #: String?
|
|
19
|
-
@belongs_to_routes = nil #: Array[Array[untyped]]?
|
|
10
|
+
@collection_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
|
|
11
|
+
@belongs_to_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
|
|
20
12
|
end
|
|
21
13
|
|
|
22
14
|
#: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
|
|
23
15
|
def collection_routes(model_class, tree, target_type, path: '')
|
|
24
|
-
cached
|
|
25
|
-
if cached && @collection_model.equal?(model_class) && @collection_tree.equal?(tree) &&
|
|
26
|
-
@collection_type == target_type && @collection_path == path
|
|
27
|
-
return cached
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
computed = freeze_routes(routes(model_class, tree, target_type, :has_many, path: path))
|
|
31
|
-
@collection_model = model_class
|
|
32
|
-
@collection_tree = tree
|
|
33
|
-
@collection_type = Support.immutable_copy(target_type)
|
|
34
|
-
@collection_path = Support.immutable_copy(path)
|
|
35
|
-
@collection_routes = computed
|
|
16
|
+
cached(@collection_routes, model_class, tree, target_type, path, :has_many)
|
|
36
17
|
end
|
|
37
18
|
|
|
38
19
|
#: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
|
|
39
20
|
def belongs_to_routes(model_class, tree, target_type, path: '')
|
|
40
|
-
cached
|
|
41
|
-
if cached && @belongs_to_model.equal?(model_class) && @belongs_to_tree.equal?(tree) &&
|
|
42
|
-
@belongs_to_type == target_type && @belongs_to_path == path
|
|
43
|
-
return cached
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
computed = freeze_routes(routes(model_class, tree, target_type, :belongs_to, path: path))
|
|
47
|
-
@belongs_to_model = model_class
|
|
48
|
-
@belongs_to_tree = tree
|
|
49
|
-
@belongs_to_type = Support.immutable_copy(target_type)
|
|
50
|
-
@belongs_to_path = Support.immutable_copy(path)
|
|
51
|
-
@belongs_to_routes = computed
|
|
21
|
+
cached(@belongs_to_routes, model_class, tree, target_type, path, :belongs_to)
|
|
52
22
|
end
|
|
53
23
|
|
|
54
24
|
private
|
|
55
25
|
|
|
56
26
|
# @rbs @traversal: AssociationTraversal
|
|
57
|
-
# @rbs @
|
|
58
|
-
# @rbs @
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
27
|
+
# @rbs @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
|
|
28
|
+
# @rbs @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
|
|
29
|
+
|
|
30
|
+
# Timelines interleave event types, so every requested combination is
|
|
31
|
+
# retained. Model, tree, and path are fixed per adapter, leaving one entry
|
|
32
|
+
# per selected item type.
|
|
33
|
+
#: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
|
|
34
|
+
def cached(store, model_class, tree, target_type, path, macro) # rubocop:disable Metrics/ParameterLists
|
|
35
|
+
key = [model_class, tree, target_type, path]
|
|
36
|
+
existing = store[key]
|
|
37
|
+
return existing if existing
|
|
38
|
+
|
|
39
|
+
store[cache_key(key)] =
|
|
40
|
+
freeze_routes(routes(model_class, tree, target_type, macro, path: path))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
44
|
+
def cache_key(key)
|
|
45
|
+
key.map { |part| part.is_a?(String) ? -part : part }.freeze
|
|
46
|
+
end
|
|
67
47
|
|
|
68
48
|
#: (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
69
49
|
def freeze_routes(routes)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Root checkpoint steps recovered from the snapshots an activity pass retained,
|
|
6
|
+
# so a combined result does not reconstruct the same boundaries twice.
|
|
7
|
+
module ActivityRootSteps
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
11
|
+
def call(root_versions, root_snapshots)
|
|
12
|
+
snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
|
|
13
|
+
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
14
|
+
Step.new(
|
|
15
|
+
from_version: versions.fetch(0),
|
|
16
|
+
to_version: versions.fetch(1),
|
|
17
|
+
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
18
|
+
)
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: (untyped) -> Array[untyped]
|
|
23
|
+
def version_key(version)
|
|
24
|
+
[version.class.name, version.id]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -11,9 +11,14 @@ module PaperTrailDiff
|
|
|
11
11
|
@preparer = preparer
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
#: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
15
|
-
def prepare(
|
|
16
|
-
|
|
14
|
+
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
15
|
+
def prepare(
|
|
16
|
+
record,
|
|
17
|
+
root_versions,
|
|
18
|
+
start_at: root_versions.first.created_at,
|
|
19
|
+
end_at: root_versions.last.created_at
|
|
20
|
+
)
|
|
21
|
+
@preparer.call(record, root_versions, start_at: start_at, end_at: end_at)
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -17,6 +17,7 @@ module PaperTrailDiff
|
|
|
17
17
|
#: () -> Array[ActivityStep]
|
|
18
18
|
def build
|
|
19
19
|
return time_builder.build if @range.time?
|
|
20
|
+
return no_steps if @range.unresolved?
|
|
20
21
|
return build_to_current if Endpoint.record?(@to)
|
|
21
22
|
|
|
22
23
|
Endpoint.validate!(@to)
|
|
@@ -27,6 +28,7 @@ module PaperTrailDiff
|
|
|
27
28
|
#: () -> Analysis
|
|
28
29
|
def analyze
|
|
29
30
|
return time_builder.analyze if @range.time?
|
|
31
|
+
return Analysis.empty if @range.unresolved?
|
|
30
32
|
|
|
31
33
|
unless Endpoint.version?(@to)
|
|
32
34
|
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
@@ -35,7 +37,7 @@ module PaperTrailDiff
|
|
|
35
37
|
root_versions = @range.select
|
|
36
38
|
prepare_history(root_versions)
|
|
37
39
|
events = collect_events(root_versions)
|
|
38
|
-
build_analysis(root_versions, event_history(root_versions, events))
|
|
40
|
+
build_analysis(root_versions, events, event_history(root_versions, events))
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
private
|
|
@@ -47,6 +49,12 @@ module PaperTrailDiff
|
|
|
47
49
|
# @rbs @tree: AssociationTree
|
|
48
50
|
# @rbs @snapshotter: untyped
|
|
49
51
|
|
|
52
|
+
#: () -> Array[ActivityStep]
|
|
53
|
+
def no_steps
|
|
54
|
+
steps = [] #: Array[ActivityStep]
|
|
55
|
+
steps.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
50
58
|
#: () -> Array[ActivityStep]
|
|
51
59
|
def build_between_versions
|
|
52
60
|
root_versions = @range.select
|
|
@@ -60,7 +68,9 @@ module PaperTrailDiff
|
|
|
60
68
|
validate_current_range!
|
|
61
69
|
current_snapshot, captured_at = capture_current
|
|
62
70
|
root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
|
|
63
|
-
|
|
71
|
+
# Descendants can move after the last root version, so the prepared range
|
|
72
|
+
# ends at the captured instant rather than at that version.
|
|
73
|
+
prepare_history(root_versions, end_at: captured_at)
|
|
64
74
|
events = collect_events(root_versions, range_end: captured_at)
|
|
65
75
|
build_event_steps(
|
|
66
76
|
root_versions,
|
|
@@ -102,15 +112,12 @@ module PaperTrailDiff
|
|
|
102
112
|
).call
|
|
103
113
|
end
|
|
104
114
|
|
|
105
|
-
#: (Array[untyped], ?
|
|
106
|
-
def prepare_history(root_versions,
|
|
115
|
+
#: (Array[untyped], ?end_at: untyped) -> void
|
|
116
|
+
def prepare_history(root_versions, end_at: nil)
|
|
107
117
|
return unless @snapshotter.respond_to?(:prepare)
|
|
118
|
+
return @snapshotter.prepare(@record, root_versions) unless end_at
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
@snapshotter.prepare(@record, root_versions, start_at: start_at)
|
|
111
|
-
else
|
|
112
|
-
@snapshotter.prepare(@record, root_versions)
|
|
113
|
-
end
|
|
120
|
+
@snapshotter.prepare(@record, root_versions, end_at: end_at)
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
#: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
@@ -122,6 +129,18 @@ module PaperTrailDiff
|
|
|
122
129
|
final_snapshot: nil
|
|
123
130
|
)
|
|
124
131
|
history = event_history(root_versions, events, current: current)
|
|
132
|
+
activity_steps(
|
|
133
|
+
history,
|
|
134
|
+
events,
|
|
135
|
+
final_boundary || destroyed_boundary(root_versions),
|
|
136
|
+
final_snapshot
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Appends the transition into an explicit closing boundary, which is either
|
|
141
|
+
# a requested current record or the absence a final root destroy leaves.
|
|
142
|
+
#: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
143
|
+
def activity_steps(history, events, final_boundary, final_snapshot)
|
|
125
144
|
steps = history.steps.dup
|
|
126
145
|
previous_event = events.last
|
|
127
146
|
previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
|
|
@@ -135,6 +154,16 @@ module PaperTrailDiff
|
|
|
135
154
|
steps.freeze
|
|
136
155
|
end
|
|
137
156
|
|
|
157
|
+
# A destroyed root has no later version, but its own event states that
|
|
158
|
+
# nothing follows it, so the removal can still close the timeline.
|
|
159
|
+
#: (Array[untyped]) -> ActivityBoundary?
|
|
160
|
+
def destroyed_boundary(root_versions)
|
|
161
|
+
version = root_versions.last
|
|
162
|
+
return unless version && version.event.to_s == 'destroy'
|
|
163
|
+
|
|
164
|
+
ActivityBoundary.destroyed(version)
|
|
165
|
+
end
|
|
166
|
+
|
|
138
167
|
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
139
168
|
def event_history(root_versions, events, current: nil)
|
|
140
169
|
ActivityHistoryBuilder.new(
|
|
@@ -145,32 +174,18 @@ module PaperTrailDiff
|
|
|
145
174
|
).call
|
|
146
175
|
end
|
|
147
176
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
# Only the activity view gains the closing removal. The endpoint diff and
|
|
178
|
+
# the root timeline keep their `compare` and `timeline` semantics, under
|
|
179
|
+
# which the state at a destroy version is the state before the deletion.
|
|
180
|
+
#: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
181
|
+
def build_analysis(root_versions, events, history)
|
|
153
182
|
Analysis.new(
|
|
154
183
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
155
|
-
timeline:
|
|
156
|
-
activity_timeline:
|
|
157
|
-
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
#: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
|
|
161
|
-
def build_root_steps(root_versions, snapshots)
|
|
162
|
-
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
163
|
-
Step.new(
|
|
164
|
-
from_version: versions.fetch(0),
|
|
165
|
-
to_version: versions.fetch(1),
|
|
166
|
-
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
184
|
+
timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
|
|
185
|
+
activity_timeline: activity_steps(
|
|
186
|
+
history, events, destroyed_boundary(root_versions), nil
|
|
167
187
|
)
|
|
168
|
-
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
#: (untyped) -> Array[untyped]
|
|
172
|
-
def version_key(version)
|
|
173
|
-
[version.class.name, version.id]
|
|
188
|
+
)
|
|
174
189
|
end
|
|
175
190
|
|
|
176
191
|
#: () -> TimeActivityTimelineBuilder
|
|
@@ -8,6 +8,15 @@ module PaperTrailDiff
|
|
|
8
8
|
attr_reader :timeline #: Array[Step]
|
|
9
9
|
attr_reader :activity_timeline #: Array[ActivityStep]?
|
|
10
10
|
|
|
11
|
+
# The result for a record whose requested history contains no versions,
|
|
12
|
+
# which is an empty history rather than a failed request.
|
|
13
|
+
#: () -> Analysis
|
|
14
|
+
def self.empty
|
|
15
|
+
timeline = [] #: Array[Step]
|
|
16
|
+
activity_timeline = [] #: Array[ActivityStep]
|
|
17
|
+
new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
#: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
|
|
12
21
|
def initialize(diff:, timeline:, activity_timeline: nil)
|
|
13
22
|
@diff = diff
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Builds one Analysis per root over a shared range, selecting every root's
|
|
6
|
+
# versions and preparing their association history once for the whole batch
|
|
7
|
+
# rather than once per root.
|
|
8
|
+
class AnalysisBatch
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
|
|
10
|
+
def initialize(records, time_range:, live_loader:, history_preparer:, analyzer:)
|
|
11
|
+
@records = records
|
|
12
|
+
@time_range = time_range
|
|
13
|
+
@live_loader = live_loader
|
|
14
|
+
@history_preparer = history_preparer
|
|
15
|
+
@analyzer = analyzer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> Hash[identity, Analysis]
|
|
19
|
+
def call
|
|
20
|
+
records = validated_records
|
|
21
|
+
selected = BatchedRootVersions.new(records, time_range: @time_range).call
|
|
22
|
+
prepare(records, selected)
|
|
23
|
+
records.to_h do |record|
|
|
24
|
+
key = Endpoint.identity(record)
|
|
25
|
+
[Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
|
|
26
|
+
end.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @rbs @records: Array[untyped]
|
|
32
|
+
# @rbs @time_range: TimeRange?
|
|
33
|
+
# @rbs @live_loader: untyped
|
|
34
|
+
# @rbs @history_preparer: untyped
|
|
35
|
+
# @rbs @analyzer: untyped
|
|
36
|
+
|
|
37
|
+
#: () -> Array[untyped]
|
|
38
|
+
def validated_records
|
|
39
|
+
raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
@records.each { |record| Endpoint.validate!(record) }
|
|
42
|
+
identities = @records.map { |record| Endpoint.identity(record) }
|
|
43
|
+
return @records if identities.uniq.length == identities.length
|
|
44
|
+
|
|
45
|
+
raise ConfigurationError, 'records: identities must be unique'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Association history is prepared per model class across every selected root
|
|
49
|
+
# version, which is the work that would otherwise repeat for each record.
|
|
50
|
+
# The roots are preloaded first, because preparation reads their current
|
|
51
|
+
# association state as a fallback and would otherwise walk it one root at a
|
|
52
|
+
# time.
|
|
53
|
+
#: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
|
|
54
|
+
def prepare(records, selected)
|
|
55
|
+
loaded = @live_loader.call(records)
|
|
56
|
+
records.group_by(&:class).each_value do |grouped|
|
|
57
|
+
versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
|
|
58
|
+
next if versions.empty?
|
|
59
|
+
|
|
60
|
+
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
61
|
+
@history_preparer.call(roots, versions)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A root with no versions in range has nothing to report, which is an empty
|
|
66
|
+
# result rather than a failed request.
|
|
67
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
68
|
+
def analysis_for(record, versions)
|
|
69
|
+
return Analysis.empty if versions.empty?
|
|
70
|
+
|
|
71
|
+
@analyzer.call(record, versions)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Resolves `:first` and `:last` endpoints for a whole batch in two queries per
|
|
6
|
+
# model class. Left to the caller this is a per-root lookup, which reintroduces
|
|
7
|
+
# exactly the queries a batched comparison exists to remove.
|
|
8
|
+
class BatchBoundaryResolver
|
|
9
|
+
BOUNDARIES = %i[first last].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
#: (untyped) -> bool
|
|
13
|
+
def symbolic?(endpoint)
|
|
14
|
+
endpoint.is_a?(Symbol)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (Array[[untyped, untyped]]) -> void
|
|
19
|
+
def initialize(pairs)
|
|
20
|
+
@pairs = pairs
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the pairs with every resolvable symbol replaced. A symbol that
|
|
24
|
+
# names history the record does not have is left in place, so the caller can
|
|
25
|
+
# decide what an absent history means rather than being handed nil.
|
|
26
|
+
#: () -> Array[[untyped, untyped]]
|
|
27
|
+
def call
|
|
28
|
+
return @pairs unless @pairs.flatten(1).any? { |endpoint| symbolic?(endpoint) }
|
|
29
|
+
|
|
30
|
+
index = boundary_index
|
|
31
|
+
@pairs.map do |from, to|
|
|
32
|
+
[resolve(from, to, index), resolve(to, from, index)]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# @rbs @pairs: Array[[untyped, untyped]]
|
|
39
|
+
|
|
40
|
+
#: (untyped) -> bool
|
|
41
|
+
def symbolic?(endpoint)
|
|
42
|
+
self.class.symbolic?(endpoint)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
|
|
46
|
+
def resolve(endpoint, other, index)
|
|
47
|
+
return endpoint unless symbolic?(endpoint)
|
|
48
|
+
|
|
49
|
+
unless BOUNDARIES.include?(endpoint)
|
|
50
|
+
raise ConfigurationError,
|
|
51
|
+
"unsupported boundary: #{endpoint.inspect}; use :first, :last, a version, or a record"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
identity = anchor_identity(other)
|
|
55
|
+
unless identity
|
|
56
|
+
raise ConfigurationError,
|
|
57
|
+
'a :first or :last endpoint needs the other endpoint to name a record'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
index.dig(identity, endpoint) || endpoint
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A symbol carries no identity of its own, so the pair's other endpoint has
|
|
64
|
+
# to say which record is meant.
|
|
65
|
+
#: (untyped) -> Array[String]?
|
|
66
|
+
def anchor_identity(endpoint)
|
|
67
|
+
return if endpoint.nil? || symbolic?(endpoint)
|
|
68
|
+
|
|
69
|
+
Endpoint.identity(endpoint)
|
|
70
|
+
rescue InvalidEndpointError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: () -> Hash[Array[String], Hash[Symbol, untyped]]
|
|
75
|
+
def boundary_index
|
|
76
|
+
index = {} #: Hash[Array[String], Hash[Symbol, untyped]]
|
|
77
|
+
anchors.group_by { |model_class, _id| model_class }.each do |model_class, entries|
|
|
78
|
+
add_model_boundaries(index, model_class, entries.map { |_klass, id| id }.uniq)
|
|
79
|
+
end
|
|
80
|
+
index
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
#: () -> Array[[untyped, untyped]]
|
|
84
|
+
def anchors
|
|
85
|
+
@pairs.flatten(1).filter_map { |endpoint| anchor(endpoint) }.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (untyped) -> [untyped, untyped]?
|
|
89
|
+
def anchor(endpoint)
|
|
90
|
+
return if endpoint.nil? || symbolic?(endpoint)
|
|
91
|
+
|
|
92
|
+
if Endpoint.version?(endpoint)
|
|
93
|
+
[Endpoint.model_class(endpoint), endpoint.item_id]
|
|
94
|
+
elsif Endpoint.record?(endpoint)
|
|
95
|
+
[endpoint.class, endpoint.id]
|
|
96
|
+
end
|
|
97
|
+
rescue InvalidEndpointError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
|
|
102
|
+
def add_model_boundaries(index, model_class, ids)
|
|
103
|
+
first_ids, last_ids, versions = boundary_versions(model_class, ids)
|
|
104
|
+
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
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Two grouped queries name each root's outermost versions, and one more
|
|
113
|
+
# loads them, whatever the size of the batch.
|
|
114
|
+
#: (untyped, Array[untyped]) -> [Hash[untyped, untyped], Hash[untyped, untyped], Hash[untyped, untyped]]
|
|
115
|
+
def boundary_versions(model_class, ids)
|
|
116
|
+
version_class = model_class.paper_trail.version_class
|
|
117
|
+
scope = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
|
|
118
|
+
first_ids = scope.group(:item_id).minimum(:id)
|
|
119
|
+
last_ids = scope.group(:item_id).maximum(:id)
|
|
120
|
+
loaded = version_class.where(id: (first_ids.values + last_ids.values).uniq).index_by(&:id)
|
|
121
|
+
[first_ids, last_ids, loaded]
|
|
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]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
#: (untyped, untyped) -> Array[String]
|
|
133
|
+
def identity_key(model_class, id)
|
|
134
|
+
[model_class.base_class.name.to_s, id.to_s]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Builds one root's Analysis inside a batch, from versions the batch already
|
|
6
|
+
# selected and history it already prepared.
|
|
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:)
|
|
10
|
+
@tree = tree
|
|
11
|
+
@timeline_snapshotter = timeline_snapshotter
|
|
12
|
+
@activity_snapshotter = activity_snapshotter
|
|
13
|
+
@preparer = preparer
|
|
14
|
+
@activity = activity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
18
|
+
def call(record, versions)
|
|
19
|
+
@preparer.call(record.class, historical: true)
|
|
20
|
+
return activity_analysis(record, versions) if @activity
|
|
21
|
+
|
|
22
|
+
TimelineBuilder.new(
|
|
23
|
+
record,
|
|
24
|
+
from: versions.first,
|
|
25
|
+
to: versions.last,
|
|
26
|
+
within: nil,
|
|
27
|
+
versions: versions,
|
|
28
|
+
snapshotter: @timeline_snapshotter
|
|
29
|
+
).analyze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @rbs @tree: AssociationTree
|
|
35
|
+
# @rbs @timeline_snapshotter: untyped
|
|
36
|
+
# @rbs @activity_snapshotter: untyped
|
|
37
|
+
# @rbs @preparer: untyped
|
|
38
|
+
# @rbs @activity: bool
|
|
39
|
+
|
|
40
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
41
|
+
def activity_analysis(record, versions)
|
|
42
|
+
range = TimelineRange.new(
|
|
43
|
+
record, from: versions.first, to: versions.last, within: nil, versions: versions
|
|
44
|
+
)
|
|
45
|
+
ActivityTimelineBuilder.new(
|
|
46
|
+
record, range: range, tree: @tree, snapshotter: @activity_snapshotter
|
|
47
|
+
).analyze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects each root's versions for a batched range in a fixed number of
|
|
6
|
+
# queries. Only the range forms that mean the same thing for every root are
|
|
7
|
+
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
|
+
class BatchedRootVersions
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?) -> void
|
|
10
|
+
def initialize(records, time_range:)
|
|
11
|
+
@records = records
|
|
12
|
+
@time_range = time_range
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns root versions per record identity, in chronological order.
|
|
16
|
+
#: () -> Hash[Array[String], Array[untyped]]
|
|
17
|
+
def call
|
|
18
|
+
return {} if @records.empty?
|
|
19
|
+
|
|
20
|
+
selected = {} #: Hash[Array[String], Array[untyped]]
|
|
21
|
+
@records.group_by(&:class).each do |model_class, records|
|
|
22
|
+
select_model(model_class, records, selected)
|
|
23
|
+
end
|
|
24
|
+
selected
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs @records: Array[untyped]
|
|
30
|
+
# @rbs @time_range: TimeRange?
|
|
31
|
+
|
|
32
|
+
#: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
|
|
33
|
+
def select_model(model_class, records, selected)
|
|
34
|
+
ids = records.map(&:id)
|
|
35
|
+
range = @time_range
|
|
36
|
+
grouped = grouped_versions(model_class, ids, range)
|
|
37
|
+
empty = {} #: Hash[String, untyped]
|
|
38
|
+
trailing = range ? trailing_versions(model_class, ids, range) : empty
|
|
39
|
+
records.each do |record|
|
|
40
|
+
key = identity(model_class, record.id)
|
|
41
|
+
selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The window's own versions plus, when it has one, the later version needed
|
|
46
|
+
# to reveal the last mutation inside it. A window closing on the root's own
|
|
47
|
+
# destruction needs no later version, because none can exist.
|
|
48
|
+
#: (Array[untyped], untyped) -> Array[untyped]
|
|
49
|
+
def versions_for(in_range, trailing)
|
|
50
|
+
return in_range if @time_range.nil?
|
|
51
|
+
return in_range.freeze if in_range.empty?
|
|
52
|
+
return in_range.freeze if trailing.nil? && terminal_destroy?(in_range)
|
|
53
|
+
|
|
54
|
+
unless trailing
|
|
55
|
+
raise IncompleteTimeRangeError,
|
|
56
|
+
'time range requires a later root version to reconstruct its final change'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
(in_range + [trailing]).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (Array[untyped]) -> bool
|
|
63
|
+
def terminal_destroy?(versions)
|
|
64
|
+
versions.last&.event.to_s == 'destroy'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
|
|
68
|
+
def grouped_versions(model_class, ids, range)
|
|
69
|
+
scope = base_scope(model_class, ids)
|
|
70
|
+
scope = range.scope(scope) if range
|
|
71
|
+
ordered(scope).group_by { |version| version.item_id.to_s }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One row per root: the earliest version after the window, found without a
|
|
75
|
+
# window function so the query stays portable.
|
|
76
|
+
#: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
|
|
77
|
+
def trailing_versions(model_class, ids, range)
|
|
78
|
+
table = model_class.paper_trail.version_class.arel_table
|
|
79
|
+
relation = range.trailing_scope(base_scope(model_class, ids))
|
|
80
|
+
ordered(relation.where(no_earlier_trailing(table, range)))
|
|
81
|
+
.to_h { |version| [version.item_id.to_s, version] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: (untyped, TimeRange) -> untyped
|
|
85
|
+
def no_earlier_trailing(table, range)
|
|
86
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
87
|
+
later = table.alias('paper_trail_diff_later_roots')
|
|
88
|
+
arel.const_get(:SelectManager).new
|
|
89
|
+
.from(later)
|
|
90
|
+
.project(arel.sql('1'))
|
|
91
|
+
.where(preceding(table, later, range))
|
|
92
|
+
.exists
|
|
93
|
+
.not
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Mirrors the window's own end handling, so a version sitting exactly on an
|
|
97
|
+
# inclusive boundary stays inside the window instead of masking the trailing
|
|
98
|
+
# version that follows it.
|
|
99
|
+
#: (untyped, untyped, TimeRange) -> untyped
|
|
100
|
+
def preceding(table, later, range)
|
|
101
|
+
same_item = later[:item_type].eq(table[:item_type])
|
|
102
|
+
.and(later[:item_id].eq(table[:item_id]))
|
|
103
|
+
same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (untyped, TimeRange) -> untyped
|
|
107
|
+
def after_window(later, range)
|
|
108
|
+
return later[:created_at].gteq(range.end_time) if range.exclude_end?
|
|
109
|
+
|
|
110
|
+
later[:created_at].gt(range.end_time)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: (untyped, Array[untyped]) -> untyped
|
|
114
|
+
def base_scope(model_class, ids)
|
|
115
|
+
model_class.paper_trail.version_class.where(
|
|
116
|
+
item_type: model_class.base_class.name.to_s,
|
|
117
|
+
item_id: ids
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (untyped) -> Array[untyped]
|
|
122
|
+
def ordered(relation)
|
|
123
|
+
relation.reorder(created_at: :asc, id: :asc).to_a
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: (untyped, untyped) -> Array[String]
|
|
127
|
+
def identity(model_class, id)
|
|
128
|
+
[model_class.base_class.name.to_s, id.to_s]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|