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
|
@@ -142,9 +142,22 @@ module PaperTrailDiff
|
|
|
142
142
|
identities.sort_by { |identity| sortable_identity(identity) }
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Identities sort by type first so that mixed id types stay comparable, then
|
|
146
|
+
# naturally within one type. Ordering by the printed form instead would put
|
|
147
|
+
# id 10 before id 2, which is deterministic but reads as unsorted wherever a
|
|
148
|
+
# result is rendered.
|
|
145
149
|
#: (identity) -> Array[untyped]
|
|
146
150
|
def sortable_identity(identity)
|
|
147
|
-
[identity.fetch(0), identity.fetch(1)
|
|
151
|
+
[identity.fetch(0), *sortable_id(identity.fetch(1))]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: (untyped) -> Array[untyped]
|
|
155
|
+
def sortable_id(id)
|
|
156
|
+
case id
|
|
157
|
+
when Numeric then [0, id, '']
|
|
158
|
+
when String, Symbol then [1, 0, id.to_s]
|
|
159
|
+
else [2, 0, id.inspect]
|
|
160
|
+
end
|
|
148
161
|
end
|
|
149
162
|
|
|
150
163
|
#: (identity) -> void
|
|
@@ -7,9 +7,12 @@ module PaperTrailDiff
|
|
|
7
7
|
attr_reader :before #: RecordSnapshot?
|
|
8
8
|
attr_reader :after #: RecordSnapshot?
|
|
9
9
|
|
|
10
|
+
# The origin is recorded by serial rather than by reference, because
|
|
11
|
+
# holding it would retain every earlier snapshot at the same path for the
|
|
12
|
+
# whole timeline.
|
|
10
13
|
#: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
|
|
11
14
|
def initialize(from:, before:, after:)
|
|
12
|
-
@
|
|
15
|
+
@from_serial = from.serial
|
|
13
16
|
@before = before
|
|
14
17
|
@after = after
|
|
15
18
|
freeze
|
|
@@ -17,9 +20,9 @@ module PaperTrailDiff
|
|
|
17
20
|
|
|
18
21
|
#: (AssociationSnapshot) -> bool
|
|
19
22
|
def from?(association)
|
|
20
|
-
@
|
|
23
|
+
@from_serial == association.serial
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
# @rbs @
|
|
26
|
+
# @rbs @from_serial: Integer
|
|
24
27
|
end
|
|
25
28
|
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'
|
|
@@ -10,10 +10,35 @@ module PaperTrailDiff
|
|
|
10
10
|
def validate_pair!(from_endpoint, to_endpoint)
|
|
11
11
|
validate!(from_endpoint)
|
|
12
12
|
validate!(to_endpoint)
|
|
13
|
-
|
|
13
|
+
unless identity(from_endpoint) == identity(to_endpoint)
|
|
14
|
+
raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
validate_order!(from_endpoint, to_endpoint)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Two versions carry no visible cue about which is earlier, so transposing
|
|
21
|
+
# them is easy to do by accident and impossible to detect afterwards: the
|
|
22
|
+
# result is a valid inverse diff and carries no direction of its own. It is
|
|
23
|
+
# also redundant, because the two orders differ only in which side of each
|
|
24
|
+
# change is `from`. A current-record endpoint is exempt: it is self-evidently
|
|
25
|
+
# the live state, so putting it first is a deliberate reverse comparison.
|
|
26
|
+
#: (untyped, untyped) -> void
|
|
27
|
+
def validate_order!(from_endpoint, to_endpoint)
|
|
28
|
+
return unless reversed_versions?(from_endpoint, to_endpoint)
|
|
29
|
+
|
|
30
|
+
raise ReversedEndpointsError,
|
|
31
|
+
'version endpoints must be given in chronological order; ' \
|
|
32
|
+
'swap them to read the same difference in the other direction'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (untyped, untyped) -> bool
|
|
36
|
+
def reversed_versions?(from_endpoint, to_endpoint)
|
|
37
|
+
return false unless version?(from_endpoint) && version?(to_endpoint)
|
|
14
38
|
|
|
15
|
-
|
|
39
|
+
Support.compare_versions(from_endpoint, to_endpoint).positive?
|
|
16
40
|
end
|
|
41
|
+
private_class_method :reversed_versions?
|
|
17
42
|
|
|
18
43
|
#: (untyped) -> void
|
|
19
44
|
def validate!(endpoint)
|
|
@@ -11,6 +11,9 @@ module PaperTrailDiff
|
|
|
11
11
|
# Raised when compare endpoints do not belong to the same PaperTrail item.
|
|
12
12
|
class VersionMismatchError < Error; end
|
|
13
13
|
|
|
14
|
+
# Raised when comparison endpoints are given in reverse chronological order.
|
|
15
|
+
class ReversedEndpointsError < Error; end
|
|
16
|
+
|
|
14
17
|
# Raised when a live endpoint is not a clean, persisted ActiveRecord record.
|
|
15
18
|
class InvalidEndpointError < Error; end
|
|
16
19
|
|
|
@@ -11,7 +11,10 @@ module PaperTrailDiff
|
|
|
11
11
|
@habtm_transaction_id = habtm_version.transaction_id
|
|
12
12
|
end
|
|
13
13
|
@version_at = version.created_at
|
|
14
|
-
|
|
14
|
+
# Keyed on record identity rather than `object_id`, which Ruby may reuse
|
|
15
|
+
# once an earlier record in the same pass has been collected.
|
|
16
|
+
reified = {} #: Hash[untyped, Hash[untyped, bool]]
|
|
17
|
+
@reified_associations = reified.compare_by_identity
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -24,14 +27,14 @@ module PaperTrailDiff
|
|
|
24
27
|
# @rbs @transaction_id: untyped
|
|
25
28
|
# @rbs @habtm_transaction_id: untyped
|
|
26
29
|
# @rbs @version_at: untyped
|
|
27
|
-
# @rbs @reified_associations: Hash[
|
|
30
|
+
# @rbs @reified_associations: Hash[untyped, Hash[untyped, bool]]
|
|
28
31
|
|
|
29
32
|
#: (untyped, untyped) -> void
|
|
30
33
|
def reify_association(record, reflection)
|
|
31
|
-
|
|
32
|
-
return if
|
|
34
|
+
reified = @reified_associations[record] ||= {} #: Hash[untyped, bool]
|
|
35
|
+
return if reified[reflection.name]
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
reified[reflection.name] = true
|
|
35
38
|
case reflection.macro
|
|
36
39
|
when :belongs_to
|
|
37
40
|
reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
|
|
@@ -16,20 +16,27 @@ module PaperTrailDiff
|
|
|
16
16
|
@prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
#: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
20
|
-
def prepare(
|
|
19
|
+
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
20
|
+
def prepare(
|
|
21
|
+
record,
|
|
22
|
+
root_versions,
|
|
23
|
+
start_at: root_versions.first.created_at,
|
|
24
|
+
end_at: root_versions.last.created_at
|
|
25
|
+
)
|
|
21
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)) }
|
|
22
29
|
|
|
23
30
|
@prepared_history = PreparedHistoryLoader.new(
|
|
24
31
|
record,
|
|
25
32
|
root_versions: root_versions,
|
|
26
33
|
start_at: start_at,
|
|
34
|
+
end_at: end_at,
|
|
27
35
|
tree: @tree,
|
|
28
36
|
traversal: @traversal
|
|
29
37
|
).call
|
|
30
38
|
end
|
|
31
39
|
|
|
32
|
-
# Prepares selected history for several roots of the same model class.
|
|
33
40
|
#: (Array[untyped], Array[untyped]) -> void
|
|
34
41
|
def prepare_batch(records, root_versions)
|
|
35
42
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
@@ -44,12 +51,7 @@ module PaperTrailDiff
|
|
|
44
51
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
45
52
|
return @snapshots[key] if @snapshots.key?(key)
|
|
46
53
|
|
|
47
|
-
@snapshots[key] =
|
|
48
|
-
root_endpoint,
|
|
49
|
-
context_endpoint,
|
|
50
|
-
tree: @tree,
|
|
51
|
-
normalizer: @normalizer
|
|
52
|
-
)
|
|
54
|
+
@snapshots[key] = uncached(root_endpoint, context_endpoint)
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -139,6 +141,7 @@ module PaperTrailDiff
|
|
|
139
141
|
root_ids: records.map(&:id),
|
|
140
142
|
root_versions: root_versions,
|
|
141
143
|
start_at: root_versions.map(&:created_at).min,
|
|
144
|
+
end_at: root_versions.map(&:created_at).max,
|
|
142
145
|
tree: @tree,
|
|
143
146
|
traversal: @traversal,
|
|
144
147
|
live_records: @live_graph_collector.call(records)
|
|
@@ -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)
|
|
@@ -50,7 +52,7 @@ module PaperTrailDiff
|
|
|
50
52
|
|
|
51
53
|
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
|
|
52
54
|
def timeline(record, from:, to:, within:)
|
|
53
|
-
|
|
55
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
54
56
|
builder = TimelineBuilder.new(
|
|
55
57
|
record,
|
|
56
58
|
from: from,
|
|
@@ -65,7 +67,7 @@ module PaperTrailDiff
|
|
|
65
67
|
def activity_timeline(record, from:, to:, within:)
|
|
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
72
|
steps = activity_builder(record, from: from, to: to, within: within).build
|
|
71
73
|
payload[:step_count] = steps.length
|
|
@@ -76,11 +78,11 @@ module PaperTrailDiff
|
|
|
76
78
|
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
|
|
77
79
|
def analyze(record, from:, to:, within:, activity: false)
|
|
78
80
|
if activity
|
|
79
|
-
|
|
81
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
80
82
|
return activity_builder(record, from: from, to: to, within: within).analyze
|
|
81
83
|
end
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
84
86
|
TimelineBuilder.new(
|
|
85
87
|
record,
|
|
86
88
|
from: from,
|
|
@@ -90,6 +92,22 @@ module PaperTrailDiff
|
|
|
90
92
|
).analyze
|
|
91
93
|
end
|
|
92
94
|
|
|
95
|
+
# Analyzes many roots over one shared range, preparing their history once.
|
|
96
|
+
#: (Array[untyped], within: untyped, ?activity: bool) -> Hash[identity, Analysis]
|
|
97
|
+
def analyze_many(records, within:, activity: false)
|
|
98
|
+
count = records.is_a?(Array) ? records.length : 0
|
|
99
|
+
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
100
|
+
Instrumentation.instrument('analyze_many', payload) do
|
|
101
|
+
AnalysisBatch.new(
|
|
102
|
+
records,
|
|
103
|
+
time_range: within.nil? ? nil : TimeRange.new(within),
|
|
104
|
+
live_loader: @live_endpoints.method(:call),
|
|
105
|
+
history_preparer: @historical_store.method(:prepare_batch),
|
|
106
|
+
analyzer: batched_root_analyzer(activity)
|
|
107
|
+
).call
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
93
111
|
private
|
|
94
112
|
|
|
95
113
|
# @rbs @association_tree: AssociationTree
|
|
@@ -97,19 +115,38 @@ module PaperTrailDiff
|
|
|
97
115
|
# @rbs @instrumentation_payload: Hash[Symbol, untyped]
|
|
98
116
|
# @rbs @ignore_policy: IgnorePolicy
|
|
99
117
|
# @rbs @traversal: AssociationTraversal
|
|
118
|
+
# @rbs @traversal_preparer: TraversalPreparer
|
|
100
119
|
# @rbs @snapshot_pool: SnapshotPool
|
|
101
120
|
# @rbs @normalizer: SnapshotNormalizer
|
|
102
121
|
# @rbs @historical_store: HistoricalSnapshotStore
|
|
103
122
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
104
123
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
105
124
|
|
|
125
|
+
#: () -> void
|
|
126
|
+
def build_snapshotters
|
|
127
|
+
@historical_store = build_historical_store
|
|
128
|
+
@timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
|
|
129
|
+
@activity_snapshotter = build_activity_snapshotter
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
#: (bool) -> BatchedRootAnalyzer
|
|
133
|
+
def batched_root_analyzer(activity)
|
|
134
|
+
BatchedRootAnalyzer.new(
|
|
135
|
+
tree: @association_tree,
|
|
136
|
+
timeline_snapshotter: @timeline_snapshotter,
|
|
137
|
+
activity_snapshotter: @activity_snapshotter,
|
|
138
|
+
preparer: @traversal_preparer.method(:call),
|
|
139
|
+
activity: activity
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
106
143
|
#: () -> HistoricalSnapshotStore
|
|
107
144
|
def build_historical_store
|
|
108
145
|
HistoricalSnapshotStore.new(
|
|
109
146
|
tree: @association_tree,
|
|
110
147
|
traversal: @traversal,
|
|
111
148
|
normalizer: @normalizer,
|
|
112
|
-
preparer: method(:
|
|
149
|
+
preparer: @traversal_preparer.method(:call)
|
|
113
150
|
)
|
|
114
151
|
end
|
|
115
152
|
|
|
@@ -180,30 +217,9 @@ module PaperTrailDiff
|
|
|
180
217
|
|
|
181
218
|
#: (untyped) -> RecordSnapshot
|
|
182
219
|
def normalize_live_snapshot(current)
|
|
183
|
-
|
|
220
|
+
@traversal_preparer.call(current.class, historical: false)
|
|
184
221
|
@normalizer.call(current, reifier: LiveAssociationReader.new) ||
|
|
185
222
|
raise(InvalidEndpointError, 'current record endpoint could not be normalized')
|
|
186
223
|
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
224
|
end
|
|
209
225
|
end
|
|
@@ -10,7 +10,10 @@ module PaperTrailDiff
|
|
|
10
10
|
@boundary = boundary
|
|
11
11
|
@habtm_boundary = habtm_boundary
|
|
12
12
|
@fallback = fallback
|
|
13
|
-
|
|
13
|
+
# Keyed on record identity rather than `object_id`, which Ruby may reuse
|
|
14
|
+
# once an earlier record in the same pass has been collected.
|
|
15
|
+
reified = {} #: Hash[untyped, Hash[untyped, bool]]
|
|
16
|
+
@reified = reified.compare_by_identity
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -24,14 +27,14 @@ module PaperTrailDiff
|
|
|
24
27
|
# @rbs @boundary: untyped
|
|
25
28
|
# @rbs @habtm_boundary: untyped
|
|
26
29
|
# @rbs @fallback: HistoricalAssociationReifier
|
|
27
|
-
# @rbs @reified: Hash[
|
|
30
|
+
# @rbs @reified: Hash[untyped, Hash[untyped, bool]]
|
|
28
31
|
|
|
29
32
|
#: (untyped, untyped) -> void
|
|
30
33
|
def reify_association(record, reflection)
|
|
31
|
-
|
|
32
|
-
return if
|
|
34
|
+
reified = @reified[record] ||= {} #: Hash[untyped, bool]
|
|
35
|
+
return if reified[reflection.name]
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
reified[reflection.name] = true
|
|
35
38
|
handled, records = @history.resolve(
|
|
36
39
|
record,
|
|
37
40
|
reflection,
|
|
@@ -164,13 +164,15 @@ module PaperTrailDiff
|
|
|
164
164
|
{ model_class.name.to_s => group }
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
# Keyed rather than scanned so that a wide edge stays linear in its width.
|
|
167
168
|
#: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
168
169
|
def group_ids_by_owner(pairs)
|
|
169
|
-
grouped = {} #: Hash[String,
|
|
170
|
-
pairs.
|
|
171
|
-
ids =
|
|
172
|
-
ids
|
|
170
|
+
grouped = {} #: Hash[String, Hash[untyped, true]]
|
|
171
|
+
pairs.each do |owner_id, child_id|
|
|
172
|
+
ids = grouped[owner_id.to_s] ||= {} #: Hash[untyped, true]
|
|
173
|
+
ids[child_id] = true
|
|
173
174
|
end
|
|
175
|
+
grouped.transform_values(&:keys)
|
|
174
176
|
end
|
|
175
177
|
|
|
176
178
|
#: (Hash[String, Array[untyped]], Hash[String, Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Expands the explicit association tree into a request-scoped PreparedHistory.
|
|
6
6
|
class PreparedHistoryLoader
|
|
7
|
-
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
9
|
record,
|
|
10
10
|
root_versions:,
|
|
@@ -12,13 +12,14 @@ module PaperTrailDiff
|
|
|
12
12
|
traversal:,
|
|
13
13
|
root_ids: [record.id],
|
|
14
14
|
start_at: root_versions.first.created_at,
|
|
15
|
+
end_at: nil,
|
|
15
16
|
live_records: []
|
|
16
17
|
)
|
|
17
18
|
@record = record
|
|
18
19
|
@root_ids = root_ids
|
|
19
20
|
@tree = tree
|
|
20
21
|
@traversal = traversal
|
|
21
|
-
@records = PreparedRecordIndex.new(start_at, live_records: live_records)
|
|
22
|
+
@records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
|
|
22
23
|
@history = PreparedHistory.new(@records)
|
|
23
24
|
@edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
|
|
24
25
|
@prepared = {} #: Hash[Array[String], Array[String]]
|
|
@@ -21,9 +21,14 @@ module PaperTrailDiff
|
|
|
21
21
|
state
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Mirrors PaperTrail's reifier, which writes attributes directly. Mass
|
|
25
|
+
# assignment would route reconstructed state through application-defined
|
|
26
|
+
# attribute writers and reify a different record than the version stored.
|
|
24
27
|
#: () -> untyped
|
|
25
28
|
def instantiate
|
|
26
|
-
@model_class.new
|
|
29
|
+
record = @model_class.new
|
|
30
|
+
@attributes.each { |name, value| record[name] = value }
|
|
31
|
+
record
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -108,6 +113,7 @@ module PaperTrailDiff
|
|
|
108
113
|
@version_positions = @versions.each_with_index.to_h do |version, index|
|
|
109
114
|
[version.id.to_s, index]
|
|
110
115
|
end.freeze
|
|
116
|
+
@transaction_positions = build_transaction_positions
|
|
111
117
|
@live = live
|
|
112
118
|
@state_loader = state_loader
|
|
113
119
|
@states = {} #: Hash[untyped, PreparedRecordState?]
|
|
@@ -115,8 +121,8 @@ module PaperTrailDiff
|
|
|
115
121
|
|
|
116
122
|
#: (untyped) -> untyped
|
|
117
123
|
def record_before(boundary)
|
|
118
|
-
|
|
119
|
-
return state_for(
|
|
124
|
+
index = boundary_index(boundary)
|
|
125
|
+
return state_for(versions.fetch(index))&.instantiate if index
|
|
120
126
|
|
|
121
127
|
@live&.instantiate
|
|
122
128
|
end
|
|
@@ -146,6 +152,7 @@ module PaperTrailDiff
|
|
|
146
152
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
147
153
|
# @rbs @states: Hash[untyped, PreparedRecordState?]
|
|
148
154
|
# @rbs @version_positions: Hash[String, Integer]
|
|
155
|
+
# @rbs @transaction_positions: Hash[untyped, Integer]
|
|
149
156
|
|
|
150
157
|
#: (Integer) -> PreparedRecordState?
|
|
151
158
|
def state_after(index)
|
|
@@ -153,16 +160,41 @@ module PaperTrailDiff
|
|
|
153
160
|
successor ? state_for(successor) : @live
|
|
154
161
|
end
|
|
155
162
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
# A version is eligible when it is at or after the boundary, or shares the
|
|
164
|
+
# boundary's transaction. The first is monotonic over the chronological
|
|
165
|
+
# list and the second is indexed, so neither rescans a long history once
|
|
166
|
+
# per boundary.
|
|
167
|
+
#: (untyped) -> Integer?
|
|
168
|
+
def boundary_index(boundary)
|
|
169
|
+
created_at = boundary.created_at
|
|
170
|
+
chronological = versions.bsearch_index do |candidate|
|
|
171
|
+
candidate.created_at >= created_at
|
|
172
|
+
end
|
|
173
|
+
transactional = transaction_index(boundary)
|
|
174
|
+
return chronological unless transactional
|
|
175
|
+
return transactional unless chronological
|
|
176
|
+
|
|
177
|
+
[chronological, transactional].min
|
|
159
178
|
end
|
|
160
179
|
|
|
161
|
-
#: (untyped
|
|
162
|
-
def
|
|
180
|
+
#: (untyped) -> Integer?
|
|
181
|
+
def transaction_index(boundary)
|
|
163
182
|
transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
|
|
164
|
-
|
|
165
|
-
|
|
183
|
+
return unless transaction_id
|
|
184
|
+
|
|
185
|
+
@transaction_positions[transaction_id]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
#: () -> Hash[untyped, Integer]
|
|
189
|
+
def build_transaction_positions
|
|
190
|
+
positions = {} #: Hash[untyped, Integer]
|
|
191
|
+
@versions.each_with_index do |version, index|
|
|
192
|
+
next unless version.respond_to?(:transaction_id)
|
|
193
|
+
|
|
194
|
+
transaction_id = version.transaction_id
|
|
195
|
+
positions[transaction_id] ||= index if transaction_id
|
|
196
|
+
end
|
|
197
|
+
positions.freeze
|
|
166
198
|
end
|
|
167
199
|
|
|
168
200
|
#: (untyped) -> PreparedRecordState?
|
|
@@ -185,9 +217,10 @@ module PaperTrailDiff
|
|
|
185
217
|
|
|
186
218
|
# Request-scoped scalar history loaded once per model identity.
|
|
187
219
|
class PreparedRecordIndex
|
|
188
|
-
#: (untyped, ?live_records: Array[untyped]) -> void
|
|
189
|
-
def initialize(start_at, live_records: [])
|
|
190
|
-
@start_time = start_at
|
|
220
|
+
#: (untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
|
|
221
|
+
def initialize(start_at, end_at: nil, live_records: [])
|
|
222
|
+
@start_time = boundary_time(start_at)
|
|
223
|
+
@end_time = boundary_time(end_at)
|
|
191
224
|
@seeded_live_records = live_records.to_h do |record|
|
|
192
225
|
[identity(record.class, record.id), record]
|
|
193
226
|
end
|
|
@@ -230,10 +263,17 @@ module PaperTrailDiff
|
|
|
230
263
|
private
|
|
231
264
|
|
|
232
265
|
# @rbs @start_time: untyped
|
|
266
|
+
# @rbs @end_time: untyped
|
|
233
267
|
# @rbs @seeded_live_records: Hash[Array[String], untyped]
|
|
234
268
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
235
269
|
# @rbs @series: Hash[Array[String], PreparedRecordSeries]
|
|
236
270
|
|
|
271
|
+
# Callers bound the range with either a version or a bare timestamp.
|
|
272
|
+
#: (untyped) -> untyped
|
|
273
|
+
def boundary_time(value)
|
|
274
|
+
value.respond_to?(:created_at) ? value.created_at : value
|
|
275
|
+
end
|
|
276
|
+
|
|
237
277
|
#: (untyped, untyped) -> Array[String]
|
|
238
278
|
def identity(model_class, id)
|
|
239
279
|
[model_class.base_class.name.to_s, id.to_s]
|
|
@@ -254,12 +294,47 @@ module PaperTrailDiff
|
|
|
254
294
|
)
|
|
255
295
|
end
|
|
256
296
|
|
|
297
|
+
# A PaperTrail version is a pre-change snapshot, so the state at the last
|
|
298
|
+
# selected boundary can only live in the next version after it. One
|
|
299
|
+
# trailing version per identity is retained, rather than every version
|
|
300
|
+
# recorded between the requested range and the present.
|
|
257
301
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
258
302
|
def versions_for(model_class, ids)
|
|
259
|
-
model_class.paper_trail.version_class
|
|
303
|
+
version_class = model_class.paper_trail.version_class
|
|
304
|
+
scope = version_class.where(
|
|
260
305
|
item_type: model_class.base_class.name,
|
|
261
306
|
item_id: ids
|
|
262
|
-
).where(
|
|
307
|
+
).where(created_at: @start_time..)
|
|
308
|
+
scope = scope.where(window_condition(version_class)) if @end_time
|
|
309
|
+
scope.order(:id).to_a
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# In range, or the earliest version after it, resolved in the same query
|
|
313
|
+
# rather than with a window function or one query per identity.
|
|
314
|
+
#: (untyped) -> untyped
|
|
315
|
+
def window_condition(version_class)
|
|
316
|
+
table = version_class.arel_table
|
|
317
|
+
table[:created_at].lteq(@end_time).or(first_after_range(table))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
#: (untyped) -> untyped
|
|
321
|
+
def first_after_range(table)
|
|
322
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
323
|
+
later = table.alias('paper_trail_diff_later_versions')
|
|
324
|
+
arel.const_get(:SelectManager).new
|
|
325
|
+
.from(later)
|
|
326
|
+
.project(arel.sql('1'))
|
|
327
|
+
.where(preceding_trailing_version(table, later))
|
|
328
|
+
.exists
|
|
329
|
+
.not
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
#: (untyped, untyped) -> untyped
|
|
333
|
+
def preceding_trailing_version(table, later)
|
|
334
|
+
later[:item_type].eq(table[:item_type])
|
|
335
|
+
.and(later[:item_id].eq(table[:item_id]))
|
|
336
|
+
.and(later[:created_at].gt(@end_time))
|
|
337
|
+
.and(later[:created_at].lt(table[:created_at]))
|
|
263
338
|
end
|
|
264
339
|
|
|
265
340
|
#: (untyped, Array[untyped]) -> Array[untyped]
|