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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/README.md +269 -30
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
- data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
- data/lib/paper_trail_diff/activity_relationship.rb +55 -0
- data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
- data/lib/paper_trail_diff/analysis.rb +9 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
- data/lib/paper_trail_diff/collection_comparator.rb +14 -1
- data/lib/paper_trail_diff/collection_transition.rb +6 -3
- data/lib/paper_trail_diff/endpoint.rb +27 -2
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
- data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
- data/lib/paper_trail_diff/snapshot.rb +26 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
- data/lib/paper_trail_diff/time_version_range.rb +11 -0
- data/lib/paper_trail_diff/timeline_range.rb +57 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
- data/lib/paper_trail_diff.rb +10 -0
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
- data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
- data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
- data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- 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
|