paper_trail_diff 0.2.0 → 0.3.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 +47 -0
- data/QUICKSTART.md +44 -2
- data/README.md +179 -14
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +74 -12
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- data/sig/generated/paper_trail_diff/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +16 -8
- metadata +38 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Internal scalar transition for one isolated PaperTrail update event.
|
|
6
|
+
class ActivitySnapshotDelta
|
|
7
|
+
#: (before_attributes: Hash[untyped, untyped], after_attributes: Hash[untyped, untyped]) -> void
|
|
8
|
+
def initialize(before_attributes:, after_attributes:)
|
|
9
|
+
@before_attributes = normalize_hash(before_attributes)
|
|
10
|
+
@after_attributes = normalize_hash(after_attributes)
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (RecordSnapshot) -> RecordSnapshot
|
|
15
|
+
def apply(snapshot)
|
|
16
|
+
attributes = snapshot.attributes.dup
|
|
17
|
+
attributes.each_key do |name|
|
|
18
|
+
attributes[name] = @after_attributes[name] if @after_attributes.key?(name)
|
|
19
|
+
end
|
|
20
|
+
return snapshot if attributes == snapshot.attributes
|
|
21
|
+
|
|
22
|
+
RecordSnapshot.new(
|
|
23
|
+
type: snapshot.type,
|
|
24
|
+
id: snapshot.id,
|
|
25
|
+
attributes: attributes,
|
|
26
|
+
associations: snapshot.associations
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#: (untyped) -> untyped
|
|
31
|
+
def before_value(name)
|
|
32
|
+
@before_attributes[name.to_s]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (untyped) -> untyped
|
|
36
|
+
def after_value(name)
|
|
37
|
+
@after_attributes[name.to_s]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped) -> bool
|
|
41
|
+
def relationship_changed?(reflection)
|
|
42
|
+
relationship_keys(reflection).any? do |name|
|
|
43
|
+
before_value(name).to_s != after_value(name).to_s
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @rbs @before_attributes: Hash[String, untyped]
|
|
50
|
+
# @rbs @after_attributes: Hash[String, untyped]
|
|
51
|
+
|
|
52
|
+
#: (Hash[untyped, untyped]) -> Hash[String, untyped]
|
|
53
|
+
def normalize_hash(hash)
|
|
54
|
+
hash.to_h { |name, value| [name.to_s, value] }.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#: (untyped) -> Array[untyped]
|
|
58
|
+
def relationship_keys(reflection)
|
|
59
|
+
keys = Array(reflection.foreign_key)
|
|
60
|
+
keys << reflection.type if reflection.options[:as]
|
|
61
|
+
keys
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -11,9 +11,9 @@ module PaperTrailDiff
|
|
|
11
11
|
@preparer = preparer
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
#: (untyped, Array[untyped]) -> void
|
|
15
|
-
def prepare(record, root_versions)
|
|
16
|
-
@preparer.call(record, root_versions)
|
|
14
|
+
#: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
15
|
+
def prepare(record, root_versions, start_at: root_versions.first.created_at)
|
|
16
|
+
@preparer.call(record, root_versions, start_at: start_at)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -4,17 +4,19 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
6
6
|
class ActivityTimelineBuilder
|
|
7
|
-
#: (untyped,
|
|
8
|
-
def initialize(record,
|
|
7
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
8
|
+
def initialize(record, range:, tree:, snapshotter:)
|
|
9
9
|
@record = record
|
|
10
|
-
@from = from
|
|
11
|
-
@to = to
|
|
10
|
+
@from = range.from
|
|
11
|
+
@to = range.to
|
|
12
|
+
@range = range
|
|
12
13
|
@tree = tree
|
|
13
14
|
@snapshotter = snapshotter
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
#: () -> Array[ActivityStep]
|
|
17
18
|
def build
|
|
19
|
+
return time_builder.build if @range.time?
|
|
18
20
|
return build_to_current if Endpoint.record?(@to)
|
|
19
21
|
|
|
20
22
|
Endpoint.validate!(@to)
|
|
@@ -24,15 +26,16 @@ module PaperTrailDiff
|
|
|
24
26
|
# Builds all three version-bounded views from one activity snapshot pass.
|
|
25
27
|
#: () -> Analysis
|
|
26
28
|
def analyze
|
|
29
|
+
return time_builder.analyze if @range.time?
|
|
30
|
+
|
|
27
31
|
unless Endpoint.version?(@to)
|
|
28
32
|
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
root_versions =
|
|
35
|
+
root_versions = @range.select
|
|
32
36
|
prepare_history(root_versions)
|
|
33
37
|
events = collect_events(root_versions)
|
|
34
|
-
|
|
35
|
-
build_analysis(root_versions, root_snapshots, activity_steps)
|
|
38
|
+
build_analysis(root_versions, event_history(root_versions, events))
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
private
|
|
@@ -40,12 +43,13 @@ module PaperTrailDiff
|
|
|
40
43
|
# @rbs @record: untyped
|
|
41
44
|
# @rbs @from: untyped
|
|
42
45
|
# @rbs @to: untyped
|
|
46
|
+
# @rbs @range: TimelineRange
|
|
43
47
|
# @rbs @tree: AssociationTree
|
|
44
48
|
# @rbs @snapshotter: untyped
|
|
45
49
|
|
|
46
50
|
#: () -> Array[ActivityStep]
|
|
47
51
|
def build_between_versions
|
|
48
|
-
root_versions =
|
|
52
|
+
root_versions = @range.select
|
|
49
53
|
prepare_history(root_versions)
|
|
50
54
|
events = collect_events(root_versions)
|
|
51
55
|
build_event_steps(root_versions, events)
|
|
@@ -82,20 +86,31 @@ module PaperTrailDiff
|
|
|
82
86
|
Endpoint.validate_pair!(@record, @to)
|
|
83
87
|
end
|
|
84
88
|
|
|
85
|
-
#: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
|
|
86
|
-
def collect_events(
|
|
89
|
+
#: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
|
|
90
|
+
def collect_events(
|
|
91
|
+
root_versions,
|
|
92
|
+
range_start: root_versions.first,
|
|
93
|
+
range_end: root_versions.last
|
|
94
|
+
)
|
|
87
95
|
ActivityVersionCollector.new(
|
|
88
96
|
@record,
|
|
89
97
|
root_versions: root_versions,
|
|
90
98
|
tree: @tree,
|
|
91
99
|
traversal: AssociationTraversal.new(@tree),
|
|
100
|
+
range_start: range_start,
|
|
92
101
|
range_end: range_end
|
|
93
102
|
).call
|
|
94
103
|
end
|
|
95
104
|
|
|
96
|
-
#: (Array[untyped]) -> void
|
|
97
|
-
def prepare_history(root_versions)
|
|
98
|
-
|
|
105
|
+
#: (Array[untyped], ?start_at: untyped) -> void
|
|
106
|
+
def prepare_history(root_versions, start_at: nil)
|
|
107
|
+
return unless @snapshotter.respond_to?(:prepare)
|
|
108
|
+
|
|
109
|
+
if start_at
|
|
110
|
+
@snapshotter.prepare(@record, root_versions, start_at: start_at)
|
|
111
|
+
else
|
|
112
|
+
@snapshotter.prepare(@record, root_versions)
|
|
113
|
+
end
|
|
99
114
|
end
|
|
100
115
|
|
|
101
116
|
#: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
@@ -106,55 +121,39 @@ module PaperTrailDiff
|
|
|
106
121
|
final_boundary: nil,
|
|
107
122
|
final_snapshot: nil
|
|
108
123
|
)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
events,
|
|
112
|
-
current: current
|
|
113
|
-
)
|
|
124
|
+
history = event_history(root_versions, events, current: current)
|
|
125
|
+
steps = history.steps.dup
|
|
114
126
|
previous_event = events.last
|
|
115
127
|
previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
|
|
116
128
|
if final_boundary && previous_boundary
|
|
117
129
|
steps << ActivityStep.new(
|
|
118
130
|
from_boundary: previous_boundary,
|
|
119
131
|
to_boundary: final_boundary,
|
|
120
|
-
diff: Engine.compare(
|
|
132
|
+
diff: Engine.compare(history.last_snapshot, final_snapshot)
|
|
121
133
|
)
|
|
122
134
|
end
|
|
123
135
|
steps.freeze
|
|
124
136
|
end
|
|
125
137
|
|
|
126
|
-
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) ->
|
|
127
|
-
def event_history(root_versions, events, current: nil)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
boundary = ActivityBoundary.from_version(event.version)
|
|
135
|
-
root_snapshots[version_key(event.version)] = snapshot if event.root?
|
|
136
|
-
if previous_boundary
|
|
137
|
-
steps << ActivityStep.new(
|
|
138
|
-
from_boundary: previous_boundary,
|
|
139
|
-
to_boundary: boundary,
|
|
140
|
-
diff: Engine.compare(previous_snapshot, snapshot)
|
|
141
|
-
)
|
|
142
|
-
end
|
|
143
|
-
previous_boundary = boundary
|
|
144
|
-
previous_snapshot = snapshot
|
|
145
|
-
end
|
|
146
|
-
[steps, root_snapshots, previous_snapshot]
|
|
138
|
+
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
139
|
+
def event_history(root_versions, events, current: nil)
|
|
140
|
+
ActivityHistoryBuilder.new(
|
|
141
|
+
root_versions,
|
|
142
|
+
events,
|
|
143
|
+
@snapshotter,
|
|
144
|
+
current: current
|
|
145
|
+
).call
|
|
147
146
|
end
|
|
148
147
|
|
|
149
|
-
#: (Array[untyped],
|
|
150
|
-
def build_analysis(root_versions,
|
|
148
|
+
#: (Array[untyped], ActivityHistory) -> Analysis
|
|
149
|
+
def build_analysis(root_versions, history)
|
|
151
150
|
snapshots = root_versions.map do |version|
|
|
152
|
-
root_snapshots.fetch(version_key(version))
|
|
151
|
+
history.root_snapshots.fetch(version_key(version))
|
|
153
152
|
end
|
|
154
153
|
Analysis.new(
|
|
155
|
-
diff: Engine.compare(
|
|
154
|
+
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
156
155
|
timeline: build_root_steps(root_versions, snapshots),
|
|
157
|
-
activity_timeline:
|
|
156
|
+
activity_timeline: history.steps
|
|
158
157
|
)
|
|
159
158
|
end
|
|
160
159
|
|
|
@@ -173,5 +172,15 @@ module PaperTrailDiff
|
|
|
173
172
|
def version_key(version)
|
|
174
173
|
[version.class.name, version.id]
|
|
175
174
|
end
|
|
175
|
+
|
|
176
|
+
#: () -> TimeActivityTimelineBuilder
|
|
177
|
+
def time_builder
|
|
178
|
+
TimeActivityTimelineBuilder.new(
|
|
179
|
+
@record,
|
|
180
|
+
range: @range,
|
|
181
|
+
tree: @tree,
|
|
182
|
+
snapshotter: @snapshotter
|
|
183
|
+
)
|
|
184
|
+
end
|
|
176
185
|
end
|
|
177
186
|
end
|
|
@@ -4,13 +4,20 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Collects root and selected-descendant versions relevant to one root history range.
|
|
6
6
|
class ActivityVersionCollector
|
|
7
|
-
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_end: untyped) -> void
|
|
8
|
-
def initialize(
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
record,
|
|
10
|
+
root_versions:,
|
|
11
|
+
tree:,
|
|
12
|
+
traversal:,
|
|
13
|
+
range_start: root_versions.first,
|
|
14
|
+
range_end: root_versions.last
|
|
15
|
+
)
|
|
9
16
|
@record = record
|
|
10
17
|
@root_versions = root_versions
|
|
11
18
|
@tree = tree
|
|
12
19
|
@traversal = traversal
|
|
13
|
-
@range = ActivityRange.new(
|
|
20
|
+
@range = ActivityRange.new(range_start, range_end)
|
|
14
21
|
@registry = ActivityEventRegistry.new
|
|
15
22
|
end
|
|
16
23
|
|
|
@@ -98,15 +105,10 @@ module PaperTrailDiff
|
|
|
98
105
|
end
|
|
99
106
|
|
|
100
107
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
101
|
-
def direct_child_groups(parent_class, parent_ids, reflection)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
foreign_key_id: parent_ids
|
|
106
|
-
).where(foreign_type: related_parent_types(parent_class))
|
|
107
|
-
item_ids = relation.where(
|
|
108
|
-
version_class.table_name => { item_type: reflection.klass.base_class.name }
|
|
109
|
-
).distinct.pluck(version_class.arel_table[:item_id])
|
|
108
|
+
def direct_child_groups(parent_class, parent_ids, reflection)
|
|
109
|
+
item_ids = ActivityChildCandidateLoader.new(
|
|
110
|
+
parent_class, parent_ids, reflection, @range
|
|
111
|
+
).call
|
|
110
112
|
return {} if item_ids.empty?
|
|
111
113
|
|
|
112
114
|
{ reflection.klass.name => group(reflection.klass, item_ids) }
|
|
@@ -178,11 +180,6 @@ module PaperTrailDiff
|
|
|
178
180
|
nil
|
|
179
181
|
end
|
|
180
182
|
|
|
181
|
-
#: (untyped) -> Array[String?]
|
|
182
|
-
def related_parent_types(parent_class)
|
|
183
|
-
[nil, '', parent_class.name.to_s, parent_class.base_class.name.to_s].uniq
|
|
184
|
-
end
|
|
185
|
-
|
|
186
183
|
#: (untyped) -> bool
|
|
187
184
|
def in_range?(version)
|
|
188
185
|
@range.include?(version)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Reconstructs selected root branches and merges them into a prior immutable snapshot.
|
|
6
6
|
class BranchSnapshotRefresher
|
|
7
|
-
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped) -> void
|
|
7
|
+
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
9
9
|
tree:,
|
|
10
10
|
ignore_policy:,
|
|
@@ -13,7 +13,8 @@ module PaperTrailDiff
|
|
|
13
13
|
normalizer:,
|
|
14
14
|
full_snapshotter:,
|
|
15
15
|
partial_snapshotter:,
|
|
16
|
-
association_reader
|
|
16
|
+
association_reader:,
|
|
17
|
+
record_transition:
|
|
17
18
|
)
|
|
18
19
|
@tree = tree
|
|
19
20
|
@ignore_policy = ignore_policy
|
|
@@ -26,7 +27,8 @@ module PaperTrailDiff
|
|
|
26
27
|
traversal: traversal,
|
|
27
28
|
pool: pool,
|
|
28
29
|
components: method(:components),
|
|
29
|
-
association_reader: association_reader
|
|
30
|
+
association_reader: association_reader,
|
|
31
|
+
record_transition: record_transition
|
|
30
32
|
)
|
|
31
33
|
@root_refresher = ActivityRootSnapshotRefresher.new(
|
|
32
34
|
tree: tree,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Compares collection snapshots with a low-allocation path for stable membership.
|
|
6
|
+
class CollectionComparator
|
|
7
|
+
#: (AssociationSnapshot, AssociationSnapshot, record_comparer: untyped) -> void
|
|
8
|
+
def initialize(from_association, to_association, record_comparer:)
|
|
9
|
+
@from = from_association
|
|
10
|
+
@to = to_association
|
|
11
|
+
@record_comparer = record_comparer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: () -> CollectionAssociationDiff
|
|
15
|
+
def call
|
|
16
|
+
transition = @to.transition_from(@from)
|
|
17
|
+
return transition_difference(transition) if transition
|
|
18
|
+
|
|
19
|
+
aligned = aligned_changes
|
|
20
|
+
return difference(added: [], removed: [], changed: aligned) if aligned
|
|
21
|
+
|
|
22
|
+
indexed_difference
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# @rbs @from: AssociationSnapshot
|
|
28
|
+
# @rbs @to: AssociationSnapshot
|
|
29
|
+
# @rbs @record_comparer: untyped
|
|
30
|
+
|
|
31
|
+
#: (CollectionTransition) -> CollectionAssociationDiff
|
|
32
|
+
def transition_difference(transition)
|
|
33
|
+
@from.validate_unique_identities!
|
|
34
|
+
@to.validate_unique_identities!
|
|
35
|
+
before = transition.before
|
|
36
|
+
after = transition.after
|
|
37
|
+
changed = compare_transition_records(before, after)
|
|
38
|
+
difference(
|
|
39
|
+
added: before.nil? && after ? [after] : [],
|
|
40
|
+
removed: after.nil? && before ? [before] : [],
|
|
41
|
+
changed: changed ? [changed] : []
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
|
|
46
|
+
def compare_transition_records(before, after)
|
|
47
|
+
return unless before && after && before.identity == after.identity
|
|
48
|
+
|
|
49
|
+
@record_comparer.call(before, after)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: () -> Array[RecordChange]?
|
|
53
|
+
def aligned_changes
|
|
54
|
+
pairs = aligned_changed_pairs
|
|
55
|
+
return unless pairs
|
|
56
|
+
|
|
57
|
+
pairs.sort_by { |_from, to| sortable_identity(to.identity) }.filter_map do |pair|
|
|
58
|
+
@record_comparer.call(pair.fetch(0), pair.fetch(1))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: () -> Array[[RecordSnapshot, RecordSnapshot]]?
|
|
63
|
+
def aligned_changed_pairs
|
|
64
|
+
from_records = @from.records
|
|
65
|
+
to_records = @to.records
|
|
66
|
+
return unless from_records.length == to_records.length
|
|
67
|
+
|
|
68
|
+
collect_aligned_pairs(from_records, to_records)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: (Array[RecordSnapshot], Array[RecordSnapshot]) -> Array[[RecordSnapshot, RecordSnapshot]]?
|
|
72
|
+
def collect_aligned_pairs(from_records, to_records)
|
|
73
|
+
seen = {} #: Hash[identity, bool]
|
|
74
|
+
changed = [] #: Array[[RecordSnapshot, RecordSnapshot]]
|
|
75
|
+
index = 0
|
|
76
|
+
while index < from_records.length
|
|
77
|
+
from_record = from_records.fetch(index)
|
|
78
|
+
to_record = to_records.fetch(index)
|
|
79
|
+
identity = from_record.identity
|
|
80
|
+
raise_duplicate_identity!(identity) if seen.key?(identity)
|
|
81
|
+
return unless identity == to_record.identity
|
|
82
|
+
|
|
83
|
+
seen[identity] = true
|
|
84
|
+
changed << [from_record, to_record] unless from_record.equal?(to_record)
|
|
85
|
+
index += 1
|
|
86
|
+
end
|
|
87
|
+
changed
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: () -> CollectionAssociationDiff
|
|
91
|
+
def indexed_difference
|
|
92
|
+
from_records = index_records(@from.records)
|
|
93
|
+
to_records = index_records(@to.records)
|
|
94
|
+
difference(
|
|
95
|
+
added: records_missing_from(to_records, from_records),
|
|
96
|
+
removed: records_missing_from(from_records, to_records),
|
|
97
|
+
changed: changed_records(from_records, to_records)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (added: Array[RecordSnapshot], removed: Array[RecordSnapshot], changed: Array[RecordChange]) -> CollectionAssociationDiff
|
|
102
|
+
def difference(added:, removed:, changed:)
|
|
103
|
+
CollectionAssociationDiff.new(
|
|
104
|
+
kind: @from.kind,
|
|
105
|
+
added: added,
|
|
106
|
+
removed: removed,
|
|
107
|
+
changed: changed
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
|
|
112
|
+
def records_missing_from(records, other_records)
|
|
113
|
+
identities = sorted_identities(records.keys - other_records.keys)
|
|
114
|
+
identities.map { |identity| records.fetch(identity) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
|
|
118
|
+
def changed_records(from_records, to_records)
|
|
119
|
+
sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
|
|
120
|
+
from_record = from_records.fetch(identity)
|
|
121
|
+
to_record = to_records.fetch(identity)
|
|
122
|
+
next if from_record.equal?(to_record)
|
|
123
|
+
|
|
124
|
+
@record_comparer.call(from_record, to_record)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (Array[RecordSnapshot]) -> Hash[identity, RecordSnapshot]
|
|
129
|
+
def index_records(records)
|
|
130
|
+
index = {} #: Hash[identity, RecordSnapshot]
|
|
131
|
+
records.each do |record|
|
|
132
|
+
identity = record.identity
|
|
133
|
+
raise_duplicate_identity!(identity) if index.key?(identity)
|
|
134
|
+
|
|
135
|
+
index[identity] = record
|
|
136
|
+
end
|
|
137
|
+
index
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: (Array[identity]) -> Array[identity]
|
|
141
|
+
def sorted_identities(identities)
|
|
142
|
+
identities.sort_by { |identity| sortable_identity(identity) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: (identity) -> Array[untyped]
|
|
146
|
+
def sortable_identity(identity)
|
|
147
|
+
[identity.fetch(0), identity.fetch(1).inspect]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
#: (identity) -> void
|
|
151
|
+
def raise_duplicate_identity!(identity)
|
|
152
|
+
raise ArgumentError, "duplicate record identity: #{identity.inspect}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Immutable positions for one collection membership and ordering.
|
|
6
|
+
class CollectionIdentityIndex
|
|
7
|
+
#: (Array[untyped]) -> void
|
|
8
|
+
def initialize(records)
|
|
9
|
+
@positions = {} #: Hash[Array[untyped], Integer]
|
|
10
|
+
@serialized_positions = {} #: Hash[Array[String], Integer]
|
|
11
|
+
@id_positions = {} #: Hash[String, Integer?]
|
|
12
|
+
records.each_with_index { |record, index| register(record, index) }
|
|
13
|
+
@positions.freeze
|
|
14
|
+
@serialized_positions.freeze
|
|
15
|
+
@id_positions.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: (untyped, untyped) -> Integer?
|
|
20
|
+
def position(type, id)
|
|
21
|
+
@serialized_positions[[type.to_s, id.to_s]]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (untyped) -> Integer?
|
|
25
|
+
def position_for_id(id)
|
|
26
|
+
@id_positions[id.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @rbs @positions: Hash[Array[untyped], Integer]
|
|
32
|
+
# @rbs @serialized_positions: Hash[Array[String], Integer]
|
|
33
|
+
# @rbs @id_positions: Hash[String, Integer?]
|
|
34
|
+
|
|
35
|
+
#: (untyped, Integer) -> void
|
|
36
|
+
def register(record, index)
|
|
37
|
+
identity = record.identity
|
|
38
|
+
if @positions.key?(identity)
|
|
39
|
+
raise ArgumentError, "duplicate record identity: #{identity.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@positions[identity] = index
|
|
43
|
+
@serialized_positions[[record.type.to_s, record.id.to_s]] = index
|
|
44
|
+
register_id(record.id, index)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: (untyped, Integer) -> void
|
|
48
|
+
def register_id(id, index)
|
|
49
|
+
key = id.to_s
|
|
50
|
+
@id_positions[key] = @id_positions.key?(key) ? nil : index
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One collection mutation between structurally adjacent snapshots.
|
|
6
|
+
class CollectionTransition
|
|
7
|
+
attr_reader :before #: RecordSnapshot?
|
|
8
|
+
attr_reader :after #: RecordSnapshot?
|
|
9
|
+
|
|
10
|
+
#: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
|
|
11
|
+
def initialize(from:, before:, after:)
|
|
12
|
+
@from_object_id = from.object_id
|
|
13
|
+
@before = before
|
|
14
|
+
@after = after
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (AssociationSnapshot) -> bool
|
|
19
|
+
def from?(association)
|
|
20
|
+
@from_object_id == association.object_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs @from_object_id: Integer
|
|
24
|
+
end
|
|
25
|
+
end
|