paper_trail_diff 0.3.0 → 0.4.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +269 -30
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  6. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  7. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  8. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  9. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  10. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  11. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  12. data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
  13. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  14. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  15. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  16. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  17. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  18. data/lib/paper_trail_diff/analysis.rb +9 -0
  19. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  20. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  21. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  22. data/lib/paper_trail_diff/endpoint.rb +27 -2
  23. data/lib/paper_trail_diff/errors.rb +3 -0
  24. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  25. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  28. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  29. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  30. data/lib/paper_trail_diff/snapshot.rb +26 -0
  31. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  32. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  33. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  34. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  35. data/lib/paper_trail_diff/version.rb +1 -1
  36. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  37. data/lib/paper_trail_diff.rb +10 -0
  38. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  39. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  40. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  41. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  42. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  43. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  44. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  45. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  46. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
  47. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  48. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  49. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  50. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  51. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  52. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  53. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  54. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  57. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  58. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  59. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  60. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  61. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  62. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  63. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  64. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  65. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  66. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  67. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  68. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  69. metadata +24 -4
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies a safe collection event across every selected route for its record type.
6
+ class ActivityCollectionEventApplier
7
+ #: (record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer, route_updater: ActivityCollectionRouteUpdater, relationship: ActivityRelationship) -> void
8
+ def initialize(record_resolver:, record_normalizer:, route_updater:, relationship:)
9
+ @record_resolver = record_resolver
10
+ @record_normalizer = record_normalizer
11
+ @route_updater = route_updater
12
+ @relationship = relationship
13
+ end
14
+
15
+ #: (Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> bool
16
+ def safe?(routes, version, delta)
17
+ !unsafe_through_membership_event?(routes, version) &&
18
+ !unsafe_nested_membership_delta?(routes, delta)
19
+ end
20
+
21
+ #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> [bool, RecordSnapshot]
22
+ def call( # rubocop:disable Metrics/ParameterLists
23
+ root_endpoint,
24
+ context_endpoint,
25
+ previous,
26
+ normalizer,
27
+ routes,
28
+ version,
29
+ delta
30
+ )
31
+ fallback_record = [] #: Array[untyped]
32
+ endpoints = [root_endpoint, context_endpoint]
33
+ snapshot = routes.reduce(previous) do |current, route|
34
+ apply_route(
35
+ current, route, version, delta, fallback_record, normalizer, endpoints
36
+ )
37
+ end
38
+ [true, snapshot]
39
+ end
40
+
41
+ private
42
+
43
+ # @rbs @record_resolver: ActivityEventRecordResolver
44
+ # @rbs @record_normalizer: ActivityEventRecordNormalizer
45
+ # @rbs @route_updater: ActivityCollectionRouteUpdater
46
+ # @rbs @relationship: ActivityRelationship
47
+
48
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
49
+ def apply_route( # rubocop:disable Metrics/ParameterLists
50
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
51
+ )
52
+ record, replacement = route_event_state(
53
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
54
+ )
55
+ change = ActivityCollectionRouteChange.new(
56
+ route: route,
57
+ version: version,
58
+ record: record,
59
+ replacement: replacement
60
+ )
61
+ @route_updater.call(snapshot, change).fetch(0)
62
+ end
63
+
64
+ #: (Array[Array[untyped]], untyped) -> bool
65
+ def unsafe_through_membership_event?(routes, version)
66
+ return false if version.event.to_s == 'update'
67
+
68
+ routes.any? do |route|
69
+ route.first(route.length - 1).any? do |entry|
70
+ entry.fetch(1).options[:through]
71
+ end
72
+ end
73
+ end
74
+
75
+ #: (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
76
+ def unsafe_nested_membership_delta?(routes, delta)
77
+ return false unless delta
78
+
79
+ routes.any? do |route|
80
+ route.length > 1 && delta.relationship_changed?(route.last.fetch(1))
81
+ end
82
+ end
83
+
84
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [untyped, RecordSnapshot?]
85
+ def route_event_state( # rubocop:disable Metrics/ParameterLists
86
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
87
+ )
88
+ existing = existing_delta_record(snapshot, route, version, delta) if delta
89
+ return [delta, delta.apply(existing)] if delta && existing
90
+
91
+ fallback_record << @record_resolver.record_after(version) if fallback_record.empty?
92
+ record = fallback_record.first
93
+ return [record, nil] unless record
94
+
95
+ [record, normalized_event_record(record, route, normalizer, endpoints)]
96
+ end
97
+
98
+ #: (untyped, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot?
99
+ def normalized_event_record(record, route, normalizer, endpoints)
100
+ _name, reflection, subtree, path = route.last
101
+ @record_normalizer.call(
102
+ record,
103
+ reflection: reflection,
104
+ subtree: subtree,
105
+ path: path,
106
+ normalizer: normalizer,
107
+ root_endpoint: endpoints.fetch(0),
108
+ context_endpoint: endpoints.fetch(1)
109
+ )
110
+ end
111
+
112
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?) -> RecordSnapshot?
113
+ def existing_delta_record(snapshot, route, version, delta)
114
+ return unless delta && route.length.between?(1, 2)
115
+
116
+ owner = route.length == 1 ? snapshot : nested_delta_owner(snapshot, route, delta)
117
+ return unless owner
118
+
119
+ association = owner.associations.fetch(route.last.fetch(0))
120
+ position = association.position_for_id(version.item_id)
121
+ association.records.fetch(position) if position
122
+ end
123
+
124
+ #: (RecordSnapshot, Array[untyped], ActivitySnapshotDelta) -> RecordSnapshot?
125
+ def nested_delta_owner(snapshot, route, delta)
126
+ parent_name = route.first.fetch(0)
127
+ reflection = route.last.fetch(1)
128
+ association = snapshot.associations.fetch(parent_name)
129
+ owner_id = @relationship.owner_id(delta, reflection, state: :before)
130
+ position = association.position_for_id(owner_id)
131
+ return unless position
132
+
133
+ owner = association.records.fetch(position)
134
+ owner if @relationship.member_of_owner?(delta, reflection, owner, state: :before)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies one event to the leaf records of a collection association snapshot.
6
+ class ActivityCollectionRecordUpdater
7
+ #: (ActivityRelationship) -> void
8
+ def initialize(relationship)
9
+ @relationship = relationship
10
+ end
11
+
12
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, untyped, RecordSnapshot) -> RecordSnapshot?
13
+ def event_child(association, change, reflection, owner)
14
+ version = change.version
15
+ record = change.record
16
+ replacement = change.replacement
17
+ return unless record
18
+
19
+ unless record.is_a?(ActivitySnapshotDelta)
20
+ return replacement if @relationship.member_of_owner?(record, reflection, owner)
21
+
22
+ return
23
+ end
24
+ unless record.relationship_changed?(reflection)
25
+ return replacement if association.position_for_id(version.item_id)
26
+
27
+ return
28
+ end
29
+
30
+ replacement if @relationship.member_of_owner?(record, reflection, owner)
31
+ end
32
+
33
+ #: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
34
+ def call(association, version, replacement)
35
+ records = association.records
36
+ position = association.position(replacement_type(version, replacement), version.item_id)
37
+ return [records, nil, nil, true] unless replacement || position
38
+
39
+ before = records.fetch(position) if position
40
+ updated = replacement_records(records, position, replacement)
41
+ [updated, before, replacement, !before.nil? && !replacement.nil?]
42
+ end
43
+
44
+ #: (untyped, RecordSnapshot?) -> String
45
+ def replacement_type(version, replacement)
46
+ return replacement.type.to_s if replacement
47
+
48
+ Endpoint.model_class(version).name.to_s
49
+ end
50
+
51
+ private
52
+
53
+ # @rbs @relationship: ActivityRelationship
54
+
55
+ #: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
56
+ def replacement_records(records, position, replacement)
57
+ updated = records.dup
58
+ if replacement
59
+ position ? updated[position] = replacement : updated << replacement
60
+ else
61
+ position ||= raise(ConfigurationError, 'missing collection event identity')
62
+ updated.delete_at(position)
63
+ end
64
+ updated.freeze
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Carries one collection event through recursive immutable route replacement.
6
+ class ActivityCollectionRouteChange
7
+ attr_reader :route #: Array[untyped]
8
+ attr_reader :version #: untyped
9
+ attr_reader :record #: untyped
10
+ attr_reader :replacement #: RecordSnapshot?
11
+
12
+ #: (route: Array[untyped], version: untyped, record: untyped, replacement: RecordSnapshot?) -> void
13
+ def initialize(route:, version:, record:, replacement:)
14
+ @route = route
15
+ @version = version
16
+ @record = record
17
+ @replacement = replacement
18
+ freeze
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Replaces a collection event record along one explicit immutable snapshot route.
6
+ class ActivityCollectionRouteUpdater
7
+ #: (pool: SnapshotPool, relationship: ActivityRelationship) -> void
8
+ def initialize(pool:, relationship:)
9
+ @pool = pool
10
+ @relationship = relationship
11
+ @record_updater = ActivityCollectionRecordUpdater.new(relationship)
12
+ end
13
+
14
+ #: (RecordSnapshot, ActivityCollectionRouteChange, ?depth: Integer) -> [RecordSnapshot, bool]
15
+ def call(snapshot, change, depth: 0) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
16
+ name, _reflection, _subtree, path = change.route.fetch(depth)
17
+ association = snapshot.associations.fetch(name)
18
+ transition = updated_records(association, snapshot, change, depth)
19
+ records, before, after, membership_preserved, changed = transition
20
+ return [snapshot, false] unless changed
21
+
22
+ updated_association = @pool.association(
23
+ path,
24
+ association.transition_to(
25
+ records,
26
+ before: before,
27
+ after: after,
28
+ membership_preserved: membership_preserved
29
+ )
30
+ )
31
+ updated_snapshot = replace_association(snapshot, name, updated_association)
32
+ parent_path = depth.zero? ? '' : change.route.fetch(depth - 1).fetch(3)
33
+ updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
34
+ [updated_snapshot, true]
35
+ end
36
+
37
+ private
38
+
39
+ # @rbs @pool: SnapshotPool
40
+ # @rbs @relationship: ActivityRelationship
41
+ # @rbs @record_updater: ActivityCollectionRecordUpdater
42
+
43
+ #: (AssociationSnapshot, RecordSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
44
+ def updated_records(association, owner, change, depth)
45
+ return leaf_records(association, owner, change) if depth == change.route.length - 1
46
+
47
+ targeted = targeted_parent(association, change, depth)
48
+ if targeted
49
+ records, before, after, changed = targeted
50
+ return [records, before, after, true, changed]
51
+ end
52
+
53
+ recursive_records(association, change, depth)
54
+ end
55
+
56
+ #: (AssociationSnapshot, RecordSnapshot, ActivityCollectionRouteChange) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
57
+ def leaf_records(association, owner, change)
58
+ reflection = change.route.last.fetch(1)
59
+ child = @record_updater.event_child(association, change, reflection, owner)
60
+ records, before, after, preserved = @record_updater.call(
61
+ association, change.version, child
62
+ )
63
+ [records, before, after, preserved, !records.equal?(association.records)]
64
+ end
65
+
66
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]?
67
+ def targeted_parent(association, change, depth)
68
+ return unless depth == change.route.length - 2
69
+
70
+ reflection = change.route.fetch(depth + 1).fetch(1)
71
+ position = nested_owner_position(association, reflection, change)
72
+ return unless position
73
+
74
+ before = association.records.fetch(position)
75
+ after, changed = call(before, change, depth: depth + 1)
76
+ targeted_parent_replacement(association.records, position, before, after, changed)
77
+ end
78
+
79
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
80
+ def recursive_records(association, change, depth)
81
+ state = [nil, nil, false] #: [RecordSnapshot?, RecordSnapshot?, bool]
82
+ records = association.records.map do |child|
83
+ updated, child_changed = call(child, change, depth: depth + 1)
84
+ state = recursive_transition(state, child, updated, child_changed)
85
+ updated
86
+ end.freeze
87
+ [records, state.fetch(0), state.fetch(1), true, state.fetch(2)]
88
+ end
89
+
90
+ #: ([RecordSnapshot?, RecordSnapshot?, bool], RecordSnapshot, RecordSnapshot, bool) -> [RecordSnapshot?, RecordSnapshot?, bool]
91
+ def recursive_transition(state, child, updated, child_changed)
92
+ return state unless child_changed
93
+ return [nil, nil, true] if state.fetch(2)
94
+
95
+ [child, updated, true]
96
+ end
97
+
98
+ #: (Array[RecordSnapshot], Integer, RecordSnapshot, RecordSnapshot, bool) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
99
+ def targeted_parent_replacement(records, position, before, after, changed)
100
+ return [records, nil, nil, false] unless changed
101
+
102
+ updated = records.dup
103
+ updated[position] = after
104
+ [updated.freeze, before, after, true]
105
+ end
106
+
107
+ #: (AssociationSnapshot, untyped, ActivityCollectionRouteChange) -> Integer?
108
+ def nested_owner_position(association, reflection, change)
109
+ membership_record = change.record || change.version.reify(dup: true)
110
+ return unless membership_record
111
+ return if membership_record.is_a?(ActivitySnapshotDelta) &&
112
+ membership_record.relationship_changed?(reflection)
113
+
114
+ owner_id = @relationship.owner_id(membership_record, reflection, state: :after)
115
+ association.position_for_id(owner_id)
116
+ end
117
+
118
+ #: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
119
+ def replace_association(snapshot, name, association)
120
+ RecordSnapshot.new(
121
+ type: snapshot.type,
122
+ id: snapshot.id,
123
+ attributes: snapshot.attributes,
124
+ associations: snapshot.associations.merge(name => association)
125
+ )
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Normalizes one event record with the historical reader for its boundary.
6
+ class ActivityEventRecordNormalizer
7
+ #: (?association_reader: untyped) -> void
8
+ def initialize(association_reader: nil)
9
+ @association_reader = association_reader || method(:historical_reader)
10
+ end
11
+
12
+ #: (untyped, reflection: untyped, subtree: AssociationTree, path: String, normalizer: SnapshotNormalizer, root_endpoint: untyped, context_endpoint: untyped) -> RecordSnapshot?
13
+ def call( # rubocop:disable Metrics/ParameterLists
14
+ record,
15
+ reflection:,
16
+ subtree:,
17
+ path:,
18
+ normalizer:,
19
+ root_endpoint:,
20
+ context_endpoint:
21
+ )
22
+ boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
23
+ reifier = @association_reader.call(context_endpoint, habtm_version: boundary)
24
+ normalizer.call_child(
25
+ record,
26
+ tree: subtree,
27
+ path: path,
28
+ incoming: reflection,
29
+ reifier: reifier
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ # @rbs @association_reader: untyped
36
+
37
+ #: (untyped, habtm_version: untyped) -> HistoricalAssociationReifier
38
+ def historical_reader(context_endpoint, habtm_version:)
39
+ HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reconstructs an event's post-change record or immutable scalar delta.
6
+ class ActivityEventRecordResolver
7
+ RECORD_AFTER_HANDLERS = {
8
+ 'create' => :created_record_after,
9
+ 'update' => :updated_record_after
10
+ }.freeze
11
+ private_constant :RECORD_AFTER_HANDLERS
12
+
13
+ #: (?record_transition: untyped) -> void
14
+ def initialize(record_transition: nil)
15
+ @record_transition = record_transition
16
+ end
17
+
18
+ #: (untyped) -> untyped
19
+ def record_after(version)
20
+ return if version.event.to_s == 'destroy'
21
+
22
+ changed_record = changed_record_after(version)
23
+ return changed_record if changed_record
24
+
25
+ successor = version.next
26
+ record = successor&.reify(dup: true)
27
+ return record if record
28
+
29
+ model_class = Endpoint.model_class(version)
30
+ criteria = { model_class.primary_key => version.item_id } #: Hash[untyped, untyped]
31
+ model_class.unscoped.find_by(criteria)
32
+ end
33
+
34
+ #: (untyped) -> ActivitySnapshotDelta?
35
+ def snapshot_delta(version)
36
+ return unless version.event.to_s == 'update' && version.object
37
+
38
+ transition = @record_transition&.call(version)
39
+ return unless transition
40
+
41
+ ActivitySnapshotDelta.new(
42
+ before_attributes: transition.fetch(0),
43
+ after_attributes: transition.fetch(1)
44
+ )
45
+ end
46
+
47
+ #: (untyped) -> untyped
48
+ def changed_record_after(version)
49
+ handler = RECORD_AFTER_HANDLERS[version.event.to_s]
50
+ send(handler, version) if handler
51
+ end
52
+
53
+ #: (untyped) -> untyped
54
+ def created_record_after(version)
55
+ model_class = Endpoint.model_class(version)
56
+ changes = deserialized_changeset(version, model_class)
57
+ return unless changes.respond_to?(:each) && !changes.empty?
58
+
59
+ model_class.new(after_attributes(changes, model_class))
60
+ end
61
+
62
+ #: (untyped) -> untyped
63
+ def updated_record_after(version)
64
+ record = version.reify(dup: true)
65
+ changes = deserialized_changeset(version, record&.class)
66
+ apply_changes(record, changes)
67
+ end
68
+
69
+ # Mirrors PaperTrail's changeset deserialization without resolving
70
+ # +version.item+, which would issue one live-record query per event.
71
+ #: (untyped, untyped) -> untyped
72
+ def deserialized_changeset(version, model_class)
73
+ return unless model_class && version.class.column_names.include?('object_changes')
74
+
75
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
76
+ adapter = paper_trail.config.object_changes_adapter
77
+ return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
78
+
79
+ standard_changeset(paper_trail, version, model_class)
80
+ end
81
+
82
+ private
83
+
84
+ # @rbs @record_transition: untyped
85
+
86
+ #: (untyped, untyped) -> Hash[untyped, untyped]
87
+ def after_attributes(changes, model_class)
88
+ names = model_class.attribute_names
89
+ attributes = {} #: Hash[untyped, untyped]
90
+ changes.each do |name, values|
91
+ next unless names.include?(name.to_s)
92
+
93
+ attributes[name] = values.last
94
+ end
95
+ attributes
96
+ end
97
+
98
+ #: (untyped, untyped) -> untyped
99
+ def apply_changes(record, changes)
100
+ return unless record && changes.respond_to?(:each) && !changes.empty?
101
+
102
+ changes.each do |name, values|
103
+ next unless record.has_attribute?(name) && values.respond_to?(:last)
104
+
105
+ record[name] = values.last
106
+ end
107
+ record
108
+ end
109
+
110
+ #: (untyped, untyped, untyped) -> untyped
111
+ def standard_changeset(paper_trail, version, model_class)
112
+ raw_changes = version.send(:object_changes_deserialized)
113
+ active_support = Object.const_get(:ActiveSupport) #: untyped
114
+ changes = active_support.const_get(:HashWithIndifferentAccess).new(raw_changes)
115
+ serializers = paper_trail.const_get(:AttributeSerializers)
116
+ serializers.const_get(:ObjectChangesAttribute).new(model_class).deserialize(changes)
117
+ changes
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Finds explicitly selected collection and belongs-to routes for one event type.
6
+ class ActivityEventRouteFinder
7
+ #: (AssociationTraversal) -> void
8
+ def initialize(traversal)
9
+ @traversal = traversal
10
+ @collection_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
11
+ @belongs_to_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
12
+ end
13
+
14
+ #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
15
+ def collection_routes(model_class, tree, target_type, path: '')
16
+ cached(@collection_routes, model_class, tree, target_type, path, :has_many)
17
+ end
18
+
19
+ #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
20
+ def belongs_to_routes(model_class, tree, target_type, path: '')
21
+ cached(@belongs_to_routes, model_class, tree, target_type, path, :belongs_to)
22
+ end
23
+
24
+ private
25
+
26
+ # @rbs @traversal: AssociationTraversal
27
+ # @rbs @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
28
+ # @rbs @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
29
+
30
+ # Timelines interleave event types, so every requested combination is
31
+ # retained. Model, tree, and path are fixed per adapter, leaving one entry
32
+ # per selected item type.
33
+ #: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
34
+ def cached(store, model_class, tree, target_type, path, macro) # rubocop:disable Metrics/ParameterLists
35
+ key = [model_class, tree, target_type, path]
36
+ existing = store[key]
37
+ return existing if existing
38
+
39
+ store[cache_key(key)] =
40
+ freeze_routes(routes(model_class, tree, target_type, macro, path: path))
41
+ end
42
+
43
+ #: (Array[untyped]) -> Array[untyped]
44
+ def cache_key(key)
45
+ key.map { |part| part.is_a?(String) ? -part : part }.freeze
46
+ end
47
+
48
+ #: (Array[Array[untyped]]) -> Array[Array[untyped]]
49
+ def freeze_routes(routes)
50
+ routes.each do |route|
51
+ route.each(&:freeze)
52
+ route.freeze
53
+ end.freeze
54
+ end
55
+
56
+ #: (untyped, AssociationTree, String, Symbol, path: String) -> Array[Array[untyped]]
57
+ def routes(model_class, tree, target_type, macro, path: '')
58
+ @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
59
+ routes_for_reflection(reflection, tree, target_type, macro, path)
60
+ end
61
+ end
62
+
63
+ #: (untyped, AssociationTree, String, Symbol, String) -> Array[Array[untyped]]
64
+ def routes_for_reflection(reflection, tree, target_type, macro, path)
65
+ name = reflection.name.to_s
66
+ subtree = tree.child(name)
67
+ return [] unless subtree
68
+
69
+ child_path = Support.association_path(path, name)
70
+ entry = [name, reflection, subtree, child_path]
71
+ direct = [] #: Array[Array[untyped]]
72
+ direct << [entry] if direct_route?(reflection, target_type, macro)
73
+ return direct if subtree.empty? || reflection.polymorphic?
74
+
75
+ nested = routes(reflection.klass, subtree, target_type, macro, path: child_path)
76
+ direct.concat(nested.map { |route| [entry, *route] })
77
+ end
78
+
79
+ #: (untyped, String, Symbol) -> bool
80
+ def direct_route?(reflection, target_type, macro)
81
+ reflection.macro == macro && supported_direct_reflection?(reflection, macro) &&
82
+ reflection.klass.base_class.name.to_s == target_type
83
+ end
84
+
85
+ #: (untyped, Symbol) -> bool
86
+ def supported_direct_reflection?(reflection, macro)
87
+ return !reflection.options[:through] if macro == :has_many
88
+
89
+ !reflection.polymorphic?
90
+ end
91
+ end
92
+ end