paper_trail_diff 0.2.0 → 0.3.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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +188 -14
  5. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  8. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  9. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  10. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  11. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
  15. data/lib/paper_trail_diff/activity_history.rb +136 -0
  16. data/lib/paper_trail_diff/activity_range.rb +28 -12
  17. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  18. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  19. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  20. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  21. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  22. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -3
  23. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  24. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  25. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  26. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  27. data/lib/paper_trail_diff/engine.rb +5 -45
  28. data/lib/paper_trail_diff/errors.rb +9 -0
  29. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  30. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  31. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  32. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  33. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  34. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  35. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  36. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  37. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  38. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  39. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  40. data/lib/paper_trail_diff/snapshot.rb +66 -3
  41. data/lib/paper_trail_diff/support.rb +18 -0
  42. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  43. data/lib/paper_trail_diff/time_range.rb +85 -0
  44. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  45. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  46. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  47. data/lib/paper_trail_diff/version.rb +1 -1
  48. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  49. data/lib/paper_trail_diff.rb +83 -12
  50. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  51. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  52. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  53. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  54. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  55. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  56. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  57. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  58. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  59. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
  60. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  61. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  62. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  63. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  64. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  65. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  66. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  67. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  68. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  69. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  70. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  71. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  72. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  73. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  74. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  75. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  76. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  77. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  78. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  79. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  80. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  81. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  82. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  83. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  84. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  85. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  86. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  87. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  88. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  89. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  90. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  91. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  92. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  93. data/sig/generated/paper_trail_diff.rbs +16 -8
  94. metadata +56 -4
@@ -2,21 +2,49 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Applies safe descendant event deltas without rebuilding an entire selected branch.
6
- # rubocop:disable Metrics/ClassLength
5
+ # Dispatches safe descendant events to focused immutable snapshot appliers.
7
6
  class ActivityEventSnapshotRefresher
8
- RECORD_AFTER_HANDLERS = {
9
- 'create' => :created_record_after,
10
- 'update' => :updated_record_after
11
- }.freeze
12
- private_constant :RECORD_AFTER_HANDLERS
13
-
14
- #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped) -> void
15
- def initialize(traversal:, pool:, components:, association_reader: nil)
16
- @traversal = traversal
17
- @pool = pool
7
+ #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
8
+ def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
18
9
  @components = components
19
- @association_reader = association_reader || method(:historical_reader)
10
+ @record_resolver = ActivityEventRecordResolver.new(record_transition: record_transition)
11
+ @route_finder = ActivityEventRouteFinder.new(traversal)
12
+ @relationship = ActivityRelationship.new
13
+ configure_appliers(pool, association_reader)
14
+ end
15
+
16
+ #: (SnapshotPool, untyped) -> void
17
+ def configure_appliers(pool, association_reader)
18
+ route_updater = collection_updater(pool)
19
+ record_normalizer = ActivityEventRecordNormalizer.new(
20
+ association_reader: association_reader
21
+ )
22
+ @collection_applier = collection_applier(record_normalizer, route_updater)
23
+ @belongs_to_applier = belongs_to_applier(record_normalizer)
24
+ end
25
+
26
+ #: (SnapshotPool) -> ActivityCollectionRouteUpdater
27
+ def collection_updater(pool)
28
+ ActivityCollectionRouteUpdater.new(pool: pool, relationship: @relationship)
29
+ end
30
+
31
+ #: (ActivityEventRecordNormalizer, ActivityCollectionRouteUpdater) -> ActivityCollectionEventApplier
32
+ def collection_applier(record_normalizer, route_updater)
33
+ ActivityCollectionEventApplier.new(
34
+ record_resolver: @record_resolver,
35
+ record_normalizer: record_normalizer,
36
+ route_updater: route_updater,
37
+ relationship: @relationship
38
+ )
39
+ end
40
+
41
+ #: (ActivityEventRecordNormalizer) -> ActivityBelongsToEventApplier
42
+ def belongs_to_applier(record_normalizer)
43
+ ActivityBelongsToEventApplier.new(
44
+ route_finder: @route_finder,
45
+ record_resolver: @record_resolver,
46
+ record_normalizer: record_normalizer
47
+ )
20
48
  end
