paper_trail_diff 0.3.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +9 -0
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  6. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  7. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  8. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  9. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  10. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  11. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  12. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  13. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  14. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  15. data/lib/paper_trail_diff/version.rb +1 -1
  16. data/lib/paper_trail_diff.rb +9 -0
  17. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  18. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  19. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  20. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  21. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  22. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  23. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  24. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  25. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  26. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  27. metadata +22 -4
@@ -2,26 +2,53 @@
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
7
  #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
15
8
  def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
16
- @traversal = traversal
17
- @pool = pool
18
9
  @components = components
19
- @association_reader = association_reader || method(:historical_reader)
20
- @record_transition = record_transition
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
+ )
21
48
  end
22
49
 
23
50
  #: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent?) -> [bool, RecordSnapshot?]
24
- def call( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
51
+ def call( # rubocop:disable Metrics/MethodLength
25
52
  root_endpoint,
26
53
  context_endpoint,
27
54
  previous,
@@ -31,29 +58,25 @@ module PaperTrailDiff
31
58
  return [false, nil] unless event && branches.one?
32
59
 
33
60
  tree, normalizer = @components.call(branches)
34
- collection_routes = direct_collection_routes(
35
- endpoint_model_class(root_endpoint),
36
- tree,
37
- 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
38
63
  )
39
- unless collection_routes.empty?
40
- return [false, nil] if unsafe_through_membership_event?(collection_routes, event.version)
41
-
42
- delta = activity_snapshot_delta(event.version)
43
- return [false, nil] if unsafe_nested_membership_delta?(collection_routes, delta)
64
+ unless routes.empty?
65
+ delta = @record_resolver.snapshot_delta(event.version)
66
+ return [false, nil] unless @collection_applier.safe?(routes, event.version, delta)
44
67
 
45
- return collection_target_snapshot(
68
+ return @collection_applier.call(
46
69
  root_endpoint,
47
70
  context_endpoint,
48
71
  previous,
49
72
  normalizer,
50
- collection_routes,
73
+ routes,
51
74
  event.version,
52
75
  delta
53
76
  )
54
77
  end
55
78
 
56
- belongs_to_target_snapshot(
79
+ @belongs_to_applier.call(
57
80
  root_endpoint,
58
81
  context_endpoint,
59
82
  previous,
@@ -65,639 +88,23 @@ module PaperTrailDiff
65
88
 
66
89
  private
67
90
 
68
- # @rbs @traversal: AssociationTraversal
69
- # @rbs @pool: SnapshotPool
70
91
  # @rbs @components: untyped
71
- # @rbs @association_reader: untyped
72
- # @rbs @record_transition: untyped
73
-
74
- #: (Array[Array[untyped]], untyped) -> bool
75
- def unsafe_through_membership_event?(routes, version)
76
- return false if version.event.to_s == 'update'
77
-
78
- routes.any? do |route|
79
- route.first(route.length - 1).any? do |entry|
80
- reflection = entry.fetch(1)
81
- reflection.options[:through]
82
- end
83
- end
84
- end
85
-
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]
96
- def collection_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
97
- root_endpoint,
98
- context_endpoint,
99
- previous,
100
- normalizer,
101
- routes,
102
- version,
103
- delta
104
- )
105
- fallback_record = [] #: Array[untyped]
106
- snapshot = previous
107
- routes.each do |route|
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
- )
118
- snapshot, = replace_collection_route(snapshot, route, version, record, replacement)
119
- end
120
- [true, snapshot]
121
- end
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
-
191
- #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
192
- def direct_collection_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
193
- model_class,
194
- tree,
195
- target_type,
196
- path: ''
197
- )
198
- @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
199
- name = reflection.name.to_s
200
- subtree = tree.child(name)
201
- next [] unless subtree
202
-
203
- child_path = Support.association_path(path, name)
204
- route_entry = [name, reflection, subtree, child_path]
205
- routes = [] #: Array[Array[untyped]]
206
- if reflection.macro == :has_many && !reflection.options[:through] &&
207
- reflection.klass.base_class.name.to_s == target_type
208
- routes << [route_entry]
209
- end
210
- unless subtree.empty? || reflection.polymorphic?
211
- nested = direct_collection_routes(
212
- reflection.klass,
213
- subtree,
214
- target_type,
215
- path: child_path
216
- )
217
- routes.concat(nested.map { |route| [route_entry, *route] })
218
- end
219
- routes
220
- end
221
- end
222
-
223
- #: (RecordSnapshot, Array[untyped], untyped, untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
224
- def replace_collection_route( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
225
- snapshot,
226
- route,
227
- version,
228
- record,
229
- replacement,
230
- depth: 0
231
- )
232
- name, reflection, _subtree, path = route.fetch(depth)
233
- association = snapshot.associations.fetch(name)
234
- changed = false
235
- transition_before = nil #: RecordSnapshot?
236
- transition_after = nil #: RecordSnapshot?
237
- membership_preserved = true
238
- updated_records = if depth == route.length - 1
239
- child = collection_event_child(
240
- association,
241
- version,
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
260
- else
261
- association.records.map do |child_snapshot|
262
- updated, child_changed = replace_collection_route(
263
- child_snapshot,
264
- route,
265
- version,
266
- record,
267
- replacement,
268
- depth: depth + 1
269
- )
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
279
- updated
280
- end.freeze
281
- end
282
- return [snapshot, false] unless changed
283
-
284
- updated_association = @pool.association(
285
- path,
286
- association.transition_to(
287
- updated_records,
288
- before: transition_before,
289
- after: transition_after,
290
- membership_preserved: membership_preserved
291
- )
292
- )
293
- updated_snapshot = RecordSnapshot.new(
294
- type: snapshot.type,
295
- id: snapshot.id,
296
- attributes: snapshot.attributes,
297
- associations: snapshot.associations.merge(name => updated_association)
298
- )
299
- parent_path = depth.zero? ? '' : route.fetch(depth - 1).fetch(3)
300
- updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
301
- [updated_snapshot, true]
302
- end
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
-
371
- #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
372
- def belongs_to_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
373
- root_endpoint,
374
- context_endpoint,
375
- previous,
376
- tree,
377
- normalizer,
378
- version
379
- )
380
- return [false, nil] unless %w[update destroy].include?(version.event.to_s)
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
381
97
 
382
- root_class = endpoint_model_class(root_endpoint)
383
- routes = belongs_to_routes(root_class, tree, version.item_type.to_s)
384
- return [false, nil] if routes.empty?
385
-
386
- snapshot = previous
387
- routes.each do |route|
388
- replacement = normalized_route_record(
389
- route,
390
- version,
391
- normalizer,
392
- root_endpoint,
393
- context_endpoint
394
- )
395
- snapshot, = replace_route(snapshot, route, version, replacement)
396
- end
397
- [true, snapshot]
398
- end
399
-
400
- #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
401
- def belongs_to_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
402
- model_class,
403
- tree,
404
- target_type,
405
- path: ''
406
- )
407
- @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
408
- name = reflection.name.to_s
409
- subtree = tree.child(name)
410
- next [] unless subtree
411
-
412
- child_path = Support.association_path(path, name)
413
- route_entry = [name, reflection, subtree, child_path]
414
- routes = [] #: Array[Array[untyped]]
415
- if reflection.macro == :belongs_to && !reflection.polymorphic? &&
416
- reflection.klass.base_class.name.to_s == target_type
417
- routes << [route_entry]
418
- end
419
- unless subtree.empty? || reflection.polymorphic?
420
- nested = belongs_to_routes(reflection.klass, subtree, target_type, path: child_path)
421
- routes.concat(nested.map { |route| [route_entry, *route] })
422
- end
423
- routes
424
- end
425
- end
426
-
427
- #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
428
- def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
429
- record = record_after(version)
430
- return unless record
431
-
432
- _name, reflection, subtree, path = route.last
433
- normalize_event_record(
434
- record,
435
- reflection,
436
- subtree,
437
- path,
438
- normalizer,
439
- root_endpoint,
440
- context_endpoint
441
- )
442
- end
443
-
444
- #: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
445
- def replace_route( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
446
- snapshot,
447
- route,
448
- version,
449
- replacement,
450
- depth: 0
451
- )
452
- name, _reflection, _subtree, = route.fetch(depth)
453
- association = snapshot.associations.fetch(name)
454
-
455
- records = association.records
456
- changed = false
457
- updated_records = if depth == route.length - 1
458
- replace_existing_target(records, version, replacement).tap do |value|
459
- changed = !value.equal?(records)
460
- end
461
- else
462
- records.map do |record|
463
- updated, record_changed = replace_route(
464
- record,
465
- route,
466
- version,
467
- replacement,
468
- depth: depth + 1
469
- )
470
- changed ||= record_changed
471
- updated
472
- end.freeze
473
- end
474
- return [snapshot, false] unless changed
475
-
476
- updated_association = AssociationSnapshot.new(
477
- kind: association.kind,
478
- records: updated_records
479
- )
480
- updated_snapshot = RecordSnapshot.new(
481
- type: snapshot.type,
482
- id: snapshot.id,
483
- attributes: snapshot.attributes,
484
- associations: snapshot.associations.merge(name => updated_association)
485
- )
486
- [updated_snapshot, true]
487
- end
488
-
489
- #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
490
- def replace_existing_target(records, version, replacement)
491
- index = records.index do |record|
492
- record.id.to_s == version.item_id.to_s &&
493
- record.type.to_s == replacement_type(version, replacement)
494
- end
495
- return records unless index
496
-
497
- updated = records.dup
498
- replacement ? updated[index] = replacement : updated.delete_at(index)
499
- updated.freeze
500
- end
98
+ private :belongs_to_applier, :collection_applier, :collection_updater, :configure_appliers
501
99
 
