paper_trail_diff 0.11.0 → 0.13.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 +144 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1367
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +181 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +59 -24
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +25 -2
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/value_objects.rb +66 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +81 -43
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +37 -27
- metadata +31 -5
|
@@ -16,23 +16,29 @@ module PaperTrailDiff
|
|
|
16
16
|
# names and locales routinely do -- and joining would make `a.b` ambiguous
|
|
17
17
|
# between one key and two.
|
|
18
18
|
#
|
|
19
|
-
# Arrays are
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
19
|
+
# Arrays are reported by membership, not by position -- see ArrayChange. Their
|
|
20
|
+
# elements carry no identity, so an insertion at the front makes every later
|
|
21
|
+
# index look changed, and one insertion reads as several edits. What is added
|
|
22
|
+
# and removed can be answered without claiming any pairing; what cannot is the
|
|
23
|
+
# same elements in a new order, so that is named rather than passed over.
|
|
24
24
|
#
|
|
25
25
|
# An absent key is not a null one. `{"a": null}` and `{}` mean different
|
|
26
26
|
# things in JSON and an audit trail that conflated them would be lying about
|
|
27
27
|
# one of them, so absence is its own value rather than nil.
|
|
28
28
|
class NestedComparator
|
|
29
29
|
# Stands in for a key that was not there at all.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ABSENT.freeze
|
|
30
|
+
class Absent
|
|
31
|
+
#: () -> String
|
|
32
|
+
def inspect = '#<PaperTrailDiff absent>'
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
#: () -> String
|
|
35
|
+
def to_s = 'absent'
|
|
36
|
+
end
|
|
37
|
+
private_constant :Absent
|
|
38
|
+
|
|
39
|
+
ABSENT = Absent.new.freeze
|
|
40
|
+
|
|
41
|
+
#: (untyped, untyped) -> nested_changes
|
|
36
42
|
def self.call(from_value, to_value)
|
|
37
43
|
new(from_value, to_value).call
|
|
38
44
|
end
|
|
@@ -46,12 +52,12 @@ module PaperTrailDiff
|
|
|
46
52
|
# Returns the changed paths, or an empty hash when the pair is not two
|
|
47
53
|
# structures this can look inside. An empty result therefore means "nothing
|
|
48
54
|
# to report at this depth", and the caller still has the whole-value change.
|
|
49
|
-
#: () ->
|
|
55
|
+
#: () -> nested_changes
|
|
50
56
|
def call
|
|
51
57
|
from_structure, to_structure = structures
|
|
52
|
-
|
|
58
|
+
changes = {} #: nested_changes
|
|
59
|
+
return changes.freeze unless from_structure && to_structure
|
|
53
60
|
|
|
54
|
-
changes = {} #: Hash[Array[String], ValueChange]
|
|
55
61
|
walk(from_structure, to_structure, [], changes)
|
|
56
62
|
changes.freeze
|
|
57
63
|
end
|
|
@@ -76,33 +82,62 @@ module PaperTrailDiff
|
|
|
76
82
|
|
|
77
83
|
parsed = begin
|
|
78
84
|
JSON.parse(value)
|
|
79
|
-
rescue JSON::ParserError
|
|
85
|
+
rescue JSON::ParserError
|
|
80
86
|
nil
|
|
81
87
|
end
|
|
82
|
-
|
|
88
|
+
# JSON parses objects into plain Hashes, never a subclass.
|
|
89
|
+
parsed if parsed.instance_of?(Hash)
|
|
83
90
|
end
|
|
84
91
|
|
|
85
|
-
#: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String],
|
|
92
|
+
#: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
|
|
86
93
|
def walk(from_hash, to_hash, path, changes)
|
|
94
|
+
from_hash = normalize_keys(from_hash)
|
|
95
|
+
to_hash = normalize_keys(to_hash)
|
|
87
96
|
keys(from_hash, to_hash).each do |key|
|
|
88
|
-
from_item = from_hash.
|
|
89
|
-
to_item = to_hash.
|
|
97
|
+
from_item = from_hash.fetch(key, ABSENT)
|
|
98
|
+
to_item = to_hash.fetch(key, ABSENT)
|
|
90
99
|
next if from_item == to_item
|
|
91
100
|
|
|
92
|
-
here =
|
|
101
|
+
here = (path + [key]).freeze
|
|
93
102
|
if from_item.is_a?(Hash) && to_item.is_a?(Hash)
|
|
94
103
|
walk(from_item, to_item, here, changes)
|
|
95
104
|
else
|
|
96
|
-
changes[here
|
|
105
|
+
changes[here] = change_for(from_item, to_item)
|
|
97
106
|
end
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
109
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
#: (Hash[untyped, untyped]) -> Hash[String, untyped]
|
|
111
|
+
def normalize_keys(hash)
|
|
112
|
+
normalized = {} #: Hash[String, untyped]
|
|
113
|
+
hash.each do |key, value|
|
|
114
|
+
name = key.to_s
|
|
115
|
+
if normalized.key?(name)
|
|
116
|
+
raise AmbiguousNestedKeyError, "duplicate nested key after normalization: #{name.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
normalized[name] = value
|
|
120
|
+
end
|
|
121
|
+
normalized
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Two arrays are a membership change; anything else is a change to the
|
|
125
|
+
# value as a whole, including an array facing something that is not one.
|
|
126
|
+
#: (untyped, untyped) -> nested_change
|
|
127
|
+
def change_for(from_item, to_item)
|
|
128
|
+
if from_item.is_a?(Array) && to_item.is_a?(Array)
|
|
129
|
+
ArrayChange.new(from: from_item, to: to_item)
|
|
130
|
+
else
|
|
131
|
+
ValueChange.new(from: from_item, to: to_item)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Sorted so a report reads the same twice. Both hashes are already keyed by
|
|
136
|
+
# strings, because a hash loaded from JSON and one built in Ruby can key the
|
|
137
|
+
# same field differently.
|
|
138
|
+
#: (Hash[String, untyped], Hash[String, untyped]) -> Array[String]
|
|
104
139
|
def keys(from_hash, to_hash)
|
|
105
|
-
(from_hash.keys | to_hash.keys).
|
|
140
|
+
(from_hash.keys | to_hash.keys).sort
|
|
106
141
|
end
|
|
107
142
|
end
|
|
108
143
|
end
|
|
@@ -10,11 +10,12 @@ module PaperTrailDiff
|
|
|
10
10
|
# The only thing a wall-clock window can close on besides a later version.
|
|
11
11
|
CLOSE_ON_CURRENT = :current
|
|
12
12
|
|
|
13
|
-
#: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
|
|
14
|
-
def initialize(associations:, ignore:, reload_live_endpoints: true)
|
|
13
|
+
#: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> void
|
|
14
|
+
def initialize(associations:, ignore:, reload_live_endpoints: true, allow_expensive: false)
|
|
15
15
|
@association_tree = AssociationTree.build(associations)
|
|
16
16
|
@ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
|
|
17
17
|
@traversal = AssociationTraversal.new(@association_tree)
|
|
18
|
+
@batch_safety = BatchSafety.new(@traversal, allow_expensive: allow_expensive)
|
|
18
19
|
@traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
|
|
19
20
|
@live_endpoints = LiveEndpointProvider.new(
|
|
20
21
|
tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
|
|
@@ -34,7 +35,14 @@ module PaperTrailDiff
|
|
|
34
35
|
Instrumentation.instrument('compare', payload) do
|
|
35
36
|
Endpoint.validate_pair!(from_endpoint, to_endpoint)
|
|
36
37
|
notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
|
|
37
|
-
|
|
38
|
+
if @association_tree.empty?
|
|
39
|
+
Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
|
|
40
|
+
else
|
|
41
|
+
# Prepared history orders versions that share a timestamp; reading
|
|
42
|
+
# associations straight from PT-AT would resolve them by time alone.
|
|
43
|
+
comparison_batch([{ from: from_endpoint, to: to_endpoint }],
|
|
44
|
+
preparer: @traversal_preparer.method(:call)).call.values.fetch(0)
|
|
45
|
+
end
|
|
38
46
|
end
|
|
39
47
|
end
|
|
40
48
|
|
|
@@ -44,19 +52,14 @@ module PaperTrailDiff
|
|
|
44
52
|
count = comparisons.is_a?(Array) ? comparisons.length : 0
|
|
45
53
|
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
46
54
|
Instrumentation.instrument('compare_many', payload) do
|
|
47
|
-
|
|
48
|
-
comparisons,
|
|
49
|
-
live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
|
|
50
|
-
history_preparer: @historical_store.method(:prepare_batch),
|
|
51
|
-
historical_snapshotter: method(:historical_snapshot),
|
|
52
|
-
live_normalizer: method(:normalize_live_snapshot)
|
|
53
|
-
).call
|
|
55
|
+
comparison_batch(comparisons, preparer: method(:prepare_batch_model)).call
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
58
60
|
def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
59
61
|
@traversal_preparer.call(record.class, historical: true)
|
|
62
|
+
from, to = default_range(record, from, to, within)
|
|
60
63
|
TimelineBuilder.new(
|
|
61
64
|
record,
|
|
62
65
|
from: from,
|
|
@@ -73,8 +76,8 @@ module PaperTrailDiff
|
|
|
73
76
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
74
77
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
75
78
|
@traversal_preparer.call(record.class, historical: true)
|
|
79
|
+
from, to = default_range(record, from, to, within)
|
|
76
80
|
live = live_endpoint_for(record, close_on, within)
|
|
77
|
-
reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
|
|
78
81
|
steps = activity_builder(
|
|
79
82
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
80
83
|
live_endpoint: live, snapshots: snapshots, group: group
|
|
@@ -86,7 +89,9 @@ module PaperTrailDiff
|
|
|
86
89
|
|
|
87
90
|
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
88
91
|
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
92
|
+
ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
|
|
89
93
|
@traversal_preparer.call(record.class, historical: true)
|
|
94
|
+
from, to = default_range(record, from, to, within)
|
|
90
95
|
live = live_endpoint_for(record, close_on, within)
|
|
91
96
|
if activity
|
|
92
97
|
return analyze_activity(
|
|
@@ -102,19 +107,21 @@ module PaperTrailDiff
|
|
|
102
107
|
end
|
|
103
108
|
|
|
104
109
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
105
|
-
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
106
|
-
def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
|
|
110
|
+
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
|
|
111
|
+
def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
112
|
+
ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
|
|
107
113
|
count = records.is_a?(Array) ? records.length : 0
|
|
108
114
|
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
109
115
|
Instrumentation.instrument('analyze_many', payload) do
|
|
110
116
|
AnalysisBatch.new(
|
|
111
117
|
records,
|
|
112
|
-
time_range: within.nil? ? nil : TimeRange.new(within),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
live_loader:
|
|
118
|
+
time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
|
|
119
|
+
close_on_current: close_on_current?(close_on, within) || within.nil?,
|
|
120
|
+
context_required: activity && !@association_tree.empty?,
|
|
121
|
+
live_loader: method(:load_batch_endpoints),
|
|
116
122
|
history_preparer: @historical_store.method(:prepare_batch),
|
|
117
|
-
analyzer: batched_root_analyzer(activity
|
|
123
|
+
analyzer: batched_root_analyzer(activity, within: within, snapshots: snapshots,
|
|
124
|
+
group: group)
|
|
118
125
|
).call
|
|
119
126
|
end
|
|
120
127
|
end
|
|
@@ -123,22 +130,65 @@ module PaperTrailDiff
|
|
|
123
130
|
# caller reporting on a population does not have to rediscover which of its
|
|
124
131
|
# members changed. The roots the relation could not reach come back named
|
|
125
132
|
# rather than dropped -- see `PaperTrailDiff.analyze_scope`.
|
|
126
|
-
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
127
|
-
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
|
|
134
|
+
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
135
|
+
ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
|
|
136
|
+
@batch_safety.call(scope.all.model) if scope.respond_to?(:all) && scope.respond_to?(:where)
|
|
137
|
+
# @type var options: report_options
|
|
138
|
+
options = { within: within, activity: activity, version_scope: version_scope,
|
|
139
|
+
close_on: close_on, snapshots: snapshots, group: group }
|
|
140
|
+
ScopedAnalysisBuilder.new(
|
|
141
|
+
scope, limit: limit, within: within, historical_filter: historical_filter,
|
|
142
|
+
max_filter_versions: max_filter_versions, include_deleted: include_deleted,
|
|
143
|
+
analyzer: ->(records) { analyze_many(records, **options, within: within) },
|
|
144
|
+
deleted_analyzer: lambda { |identities|
|
|
145
|
+
analyze_deleted(scope.all.model, identities, options)
|
|
146
|
+
}
|
|
132
147
|
).call
|
|
133
|
-
ScopedAnalysis.new(
|
|
134
|
-
analyses: analyze_many(selection.records, within: within, activity: activity,
|
|
135
|
-
version_scope: version_scope, close_on: close_on),
|
|
136
|
-
unreachable: selection.unreachable
|
|
137
|
-
)
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
private
|
|
141
151
|
|
|
152
|
+
#: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
|
|
153
|
+
def comparison_batch(comparisons, preparer:)
|
|
154
|
+
ComparisonBatch.new(
|
|
155
|
+
comparisons,
|
|
156
|
+
live_loader: @live_endpoints, preparer: preparer,
|
|
157
|
+
history_preparer: @historical_store.method(:prepare_batch),
|
|
158
|
+
historical_snapshotter: method(:historical_snapshot),
|
|
159
|
+
live_normalizer: method(:normalize_live_snapshot)
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
#: (Array[untyped]) -> Hash[identity, untyped]
|
|
164
|
+
def load_batch_endpoints(records)
|
|
165
|
+
records.map(&:class).uniq.each { |model| @batch_safety.call(model) }
|
|
166
|
+
@live_endpoints.call(records)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: (untyped, historical: bool) -> void
|
|
170
|
+
def prepare_batch_model(model, historical:)
|
|
171
|
+
@batch_safety.call(model, historical: historical)
|
|
172
|
+
@traversal_preparer.call(model, historical: historical)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @rbs @batch_safety: BatchSafety
|
|
176
|
+
|
|
177
|
+
#: (untyped, Array[Array[String]], Hash[Symbol, untyped]) -> Hash[Array[String], Analysis?]
|
|
178
|
+
def analyze_deleted(model, identities, options)
|
|
179
|
+
within = options.fetch(:within)
|
|
180
|
+
analyzer = batched_root_analyzer(
|
|
181
|
+
options.fetch(:activity), within: within, snapshots: options.fetch(:snapshots),
|
|
182
|
+
group: options.fetch(:group)
|
|
183
|
+
)
|
|
184
|
+
DeletedAnalysisBatch.new(
|
|
185
|
+
model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
|
|
186
|
+
version_scope: options.fetch(:version_scope),
|
|
187
|
+
history_preparer: @historical_store.method(:prepare_deleted_batch),
|
|
188
|
+
analyzer: analyzer
|
|
189
|
+
).call
|
|
190
|
+
end
|
|
191
|
+
|
|
142
192
|
# @rbs @association_tree: AssociationTree
|
|
143
193
|
# @rbs @live_endpoints: LiveEndpointProvider
|
|
144
194
|
# @rbs @instrumentation_payload: Hash[Symbol, untyped]
|
|
@@ -153,7 +203,6 @@ module PaperTrailDiff
|
|
|
153
203
|
|
|
154
204
|
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
155
205
|
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
156
|
-
reject_live_habtm_activity!(record.class) if live_endpoint
|
|
157
206
|
activity_builder(
|
|
158
207
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
159
208
|
live_endpoint: live_endpoint, snapshots: snapshots, group: group
|
|
@@ -181,25 +230,38 @@ module PaperTrailDiff
|
|
|
181
230
|
)
|
|
182
231
|
end
|
|
183
232
|
|
|
184
|
-
#
|
|
185
|
-
#
|
|
233
|
+
# A window with nothing recorded after its last change ends at the live
|
|
234
|
+
# record, which holds the state that change produced -- whether the window
|
|
235
|
+
# ended a moment ago or long before. `close_on: :current` asked for this
|
|
236
|
+
# before it was the default and is still accepted. It names what ends a
|
|
237
|
+
# wall-clock window, so it is meaningless for explicit endpoints.
|
|
186
238
|
#: (Symbol?, untyped) -> bool
|
|
187
239
|
def close_on_current?(close_on, within)
|
|
188
|
-
|
|
189
|
-
unless close_on == CLOSE_ON_CURRENT
|
|
240
|
+
unless close_on.nil? || close_on == CLOSE_ON_CURRENT
|
|
190
241
|
raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
|
|
191
242
|
end
|
|
192
|
-
raise ConfigurationError, 'close_on: requires `within`' if within.nil?
|
|
243
|
+
raise ConfigurationError, 'close_on: requires `within`' if close_on && within.nil?
|
|
193
244
|
|
|
194
|
-
|
|
245
|
+
!within.nil?
|
|
195
246
|
end
|
|
196
247
|
|
|
197
|
-
#
|
|
198
|
-
#
|
|
248
|
+
# With neither a window nor endpoints, a range is the record's whole
|
|
249
|
+
# history through its current saved state; a missing endpoint takes that
|
|
250
|
+
# side of the same default.
|
|
251
|
+
#: (untyped, untyped, untyped, untyped) -> [untyped, untyped]
|
|
252
|
+
def default_range(record, from, to, within)
|
|
253
|
+
return [from, to] unless within.nil?
|
|
254
|
+
|
|
255
|
+
[from.nil? ? :first : from, to.nil? ? record : to]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Only a saved row has current state to close on. A destroyed root's own
|
|
259
|
+
# destroy version already terminates its history, and a reified or unsaved
|
|
260
|
+
# instance stands for a state that is not in the table.
|
|
199
261
|
#: (untyped, Symbol?, untyped) -> untyped
|
|
200
262
|
def live_endpoint_for(record, close_on, within)
|
|
201
263
|
return unless close_on_current?(close_on, within)
|
|
202
|
-
return unless Endpoint.record?(record) &&
|
|
264
|
+
return unless Endpoint.record?(record) && record.persisted?
|
|
203
265
|
|
|
204
266
|
record
|
|
205
267
|
end
|
|
@@ -213,14 +275,14 @@ module PaperTrailDiff
|
|
|
213
275
|
@activity_snapshotter = build_activity_snapshotter
|
|
214
276
|
end
|
|
215
277
|
|
|
216
|
-
#: (bool) -> BatchedRootAnalyzer
|
|
217
|
-
def batched_root_analyzer(activity)
|
|
278
|
+
#: (bool, within: untyped, snapshots: bool, group: Symbol?) -> BatchedRootAnalyzer
|
|
279
|
+
def batched_root_analyzer(activity, within:, snapshots:, group:)
|
|
218
280
|
BatchedRootAnalyzer.new(
|
|
219
281
|
tree: @association_tree,
|
|
220
282
|
timeline_snapshotter: @timeline_snapshotter,
|
|
221
283
|
activity_snapshotter: @activity_snapshotter,
|
|
222
284
|
preparer: @traversal_preparer.method(:call),
|
|
223
|
-
activity: activity
|
|
285
|
+
activity: activity, within: within, snapshots: snapshots, group: group
|
|
224
286
|
)
|
|
225
287
|
end
|
|
226
288
|
|
|
@@ -243,12 +305,13 @@ module PaperTrailDiff
|
|
|
243
305
|
full_snapshotter: method(:snapshot_at),
|
|
244
306
|
partial_snapshotter: @historical_store.method(:custom),
|
|
245
307
|
association_reader: @historical_store.method(:association_reader),
|
|
246
|
-
record_transition: @historical_store.method(:record_transition)
|
|
308
|
+
record_transition: @historical_store.method(:record_transition),
|
|
309
|
+
prepared_record_after: @historical_store.method(:record_after)
|
|
247
310
|
)
|
|
248
311
|
ActivitySnapshotProvider.new(
|
|
249
|
-
snapshotter: method(:snapshot_at),
|
|
250
|
-
|
|
251
|
-
|
|
312
|
+
snapshotter: method(:snapshot_at), refresher: refresher,
|
|
313
|
+
preparer: @historical_store.method(:prepare),
|
|
314
|
+
activity_index: @historical_store.method(:activity_index)
|
|
252
315
|
)
|
|
253
316
|
end
|
|
254
317
|
|
|
@@ -267,15 +330,6 @@ module PaperTrailDiff
|
|
|
267
330
|
)
|
|
268
331
|
end
|
|
269
332
|
|
|
270
|
-
#: (untyped) -> void
|
|
271
|
-
def reject_live_habtm_activity!(model_class)
|
|
272
|
-
paths = @traversal.habtm_paths(model_class)
|
|
273
|
-
return if paths.empty?
|
|
274
|
-
|
|
275
|
-
message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
|
|
276
|
-
raise UnsupportedLiveActivityError, message
|
|
277
|
-
end
|
|
278
|
-
|
|
279
333
|
#: (untyped) -> RecordSnapshot?
|
|
280
334
|
def snapshot_for_endpoint(endpoint)
|
|
281
335
|
Endpoint.version?(endpoint) ? historical_snapshot(endpoint) : live_snapshot(endpoint)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Shares association event discovery across every root in a prepared graph.
|
|
6
|
+
class PreparedActivityIndex
|
|
7
|
+
#: (PreparedRecordIndex, Array[untyped], start_at: untyped, end_at: untyped) -> void
|
|
8
|
+
def initialize(records, root_versions, start_at:, end_at:)
|
|
9
|
+
@records = records
|
|
10
|
+
@range = ActivityRange.new(start_at, end_at)
|
|
11
|
+
@roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
|
|
12
|
+
@nodes = {} #: Hash[String, Array[untyped]]
|
|
13
|
+
@children = {} #: Hash[Array[String], untyped]
|
|
14
|
+
@rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, Array[untyped]) -> void
|
|
18
|
+
def register(model, ids)
|
|
19
|
+
name = model.base_class.name.to_s
|
|
20
|
+
@nodes[name] = (@nodes.fetch(name, []) | ids)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
24
|
+
def versions(model, ids)
|
|
25
|
+
ids.flat_map do |id|
|
|
26
|
+
key = [model.base_class.name.to_s, id.to_s]
|
|
27
|
+
@roots[key] || @records.versions_for_id(model, id)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
32
|
+
def child_ids(model, ids, reflection, range)
|
|
33
|
+
key = [model.base_class.name.to_s, reflection.name.to_s]
|
|
34
|
+
historical, live = @children[key] ||= child_rows(model, reflection)
|
|
35
|
+
empty = [] #: Array[untyped]
|
|
36
|
+
selected = historical_ids(reflection.klass, ids, historical, range)
|
|
37
|
+
selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
|
|
38
|
+
selected.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (untyped, Array[untyped], untyped, ?transactions: bool) -> Array[untyped]
|
|
42
|
+
def association_rows(model, selected_versions, reflection, transactions: false)
|
|
43
|
+
key = [model.base_class.name.to_s, reflection.name.to_s]
|
|
44
|
+
rows = @rows[key] ||= load_association_rows(model, reflection, transactions)
|
|
45
|
+
version_ids(selected_versions, transactions).flat_map { |id| rows.fetch(id.to_s, []) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @rbs @records: PreparedRecordIndex
|
|
51
|
+
# @rbs @range: ActivityRange
|
|
52
|
+
# @rbs @roots: Hash[Array[String], Array[untyped]]
|
|
53
|
+
# @rbs @nodes: Hash[String, Array[untyped]]
|
|
54
|
+
# @rbs @children: Hash[Array[String], untyped]
|
|
55
|
+
# @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
56
|
+
|
|
57
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
58
|
+
def historical_ids(model, ids, historical, range)
|
|
59
|
+
empty = [] #: Array[untyped]
|
|
60
|
+
ids.flat_map do |id|
|
|
61
|
+
historical.fetch(id.to_s, empty).filter_map do |row|
|
|
62
|
+
row[1] if eligible_child?(model, row, range)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (untyped) -> Array[untyped]
|
|
68
|
+
def node_ids(model)
|
|
69
|
+
name = model.base_class.name.to_s
|
|
70
|
+
@nodes.fetch(name) do
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
"prepared activity graph is missing #{name}; " \
|
|
73
|
+
'register the model before collecting activity'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (untyped, untyped) -> untyped
|
|
78
|
+
def child_rows(model, reflection)
|
|
79
|
+
loader = ActivityChildCandidateLoader.new(
|
|
80
|
+
model, node_ids(model), reflection, @range
|
|
81
|
+
)
|
|
82
|
+
[loader.historical_rows.group_by { |row| row.first.to_s },
|
|
83
|
+
loader.current_pairs.group_by { |row| row.first.to_s }
|
|
84
|
+
.transform_values { |rows| rows.map(&:last) }]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
|
|
88
|
+
# series includes changes to other owners too, so an old membership cannot
|
|
89
|
+
# survive a later pre-window reassignment just because that owner is absent.
|
|
90
|
+
#: (untyped, Array[untyped], ActivityRange) -> bool
|
|
91
|
+
def eligible_child?(model, row, range)
|
|
92
|
+
_owner, id, timestamp, event = row
|
|
93
|
+
return timestamp <= range.end_time if timestamp >= range.start_time
|
|
94
|
+
return false if event == 'destroy'
|
|
95
|
+
|
|
96
|
+
later = @records.versions_for_id(model, id)
|
|
97
|
+
.bsearch { |version| version.created_at > timestamp }
|
|
98
|
+
!later || later.created_at >= range.start_time
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (untyped, untyped, bool) -> Hash[String, Array[untyped]]
|
|
102
|
+
def load_association_rows(model, reflection, transactions)
|
|
103
|
+
all_versions = versions(model, node_ids(model))
|
|
104
|
+
foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
|
|
105
|
+
model.paper_trail.version_association_class.where(
|
|
106
|
+
version_id: version_ids(all_versions, transactions), foreign_key_name: foreign_key
|
|
107
|
+
).to_a.group_by { |row| row.version_id.to_s }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: (Array[untyped], bool) -> Array[untyped]
|
|
111
|
+
def version_ids(versions, transactions)
|
|
112
|
+
if transactions
|
|
113
|
+
versions.filter_map do |version|
|
|
114
|
+
version.transaction_id if version.respond_to?(:transaction_id)
|
|
115
|
+
end.uniq
|
|
116
|
+
else
|
|
117
|
+
versions.map(&:id)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -50,11 +50,15 @@ module PaperTrailDiff
|
|
|
50
50
|
def belongs_to_groups(owner_class, owner_ids, reflection)
|
|
51
51
|
key = scalar_foreign_key(reflection)
|
|
52
52
|
return {} unless key
|
|
53
|
+
unless reflection.polymorphic?
|
|
54
|
+
return group_for(reflection.klass,
|
|
55
|
+
@records.attribute_values_for(owner_class, owner_ids, key))
|
|
56
|
+
end
|
|
53
57
|
|
|
54
58
|
grouped = {} #: Hash[String, Hash[Symbol, untyped]]
|
|
55
59
|
@records.records_for(owner_class, owner_ids).each do |owner|
|
|
56
60
|
id = owner.public_send(key)
|
|
57
|
-
model_class =
|
|
61
|
+
model_class = AssociationTraversal.belongs_to_target(reflection, owner)
|
|
58
62
|
add_identity(grouped, model_class, id) if model_class && id
|
|
59
63
|
end
|
|
60
64
|
grouped
|
|
@@ -109,7 +113,12 @@ module PaperTrailDiff
|
|
|
109
113
|
association_class.arel_table[:foreign_key_id],
|
|
110
114
|
version_class.arel_table[:item_id]
|
|
111
115
|
)
|
|
112
|
-
group_ids_by_owner(pairs)
|
|
116
|
+
group_ids_by_owner(typed_child_ids(reflection.klass, pairs))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (untyped, Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
120
|
+
def typed_child_ids(model_class, pairs)
|
|
121
|
+
pairs.map { |owner_id, child_id| [owner_id, Support.record_id(model_class, child_id)] }
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
#: (untyped, Array[untyped], untyped, String) -> untyped
|
|
@@ -139,15 +148,6 @@ module PaperTrailDiff
|
|
|
139
148
|
group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
|
|
140
149
|
end
|
|
141
150
|
|
|
142
|
-
#: (untyped, untyped) -> untyped
|
|
143
|
-
def target_class(reflection, owner)
|
|
144
|
-
return reflection.klass unless reflection.polymorphic?
|
|
145
|
-
|
|
146
|
-
Object.const_get(owner.public_send(reflection.foreign_type).to_s)
|
|
147
|
-
rescue NameError
|
|
148
|
-
nil
|
|
149
|
-
end
|
|
150
|
-
|
|
151
151
|
#: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
|
|
152
152
|
def add_identity(groups, model_class, id)
|
|
153
153
|
group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }
|