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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +9 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -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 +112 -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/branch_snapshot_refresher.rb +2 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +9 -0
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -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 +54 -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
- metadata +22 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6aa099f4051971c69ed9b957e68ed4acca7e3cdde7c97efd2cb9a1af90d91dd1
|
|
4
|
+
data.tar.gz: 67ef88d6978b8b559a1a156f52aaea97a6535c71ec04df5d913f062114ec7a43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d916bf31322056bc95bfe39d7640207f2ddc6b30f154ca8e517b4615558ae728b1642f01040bb793ff1f258b3b77113ab6564f6f24daae9a23b09114ec7a6dca
|
|
7
|
+
data.tar.gz: b39856d07d0d335d12f7e1855b67f07b2a84443ff799f48a77e9b6a41609547310927fe8b8b69e32add5fbb30711982af3b125a80a40a59b1185a6ae8d1eb4fb
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The
|
|
4
4
|
project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.3.1] - 2026-08-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Preserve immutable activity snapshots for nested events that do not match a
|
|
11
|
+
selected child record, instead of reporting a false change.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Split activity-event reconstruction, route discovery, relationship matching,
|
|
16
|
+
collection mutation, and `belongs_to` target application into focused
|
|
17
|
+
internal collaborators without changing result shapes.
|
|
18
|
+
- Reuse immutable activity-event routes and cached branch components across
|
|
19
|
+
repeated event types to reduce timeline allocation overhead.
|
|
20
|
+
|
|
6
21
|
## [0.3.0] - 2026-08-10
|
|
7
22
|
|
|
8
23
|
### Added
|
data/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# paper_trail_diff
|
|
2
2
|
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **Experimental software and AI disclosure:** This gem is experimental and may
|
|
5
|
+
> contain incomplete behavior, reconstruction errors, performance problems, or
|
|
6
|
+
> breaking changes. AI-assisted coding tools made significant contributions to
|
|
7
|
+
> its design, implementation, tests, and documentation under maintainer
|
|
8
|
+
> direction. AI involvement is not a substitute for independent review: audit
|
|
9
|
+
> the code and validate it against your own PaperTrail history before relying on
|
|
10
|
+
> it in production, compliance, security, or other high-stakes workflows.
|
|
11
|
+
|
|
3
12
|
`paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
|
|
4
13
|
comparisons to
|
|
5
14
|
[PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Applies selected non-polymorphic belongs-to target updates to a snapshot.
|
|
6
|
+
class ActivityBelongsToEventApplier
|
|
7
|
+
#: (route_finder: ActivityEventRouteFinder, record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer) -> void
|
|
8
|
+
def initialize(route_finder:, record_resolver:, record_normalizer:)
|
|
9
|
+
@route_finder = route_finder
|
|
10
|
+
@record_resolver = record_resolver
|
|
11
|
+
@record_normalizer = record_normalizer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
|
|
15
|
+
def call( # rubocop:disable Metrics/ParameterLists
|
|
16
|
+
root_endpoint,
|
|
17
|
+
context_endpoint,
|
|
18
|
+
previous,
|
|
19
|
+
tree,
|
|
20
|
+
normalizer,
|
|
21
|
+
version
|
|
22
|
+
)
|
|
23
|
+
return [false, nil] unless %w[update destroy].include?(version.event.to_s)
|
|
24
|
+
|
|
25
|
+
routes = @route_finder.belongs_to_routes(
|
|
26
|
+
endpoint_model_class(root_endpoint), tree, version.item_type.to_s
|
|
27
|
+
)
|
|
28
|
+
return [false, nil] if routes.empty?
|
|
29
|
+
|
|
30
|
+
snapshot = previous
|
|
31
|
+
routes.each do |route|
|
|
32
|
+
replacement = normalized_route_record(
|
|
33
|
+
route, version, normalizer, root_endpoint, context_endpoint
|
|
34
|
+
)
|
|
35
|
+
snapshot, = replace_route(snapshot, route, version, replacement)
|
|
36
|
+
end
|
|
37
|
+
[true, snapshot]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @rbs @route_finder: ActivityEventRouteFinder
|
|
43
|
+
# @rbs @record_resolver: ActivityEventRecordResolver
|
|
44
|
+
# @rbs @record_normalizer: ActivityEventRecordNormalizer
|
|
45
|
+
|
|
46
|
+
#: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
47
|
+
def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
|
|
48
|
+
record = @record_resolver.record_after(version)
|
|
49
|
+
return unless record
|
|
50
|
+
|
|
51
|
+
_name, reflection, subtree, path = route.last
|
|
52
|
+
@record_normalizer.call(
|
|
53
|
+
record,
|
|
54
|
+
reflection: reflection,
|
|
55
|
+
subtree: subtree,
|
|
56
|
+
path: path,
|
|
57
|
+
normalizer: normalizer,
|
|
58
|
+
root_endpoint: root_endpoint,
|
|
59
|
+
context_endpoint: context_endpoint
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
|
|
64
|
+
def replace_route(snapshot, route, version, replacement, depth: 0)
|
|
65
|
+
name, _reflection, _subtree, = route.fetch(depth)
|
|
66
|
+
association = snapshot.associations.fetch(name)
|
|
67
|
+
records, changed = replacement_records(
|
|
68
|
+
association.records, route, version, replacement, depth
|
|
69
|
+
)
|
|
70
|
+
return [snapshot, false] unless changed
|
|
71
|
+
|
|
72
|
+
updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
|
|
73
|
+
[replace_association(snapshot, name, updated_association), true]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
|
|
77
|
+
def replacement_records(records, route, version, replacement, depth)
|
|
78
|
+
return replace_existing_target(records, version, replacement) if depth == route.length - 1
|
|
79
|
+
|
|
80
|
+
changed = false
|
|
81
|
+
updated = records.map do |record|
|
|
82
|
+
result, record_changed = replace_route(
|
|
83
|
+
record, route, version, replacement, depth: depth + 1
|
|
84
|
+
)
|
|
85
|
+
changed ||= record_changed
|
|
86
|
+
result
|
|
87
|
+
end.freeze
|
|
88
|
+
[updated, changed]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
|
|
92
|
+
def replace_existing_target(records, version, replacement)
|
|
93
|
+
index = records.index do |record|
|
|
94
|
+
record.id.to_s == version.item_id.to_s &&
|
|
95
|
+
record.type.to_s == replacement_type(version, replacement)
|
|
96
|
+
end
|
|
97
|
+
return [records, false] unless index
|
|
98
|
+
|
|
99
|
+
updated = records.dup
|
|
100
|
+
replacement ? updated[index] = replacement : updated.delete_at(index)
|
|
101
|
+
[updated.freeze, true]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
105
|
+
def replace_association(snapshot, name, association)
|
|
106
|
+
RecordSnapshot.new(
|
|
107
|
+
type: snapshot.type,
|
|
108
|
+
id: snapshot.id,
|
|
109
|
+
attributes: snapshot.attributes,
|
|
110
|
+
associations: snapshot.associations.merge(name => association)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#: (untyped, RecordSnapshot?) -> String
|
|
115
|
+
def replacement_type(version, replacement)
|
|
116
|
+
return replacement.type.to_s if replacement
|
|
117
|
+
|
|
118
|
+
Endpoint.model_class(version).name.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (untyped) -> untyped
|
|
122
|
+
def endpoint_model_class(endpoint)
|
|
123
|
+
Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -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
|