502
100
  #: (untyped) -> untyped
503
101
  def record_after(version)
504
- return if version.event.to_s == 'destroy'
505
-
506
- changed_record = changed_record_after(version)
507
- return changed_record if changed_record
508
-
509
- successor = version.next
510
- record = successor&.reify(dup: true)
511
- return record if record
512
-
513
- model_class = Endpoint.model_class(version)
514
- criteria = { model_class.primary_key => version.item_id } #: Hash[untyped, untyped]
515
- model_class.unscoped.find_by(criteria)
516
- end
517
-
518
- # PaperTrail create/update versions contain deserialized change pairs, so
519
- # their post-event records need no successor query.
520
- #: (untyped) -> untyped
521
- def changed_record_after(version)
522
- handler = RECORD_AFTER_HANDLERS[version.event.to_s]
523
- send(handler, version) if handler
524
- end
525
-
526
- #: (untyped) -> untyped
527
- def created_record_after(version)
528
- model_class = Endpoint.model_class(version)
529
- changes = deserialized_changeset(version, model_class)
530
- return unless changes.respond_to?(:each) && !changes.empty?
531
-
532
- attributes = after_attributes(changes, model_class)
533
- model_class.new(attributes)
534
- end
535
-
536
- #: (untyped) -> untyped
537
- def updated_record_after(version)
538
- record = version.reify(dup: true)
539
- changes = deserialized_changeset(version, record&.class)
540
- apply_changes(record, changes)
541
- end
542
-
543
- #: (untyped, untyped) -> Hash[untyped, untyped]
544
- def after_attributes(changes, model_class)
545
- names = model_class.attribute_names
546
- attributes = {} #: Hash[untyped, untyped]
547
- changes.each do |name, values|
548
- next unless names.include?(name.to_s)
549
-
550
- attributes[name] = values.last
551
- end
552
- attributes
102
+ @record_resolver.record_after(version)
553
103
  end