21
49
 
22
50
  #: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent?) -> [bool, RecordSnapshot?]
@@ -30,25 +58,25 @@ module PaperTrailDiff
30
58
  return [false, nil] unless event && branches.one?
31
59
 
32
60
  tree, normalizer = @components.call(branches)
33
- collection_routes = direct_collection_routes(
34
- endpoint_model_class(root_endpoint),
35
- tree,
36
- event.version.item_type.to_s
61
+ routes = @route_finder.collection_routes(
62
+ endpoint_model_class(root_endpoint), tree, event.version.item_type.to_s
37
63
  )
38
- unless collection_routes.empty?
39
- return [false, nil] if unsafe_through_membership_event?(collection_routes, event.version)
64
+ unless routes.empty?
65
+ delta = @record_resolver.snapshot_delta(event.version)
66
+ return [false, nil] unless @collection_applier.safe?(routes, event.version, delta)
40
67
 
41
- return collection_target_snapshot(
68
+ return @collection_applier.call(
42
69
  root_endpoint,
43
70
  context_endpoint,
44
71
  previous,
45
72
  normalizer,
46
- collection_routes,
47
- event.version
73
+ routes,
74
+ event.version,
75
+ delta
48
76
  )
49
77
  end
50
78
 
51
- belongs_to_target_snapshot(
79
+ @belongs_to_applier.call(
52
80
  root_endpoint,
53
81
  context_endpoint,
54
82
  previous,
@@ -60,428 +88,23 @@ module PaperTrailDiff
60
88
 
61
89
  private
62
90
 
63
- # @rbs @traversal: AssociationTraversal
64
- # @rbs @pool: SnapshotPool
65
91
  # @rbs @components: untyped
66
- # @rbs @association_reader: untyped
67
-
68
- #: (Array[Array[untyped]], untyped) -> bool
69
- def unsafe_through_membership_event?(routes, version)
70
- return false if version.event.to_s == 'update'
71
-
72
- routes.any? do |route|
73
- route.first(route.length - 1).any? do |entry|
74
- reflection = entry.fetch(1)
75
- reflection.options[:through]
76
- end
77
- end
78
- end
79
-
80
- #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped) -> [bool, RecordSnapshot]
81
- def collection_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
82
- root_endpoint,
83
- context_endpoint,
84
- previous,
85
- normalizer,
86
- routes,
87
- version
88
- )
89
- record = record_after(version)
90
- snapshot = previous
91
- routes.each do |route|
92
- _name, reflection, subtree, path = route.last
93
- replacement = if record
94
- normalize_event_record(
95
- record,
96
- reflection,
97
- subtree,
98
- path,
99
- normalizer,
100
- root_endpoint,
101
- context_endpoint
102
- )
103
- end
104
- snapshot, = replace_collection_route(snapshot, route, version, record, replacement)
105
- end
106
- [true, snapshot]
107
- end
108
-
109
- #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
110
- def direct_collection_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
111
- model_class,
112
- tree,
113
- target_type,
114
- path: ''
115
- )
116
- @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
117
- name = reflection.name.to_s
118
- subtree = tree.child(name)
119
- next [] unless subtree
120
-
121
- child_path = Support.association_path(path, name)
122
- route_entry = [name, reflection, subtree, child_path]
123
- routes = [] #: Array[Array[untyped]]
124
- if reflection.macro == :has_many && !reflection.options[:through] &&
125
- reflection.klass.base_class.name.to_s == target_type
126
- routes << [route_entry]
127
- end
128
- unless subtree.empty? || reflection.polymorphic?
129
- nested = direct_collection_routes(
130
- reflection.klass,
131
- subtree,
132
- target_type,
133
- path: child_path
134
- )
135
- routes.concat(nested.map { |route| [route_entry, *route] })
136
- end
137
- routes
138
- end
139
- end
92
+ # @rbs @record_resolver: ActivityEventRecordResolver
93
+ # @rbs @route_finder: ActivityEventRouteFinder
94
+ # @rbs @relationship: ActivityRelationship
95
+ # @rbs @collection_applier: ActivityCollectionEventApplier
96
+ # @rbs @belongs_to_applier: ActivityBelongsToEventApplier
140
97
 
141
- #: (RecordSnapshot, Array[untyped], untyped, untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
142
- def replace_collection_route( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
143
- snapshot,
144
- route,
145
- version,
146
- record,
147
- replacement,
148
- depth: 0
149
- )
150
- name, reflection, _subtree, path = route.fetch(depth)
151
- association = snapshot.associations.fetch(name)
152
- changed = false
153
- updated_records = if depth == route.length - 1
154
- child = if record && member_of_owner?(record, reflection, snapshot)
155
- replacement
156
- end
157
- replace_collection_record(
158
- association.records,
159
- version,
160
- child
161
- ).tap { |value| changed = !value.equal?(association.records) }
162
- else
163
- association.records.map do |child_snapshot|
164
- updated, child_changed = replace_collection_route(
165
- child_snapshot,
166
- route,
167
- version,
168
- record,
169
- replacement,
170
- depth: depth + 1
171
- )
172
- changed ||= child_changed
173
- updated
174
- end
175
- end
176
- return [snapshot, false] unless changed
177
-
178
- updated_association = @pool.association(
179
- path,
180
- AssociationSnapshot.new(kind: association.kind, records: updated_records)
181
- )
182
- updated_snapshot = RecordSnapshot.new(
183
- type: snapshot.type,
184
- id: snapshot.id,
185
- attributes: snapshot.attributes,
186
- associations: snapshot.associations.merge(name => updated_association)
187
- )
188
- parent_path = depth.zero? ? '' : route.fetch(depth - 1).fetch(3)
189
- updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
190
- [updated_snapshot, true]
191
- end
192
-
193
- #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
194
- def belongs_to_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
195
- root_endpoint,
196
- context_endpoint,
197
- previous,
198
- tree,
199
- normalizer,
200
- version
201
- )
202
- return [false, nil] unless %w[update destroy].include?(version.event.to_s)
203
-
204
- root_class = endpoint_model_class(root_endpoint)
205
- routes = belongs_to_routes(root_class, tree, version.item_type.to_s)
206
- return [false, nil] if routes.empty?
207
-
208
- snapshot = previous
209
- routes.each do |route|
210
- replacement = normalized_route_record(
211
- route,
212
- version,
213
- normalizer,
214
- root_endpoint,
215
- context_endpoint
216
- )
217
- snapshot, = replace_route(snapshot, route, version, replacement)
218
- end
219
- [true, snapshot]
220
- end
221
-
222
- #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
223
- def belongs_to_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
224
- model_class,
225
- tree,
226
- target_type,
227
- path: ''
228
- )
229
- @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
230
- name = reflection.name.to_s
231
- subtree = tree.child(name)
232
- next [] unless subtree
233
-
234
- child_path = Support.association_path(path, name)
235
- route_entry = [name, reflection, subtree, child_path]
236
- routes = [] #: Array[Array[untyped]]
237
- if reflection.macro == :belongs_to && !reflection.polymorphic? &&
238
- reflection.klass.base_class.name.to_s == target_type
239
- routes << [route_entry]
240
- end
241
- unless subtree.empty? || reflection.polymorphic?
242
- nested = belongs_to_routes(reflection.klass, subtree, target_type, path: child_path)
243
- routes.concat(nested.map { |route| [route_entry, *route] })
244
- end
245
- routes
246
- end
247
- end
248
-
249
- #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
250
- def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
251
- record = record_after(version)
252
- return unless record
253
-
254
- _name, reflection, subtree, path = route.last
255
- normalize_event_record(
256
- record,
257
- reflection,
258
- subtree,
259
- path,
260
- normalizer,
261
- root_endpoint,
262
- context_endpoint
263
- )
264
- end
265
-
266
- #: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
267
- def replace_route( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
268
- snapshot,
269
- route,
270
- version,
271
- replacement,
272
- depth: 0
273
- )
274
- name, _reflection, _subtree, = route.fetch(depth)
275
- association = snapshot.associations.fetch(name)
276
-
277
- records = association.records
278
- changed = false
279
- updated_records = if depth == route.length - 1
280
- replace_existing_target(records, version, replacement).tap do |value|
281
- changed = !value.equal?(records)
282
- end
283
- else
284
- records.map do |record|
285
- updated, record_changed = replace_route(
286
- record,
287
- route,
288
- version,
289
- replacement,
290
- depth: depth + 1
291
- )
292
- changed ||= record_changed
293
- updated
294
- end
295
- end
296
- return [snapshot, false] unless changed
297
-
298
- updated_association = AssociationSnapshot.new(
299
- kind: association.kind,
300
- records: updated_records
301
- )
302
- updated_snapshot = RecordSnapshot.new(
303
- type: snapshot.type,
304
- id: snapshot.id,
305
- attributes: snapshot.attributes,
306
- associations: snapshot.associations.merge(name => updated_association)
307
- )
308
- [updated_snapshot, true]
309
- end
310
-
311
- #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
312
- def replace_existing_target(records, version, replacement)
313
- index = records.index do |record|
314
- record.id.to_s == version.item_id.to_s &&
315
- record.type.to_s == replacement_type(version, replacement)
316
- end
317
- return records unless index
318
-
319
- updated = records.dup
320
- replacement ? updated[index] = replacement : updated.delete_at(index)
321
- updated
322
- end
98
+ private :belongs_to_applier, :collection_applier, :collection_updater, :configure_appliers
323
99
 
