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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reads event-side foreign keys and matches them to an immutable snapshot owner.
|
|
6
|
+
class ActivityRelationship
|
|
7
|
+
#: (untyped, untyped, RecordSnapshot, ?state: Symbol) -> bool
|
|
8
|
+
def member_of_owner?(record, reflection, owner, state: :after)
|
|
9
|
+
actual = relationship_owner_values(record, reflection, state: state)
|
|
10
|
+
expected_ids = Array(owner.id)
|
|
11
|
+
# @type var expected_ids: Array[untyped]
|
|
12
|
+
expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
|
|
13
|
+
return false unless actual == expected
|
|
14
|
+
return true unless reflection.options[:as]
|
|
15
|
+
|
|
16
|
+
type = event_attribute(record, reflection.type, state: state).to_s
|
|
17
|
+
owner_types(reflection, owner).include?(type)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#: (untyped, untyped, state: Symbol) -> untyped
|
|
21
|
+
def owner_id(record, reflection, state:)
|
|
22
|
+
values = Array(reflection.foreign_key).map do |foreign_key|
|
|
23
|
+
event_attribute(record, foreign_key, state: state)
|
|
24
|
+
end
|
|
25
|
+
values.one? ? values.first : values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
#: (untyped, untyped, state: Symbol) -> Array[String]
|
|
31
|
+
def relationship_owner_values(record, reflection, state:)
|
|
32
|
+
Array(reflection.foreign_key).map do |key|
|
|
33
|
+
event_attribute(record, key, state: state).to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (untyped, untyped, state: Symbol) -> untyped
|
|
38
|
+
def event_attribute(record, name, state:)
|
|
39
|
+
if record.is_a?(ActivitySnapshotDelta)
|
|
40
|
+
return state == :before ? record.before_value(name) : record.after_value(name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
record.public_send(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (untyped, RecordSnapshot) -> Array[String]
|
|
47
|
+
def owner_types(reflection, owner)
|
|
48
|
+
[
|
|
49
|
+
owner.type.to_s,
|
|
50
|
+
reflection.active_record.name.to_s,
|
|
51
|
+
reflection.active_record.base_class.name.to_s
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Root checkpoint steps recovered from the snapshots an activity pass retained,
|
|
6
|
+
# so a combined result does not reconstruct the same boundaries twice.
|
|
7
|
+
module ActivityRootSteps
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
11
|
+
def call(root_versions, root_snapshots)
|
|
12
|
+
snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
|
|
13
|
+
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
14
|
+
Step.new(
|
|
15
|
+
from_version: versions.fetch(0),
|
|
16
|
+
to_version: versions.fetch(1),
|
|
17
|
+
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
18
|
+
)
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: (untyped) -> Array[untyped]
|
|
23
|
+
def version_key(version)
|
|
24
|
+
[version.class.name, version.id]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -11,9 +11,14 @@ module PaperTrailDiff
|
|
|
11
11
|
@preparer = preparer
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
#: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
15
|
-
def prepare(
|
|
16
|
-
|
|
14
|
+
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
15
|
+
def prepare(
|
|
16
|
+
record,
|
|
17
|
+
root_versions,
|
|
18
|
+
start_at: root_versions.first.created_at,
|
|
19
|
+
end_at: root_versions.last.created_at
|
|
20
|
+
)
|
|
21
|
+
@preparer.call(record, root_versions, start_at: start_at, end_at: end_at)
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -17,6 +17,7 @@ module PaperTrailDiff
|
|
|
17
17
|
#: () -> Array[ActivityStep]
|
|
18
18
|
def build
|
|
19
19
|
return time_builder.build if @range.time?
|
|
20
|
+
return no_steps if @range.unresolved?
|
|
20
21
|
return build_to_current if Endpoint.record?(@to)
|
|
21
22
|
|
|
22
23
|
Endpoint.validate!(@to)
|
|
@@ -27,6 +28,7 @@ module PaperTrailDiff
|
|
|
27
28
|
#: () -> Analysis
|
|
28
29
|
def analyze
|
|
29
30
|
return time_builder.analyze if @range.time?
|
|
31
|
+
return Analysis.empty if @range.unresolved?
|
|
30
32
|
|
|
31
33
|
unless Endpoint.version?(@to)
|
|
32
34
|
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
@@ -35,7 +37,7 @@ module PaperTrailDiff
|
|
|
35
37
|
root_versions = @range.select
|
|
36
38
|
prepare_history(root_versions)
|
|
37
39
|
events = collect_events(root_versions)
|
|
38
|
-
build_analysis(root_versions, event_history(root_versions, events))
|
|
40
|
+
build_analysis(root_versions, events, event_history(root_versions, events))
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
private
|
|
@@ -47,6 +49,12 @@ module PaperTrailDiff
|
|
|
47
49
|
# @rbs @tree: AssociationTree
|
|
48
50
|
# @rbs @snapshotter: untyped
|
|
49
51
|
|
|
52
|
+
#: () -> Array[ActivityStep]
|
|
53
|
+
def no_steps
|
|
54
|
+
steps = [] #: Array[ActivityStep]
|
|
55
|
+
steps.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
50
58
|
#: () -> Array[ActivityStep]
|
|
51
59
|
def build_between_versions
|
|
52
60
|
root_versions = @range.select
|
|
@@ -60,7 +68,9 @@ module PaperTrailDiff
|
|
|
60
68
|
validate_current_range!
|
|
61
69
|
current_snapshot, captured_at = capture_current
|
|
62
70
|
root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
|
|
63
|
-
|
|
71
|
+
# Descendants can move after the last root version, so the prepared range
|
|
72
|
+
# ends at the captured instant rather than at that version.
|
|
73
|
+
prepare_history(root_versions, end_at: captured_at)
|
|
64
74
|
events = collect_events(root_versions, range_end: captured_at)
|
|
65
75
|
build_event_steps(
|
|
66
76
|
root_versions,
|
|
@@ -102,15 +112,12 @@ module PaperTrailDiff
|
|
|
102
112
|
).call
|
|
103
113
|
end
|
|
104
114
|
|
|
105
|
-
#: (Array[untyped], ?
|
|
106
|
-
def prepare_history(root_versions,
|
|
115
|
+
#: (Array[untyped], ?end_at: untyped) -> void
|
|
116
|
+
def prepare_history(root_versions, end_at: nil)
|
|
107
117
|
return unless @snapshotter.respond_to?(:prepare)
|
|
118
|
+
return @snapshotter.prepare(@record, root_versions) unless end_at
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
@snapshotter.prepare(@record, root_versions, start_at: start_at)
|
|
111
|
-
else
|
|
112
|
-
@snapshotter.prepare(@record, root_versions)
|
|
113
|
-
end
|
|
120
|
+
@snapshotter.prepare(@record, root_versions, end_at: end_at)
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
#: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
@@ -122,6 +129,18 @@ module PaperTrailDiff
|
|
|
122
129
|
final_snapshot: nil
|
|
123
130
|
)
|
|
124
131
|
history = event_history(root_versions, events, current: current)
|
|
132
|
+
activity_steps(
|
|
133
|
+
history,
|
|
134
|
+
events,
|
|
135
|
+
final_boundary || destroyed_boundary(root_versions),
|
|
136
|
+
final_snapshot
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Appends the transition into an explicit closing boundary, which is either
|
|
141
|
+
# a requested current record or the absence a final root destroy leaves.
|
|
142
|
+
#: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
143
|
+
def activity_steps(history, events, final_boundary, final_snapshot)
|
|
125
144
|
steps = history.steps.dup
|
|
126
145
|
previous_event = events.last
|
|
127
146
|
previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
|
|
@@ -135,6 +154,16 @@ module PaperTrailDiff
|
|
|
135
154
|
steps.freeze
|
|
136
155
|
end
|
|
137
156
|
|
|
157
|
+
# A destroyed root has no later version, but its own event states that
|
|
158
|
+
# nothing follows it, so the removal can still close the timeline.
|
|
159
|
+
#: (Array[untyped]) -> ActivityBoundary?
|
|
160
|
+
def destroyed_boundary(root_versions)
|
|
161
|
+
version = root_versions.last
|
|
162
|
+
return unless version && version.event.to_s == 'destroy'
|
|
163
|
+
|
|
164
|
+
ActivityBoundary.destroyed(version)
|
|
165
|
+
end
|
|
166
|
+
|
|
138
167
|
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
139
168
|
def event_history(root_versions, events, current: nil)
|
|
140
169
|
ActivityHistoryBuilder.new(
|
|
@@ -145,32 +174,18 @@ module PaperTrailDiff
|
|
|
145
174
|
).call
|
|
146
175
|
end
|
|
147
176
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
# Only the activity view gains the closing removal. The endpoint diff and
|
|
178
|
+
# the root timeline keep their `compare` and `timeline` semantics, under
|
|
179
|
+
# which the state at a destroy version is the state before the deletion.
|
|
180
|
+
#: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
181
|
+
def build_analysis(root_versions, events, history)
|
|
153
182
|
Analysis.new(
|
|
154
183
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
155
|
-
timeline:
|
|
156
|
-
activity_timeline:
|
|
157
|
-
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
#: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
|
|
161
|
-
def build_root_steps(root_versions, snapshots)
|
|
162
|
-
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
163
|
-
Step.new(
|
|
164
|
-
from_version: versions.fetch(0),
|
|
165
|
-
to_version: versions.fetch(1),
|
|
166
|
-
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
184
|
+
timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
|
|
185
|
+
activity_timeline: activity_steps(
|
|
186
|
+
history, events, destroyed_boundary(root_versions), nil
|
|
167
187
|
)
|
|
168
|
-
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
#: (untyped) -> Array[untyped]
|
|
172
|
-
def version_key(version)
|
|
173
|
-
[version.class.name, version.id]
|
|
188
|
+
)
|
|
174
189
|
end
|
|
175
190
|
|
|
176
191
|
#: () -> TimeActivityTimelineBuilder
|
|
@@ -8,6 +8,15 @@ module PaperTrailDiff
|
|
|
8
8
|
attr_reader :timeline #: Array[Step]
|
|
9
9
|
attr_reader :activity_timeline #: Array[ActivityStep]?
|
|
10
10
|
|
|
11
|
+
# The result for a record whose requested history contains no versions,
|
|
12
|
+
# which is an empty history rather than a failed request.
|
|
13
|
+
#: () -> Analysis
|
|
14
|
+
def self.empty
|
|
15
|
+
timeline = [] #: Array[Step]
|
|
16
|
+
activity_timeline = [] #: Array[ActivityStep]
|
|
17
|
+
new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
#: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
|
|
12
21
|
def initialize(diff:, timeline:, activity_timeline: nil)
|
|
13
22
|
@diff = diff
|
|
@@ -159,6 +159,8 @@ module PaperTrailDiff
|
|
|
159
159
|
|
|
160
160
|
#: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
|
|
161
161
|
def components(branches)
|
|
162
|
+
return @components[branches] if @components.key?(branches)
|
|
163
|
+
|
|
162
164
|
key = branches.sort.freeze
|
|
163
165
|
@components[key] ||= build_components(key)
|
|
164
166
|
end
|
|
@@ -142,9 +142,22 @@ module PaperTrailDiff
|
|
|
142
142
|
identities.sort_by { |identity| sortable_identity(identity) }
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Identities sort by type first so that mixed id types stay comparable, then
|
|
146
|
+
# naturally within one type. Ordering by the printed form instead would put
|
|
147
|
+
# id 10 before id 2, which is deterministic but reads as unsorted wherever a
|
|
148
|
+
# result is rendered.
|
|
145
149
|
#: (identity) -> Array[untyped]
|
|
146
150
|
def sortable_identity(identity)
|
|
147
|
-
[identity.fetch(0), identity.fetch(1)
|
|
151
|
+
[identity.fetch(0), *sortable_id(identity.fetch(1))]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: (untyped) -> Array[untyped]
|
|
155
|
+
def sortable_id(id)
|
|
156
|
+
case id
|
|
157
|
+
when Numeric then [0, id, '']
|
|
158
|
+
when String, Symbol then [1, 0, id.to_s]
|
|
159
|
+
else [2, 0, id.inspect]
|
|
160
|
+
end
|
|
148
161
|
end
|
|
149
162
|
|
|
150
163
|
#: (identity) -> void
|
|
@@ -7,9 +7,12 @@ module PaperTrailDiff
|
|
|
7
7
|
attr_reader :before #: RecordSnapshot?
|
|
8
8
|
attr_reader :after #: RecordSnapshot?
|
|
9
9
|
|
|
10
|
+
# The origin is recorded by serial rather than by reference, because
|
|
11
|
+
# holding it would retain every earlier snapshot at the same path for the
|
|
12
|
+
# whole timeline.
|
|
10
13
|
#: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
|
|
11
14
|
def initialize(from:, before:, after:)
|
|
12
|
-
@
|
|
15
|
+
@from_serial = from.serial
|
|
13
16
|
@before = before
|
|
14
17
|
@after = after
|
|
15
18
|
freeze
|
|
@@ -17,9 +20,9 @@ module PaperTrailDiff
|
|
|
17
20
|
|
|
18
21
|
#: (AssociationSnapshot) -> bool
|
|
19
22
|
def from?(association)
|
|
20
|
-
@
|
|
23
|
+
@from_serial == association.serial
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
# @rbs @
|
|
26
|
+
# @rbs @from_serial: Integer
|
|
24
27
|
end
|
|
25
28
|
end
|
|
@@ -10,10 +10,35 @@ module PaperTrailDiff
|
|
|
10
10
|
def validate_pair!(from_endpoint, to_endpoint)
|
|
11
11
|
validate!(from_endpoint)
|
|
12
12
|
validate!(to_endpoint)
|
|
13
|
-
|
|
13
|
+
unless identity(from_endpoint) == identity(to_endpoint)
|
|
14
|
+
raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
validate_order!(from_endpoint, to_endpoint)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Two versions carry no visible cue about which is earlier, so transposing
|
|
21
|
+
# them is easy to do by accident and impossible to detect afterwards: the
|
|
22
|
+
# result is a valid inverse diff and carries no direction of its own. It is
|
|
23
|
+
# also redundant, because the two orders differ only in which side of each
|
|
24
|
+
# change is `from`. A current-record endpoint is exempt: it is self-evidently
|
|
25
|
+
# the live state, so putting it first is a deliberate reverse comparison.
|
|
26
|
+
#: (untyped, untyped) -> void
|
|
27
|
+
def validate_order!(from_endpoint, to_endpoint)
|
|
28
|
+
return unless reversed_versions?(from_endpoint, to_endpoint)
|
|
29
|
+
|
|
30
|
+
raise ReversedEndpointsError,
|
|
31
|
+
'version endpoints must be given in chronological order; ' \
|
|
32
|
+
'swap them to read the same difference in the other direction'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (untyped, untyped) -> bool
|
|
36
|
+
def reversed_versions?(from_endpoint, to_endpoint)
|
|
37
|
+
return false unless version?(from_endpoint) && version?(to_endpoint)
|
|
14
38
|
|
|
15
|
-
|
|
39
|
+
Support.compare_versions(from_endpoint, to_endpoint).positive?
|
|
16
40
|
end
|
|
41
|
+
private_class_method :reversed_versions?
|
|
17
42
|
|
|
18
43
|
#: (untyped) -> void
|
|
19
44
|
def validate!(endpoint)
|
|
@@ -11,6 +11,9 @@ module PaperTrailDiff
|
|
|
11
11
|
# Raised when compare endpoints do not belong to the same PaperTrail item.
|
|
12
12
|
class VersionMismatchError < Error; end
|
|
13
13
|
|
|
14
|
+
# Raised when comparison endpoints are given in reverse chronological order.
|
|
15
|
+
class ReversedEndpointsError < Error; end
|
|
16
|
+
|
|
14
17
|
# Raised when a live endpoint is not a clean, persisted ActiveRecord record.
|
|
15
18
|
class InvalidEndpointError < Error; end
|
|
16
19
|
|
|
@@ -11,7 +11,10 @@ module PaperTrailDiff
|
|
|
11
11
|
@habtm_transaction_id = habtm_version.transaction_id
|
|
12
12
|
end
|
|
13
13
|
@version_at = version.created_at
|
|
14
|
-
|
|
14
|
+
# Keyed on record identity rather than `object_id`, which Ruby may reuse
|
|
15
|
+
# once an earlier record in the same pass has been collected.
|
|
16
|
+
reified = {} #: Hash[untyped, Hash[untyped, bool]]
|
|
17
|
+
@reified_associations = reified.compare_by_identity
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -24,14 +27,14 @@ module PaperTrailDiff
|
|
|
24
27
|
# @rbs @transaction_id: untyped
|
|
25
28
|
# @rbs @habtm_transaction_id: untyped
|
|
26
29
|
# @rbs @version_at: untyped
|
|
27
|
-
# @rbs @reified_associations: Hash[
|
|
30
|
+
# @rbs @reified_associations: Hash[untyped, Hash[untyped, bool]]
|
|
28
31
|
|
|
29
32
|
#: (untyped, untyped) -> void
|
|
30
33
|
def reify_association(record, reflection)
|
|
31
|
-
|
|
32
|
-
return if
|
|
34
|
+
reified = @reified_associations[record] ||= {} #: Hash[untyped, bool]
|
|
35
|
+
return if reified[reflection.name]
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
reified[reflection.name] = true
|
|
35
38
|
case reflection.macro
|
|
36
39
|
when :belongs_to
|
|
37
40
|
reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
|
|
@@ -16,14 +16,20 @@ module PaperTrailDiff
|
|
|
16
16
|
@prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
#: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
20
|
-
def prepare(
|
|
19
|
+
#: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
20
|
+
def prepare(
|
|
21
|
+
record,
|
|
22
|
+
root_versions,
|
|
23
|
+
start_at: root_versions.first.created_at,
|
|
24
|
+
end_at: root_versions.last.created_at
|
|
25
|
+
)
|
|
21
26
|
return if @tree.empty?
|
|
22
27
|
|
|
23
28
|
@prepared_history = PreparedHistoryLoader.new(
|
|
24
29
|
record,
|
|
25
30
|
root_versions: root_versions,
|
|
26
31
|
start_at: start_at,
|
|
32
|
+
end_at: end_at,
|
|
27
33
|
tree: @tree,
|
|
28
34
|
traversal: @traversal
|
|
29
35
|
).call
|
|
@@ -139,6 +145,7 @@ module PaperTrailDiff
|
|
|
139
145
|
root_ids: records.map(&:id),
|
|
140
146
|
root_versions: root_versions,
|
|
141
147
|
start_at: root_versions.map(&:created_at).min,
|
|
148
|
+
end_at: root_versions.map(&:created_at).max,
|
|
142
149
|
tree: @tree,
|
|
143
150
|
traversal: @traversal,
|
|
144
151
|
live_records: @live_graph_collector.call(records)
|
|
@@ -10,7 +10,10 @@ module PaperTrailDiff
|
|
|
10
10
|
@boundary = boundary
|
|
11
11
|
@habtm_boundary = habtm_boundary
|
|
12
12
|
@fallback = fallback
|
|
13
|
-
|
|
13
|
+
# Keyed on record identity rather than `object_id`, which Ruby may reuse
|
|
14
|
+
# once an earlier record in the same pass has been collected.
|
|
15
|
+
reified = {} #: Hash[untyped, Hash[untyped, bool]]
|
|
16
|
+
@reified = reified.compare_by_identity
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -24,14 +27,14 @@ module PaperTrailDiff
|
|
|
24
27
|
# @rbs @boundary: untyped
|
|
25
28
|
# @rbs @habtm_boundary: untyped
|
|
26
29
|
# @rbs @fallback: HistoricalAssociationReifier
|
|
27
|
-
# @rbs @reified: Hash[
|
|
30
|
+
# @rbs @reified: Hash[untyped, Hash[untyped, bool]]
|
|
28
31
|
|
|
29
32
|
#: (untyped, untyped) -> void
|
|
30
33
|
def reify_association(record, reflection)
|
|
31
|
-
|
|
32
|
-
return if
|
|
34
|
+
reified = @reified[record] ||= {} #: Hash[untyped, bool]
|
|
35
|
+
return if reified[reflection.name]
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
reified[reflection.name] = true
|
|
35
38
|
handled, records = @history.resolve(
|
|
36
39
|
record,
|
|
37
40
|
reflection,
|
|
@@ -164,13 +164,15 @@ module PaperTrailDiff
|
|
|
164
164
|
{ model_class.name.to_s => group }
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
# Keyed rather than scanned so that a wide edge stays linear in its width.
|
|
167
168
|
#: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
168
169
|
def group_ids_by_owner(pairs)
|
|
169
|
-
grouped = {} #: Hash[String,
|
|
170
|
-
pairs.
|
|
171
|
-
ids =
|
|
172
|
-
ids
|
|
170
|
+
grouped = {} #: Hash[String, Hash[untyped, true]]
|
|
171
|
+
pairs.each do |owner_id, child_id|
|
|
172
|
+
ids = grouped[owner_id.to_s] ||= {} #: Hash[untyped, true]
|
|
173
|
+
ids[child_id] = true
|
|
173
174
|
end
|
|
175
|
+
grouped.transform_values(&:keys)
|
|
174
176
|
end
|
|
175
177
|
|
|
176
178
|
#: (Hash[String, Array[untyped]], Hash[String, Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Expands the explicit association tree into a request-scoped PreparedHistory.
|
|
6
6
|
class PreparedHistoryLoader
|
|
7
|
-
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
9
|
record,
|
|
10
10
|
root_versions:,
|
|
@@ -12,13 +12,14 @@ module PaperTrailDiff
|
|
|
12
12
|
traversal:,
|
|
13
13
|
root_ids: [record.id],
|
|
14
14
|
start_at: root_versions.first.created_at,
|
|
15
|
+
end_at: nil,
|
|
15
16
|
live_records: []
|
|
16
17
|
)
|
|
17
18
|
@record = record
|
|
18
19
|
@root_ids = root_ids
|
|
19
20
|
@tree = tree
|
|
20
21
|
@traversal = traversal
|
|
21
|
-
@records = PreparedRecordIndex.new(start_at, live_records: live_records)
|
|
22
|
+
@records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
|
|
22
23
|
@history = PreparedHistory.new(@records)
|
|
23
24
|
@edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
|
|
24
25
|
@prepared = {} #: Hash[Array[String], Array[String]]
|