554
104
 
555
- #: (untyped, untyped) -> untyped
556
- def apply_changes(record, changes)
557
- return unless record && changes.respond_to?(:each) && !changes.empty?
558
-
559
- changes.each do |name, values|
560
- next unless record.has_attribute?(name) && values.respond_to?(:last)
561
-
562
- record[name] = values.last
563
- end
564
- record
565
- end
566
-
567
- # Mirrors PaperTrail's changeset deserialization without resolving
568
- # +version.item+, which would issue one live-record query per event.
569
105
  #: (untyped, untyped) -> untyped
570
106
  def deserialized_changeset(version, model_class)
571
- return unless model_class && version.class.column_names.include?('object_changes')
572
-
573
- paper_trail = Object.const_get(:PaperTrail) #: untyped
574
- adapter = paper_trail.config.object_changes_adapter
575
- return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
576
-
577
- standard_changeset(paper_trail, version, model_class)
578
- end
579
-
580
- #: (untyped, untyped, untyped) -> untyped
581
- def standard_changeset(paper_trail, version, model_class)
582
- raw_changes = version.send(:object_changes_deserialized)
583
- active_support = Object.const_get(:ActiveSupport) #: untyped
584
- changes = active_support.const_get(:HashWithIndifferentAccess).new(raw_changes)
585
- serializers = paper_trail.const_get(:AttributeSerializers)
586
- serializers.const_get(:ObjectChangesAttribute).new(model_class).deserialize(changes)
587
- changes
588
- end
589
-
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
605
- foreign_keys = Array(reflection.foreign_key)
606
- actual_ids = foreign_keys.map { |key| event_attribute(record, key, state: state) }
607
- expected_ids = Array(owner.id)
608
- # @type var expected_ids: Array[untyped]
609
- # Explicit blocks keep the inline RBS checker from inferring an unusable Proc type.
610
- actual = actual_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
611
- expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
612
- return false unless actual == expected
613
- return true unless reflection.options[:as]
614
-
615
- type = event_attribute(record, reflection.type, state: state).to_s
616
- owner_types = [
617
- owner.type.to_s,
618
- reflection.active_record.name.to_s,
619
- reflection.active_record.base_class.name.to_s
620
- ]
621
- owner_types.include?(type)
622
- end
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
-
641
- #: (untyped, untyped, AssociationTree, String, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
642
- def normalize_event_record( # rubocop:disable Metrics/ParameterLists
643
- record,
644
- reflection,
645
- subtree,
646
- path,
647
- normalizer,
648
- root_endpoint,
649
- context_endpoint
650
- )
651
- habtm_boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
652
- reifier = @association_reader.call(
653
- context_endpoint,
654
- habtm_version: habtm_boundary
655
- )
656
- normalizer.call_child(
657
- record,
658
- tree: subtree,
659
- path: path,
660
- incoming: reflection,
661
- reifier: reifier
662
- )
663
- end
664
-
665
- #: (untyped, habtm_version: untyped) -> HistoricalAssociationReifier
666
- def historical_reader(context_endpoint, habtm_version:)
667
- HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
668
- end
669
-
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
683
-
684
- #: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
685
- def replacement_collection_records(records, position, replacement)
686
- updated = records.dup
687
- if replacement
688
- position ? updated[position] = replacement : updated << replacement
689
- else
690
- position ||= raise(ConfigurationError, 'missing collection event identity')
691
- updated.delete_at(position)
692
- end
693
- updated.freeze
694
- end
695
-
696
- #: (untyped, RecordSnapshot?) -> String
697
- def replacement_type(version, replacement)
698
- return replacement.type.to_s if replacement
699
-
700
- Endpoint.model_class(version).name.to_s
107
+ @record_resolver.deserialized_changeset(version, model_class)
701
108
  end
702
109
 
703
110
  #: (untyped) -> untyped
@@ -705,5 +112,4 @@ module PaperTrailDiff
705
112
  Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
706
113
  end
707
114
  end
708
- # rubocop:enable Metrics/ClassLength
709
115
  end