324
100
  #: (untyped) -> untyped
325
101
  def record_after(version)
326
- return if version.event.to_s == 'destroy'
327
-
328
- changed_record = changed_record_after(version)
329
- return changed_record if changed_record
330
-
331
- successor = version.next
332
- record = successor&.reify(dup: true)
333
- return record if record
334
-
335
- model_class = Endpoint.model_class(version)
336
- criteria = { model_class.primary_key => version.item_id } #: Hash[untyped, untyped]
337
- model_class.unscoped.find_by(criteria)
338
- end
339
-
340
- # PaperTrail create/update versions contain deserialized change pairs, so
341
- # their post-event records need no successor query.
342
- #: (untyped) -> untyped
343
- def changed_record_after(version)
344
- handler = RECORD_AFTER_HANDLERS[version.event.to_s]
345
- send(handler, version) if handler
346
- end
347
-
348
- #: (untyped) -> untyped
349
- def created_record_after(version)
350
- model_class = Endpoint.model_class(version)
351
- changes = deserialized_changeset(version, model_class)
352
- return unless changes.respond_to?(:each) && !changes.empty?
353
-
354
- attributes = after_attributes(changes, model_class)
355
- model_class.new(attributes)
356
- end
357
-
358
- #: (untyped) -> untyped
359
- def updated_record_after(version)
360
- record = version.reify(dup: true)
361
- changes = deserialized_changeset(version, record&.class)
362
- apply_changes(record, changes)
363
- end
364
-
365
- #: (untyped, untyped) -> Hash[untyped, untyped]
366
- def after_attributes(changes, model_class)
367
- names = model_class.attribute_names
368
- attributes = {} #: Hash[untyped, untyped]
369
- changes.each do |name, values|
370
- next unless names.include?(name.to_s)
371
-
372
- attributes[name] = values.last
373
- end
374
- attributes
102
+ @record_resolver.record_after(version)
375
103
  end
