paper_trail_diff 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -10,6 +10,7 @@ module PaperTrailDiff
|
|
|
10
10
|
@traversal = traversal
|
|
11
11
|
@normalizer = normalizer
|
|
12
12
|
@preparer = preparer
|
|
13
|
+
@prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
|
|
13
14
|
@live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
|
|
14
15
|
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
15
16
|
@prepared_history = nil #: PreparedHistory?
|
|
@@ -24,8 +25,13 @@ module PaperTrailDiff
|
|
|
24
25
|
end_at: root_versions.last.created_at
|
|
25
26
|
)
|
|
26
27
|
return if @tree.empty?
|
|
28
|
+
|
|
27
29
|
# A batched preparation already covers these roots.
|
|
28
|
-
|
|
30
|
+
batched = root_versions.find { |version| @prepared_histories.key?(context_key(version)) }
|
|
31
|
+
if batched
|
|
32
|
+
@prepared_history = @prepared_histories.fetch(context_key(batched))
|
|
33
|
+
return
|
|
34
|
+
end
|
|
29
35
|
|
|
30
36
|
@prepared_history = PreparedHistoryLoader.new(
|
|
31
37
|
record,
|
|
@@ -37,15 +43,24 @@ module PaperTrailDiff
|
|
|
37
43
|
).call
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
#: (Array[untyped], Array[untyped]) -> void
|
|
41
|
-
def prepare_batch(records, root_versions)
|
|
46
|
+
#: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
47
|
+
def prepare_batch(records, root_versions, start_at: nil, end_at: nil)
|
|
42
48
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
43
49
|
|
|
44
50
|
Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
|
|
45
|
-
register_batch(records, root_versions)
|
|
51
|
+
register_batch(records, root_versions, start_at: start_at, end_at: end_at)
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Missing roots have no live graph to collect. Reading their associations
|
|
56
|
+
# here would issue a query per root and seed nonexistent rows as live state.
|
|
57
|
+
#: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
58
|
+
def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
|
|
59
|
+
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
60
|
+
|
|
61
|
+
register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
50
65
|
def call(root_endpoint, context_endpoint)
|
|
51
66
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
@@ -100,6 +115,11 @@ module PaperTrailDiff
|
|
|
100
115
|
)
|
|
101
116
|
end
|
|
102
117
|
|
|
118
|
+
#: () -> PreparedActivityIndex?
|
|
119
|
+
def activity_index
|
|
120
|
+
@prepared_history&.activity_index
|
|
121
|
+
end
|
|
122
|
+
|
|
103
123
|
#: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
104
124
|
def record_transition(version)
|
|
105
125
|
history = @prepared_history
|
|
@@ -108,12 +128,19 @@ module PaperTrailDiff
|
|
|
108
128
|
history.record_transition(Endpoint.model_class(version), version.item_id, version)
|
|
109
129
|
end
|
|
110
130
|
|
|
131
|
+
#: (untyped, ?direct: bool) -> [bool, untyped]
|
|
132
|
+
def record_after(version, direct: false)
|
|
133
|
+
@prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
|
|
134
|
+
direct: direct) || [false, nil]
|
|
135
|
+
end
|
|
136
|
+
|
|
111
137
|
private
|
|
112
138
|
|
|
113
139
|
# @rbs @tree: AssociationTree
|
|
114
140
|
# @rbs @traversal: AssociationTraversal
|
|
115
141
|
# @rbs @normalizer: SnapshotNormalizer
|
|
116
142
|
# @rbs @preparer: untyped
|
|
143
|
+
# @rbs @prepared_snapshotter: PreparedSnapshotBuilder
|
|
117
144
|
# @rbs @live_graph_collector: LiveGraphCollector
|
|
118
145
|
# @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
119
146
|
# @rbs @prepared_history: PreparedHistory?
|
|
@@ -126,25 +153,22 @@ module PaperTrailDiff
|
|
|
126
153
|
|
|
127
154
|
#: (Array[untyped], Array[untyped]) -> Hash[Symbol, untyped]
|
|
128
155
|
def batch_payload(records, root_versions)
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
version_count: root_versions.length,
|
|
132
|
-
model_type: records.first.class.base_class.name.to_s,
|
|
133
|
-
batched: true
|
|
134
|
-
}
|
|
156
|
+
{ root_count: records.length, version_count: root_versions.length,
|
|
157
|
+
model_type: records.first.class.base_class.name.to_s, batched: true }
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
#: (Array[untyped], Array[untyped]) -> void
|
|
138
|
-
def register_batch(records, root_versions)
|
|
160
|
+
#: (Array[untyped], Array[untyped], ?live_records: Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
161
|
+
def register_batch(records, root_versions, live_records: @live_graph_collector.call(records),
|
|
162
|
+
start_at: nil, end_at: nil)
|
|
139
163
|
history = PreparedHistoryLoader.new(
|
|
140
164
|
records.first,
|
|
141
165
|
root_ids: records.map(&:id),
|
|
142
166
|
root_versions: root_versions,
|
|
143
|
-
start_at: root_versions.map(&:created_at).min,
|
|
144
|
-
end_at: root_versions.map(&:created_at).max,
|
|
167
|
+
start_at: start_at || root_versions.map(&:created_at).min,
|
|
168
|
+
end_at: end_at || root_versions.map(&:created_at).max,
|
|
145
169
|
tree: @tree,
|
|
146
170
|
traversal: @traversal,
|
|
147
|
-
live_records:
|
|
171
|
+
live_records: live_records
|
|
148
172
|
).call
|
|
149
173
|
root_versions.each { |version| @prepared_histories[context_key(version)] = history }
|
|
150
174
|
end
|
|
@@ -154,27 +178,24 @@ module PaperTrailDiff
|
|
|
154
178
|
model_class = Endpoint.model_class(root_version)
|
|
155
179
|
@preparer.call(model_class, historical: true)
|
|
156
180
|
@traversal.ensure_habtm_history!(model_class, root_version)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tree: tree,
|
|
162
|
-
normalizer: normalizer,
|
|
163
|
-
habtm_version: root_version
|
|
181
|
+
handled, snapshot = @prepared_snapshotter.call(
|
|
182
|
+
root_version, context_version, tree: tree, normalizer: normalizer,
|
|
183
|
+
history: @prepared_histories[context_key(context_version)] ||
|
|
184
|
+
@prepared_history
|
|
164
185
|
)
|
|
186
|
+
return snapshot if handled
|
|
187
|
+
|
|
188
|
+
record = root_version.reify(dup: true)
|
|
189
|
+
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
190
|
+
habtm_version: root_version)
|
|
165
191
|
end
|
|
166
192
|
|
|
167
193
|
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
168
194
|
def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
|
|
169
195
|
record = Endpoint.reload_record(root_record)
|
|
170
196
|
@preparer.call(record.class, historical: true)
|
|
171
|
-
normalize(
|
|
172
|
-
|
|
173
|
-
context_version,
|
|
174
|
-
tree: tree,
|
|
175
|
-
normalizer: normalizer,
|
|
176
|
-
habtm_version: context_version
|
|
177
|
-
)
|
|
197
|
+
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
198
|
+
habtm_version: context_version)
|
|
178
199
|
end
|
|
179
200
|
|
|
180
201
|
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
|
|
@@ -188,12 +209,7 @@ module PaperTrailDiff
|
|
|
188
209
|
|
|
189
210
|
#: (untyped, untyped) -> Array[untyped]
|
|
190
211
|
def snapshot_key(root_endpoint, context_endpoint)
|
|
191
|
-
|
|
192
|
-
root_endpoint.class.name,
|
|
193
|
-
root_endpoint.id,
|
|
194
|
-
context_endpoint.class.name,
|
|
195
|
-
context_endpoint.id
|
|
196
|
-
]
|
|
212
|
+
context_key(root_endpoint) + context_key(context_endpoint)
|
|
197
213
|
end
|
|
198
214
|
end
|
|
199
215
|
end
|
|
@@ -9,13 +9,13 @@ module PaperTrailDiff
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
#: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
|
|
12
|
-
def instrument(event, payload, &)
|
|
12
|
+
def instrument(event, payload, &block)
|
|
13
13
|
active_support = Object.const_get(:ActiveSupport) #: untyped
|
|
14
14
|
notifications = active_support.const_get(:Notifications) #: untyped
|
|
15
15
|
notifications.instrument(
|
|
16
16
|
"#{event}.paper_trail_diff",
|
|
17
17
|
payload,
|
|
18
|
-
&
|
|
18
|
+
&block
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -27,10 +27,16 @@ module PaperTrailDiff
|
|
|
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
|
-
|
|
30
|
+
class Absent
|
|
31
|
+
#: () -> String
|
|
32
|
+
def inspect = '#<PaperTrailDiff absent>'
|
|
33
|
+
|
|
34
|
+
#: () -> String
|
|
35
|
+
def to_s = 'absent'
|
|
36
|
+
end
|
|
37
|
+
private_constant :Absent
|
|
38
|
+
|
|
39
|
+
ABSENT = Absent.new.freeze
|
|
34
40
|
|
|
35
41
|
#: (untyped, untyped) -> nested_changes
|
|
36
42
|
def self.call(from_value, to_value)
|
|
@@ -49,9 +55,9 @@ module PaperTrailDiff
|
|
|
49
55
|
#: () -> nested_changes
|
|
50
56
|
def call
|
|
51
57
|
from_structure, to_structure = structures
|
|
52
|
-
return {} unless from_structure && to_structure
|
|
53
|
-
|
|
54
58
|
changes = {} #: nested_changes
|
|
59
|
+
return changes.freeze unless from_structure && to_structure
|
|
60
|
+
|
|
55
61
|
walk(from_structure, to_structure, [], changes)
|
|
56
62
|
changes.freeze
|
|
57
63
|
end
|
|
@@ -76,20 +82,23 @@ 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
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
|
|
@@ -98,6 +107,20 @@ module PaperTrailDiff
|
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
109
|
|
|
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
|
+
|
|
101
124
|
# Two arrays are a membership change; anything else is a change to the
|
|
102
125
|
# value as a whole, including an array facing something that is not one.
|
|
103
126
|
#: (untyped, untyped) -> nested_change
|
|
@@ -109,11 +132,12 @@ module PaperTrailDiff
|
|
|
109
132
|
end
|
|
110
133
|
end
|
|
111
134
|
|
|
112
|
-
# Sorted so a report reads the same twice
|
|
113
|
-
# loaded from JSON and one built in Ruby can key the
|
|
114
|
-
|
|
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]
|
|
115
139
|
def keys(from_hash, to_hash)
|
|
116
|
-
(from_hash.keys | to_hash.keys).
|
|
140
|
+
(from_hash.keys | to_hash.keys).sort
|
|
117
141
|
end
|
|
118
142
|
end
|
|
119
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)
|