paper_trail_diff 0.1.0 → 0.3.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 +62 -0
- data/QUICKSTART.md +302 -0
- data/README.md +258 -19
- data/lib/paper_trail_diff/activity_boundary.rb +34 -3
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/association_diff_traversal.rb +184 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/snapshot_traversal.rb +97 -0
- data/lib/paper_trail_diff/step.rb +9 -0
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -0
- data/lib/paper_trail_diff/traversal.rb +80 -0
- data/lib/paper_trail_diff/traversal_emitter.rb +42 -0
- data/lib/paper_trail_diff/traversal_entry.rb +94 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +84 -13
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/association_diff_traversal.rbs +58 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- data/sig/generated/paper_trail_diff/snapshot_traversal.rbs +29 -0
- data/sig/generated/paper_trail_diff/step.rbs +7 -0
- data/sig/generated/paper_trail_diff/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/traversal.rbs +41 -0
- data/sig/generated/paper_trail_diff/traversal_emitter.rbs +16 -0
- data/sig/generated/paper_trail_diff/traversal_entry.rbs +52 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +24 -8
- metadata +49 -4
|
@@ -11,16 +11,17 @@ module PaperTrailDiff
|
|
|
11
11
|
}.freeze
|
|
12
12
|
private_constant :RECORD_AFTER_HANDLERS
|
|
13
13
|
|
|
14
|
-
#: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped) -> void
|
|
15
|
-
def initialize(traversal:, pool:, components:, association_reader: nil)
|
|
14
|
+
#: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
|
|
15
|
+
def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
|
|
16
16
|
@traversal = traversal
|
|
17
17
|
@pool = pool
|
|
18
18
|
@components = components
|
|
19
19
|
@association_reader = association_reader || method(:historical_reader)
|
|
20
|
+
@record_transition = record_transition
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
#: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent?) -> [bool, RecordSnapshot?]
|
|
23
|
-
def call( # rubocop:disable Metrics/MethodLength
|
|
24
|
+
def call( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
24
25
|
root_endpoint,
|
|
25
26
|
context_endpoint,
|
|
26
27
|
previous,
|
|
@@ -38,13 +39,17 @@ module PaperTrailDiff
|
|
|
38
39
|
unless collection_routes.empty?
|
|
39
40
|
return [false, nil] if unsafe_through_membership_event?(collection_routes, event.version)
|
|
40
41
|
|
|
42
|
+
delta = activity_snapshot_delta(event.version)
|
|
43
|
+
return [false, nil] if unsafe_nested_membership_delta?(collection_routes, delta)
|
|
44
|
+
|
|
41
45
|
return collection_target_snapshot(
|
|
42
46
|
root_endpoint,
|
|
43
47
|
context_endpoint,
|
|
44
48
|
previous,
|
|
45
49
|
normalizer,
|
|
46
50
|
collection_routes,
|
|
47
|
-
event.version
|
|
51
|
+
event.version,
|
|
52
|
+
delta
|
|
48
53
|
)
|
|
49
54
|
end
|
|
50
55
|
|
|
@@ -64,6 +69,7 @@ module PaperTrailDiff
|
|
|
64
69
|
# @rbs @pool: SnapshotPool
|
|
65
70
|
# @rbs @components: untyped
|
|
66
71
|
# @rbs @association_reader: untyped
|
|
72
|
+
# @rbs @record_transition: untyped
|
|
67
73
|
|
|
68
74
|
#: (Array[Array[untyped]], untyped) -> bool
|
|
69
75
|
def unsafe_through_membership_event?(routes, version)
|
|
@@ -77,35 +83,111 @@ module PaperTrailDiff
|
|
|
77
83
|
end
|
|
78
84
|
end
|
|
79
85
|
|
|
80
|
-
#: (
|
|
86
|
+
#: (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
|
|
87
|
+
def unsafe_nested_membership_delta?(routes, delta)
|
|
88
|
+
return false unless delta
|
|
89
|
+
|
|
90
|
+
routes.any? do |route|
|
|
91
|
+
route.length > 1 && delta.relationship_changed?(route.last.fetch(1))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> [bool, RecordSnapshot]
|
|
81
96
|
def collection_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
82
97
|
root_endpoint,
|
|
83
98
|
context_endpoint,
|
|
84
99
|
previous,
|
|
85
100
|
normalizer,
|
|
86
101
|
routes,
|
|
87
|
-
version
|
|
102
|
+
version,
|
|
103
|
+
delta
|
|
88
104
|
)
|
|
89
|
-
|
|
105
|
+
fallback_record = [] #: Array[untyped]
|
|
90
106
|
snapshot = previous
|
|
91
107
|
routes.each do |route|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
-
end
|
|
108
|
+
record, replacement = collection_route_event_state(
|
|
109
|
+
snapshot,
|
|
110
|
+
route,
|
|
111
|
+
version,
|
|
112
|
+
delta,
|
|
113
|
+
fallback_record,
|
|
114
|
+
normalizer,
|
|
115
|
+
root_endpoint,
|
|
116
|
+
context_endpoint
|
|
117
|
+
)
|
|
104
118
|
snapshot, = replace_collection_route(snapshot, route, version, record, replacement)
|
|
105
119
|
end
|
|
106
120
|
[true, snapshot]
|
|
107
121
|
end
|
|
108
122
|
|
|
123
|
+
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, untyped, untyped) -> [untyped, RecordSnapshot?]
|
|
124
|
+
def collection_route_event_state( # rubocop:disable Metrics/ParameterLists
|
|
125
|
+
snapshot,
|
|
126
|
+
route,
|
|
127
|
+
version,
|
|
128
|
+
delta,
|
|
129
|
+
fallback_record,
|
|
130
|
+
normalizer,
|
|
131
|
+
root_endpoint,
|
|
132
|
+
context_endpoint
|
|
133
|
+
)
|
|
134
|
+
existing = existing_delta_record(snapshot, route, version, delta) if delta
|
|
135
|
+
return [delta, delta.apply(existing)] if delta && existing
|
|
136
|
+
|
|
137
|
+
fallback_record << record_after(version) if fallback_record.empty?
|
|
138
|
+
record = fallback_record.first
|
|
139
|
+
return [record, nil] unless record
|
|
140
|
+
|
|
141
|
+
[record, normalized_collection_event_record(
|
|
142
|
+
record, route, normalizer, root_endpoint, context_endpoint
|
|
143
|
+
)]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
#: (untyped, Array[untyped], SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
147
|
+
def normalized_collection_event_record(
|
|
148
|
+
record,
|
|
149
|
+
route,
|
|
150
|
+
normalizer,
|
|
151
|
+
root_endpoint,
|
|
152
|
+
context_endpoint
|
|
153
|
+
)
|
|
154
|
+
_name, reflection, subtree, path = route.last
|
|
155
|
+
normalize_event_record(
|
|
156
|
+
record,
|
|
157
|
+
reflection,
|
|
158
|
+
subtree,
|
|
159
|
+
path,
|
|
160
|
+
normalizer,
|
|
161
|
+
root_endpoint,
|
|
162
|
+
context_endpoint
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?) -> RecordSnapshot?
|
|
167
|
+
def existing_delta_record(snapshot, route, version, delta)
|
|
168
|
+
return unless delta && route.length.between?(1, 2)
|
|
169
|
+
|
|
170
|
+
owner = route.length == 1 ? snapshot : nested_delta_owner(snapshot, route, delta)
|
|
171
|
+
return unless owner
|
|
172
|
+
|
|
173
|
+
association = owner.associations.fetch(route.last.fetch(0))
|
|
174
|
+
position = association.position_for_id(version.item_id)
|
|
175
|
+
association.records.fetch(position) if position
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
#: (RecordSnapshot, Array[untyped], ActivitySnapshotDelta) -> RecordSnapshot?
|
|
179
|
+
def nested_delta_owner(snapshot, route, delta)
|
|
180
|
+
parent_name = route.first.fetch(0)
|
|
181
|
+
reflection = route.last.fetch(1)
|
|
182
|
+
association = snapshot.associations.fetch(parent_name)
|
|
183
|
+
owner_id = relationship_owner_id(delta, reflection, state: :before)
|
|
184
|
+
position = association.position_for_id(owner_id)
|
|
185
|
+
return unless position
|
|
186
|
+
|
|
187
|
+
owner = association.records.fetch(position)
|
|
188
|
+
owner if member_of_owner?(delta, reflection, owner, state: :before)
|
|
189
|
+
end
|
|
190
|
+
|
|
109
191
|
#: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
|
|
110
192
|
def direct_collection_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
111
193
|
model_class,
|
|
@@ -150,15 +232,31 @@ module PaperTrailDiff
|
|
|
150
232
|
name, reflection, _subtree, path = route.fetch(depth)
|
|
151
233
|
association = snapshot.associations.fetch(name)
|
|
152
234
|
changed = false
|
|
235
|
+
transition_before = nil #: RecordSnapshot?
|
|
236
|
+
transition_after = nil #: RecordSnapshot?
|
|
237
|
+
membership_preserved = true
|
|
153
238
|
updated_records = if depth == route.length - 1
|
|
154
|
-
child =
|
|
155
|
-
|
|
156
|
-
end
|
|
157
|
-
replace_collection_record(
|
|
158
|
-
association.records,
|
|
239
|
+
child = collection_event_child(
|
|
240
|
+
association,
|
|
159
241
|
version,
|
|
160
|
-
|
|
161
|
-
|
|
242
|
+
record,
|
|
243
|
+
reflection,
|
|
244
|
+
snapshot,
|
|
245
|
+
replacement
|
|
246
|
+
)
|
|
247
|
+
records, transition_before, transition_after, membership_preserved =
|
|
248
|
+
replace_collection_record(association, version, child)
|
|
249
|
+
records.tap { |value| changed = !value.equal?(association.records) }
|
|
250
|
+
elsif (targeted = replace_targeted_collection_parent(
|
|
251
|
+
association,
|
|
252
|
+
route,
|
|
253
|
+
version,
|
|
254
|
+
record,
|
|
255
|
+
replacement,
|
|
256
|
+
depth
|
|
257
|
+
))
|
|
258
|
+
records, transition_before, transition_after, changed = targeted
|
|
259
|
+
records
|
|
162
260
|
else
|
|
163
261
|
association.records.map do |child_snapshot|
|
|
164
262
|
updated, child_changed = replace_collection_route(
|
|
@@ -169,15 +267,28 @@ module PaperTrailDiff
|
|
|
169
267
|
replacement,
|
|
170
268
|
depth: depth + 1
|
|
171
269
|
)
|
|
172
|
-
|
|
270
|
+
if child_changed
|
|
271
|
+
transition_before = nil if changed
|
|
272
|
+
transition_after = nil if changed
|
|
273
|
+
unless changed
|
|
274
|
+
transition_before = child_snapshot
|
|
275
|
+
transition_after = updated
|
|
276
|
+
end
|
|
277
|
+
changed = true
|
|
278
|
+
end
|
|
173
279
|
updated
|
|
174
|
-
end
|
|
280
|
+
end.freeze
|
|
175
281
|
end
|
|
176
282
|
return [snapshot, false] unless changed
|
|
177
283
|
|
|
178
284
|
updated_association = @pool.association(
|
|
179
285
|
path,
|
|
180
|
-
|
|
286
|
+
association.transition_to(
|
|
287
|
+
updated_records,
|
|
288
|
+
before: transition_before,
|
|
289
|
+
after: transition_after,
|
|
290
|
+
membership_preserved: membership_preserved
|
|
291
|
+
)
|
|
181
292
|
)
|
|
182
293
|
updated_snapshot = RecordSnapshot.new(
|
|
183
294
|
type: snapshot.type,
|
|
@@ -190,6 +301,73 @@ module PaperTrailDiff
|
|
|
190
301
|
[updated_snapshot, true]
|
|
191
302
|
end
|
|
192
303
|
|
|
304
|
+
#: (AssociationSnapshot, untyped, untyped, untyped, RecordSnapshot, RecordSnapshot?) -> RecordSnapshot?
|
|
305
|
+
def collection_event_child( # rubocop:disable Metrics/ParameterLists
|
|
306
|
+
association, version, record, reflection, owner, replacement
|
|
307
|
+
)
|
|
308
|
+
return unless record
|
|
309
|
+
|
|
310
|
+
unless record.is_a?(ActivitySnapshotDelta)
|
|
311
|
+
return replacement if member_of_owner?(record, reflection, owner)
|
|
312
|
+
|
|
313
|
+
return
|
|
314
|
+
end
|
|
315
|
+
unless record.relationship_changed?(reflection)
|
|
316
|
+
return replacement if association.position_for_id(version.item_id)
|
|
317
|
+
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
replacement if member_of_owner?(record, reflection, owner)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
#: (AssociationSnapshot, Array[untyped], untyped, untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]?
|
|
325
|
+
def replace_targeted_collection_parent( # rubocop:disable Metrics/ParameterLists
|
|
326
|
+
association,
|
|
327
|
+
route,
|
|
328
|
+
version,
|
|
329
|
+
record,
|
|
330
|
+
replacement,
|
|
331
|
+
depth
|
|
332
|
+
)
|
|
333
|
+
return unless depth == route.length - 2
|
|
334
|
+
|
|
335
|
+
reflection = route.fetch(depth + 1).fetch(1)
|
|
336
|
+
position = nested_owner_position(association, reflection, version, record)
|
|
337
|
+
return unless position
|
|
338
|
+
|
|
339
|
+
before = association.records.fetch(position)
|
|
340
|
+
after, changed = replace_collection_route(
|
|
341
|
+
before,
|
|
342
|
+
route,
|
|
343
|
+
version,
|
|
344
|
+
record,
|
|
345
|
+
replacement,
|
|
346
|
+
depth: depth + 1
|
|
347
|
+
)
|
|
348
|
+
targeted_parent_replacement(association.records, position, before, after, changed)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
#: (Array[RecordSnapshot], Integer, RecordSnapshot, RecordSnapshot, bool) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
|
|
352
|
+
def targeted_parent_replacement(records, position, before, after, changed)
|
|
353
|
+
return [records, nil, nil, false] unless changed
|
|
354
|
+
|
|
355
|
+
updated = records.dup
|
|
356
|
+
updated[position] = after
|
|
357
|
+
[updated.freeze, before, after, true]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
#: (AssociationSnapshot, untyped, untyped, untyped) -> Integer?
|
|
361
|
+
def nested_owner_position(association, reflection, version, record)
|
|
362
|
+
membership_record = record || version.reify(dup: true)
|
|
363
|
+
return unless membership_record
|
|
364
|
+
return if membership_record.is_a?(ActivitySnapshotDelta) &&
|
|
365
|
+
membership_record.relationship_changed?(reflection)
|
|
366
|
+
|
|
367
|
+
owner_id = relationship_owner_id(membership_record, reflection, state: :after)
|
|
368
|
+
association.position_for_id(owner_id)
|
|
369
|
+
end
|
|
370
|
+
|
|
193
371
|
#: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
|
|
194
372
|
def belongs_to_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
195
373
|
root_endpoint,
|
|
@@ -291,7 +469,7 @@ module PaperTrailDiff
|
|
|
291
469
|
)
|
|
292
470
|
changed ||= record_changed
|
|
293
471
|
updated
|
|
294
|
-
end
|
|
472
|
+
end.freeze
|
|
295
473
|
end
|
|
296
474
|
return [snapshot, false] unless changed
|
|
297
475
|
|
|
@@ -318,7 +496,7 @@ module PaperTrailDiff
|
|
|
318
496
|
|
|
319
497
|
updated = records.dup
|
|
320
498
|
replacement ? updated[index] = replacement : updated.delete_at(index)
|
|
321
|
-
updated
|
|
499
|
+
updated.freeze
|
|
322
500
|
end
|
|
323
501
|
|
|
324
502
|
#: (untyped) -> untyped
|
|
@@ -409,10 +587,23 @@ module PaperTrailDiff
|
|
|
409
587
|
changes
|
|
410
588
|
end
|
|
411
589
|
|
|
412
|
-
#: (untyped
|
|
413
|
-
def
|
|
590
|
+
#: (untyped) -> ActivitySnapshotDelta?
|
|
591
|
+
def activity_snapshot_delta(version)
|
|
592
|
+
return unless version.event.to_s == 'update' && version.object
|
|
593
|
+
|
|
594
|
+
transition = @record_transition&.call(version)
|
|
595
|
+
return unless transition
|
|
596
|
+
|
|
597
|
+
ActivitySnapshotDelta.new(
|
|
598
|
+
before_attributes: transition.fetch(0),
|
|
599
|
+
after_attributes: transition.fetch(1)
|
|
600
|
+
)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
#: (untyped, untyped, RecordSnapshot, ?state: Symbol) -> bool
|
|
604
|
+
def member_of_owner?(record, reflection, owner, state: :after) # rubocop:disable Metrics/AbcSize
|
|
414
605
|
foreign_keys = Array(reflection.foreign_key)
|
|
415
|
-
actual_ids = foreign_keys.map { |key| record
|
|
606
|
+
actual_ids = foreign_keys.map { |key| event_attribute(record, key, state: state) }
|
|
416
607
|
expected_ids = Array(owner.id)
|
|
417
608
|
# @type var expected_ids: Array[untyped]
|
|
418
609
|
# Explicit blocks keep the inline RBS checker from inferring an unusable Proc type.
|
|
@@ -421,7 +612,7 @@ module PaperTrailDiff
|
|
|
421
612
|
return false unless actual == expected
|
|
422
613
|
return true unless reflection.options[:as]
|
|
423
614
|
|
|
424
|
-
type = record
|
|
615
|
+
type = event_attribute(record, reflection.type, state: state).to_s
|
|
425
616
|
owner_types = [
|
|
426
617
|
owner.type.to_s,
|
|
427
618
|
reflection.active_record.name.to_s,
|
|
@@ -430,6 +621,23 @@ module PaperTrailDiff
|
|
|
430
621
|
owner_types.include?(type)
|
|
431
622
|
end
|
|
432
623
|
|
|
624
|
+
#: (untyped, untyped, state: Symbol) -> untyped
|
|
625
|
+
def event_attribute(record, name, state:)
|
|
626
|
+
if record.is_a?(ActivitySnapshotDelta)
|
|
627
|
+
return state == :before ? record.before_value(name) : record.after_value(name)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
record.public_send(name)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
#: (untyped, untyped, state: Symbol) -> untyped
|
|
634
|
+
def relationship_owner_id(record, reflection, state:)
|
|
635
|
+
owner_ids = Array(reflection.foreign_key).map do |foreign_key|
|
|
636
|
+
event_attribute(record, foreign_key, state: state)
|
|
637
|
+
end
|
|
638
|
+
owner_ids.one? ? owner_ids.first : owner_ids
|
|
639
|
+
end
|
|
640
|
+
|
|
433
641
|
#: (untyped, untyped, AssociationTree, String, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
434
642
|
def normalize_event_record( # rubocop:disable Metrics/ParameterLists
|
|
435
643
|
record,
|
|
@@ -459,22 +667,30 @@ module PaperTrailDiff
|
|
|
459
667
|
HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
|
|
460
668
|
end
|
|
461
669
|
|
|
462
|
-
#: (
|
|
463
|
-
def replace_collection_record(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
670
|
+
#: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
|
|
671
|
+
def replace_collection_record(association, version, replacement)
|
|
672
|
+
records = association.records
|
|
673
|
+
position = association.position(
|
|
674
|
+
replacement_type(version, replacement),
|
|
675
|
+
version.item_id
|
|
676
|
+
)
|
|
677
|
+
return [records, nil, nil, true] unless replacement || position
|
|
678
|
+
|
|
679
|
+
before = records.fetch(position) if position
|
|
680
|
+
updated = replacement_collection_records(records, position, replacement)
|
|
681
|
+
[updated, before, replacement, !before.nil? && !replacement.nil?]
|
|
682
|
+
end
|
|
469
683
|
|
|
684
|
+
#: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
|
|
685
|
+
def replacement_collection_records(records, position, replacement)
|
|
470
686
|
updated = records.dup
|
|
471
687
|
if replacement
|
|
472
|
-
|
|
688
|
+
position ? updated[position] = replacement : updated << replacement
|
|
473
689
|
else
|
|
474
|
-
position
|
|
690
|
+
position ||= raise(ConfigurationError, 'missing collection event identity')
|
|
475
691
|
updated.delete_at(position)
|
|
476
692
|
end
|
|
477
|
-
updated
|
|
693
|
+
updated.freeze
|
|
478
694
|
end
|
|
479
695
|
|
|
480
696
|
#: (untyped, RecordSnapshot?) -> String
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Immutable snapshots and steps produced by one activity-history pass.
|
|
6
|
+
class ActivityHistory
|
|
7
|
+
attr_reader :steps #: Array[ActivityStep]
|
|
8
|
+
attr_reader :root_snapshots #: Hash[Array[untyped], RecordSnapshot?]
|
|
9
|
+
attr_reader :first_snapshot #: RecordSnapshot?
|
|
10
|
+
attr_reader :last_snapshot #: RecordSnapshot?
|
|
11
|
+
|
|
12
|
+
#: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?) -> void
|
|
13
|
+
def initialize(steps:, root_snapshots:, first_snapshot:, last_snapshot:)
|
|
14
|
+
@steps = steps.freeze
|
|
15
|
+
@root_snapshots = root_snapshots.freeze
|
|
16
|
+
@first_snapshot = first_snapshot
|
|
17
|
+
@last_snapshot = last_snapshot
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: () -> ActivityHistory
|
|
22
|
+
def self.empty
|
|
23
|
+
new(steps: [], root_snapshots: {}, first_snapshot: nil, last_snapshot: nil)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builds activity steps while retaining root snapshots for combined analysis.
|
|
28
|
+
class ActivityHistoryBuilder
|
|
29
|
+
#: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
|
|
30
|
+
def initialize(root_versions, events, snapshotter, current: nil, include_step: nil)
|
|
31
|
+
@root_versions = root_versions
|
|
32
|
+
@events = events
|
|
33
|
+
@snapshotter = snapshotter
|
|
34
|
+
@current = current
|
|
35
|
+
@include_step = include_step
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: () -> ActivityHistory
|
|
39
|
+
def call
|
|
40
|
+
reset
|
|
41
|
+
sequence.each { |event, snapshot| consume(event, snapshot) }
|
|
42
|
+
ActivityHistory.new(
|
|
43
|
+
steps: @steps,
|
|
44
|
+
root_snapshots: @root_snapshots,
|
|
45
|
+
first_snapshot: @first_snapshot,
|
|
46
|
+
last_snapshot: final_snapshot
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @rbs @root_versions: Array[untyped]
|
|
53
|
+
# @rbs @events: Array[ActivityEvent]
|
|
54
|
+
# @rbs @snapshotter: untyped
|
|
55
|
+
# @rbs @current: untyped
|
|
56
|
+
# @rbs @include_step: untyped
|
|
57
|
+
# @rbs @steps: Array[ActivityStep]
|
|
58
|
+
# @rbs @root_snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
59
|
+
# @rbs @first_snapshot: RecordSnapshot?
|
|
60
|
+
# @rbs @previous_snapshot: RecordSnapshot?
|
|
61
|
+
# @rbs @selected_last_snapshot: RecordSnapshot?
|
|
62
|
+
# @rbs @previous_boundary: ActivityBoundary?
|
|
63
|
+
# @rbs @previous_event: ActivityEvent?
|
|
64
|
+
|
|
65
|
+
#: () -> void
|
|
66
|
+
def reset
|
|
67
|
+
@steps = []
|
|
68
|
+
@root_snapshots = {}
|
|
69
|
+
@first_snapshot = nil
|
|
70
|
+
@first_snapshot_seen = false
|
|
71
|
+
@previous_snapshot = nil
|
|
72
|
+
@selected_last_snapshot = nil
|
|
73
|
+
@previous_boundary = nil
|
|
74
|
+
@previous_event = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: () -> ActivitySnapshotSequence
|
|
78
|
+
def sequence
|
|
79
|
+
ActivitySnapshotSequence.new(
|
|
80
|
+
@root_versions,
|
|
81
|
+
@events,
|
|
82
|
+
@snapshotter,
|
|
83
|
+
current: @current
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
#: (ActivityEvent, RecordSnapshot?) -> void
|
|
88
|
+
def consume(event, snapshot)
|
|
89
|
+
capture_first(snapshot)
|
|
90
|
+
boundary = ActivityBoundary.from_version(event.version)
|
|
91
|
+
@root_snapshots[version_key(event.version)] = snapshot if event.root?
|
|
92
|
+
append_step(boundary, snapshot) if @previous_boundary
|
|
93
|
+
@previous_boundary = boundary
|
|
94
|
+
@previous_event = event
|
|
95
|
+
@previous_snapshot = snapshot
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: (RecordSnapshot?) -> void
|
|
99
|
+
def capture_first(snapshot)
|
|
100
|
+
return if @first_snapshot_seen
|
|
101
|
+
|
|
102
|
+
@first_snapshot = snapshot
|
|
103
|
+
@first_snapshot_seen = true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (ActivityBoundary, RecordSnapshot?) -> void
|
|
107
|
+
def append_step(boundary, snapshot)
|
|
108
|
+
return unless include_previous_step?
|
|
109
|
+
|
|
110
|
+
previous_boundary = @previous_boundary
|
|
111
|
+
return unless previous_boundary
|
|
112
|
+
|
|
113
|
+
@steps << ActivityStep.new(
|
|
114
|
+
from_boundary: previous_boundary,
|
|
115
|
+
to_boundary: boundary,
|
|
116
|
+
diff: Engine.compare(@previous_snapshot, snapshot)
|
|
117
|
+
)
|
|
118
|
+
@selected_last_snapshot = snapshot
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: () -> bool
|
|
122
|
+
def include_previous_step?
|
|
123
|
+
!@include_step || @include_step.call(@previous_event)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: () -> RecordSnapshot?
|
|
127
|
+
def final_snapshot
|
|
128
|
+
@include_step ? @selected_last_snapshot : @previous_snapshot
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
#: (untyped) -> Array[untyped]
|
|
132
|
+
def version_key(version)
|
|
133
|
+
[version.class.name, version.id]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Tests activity versions against a historical-version or wall-clock range end.
|
|
6
6
|
class ActivityRange
|
|
7
|
+
attr_reader :start_time #: untyped
|
|
8
|
+
attr_reader :end_time #: untyped
|
|
9
|
+
|
|
7
10
|
#: (untyped, untyped) -> void
|
|
8
|
-
def initialize(
|
|
9
|
-
@
|
|
11
|
+
def initialize(start_boundary, end_boundary)
|
|
12
|
+
@start_boundary = start_boundary
|
|
10
13
|
@end_boundary = end_boundary
|
|
11
|
-
@start_key =
|
|
14
|
+
@start_key = if Endpoint.version?(start_boundary)
|
|
15
|
+
Support.chronological_version_key(start_boundary)
|
|
16
|
+
end
|
|
17
|
+
@start_time = Endpoint.version?(start_boundary) ? start_boundary.created_at : start_boundary
|
|
18
|
+
@end_time = Endpoint.version?(end_boundary) ? end_boundary.created_at : end_boundary
|
|
12
19
|
@end_key = if Endpoint.version?(end_boundary)
|
|
13
20
|
Support.chronological_version_key(end_boundary)
|
|
14
21
|
end
|
|
@@ -17,23 +24,37 @@ module PaperTrailDiff
|
|
|
17
24
|
#: (untyped) -> bool
|
|
18
25
|
def include?(version)
|
|
19
26
|
key = Support.chronological_version_key(version)
|
|
20
|
-
|
|
27
|
+
after_start?(version, key) && before_end?(version, key)
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
# Narrows an Active Record version relation by timestamp. Exact tie-breaking
|
|
24
31
|
# remains in #include? so this works for integer, UUID, and custom version IDs.
|
|
25
32
|
#: (untyped) -> untyped
|
|
26
33
|
def scope(relation)
|
|
27
|
-
relation.where(created_at: @
|
|
34
|
+
relation.where(created_at: @start_time..end_time)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: () -> bool
|
|
38
|
+
def current_end?
|
|
39
|
+
!Endpoint.version?(@end_boundary)
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
private
|
|
31
43
|
|
|
32
|
-
# @rbs @
|
|
44
|
+
# @rbs @start_boundary: untyped
|
|
33
45
|
# @rbs @end_boundary: untyped
|
|
34
|
-
# @rbs @start_key: Array[untyped]
|
|
46
|
+
# @rbs @start_key: Array[untyped]?
|
|
47
|
+
# @rbs @start_time: untyped
|
|
35
48
|
# @rbs @end_key: Array[untyped]?
|
|
36
49
|
|
|
50
|
+
#: (untyped, Array[untyped]) -> bool
|
|
51
|
+
def after_start?(version, key)
|
|
52
|
+
start_key = @start_key
|
|
53
|
+
return key_before_or_equal?(start_key, key) if start_key
|
|
54
|
+
|
|
55
|
+
version.created_at >= @start_time
|
|
56
|
+
end
|
|
57
|
+
|
|
37
58
|
#: (untyped, Array[untyped]) -> bool
|
|
38
59
|
def before_end?(version, key)
|
|
39
60
|
end_key = @end_key
|
|
@@ -42,11 +63,6 @@ module PaperTrailDiff
|
|
|
42
63
|
version.created_at <= @end_boundary
|
|
43
64
|
end
|
|
44
65
|
|
|
45
|
-
#: () -> untyped
|
|
46
|
-
def end_time
|
|
47
|
-
Endpoint.version?(@end_boundary) ? @end_boundary.created_at : @end_boundary
|
|
48
|
-
end
|
|
49
|
-
|
|
50
66
|
#: (Array[untyped], Array[untyped]) -> bool
|
|
51
67
|
def key_before_or_equal?(left, right)
|
|
52
68
|
comparison = left <=> right
|