376
104
 
377
- #: (untyped, untyped) -> untyped
378
- def apply_changes(record, changes)
379
- return unless record && changes.respond_to?(:each) && !changes.empty?
380
-
381
- changes.each do |name, values|
382
- next unless record.has_attribute?(name) && values.respond_to?(:last)
383
-
384
- record[name] = values.last
385
- end
386
- record
387
- end
388
-
389
- # Mirrors PaperTrail's changeset deserialization without resolving
390
- # +version.item+, which would issue one live-record query per event.
391
105
  #: (untyped, untyped) -> untyped
392
106
  def deserialized_changeset(version, model_class)
393
- return unless model_class && version.class.column_names.include?('object_changes')
394
-
395
- paper_trail = Object.const_get(:PaperTrail) #: untyped
396
- adapter = paper_trail.config.object_changes_adapter
397
- return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
398
-
399
- standard_changeset(paper_trail, version, model_class)
400
- end
401
-
402
- #: (untyped, untyped, untyped) -> untyped
403
- def standard_changeset(paper_trail, version, model_class)
404
- raw_changes = version.send(:object_changes_deserialized)
405
- active_support = Object.const_get(:ActiveSupport) #: untyped
406
- changes = active_support.const_get(:HashWithIndifferentAccess).new(raw_changes)
407
- serializers = paper_trail.const_get(:AttributeSerializers)
408
- serializers.const_get(:ObjectChangesAttribute).new(model_class).deserialize(changes)
409
- changes
410
- end
411
-
412
- #: (untyped, untyped, RecordSnapshot) -> bool
413
- def member_of_owner?(record, reflection, owner) # rubocop:disable Metrics/AbcSize
414
- foreign_keys = Array(reflection.foreign_key)
415
- actual_ids = foreign_keys.map { |key| record.public_send(key) }
416
- expected_ids = Array(owner.id)
417
- # @type var expected_ids: Array[untyped]
418
- # Explicit blocks keep the inline RBS checker from inferring an unusable Proc type.
419
- actual = actual_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
420
- expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
421
- return false unless actual == expected
422
- return true unless reflection.options[:as]
423
-
424
- type = record.public_send(reflection.type).to_s
425
- owner_types = [
426
- owner.type.to_s,
427
- reflection.active_record.name.to_s,
428
- reflection.active_record.base_class.name.to_s
429
- ]
430
- owner_types.include?(type)
431
- end
432
-
433
- #: (untyped, untyped, AssociationTree, String, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
434
- def normalize_event_record( # rubocop:disable Metrics/ParameterLists
435
- record,
436
- reflection,
437
- subtree,
438
- path,
439
- normalizer,
440
- root_endpoint,
441
- context_endpoint
442
- )
443
- habtm_boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
444
- reifier = @association_reader.call(
445
- context_endpoint,
446
- habtm_version: habtm_boundary
447
- )
448
- normalizer.call_child(
449
- record,
450
- tree: subtree,
451
- path: path,
452
- incoming: reflection,
453
- reifier: reifier
454
- )
455
- end
456
-
457
- #: (untyped, habtm_version: untyped) -> HistoricalAssociationReifier
458
- def historical_reader(context_endpoint, habtm_version:)
459
- HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
460
- end
461
-
462
- #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
463
- def replace_collection_record(records, version, replacement)
464
- index = records.index do |record|
465
- record.id.to_s == version.item_id.to_s &&
466
- record.type.to_s == replacement_type(version, replacement)
467
- end
468
- return records unless replacement || index
469
-
470
- updated = records.dup
471
- if replacement
472
- index ? updated[index] = replacement : updated << replacement
473
- else
474
- position = index || raise(ConfigurationError, 'missing collection event identity')
475
- updated.delete_at(position)
476
- end
477
- updated
478
- end
479
-
480
- #: (untyped, RecordSnapshot?) -> String
481
- def replacement_type(version, replacement)
482
- return replacement.type.to_s if replacement
483
-
484
- Endpoint.model_class(version).name.to_s
107
+ @record_resolver.deserialized_changeset(version, model_class)
485
108
  end
486
109
 
487
110
  #: (untyped) -> untyped
@@ -489,5 +112,4 @@ module PaperTrailDiff
489
112
  Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
490
113
  end
491
114
  end
492
- # rubocop:enable Metrics/ClassLength
493
115
  end
@@ -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