paper_trail_diff 0.4.0 → 0.6.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 +44 -0
- data/README.md +120 -3
- data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
- data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
- data/lib/paper_trail_diff/analysis.rb +19 -5
- data/lib/paper_trail_diff/analysis_batch.rb +92 -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 +149 -0
- data/lib/paper_trail_diff/comparison_batch.rb +37 -13
- data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
- data/lib/paper_trail_diff/paper_trail_adapter.rb +70 -47
- data/lib/paper_trail_diff/root_version_plan.rb +86 -0
- data/lib/paper_trail_diff/root_version_selection.rb +131 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
- data/lib/paper_trail_diff/time_version_range.rb +19 -24
- data/lib/paper_trail_diff/timeline_builder.rb +21 -18
- data/lib/paper_trail_diff/timeline_range.rb +37 -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_range.rb +45 -5
- data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
- data/lib/paper_trail_diff.rb +54 -12
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
- data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +49 -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 +69 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +24 -15
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
- data/sig/generated/paper_trail_diff/timeline_range.rbs +15 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +17 -7
- metadata +20 -4
|
@@ -0,0 +1,149 @@
|
|
|
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?, ?version_scope: untyped) -> void
|
|
10
|
+
def initialize(records, time_range:, version_scope: nil)
|
|
11
|
+
@records = records
|
|
12
|
+
@time_range = time_range
|
|
13
|
+
@version_scope = version_scope
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns a plan per record identity.
|
|
17
|
+
#: () -> Hash[Array[String], RootVersionPlan]
|
|
18
|
+
def call
|
|
19
|
+
return {} if @records.empty?
|
|
20
|
+
|
|
21
|
+
selected = {} #: Hash[Array[String], RootVersionPlan]
|
|
22
|
+
@records.group_by(&:class).each do |model_class, records|
|
|
23
|
+
select_model(model_class, records, selected)
|
|
24
|
+
end
|
|
25
|
+
selected
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @rbs @records: Array[untyped]
|
|
31
|
+
# @rbs @time_range: TimeRange?
|
|
32
|
+
# @rbs @version_scope: untyped
|
|
33
|
+
|
|
34
|
+
#: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
35
|
+
def select_model(model_class, records, selected)
|
|
36
|
+
ids = records.map(&:id)
|
|
37
|
+
in_range, chosen, trailing = model_versions(model_class, ids)
|
|
38
|
+
records.each do |record|
|
|
39
|
+
key = identity(model_class, record.id)
|
|
40
|
+
selected[key] = versions_for(
|
|
41
|
+
in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
|
|
47
|
+
def model_versions(model_class, ids)
|
|
48
|
+
range = @time_range
|
|
49
|
+
empty = {} #: Hash[String, untyped]
|
|
50
|
+
[
|
|
51
|
+
grouped_versions(model_class, ids, range),
|
|
52
|
+
chosen_version_ids(model_class, ids, range),
|
|
53
|
+
range ? trailing_versions(model_class, ids, range) : empty
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
|
|
58
|
+
def versions_for(in_range, chosen, after_range)
|
|
59
|
+
RootVersionSelection.new(
|
|
60
|
+
in_range: in_range,
|
|
61
|
+
selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
|
|
62
|
+
after_range: after_range,
|
|
63
|
+
windowed: !@time_range.nil?,
|
|
64
|
+
filtered: !@version_scope.nil?
|
|
65
|
+
).call
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One extra query names the selected mutations without discarding the
|
|
69
|
+
# unfiltered versions the successor lookup still needs.
|
|
70
|
+
#: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
|
|
71
|
+
def chosen_version_ids(model_class, ids, range)
|
|
72
|
+
scope = @version_scope
|
|
73
|
+
return unless scope
|
|
74
|
+
|
|
75
|
+
# A narrowed relation is the expected return. Active Support also gives
|
|
76
|
+
# `pluck` to plain enumerables, so an array of versions works too.
|
|
77
|
+
filtered = scope.call(range_scope(model_class, ids, range))
|
|
78
|
+
Set.new(filtered.pluck(:id))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
|
|
82
|
+
def grouped_versions(model_class, ids, range)
|
|
83
|
+
ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#: (untyped, Array[untyped], TimeRange?) -> untyped
|
|
87
|
+
def range_scope(model_class, ids, range)
|
|
88
|
+
scope = base_scope(model_class, ids)
|
|
89
|
+
range ? range.scope(scope) : scope
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# One row per root: the earliest version after the window, found without a
|
|
93
|
+
# window function so the query stays portable.
|
|
94
|
+
#: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
|
|
95
|
+
def trailing_versions(model_class, ids, range)
|
|
96
|
+
table = model_class.paper_trail.version_class.arel_table
|
|
97
|
+
relation = range.trailing_scope(base_scope(model_class, ids))
|
|
98
|
+
ordered(relation.where(no_earlier_trailing(table, range)))
|
|
99
|
+
.to_h { |version| [version.item_id.to_s, version] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
#: (untyped, TimeRange) -> untyped
|
|
103
|
+
def no_earlier_trailing(table, range)
|
|
104
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
105
|
+
later = table.alias('paper_trail_diff_later_roots')
|
|
106
|
+
arel.const_get(:SelectManager).new
|
|
107
|
+
.from(later)
|
|
108
|
+
.project(arel.sql('1'))
|
|
109
|
+
.where(preceding(table, later, range))
|
|
110
|
+
.exists
|
|
111
|
+
.not
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Mirrors the window's own end handling, so a version sitting exactly on an
|
|
115
|
+
# inclusive boundary stays inside the window instead of masking the trailing
|
|
116
|
+
# version that follows it.
|
|
117
|
+
#: (untyped, untyped, TimeRange) -> untyped
|
|
118
|
+
def preceding(table, later, range)
|
|
119
|
+
same_item = later[:item_type].eq(table[:item_type])
|
|
120
|
+
.and(later[:item_id].eq(table[:item_id]))
|
|
121
|
+
same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
#: (untyped, TimeRange) -> untyped
|
|
125
|
+
def after_window(later, range)
|
|
126
|
+
return later[:created_at].gteq(range.end_time) if range.exclude_end?
|
|
127
|
+
|
|
128
|
+
later[:created_at].gt(range.end_time)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
#: (untyped, Array[untyped]) -> untyped
|
|
132
|
+
def base_scope(model_class, ids)
|
|
133
|
+
model_class.paper_trail.version_class.where(
|
|
134
|
+
item_type: model_class.base_class.name.to_s,
|
|
135
|
+
item_id: ids
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
#: (untyped) -> Array[untyped]
|
|
140
|
+
def ordered(relation)
|
|
141
|
+
relation.reorder(created_at: :asc, id: :asc).to_a
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
#: (untyped, untyped) -> Array[String]
|
|
145
|
+
def identity(model_class, id)
|
|
146
|
+
[model_class.base_class.name.to_s, id.to_s]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -23,18 +23,10 @@ module PaperTrailDiff
|
|
|
23
23
|
|
|
24
24
|
#: () -> comparison_results
|
|
25
25
|
def call
|
|
26
|
-
|
|
27
|
-
pairs.
|
|
28
|
-
ensure_unique_identities!(
|
|
29
|
-
|
|
30
|
-
live_records = load_live_records(pairs.flatten)
|
|
31
|
-
prepare_historical_batches!(pairs, live_records)
|
|
32
|
-
live_snapshots = normalize_live_records(live_records)
|
|
33
|
-
|
|
34
|
-
pairs.to_h do |from, to|
|
|
35
|
-
identity = Support.immutable_copy(Endpoint.identity(from))
|
|
36
|
-
[identity, compare(from, to, live_snapshots)]
|
|
37
|
-
end.freeze
|
|
26
|
+
resolved = BatchBoundaryResolver.new(comparison_pairs).call
|
|
27
|
+
pairs = resolved.reject { |pair| pair.any? { |endpoint| symbolic?(endpoint) } }
|
|
28
|
+
ensure_unique_identities!(resolved)
|
|
29
|
+
results(resolved, prepared_live_snapshots(pairs)).freeze
|
|
38
30
|
end
|
|
39
31
|
|
|
40
32
|
private
|
|
@@ -46,6 +38,38 @@ module PaperTrailDiff
|
|
|
46
38
|
# @rbs @historical_snapshotter: untyped
|
|
47
39
|
# @rbs @live_normalizer: untyped
|
|
48
40
|
|
|
41
|
+
#: (Array[[untyped, untyped]]) -> Hash[identity, RecordSnapshot]
|
|
42
|
+
def prepared_live_snapshots(pairs)
|
|
43
|
+
pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
|
|
44
|
+
prepare_endpoint_classes!(pairs)
|
|
45
|
+
live_records = load_live_records(pairs.flatten)
|
|
46
|
+
prepare_historical_batches!(pairs, live_records)
|
|
47
|
+
normalize_live_records(live_records)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: (untyped) -> bool
|
|
51
|
+
def symbolic?(endpoint)
|
|
52
|
+
BatchBoundaryResolver.symbolic?(endpoint)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A boundary symbol that stayed unresolved means the root has no recorded
|
|
56
|
+
# history. An absent history is an empty result rather than a failed
|
|
57
|
+
# request, matching how the timeline APIs answer the same question.
|
|
58
|
+
#: (Array[[untyped, untyped]], Hash[identity, RecordSnapshot]) -> comparison_results
|
|
59
|
+
def results(resolved, live_snapshots)
|
|
60
|
+
resolved.to_h do |from, to|
|
|
61
|
+
identity = Support.immutable_copy(entry_identity(from, to))
|
|
62
|
+
next [identity, Diff.new] if symbolic?(from) || symbolic?(to)
|
|
63
|
+
|
|
64
|
+
[identity, compare(from, to, live_snapshots)]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (untyped, untyped) -> identity
|
|
69
|
+
def entry_identity(from, to)
|
|
70
|
+
Endpoint.identity(symbolic?(from) ? to : from)
|
|
71
|
+
end
|
|
72
|
+
|
|
49
73
|
#: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
|
|
50
74
|
def compare(from, to, live_snapshots)
|
|
51
75
|
Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
|
|
@@ -125,7 +149,7 @@ module PaperTrailDiff
|
|
|
125
149
|
|
|
126
150
|
#: (Array[[untyped, untyped]]) -> void
|
|
127
151
|
def ensure_unique_identities!(pairs)
|
|
128
|
-
identities = pairs.map { |from,
|
|
152
|
+
identities = pairs.map { |from, to| entry_identity(from, to) }
|
|
129
153
|
return if identities.uniq.length == identities.length
|
|
130
154
|
|
|
131
155
|
raise ConfigurationError, 'comparisons: root identities must be unique'
|
|
@@ -24,6 +24,8 @@ module PaperTrailDiff
|
|
|
24
24
|
end_at: root_versions.last.created_at
|
|
25
25
|
)
|
|
26
26
|
return if @tree.empty?
|
|
27
|
+
# A batched preparation already covers these roots.
|
|
28
|
+
return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
|
|
27
29
|
|
|
28
30
|
@prepared_history = PreparedHistoryLoader.new(
|
|
29
31
|
record,
|
|
@@ -35,7 +37,6 @@ module PaperTrailDiff
|
|
|
35
37
|
).call
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
# Prepares selected history for several roots of the same model class.
|
|
39
40
|
#: (Array[untyped], Array[untyped]) -> void
|
|
40
41
|
def prepare_batch(records, root_versions)
|
|
41
42
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
@@ -50,12 +51,7 @@ module PaperTrailDiff
|
|
|
50
51
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
51
52
|
return @snapshots[key] if @snapshots.key?(key)
|
|
52
53
|
|
|
53
|
-
@snapshots[key] =
|
|
54
|
-
root_endpoint,
|
|
55
|
-
context_endpoint,
|
|
56
|
-
tree: @tree,
|
|
57
|
-
normalizer: @normalizer
|
|
58
|
-
)
|
|
54
|
+
@snapshots[key] = uncached(root_endpoint, context_endpoint)
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
# PaperTrail/ActiveRecord boundary that produces plain record snapshots.
|
|
6
|
-
class
|
|
5
|
+
# PaperTrail/ActiveRecord boundary that produces plain record snapshots. It is
|
|
6
|
+
# deliberately the widest class here: it fronts every public operation and is
|
|
7
|
+
# the only place allowed to know about both PaperTrail and the pure engine.
|
|
8
|
+
# Reconstruction logic lives in the collaborators it wires together.
|
|
9
|
+
class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
|
|
7
10
|
#: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
|
|
8
11
|
def initialize(associations:, ignore:, reload_live_endpoints: true)
|
|
9
12
|
@association_tree = AssociationTree.build(associations)
|
|
10
13
|
@ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
|
|
11
14
|
@traversal = AssociationTraversal.new(@association_tree)
|
|
15
|
+
@traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
|
|
12
16
|
@live_endpoints = LiveEndpointProvider.new(
|
|
13
17
|
tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
|
|
14
18
|
)
|
|
@@ -18,9 +22,7 @@ module PaperTrailDiff
|
|
|
18
22
|
tree: @association_tree, ignore_policy: @ignore_policy,
|
|
19
23
|
traversal: @traversal, pool: @snapshot_pool
|
|
20
24
|
)
|
|
21
|
-
|
|
22
|
-
@timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
|
|
23
|
-
@activity_snapshotter = build_activity_snapshotter
|
|
25
|
+
build_snapshotters
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
#: (untyped, untyped) -> Diff
|
|
@@ -40,7 +42,7 @@ module PaperTrailDiff
|
|
|
40
42
|
Instrumentation.instrument('compare_many', payload) do
|
|
41
43
|
ComparisonBatch.new(
|
|
42
44
|
comparisons,
|
|
43
|
-
live_loader: @live_endpoints.method(:call), preparer: method(:
|
|
45
|
+
live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
|
|
44
46
|
history_preparer: @historical_store.method(:prepare_batch),
|
|
45
47
|
historical_snapshotter: method(:historical_snapshot),
|
|
46
48
|
live_normalizer: method(:normalize_live_snapshot)
|
|
@@ -48,48 +50,69 @@ module PaperTrailDiff
|
|
|
48
50
|
end
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
|
|
52
|
-
def timeline(record, from:, to:, within:)
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
|
|
54
|
+
def timeline(record, from:, to:, within:, version_scope: nil)
|
|
55
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
56
|
+
TimelineBuilder.new(
|
|
55
57
|
record,
|
|
56
58
|
from: from,
|
|
57
59
|
to: to,
|
|
58
60
|
within: within,
|
|
61
|
+
version_scope: version_scope,
|
|
59
62
|
snapshotter: @timeline_snapshotter
|
|
60
|
-
)
|
|
61
|
-
builder.build
|
|
63
|
+
).build
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
|
|
65
|
-
def activity_timeline(record, from:, to:, within:)
|
|
66
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
|
|
67
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil)
|
|
66
68
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
67
69
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
68
|
-
|
|
70
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
69
71
|
reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
|
|
70
|
-
steps = activity_builder(
|
|
72
|
+
steps = activity_builder(
|
|
73
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
74
|
+
).build
|
|
71
75
|
payload[:step_count] = steps.length
|
|
72
76
|
steps
|
|
73
77
|
end
|
|
74
78
|
end
|
|
75
79
|
|
|
76
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
|
|
77
|
-
def analyze(record, from:, to:, within:, activity: false)
|
|
80
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
|
|
81
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil) # rubocop:disable Metrics/ParameterLists
|
|
82
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
78
83
|
if activity
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
return activity_builder(
|
|
85
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
86
|
+
).analyze
|
|
81
87
|
end
|
|
82
88
|
|
|
83
|
-
prepare_traversal!(record.class, historical: true)
|
|
84
89
|
TimelineBuilder.new(
|
|
85
90
|
record,
|
|
86
91
|
from: from,
|
|
87
92
|
to: to,
|
|
88
93
|
within: within,
|
|
94
|
+
version_scope: version_scope,
|
|
89
95
|
snapshotter: @timeline_snapshotter
|
|
90
96
|
).analyze
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
# Analyzes many roots over one shared range, preparing their history once.
|
|
100
|
+
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
|
|
101
|
+
def analyze_many(records, within:, activity: false, version_scope: nil)
|
|
102
|
+
count = records.is_a?(Array) ? records.length : 0
|
|
103
|
+
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
104
|
+
Instrumentation.instrument('analyze_many', payload) do
|
|
105
|
+
AnalysisBatch.new(
|
|
106
|
+
records,
|
|
107
|
+
time_range: within.nil? ? nil : TimeRange.new(within),
|
|
108
|
+
version_scope: version_scope,
|
|
109
|
+
live_loader: @live_endpoints.method(:call),
|
|
110
|
+
history_preparer: @historical_store.method(:prepare_batch),
|
|
111
|
+
analyzer: batched_root_analyzer(activity)
|
|
112
|
+
).call
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
93
116
|
private
|
|
94
117
|
|
|
95
118
|
# @rbs @association_tree: AssociationTree
|
|
@@ -97,19 +120,38 @@ module PaperTrailDiff
|
|
|
97
120
|
# @rbs @instrumentation_payload: Hash[Symbol, untyped]
|
|
98
121
|
# @rbs @ignore_policy: IgnorePolicy
|
|
99
122
|
# @rbs @traversal: AssociationTraversal
|
|
123
|
+
# @rbs @traversal_preparer: TraversalPreparer
|
|
100
124
|
# @rbs @snapshot_pool: SnapshotPool
|
|
101
125
|
# @rbs @normalizer: SnapshotNormalizer
|
|
102
126
|
# @rbs @historical_store: HistoricalSnapshotStore
|
|
103
127
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
104
128
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
105
129
|
|
|
130
|
+
#: () -> void
|
|
131
|
+
def build_snapshotters
|
|
132
|
+
@historical_store = build_historical_store
|
|
133
|
+
@timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
|
|
134
|
+
@activity_snapshotter = build_activity_snapshotter
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
#: (bool) -> BatchedRootAnalyzer
|
|
138
|
+
def batched_root_analyzer(activity)
|
|
139
|
+
BatchedRootAnalyzer.new(
|
|
140
|
+
tree: @association_tree,
|
|
141
|
+
timeline_snapshotter: @timeline_snapshotter,
|
|
142
|
+
activity_snapshotter: @activity_snapshotter,
|
|
143
|
+
preparer: @traversal_preparer.method(:call),
|
|
144
|
+
activity: activity
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
106
148
|
#: () -> HistoricalSnapshotStore
|
|
107
149
|
def build_historical_store
|
|
108
150
|
HistoricalSnapshotStore.new(
|
|
109
151
|
tree: @association_tree,
|
|
110
152
|
traversal: @traversal,
|
|
111
153
|
normalizer: @normalizer,
|
|
112
|
-
preparer: method(:
|
|
154
|
+
preparer: @traversal_preparer.method(:call)
|
|
113
155
|
)
|
|
114
156
|
end
|
|
115
157
|
|
|
@@ -131,11 +173,13 @@ module PaperTrailDiff
|
|
|
131
173
|
)
|
|
132
174
|
end
|
|
133
175
|
|
|
134
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
|
|
135
|
-
def activity_builder(record, from:, to:, within:)
|
|
176
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
|
|
177
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil)
|
|
136
178
|
ActivityTimelineBuilder.new(
|
|
137
179
|
record,
|
|
138
|
-
range: TimelineRange.new(
|
|
180
|
+
range: TimelineRange.new(
|
|
181
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
182
|
+
),
|
|
139
183
|
tree: @association_tree,
|
|
140
184
|
snapshotter: @activity_snapshotter
|
|
141
185
|
)
|
|
@@ -180,30 +224,9 @@ module PaperTrailDiff
|
|
|
180
224
|
|
|
181
225
|
#: (untyped) -> RecordSnapshot
|
|
182
226
|
def normalize_live_snapshot(current)
|
|
183
|
-
|
|
227
|
+
@traversal_preparer.call(current.class, historical: false)
|
|
184
228
|
@normalizer.call(current, reifier: LiveAssociationReader.new) ||
|
|
185
229
|
raise(InvalidEndpointError, 'current record endpoint could not be normalized')
|
|
186
230
|
end
|
|
187
|
-
|
|
188
|
-
#: (untyped, historical: bool) -> void
|
|
189
|
-
def prepare_traversal!(model_class, historical:)
|
|
190
|
-
return if @association_tree.empty?
|
|
191
|
-
|
|
192
|
-
ensure_association_tracking! if historical
|
|
193
|
-
@traversal.validate!(model_class)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
#: () -> void
|
|
197
|
-
def ensure_association_tracking!
|
|
198
|
-
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
199
|
-
config = paper_trail.config #: untyped
|
|
200
|
-
available = defined?(::PaperTrailAssociationTracking) &&
|
|
201
|
-
config.respond_to?(:track_associations?) &&
|
|
202
|
-
config.track_associations?
|
|
203
|
-
return if available
|
|
204
|
-
|
|
205
|
-
message = 'association tracking must be loaded and enabled to compare historical associations'
|
|
206
|
-
raise AssociationTrackingUnavailableError, message
|
|
207
|
-
end
|
|
208
231
|
end
|
|
209
232
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Which versions a range reconstructs, and which pairs of them become steps.
|
|
6
|
+
#
|
|
7
|
+
# Without a filter those are the same thing: every selected version is a
|
|
8
|
+
# boundary and adjacent ones bound each other. A filter separates them, because
|
|
9
|
+
# each selected mutation then has to be bounded by the version that actually
|
|
10
|
+
# reveals it rather than by the next mutation that happened to be selected.
|
|
11
|
+
#
|
|
12
|
+
# A filter also separates what is *reported* from what has to be *replayed*.
|
|
13
|
+
# The activity view rebuilds state by carrying one snapshot forward across
|
|
14
|
+
# every event in order, so skipping a filtered-out version would leave that
|
|
15
|
+
# snapshot holding a state the record had already moved past. `versions` are
|
|
16
|
+
# the boundaries the steps refer to; `reconstruction_versions` are every root
|
|
17
|
+
# version the span passes through, which is what any forward replay must walk.
|
|
18
|
+
class RootVersionPlan
|
|
19
|
+
attr_reader :versions #: Array[untyped]
|
|
20
|
+
attr_reader :reconstruction_versions #: Array[untyped]
|
|
21
|
+
attr_reader :steps #: Array[[untyped, untyped]]
|
|
22
|
+
attr_reader :context_version #: untyped
|
|
23
|
+
# The root versions this plan reports as mutations, which excludes any
|
|
24
|
+
# version present only to reveal what the last of them produced.
|
|
25
|
+
attr_reader :mutations #: Array[untyped]
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Adjacent boundaries, which is what an unfiltered range reports.
|
|
29
|
+
#: (Array[untyped], ?context_version: untyped) -> RootVersionPlan
|
|
30
|
+
def contiguous(versions, context_version: nil)
|
|
31
|
+
new(
|
|
32
|
+
versions: versions,
|
|
33
|
+
steps: versions.each_cons(2).map { |from, to| [from, to] },
|
|
34
|
+
context_version: context_version
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: () -> RootVersionPlan
|
|
39
|
+
def empty
|
|
40
|
+
versions = [] #: Array[untyped]
|
|
41
|
+
steps = [] #: Array[[untyped, untyped]]
|
|
42
|
+
new(versions: versions, steps: steps)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
|
|
47
|
+
def initialize(
|
|
48
|
+
versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil
|
|
49
|
+
)
|
|
50
|
+
@versions = versions.freeze
|
|
51
|
+
@reconstruction_versions = (reconstruction_versions || versions).freeze
|
|
52
|
+
@steps = steps.freeze
|
|
53
|
+
@context_version = context_version
|
|
54
|
+
@mutations = (mutations || default_mutations).freeze
|
|
55
|
+
@mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: () -> bool
|
|
60
|
+
def empty?
|
|
61
|
+
versions.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: (untyped) -> bool
|
|
65
|
+
def mutation?(version)
|
|
66
|
+
@mutation_keys.include?(key(version))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @rbs @mutation_keys: Set[Array[untyped]]
|
|
72
|
+
|
|
73
|
+
#: () -> Array[untyped]
|
|
74
|
+
def default_mutations
|
|
75
|
+
return @versions unless @context_version
|
|
76
|
+
|
|
77
|
+
context = key(@context_version)
|
|
78
|
+
@versions.reject { |version| key(version) == context }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (untyped) -> Array[untyped]
|
|
82
|
+
def key(version)
|
|
83
|
+
[version.class.name, version.id]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Decides which versions a range reports, and which one reveals the last of
|
|
6
|
+
# them. A version records the state before its own event, so whatever follows
|
|
7
|
+
# the final selected mutation is the only thing that can show what it produced.
|
|
8
|
+
# That successor is reconstruction context rather than a reported mutation, so
|
|
9
|
+
# a filter must never remove it.
|
|
10
|
+
#
|
|
11
|
+
# Both the single-record and the batched selectors resolve this here, because
|
|
12
|
+
# the rule is subtle enough that two copies of it would drift apart.
|
|
13
|
+
class RootVersionSelection
|
|
14
|
+
INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
|
|
15
|
+
|
|
16
|
+
#: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool) -> void
|
|
17
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
18
|
+
in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false
|
|
19
|
+
)
|
|
20
|
+
@in_range = in_range
|
|
21
|
+
@selected = selected
|
|
22
|
+
@after_range = after_range
|
|
23
|
+
@windowed = windowed
|
|
24
|
+
@context_required = context_required
|
|
25
|
+
@filtered = filtered
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: () -> RootVersionPlan
|
|
29
|
+
def call
|
|
30
|
+
return without_selection if @selected.empty?
|
|
31
|
+
|
|
32
|
+
revealing = revealing_version
|
|
33
|
+
raise IncompleteTimeRangeError, INCOMPLETE if !revealing && @windowed && !terminal_destroy?
|
|
34
|
+
return filtered_plan(revealing) if @filtered
|
|
35
|
+
|
|
36
|
+
versions = revealing ? (@selected + [revealing]) : @selected
|
|
37
|
+
RootVersionPlan.contiguous(versions, context_version: revealing)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @rbs @in_range: Array[untyped]
|
|
43
|
+
# @rbs @selected: Array[untyped]
|
|
44
|
+
# @rbs @after_range: untyped
|
|
45
|
+
# @rbs @windowed: bool
|
|
46
|
+
# @rbs @context_required: bool
|
|
47
|
+
# @rbs @filtered: bool
|
|
48
|
+
|
|
49
|
+
# An activity view still needs a root to reconstruct from even when no root
|
|
50
|
+
# version falls inside the window, because descendants may have moved.
|
|
51
|
+
#: () -> RootVersionPlan
|
|
52
|
+
def without_selection
|
|
53
|
+
return RootVersionPlan.empty unless @context_required
|
|
54
|
+
if @after_range
|
|
55
|
+
return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise IncompleteTimeRangeError, INCOMPLETE
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each selected mutation is bounded by the version that reveals it, not by
|
|
62
|
+
# the next mutation that happened to be selected. Bounding by the next
|
|
63
|
+
# selection would fold anything filtered out in between into it, so the same
|
|
64
|
+
# edit would read differently depending on what followed it.
|
|
65
|
+
#: (untyped) -> RootVersionPlan
|
|
66
|
+
def filtered_plan(revealing)
|
|
67
|
+
steps = @selected.filter_map do |version|
|
|
68
|
+
successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
|
|
69
|
+
[version, successor] if successor
|
|
70
|
+
end #: Array[[untyped, untyped]]
|
|
71
|
+
versions = chronological(steps.flatten(1) + closing_versions)
|
|
72
|
+
RootVersionPlan.new(
|
|
73
|
+
versions: versions,
|
|
74
|
+
steps: steps,
|
|
75
|
+
context_version: revealing,
|
|
76
|
+
reconstruction_versions: spanned(versions),
|
|
77
|
+
mutations: @selected
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Nothing can follow a destroy, so it never pairs into a step. It is still a
|
|
82
|
+
# selected mutation, and the activity view closes on the absence it leaves,
|
|
83
|
+
# so it has to stay a boundary or a filtered report loses the deletion
|
|
84
|
+
# entirely — the one event it can least afford to drop.
|
|
85
|
+
#: () -> Array[untyped]
|
|
86
|
+
def closing_versions
|
|
87
|
+
terminal_destroy? ? [@selected.last] : []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Everything the span passes through, filtered out or not. A replay that
|
|
91
|
+
# skipped the excluded versions would carry a stale state into the next
|
|
92
|
+
# reported step, so the two selected mutations either side of a gap would
|
|
93
|
+
# disagree about what the record looked like between them.
|
|
94
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
95
|
+
def spanned(versions)
|
|
96
|
+
first = versions.first
|
|
97
|
+
last = versions.last
|
|
98
|
+
return versions unless first && last
|
|
99
|
+
|
|
100
|
+
@in_range.select do |candidate|
|
|
101
|
+
!Support.compare_versions(first, candidate).positive? &&
|
|
102
|
+
!Support.compare_versions(candidate, last).positive?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
107
|
+
def chronological(versions)
|
|
108
|
+
versions.uniq { |version| [version.class.name, version.id] }
|
|
109
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: (untyped) -> untyped
|
|
113
|
+
def immediate_successor(version)
|
|
114
|
+
@in_range.find { |candidate| Support.compare_versions(version, candidate).negative? }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A version left out by a filter is still the state the last selected change
|
|
118
|
+
# produced, so it is preferred over anything after the range.
|
|
119
|
+
#: () -> untyped
|
|
120
|
+
def revealing_version
|
|
121
|
+
immediate_successor(@selected.last) || @after_range
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A range closing on the record's own destruction needs no later version:
|
|
125
|
+
# nothing can follow it, so demanding one would reject the range forever.
|
|
126
|
+
#: () -> bool
|
|
127
|
+
def terminal_destroy?
|
|
128
|
+
@selected.last&.event.to_s == 'destroy'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|