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