paper_trail_diff 0.5.0 → 0.6.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 +24 -0
- data/README.md +64 -1
- data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
- data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
- data/lib/paper_trail_diff/analysis.rb +19 -5
- data/lib/paper_trail_diff/analysis_batch.rb +28 -10
- data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
- data/lib/paper_trail_diff/batched_root_versions.rb +48 -30
- data/lib/paper_trail_diff/paper_trail_adapter.rb +25 -18
- data/lib/paper_trail_diff/root_version_plan.rb +86 -0
- data/lib/paper_trail_diff/root_version_selection.rb +131 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
- data/lib/paper_trail_diff/time_version_range.rb +19 -24
- data/lib/paper_trail_diff/timeline_builder.rb +21 -18
- data/lib/paper_trail_diff/timeline_range.rb +31 -7
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_range.rb +45 -5
- data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
- data/lib/paper_trail_diff.rb +24 -13
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
- data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +13 -6
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +21 -14
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +10 -10
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
- data/sig/generated/paper_trail_diff/timeline_range.rbs +11 -2
- data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +8 -8
- metadata +10 -4
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Which versions a range reconstructs, and which pairs of them become steps.
|
|
6
|
+
#
|
|
7
|
+
# Without a filter those are the same thing: every selected version is a
|
|
8
|
+
# boundary and adjacent ones bound each other. A filter separates them, because
|
|
9
|
+
# each selected mutation then has to be bounded by the version that actually
|
|
10
|
+
# reveals it rather than by the next mutation that happened to be selected.
|
|
11
|
+
#
|
|
12
|
+
# A filter also separates what is *reported* from what has to be *replayed*.
|
|
13
|
+
# The activity view rebuilds state by carrying one snapshot forward across
|
|
14
|
+
# every event in order, so skipping a filtered-out version would leave that
|
|
15
|
+
# snapshot holding a state the record had already moved past. `versions` are
|
|
16
|
+
# the boundaries the steps refer to; `reconstruction_versions` are every root
|
|
17
|
+
# version the span passes through, which is what any forward replay must walk.
|
|
18
|
+
class RootVersionPlan
|
|
19
|
+
attr_reader :versions #: Array[untyped]
|
|
20
|
+
attr_reader :reconstruction_versions #: Array[untyped]
|
|
21
|
+
attr_reader :steps #: Array[[untyped, untyped]]
|
|
22
|
+
attr_reader :context_version #: untyped
|
|
23
|
+
# The root versions this plan reports as mutations, which excludes any
|
|
24
|
+
# version present only to reveal what the last of them produced.
|
|
25
|
+
attr_reader :mutations #: Array[untyped]
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Adjacent boundaries, which is what an unfiltered range reports.
|
|
29
|
+
#: (Array[untyped], ?context_version: untyped) -> RootVersionPlan
|
|
30
|
+
def contiguous(versions, context_version: nil)
|
|
31
|
+
new(
|
|
32
|
+
versions: versions,
|
|
33
|
+
steps: versions.each_cons(2).map { |from, to| [from, to] },
|
|
34
|
+
context_version: context_version
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: () -> RootVersionPlan
|
|
39
|
+
def empty
|
|
40
|
+
versions = [] #: Array[untyped]
|
|
41
|
+
steps = [] #: Array[[untyped, untyped]]
|
|
42
|
+
new(versions: versions, steps: steps)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
|
|
47
|
+
def initialize(
|
|
48
|
+
versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil
|
|
49
|
+
)
|
|
50
|
+
@versions = versions.freeze
|
|
51
|
+
@reconstruction_versions = (reconstruction_versions || versions).freeze
|
|
52
|
+
@steps = steps.freeze
|
|
53
|
+
@context_version = context_version
|
|
54
|
+
@mutations = (mutations || default_mutations).freeze
|
|
55
|
+
@mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: () -> bool
|
|
60
|
+
def empty?
|
|
61
|
+
versions.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: (untyped) -> bool
|
|
65
|
+
def mutation?(version)
|
|
66
|
+
@mutation_keys.include?(key(version))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @rbs @mutation_keys: Set[Array[untyped]]
|
|
72
|
+
|
|
73
|
+
#: () -> Array[untyped]
|
|
74
|
+
def default_mutations
|
|
75
|
+
return @versions unless @context_version
|
|
76
|
+
|
|
77
|
+
context = key(@context_version)
|
|
78
|
+
@versions.reject { |version| key(version) == context }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (untyped) -> Array[untyped]
|
|
82
|
+
def key(version)
|
|
83
|
+
[version.class.name, version.id]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Decides which versions a range reports, and which one reveals the last of
|
|
6
|
+
# them. A version records the state before its own event, so whatever follows
|
|
7
|
+
# the final selected mutation is the only thing that can show what it produced.
|
|
8
|
+
# That successor is reconstruction context rather than a reported mutation, so
|
|
9
|
+
# a filter must never remove it.
|
|
10
|
+
#
|
|
11
|
+
# Both the single-record and the batched selectors resolve this here, because
|
|
12
|
+
# the rule is subtle enough that two copies of it would drift apart.
|
|
13
|
+
class RootVersionSelection
|
|
14
|
+
INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
|
|
15
|
+
|
|
16
|
+
#: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool) -> void
|
|
17
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
18
|
+
in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false
|
|
19
|
+
)
|
|
20
|
+
@in_range = in_range
|
|
21
|
+
@selected = selected
|
|
22
|
+
@after_range = after_range
|
|
23
|
+
@windowed = windowed
|
|
24
|
+
@context_required = context_required
|
|
25
|
+
@filtered = filtered
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: () -> RootVersionPlan
|
|
29
|
+
def call
|
|
30
|
+
return without_selection if @selected.empty?
|
|
31
|
+
|
|
32
|
+
revealing = revealing_version
|
|
33
|
+
raise IncompleteTimeRangeError, INCOMPLETE if !revealing && @windowed && !terminal_destroy?
|
|
34
|
+
return filtered_plan(revealing) if @filtered
|
|
35
|
+
|
|
36
|
+
versions = revealing ? (@selected + [revealing]) : @selected
|
|
37
|
+
RootVersionPlan.contiguous(versions, context_version: revealing)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @rbs @in_range: Array[untyped]
|
|
43
|
+
# @rbs @selected: Array[untyped]
|
|
44
|
+
# @rbs @after_range: untyped
|
|
45
|
+
# @rbs @windowed: bool
|
|
46
|
+
# @rbs @context_required: bool
|
|
47
|
+
# @rbs @filtered: bool
|
|
48
|
+
|
|
49
|
+
# An activity view still needs a root to reconstruct from even when no root
|
|
50
|
+
# version falls inside the window, because descendants may have moved.
|
|
51
|
+
#: () -> RootVersionPlan
|
|
52
|
+
def without_selection
|
|
53
|
+
return RootVersionPlan.empty unless @context_required
|
|
54
|
+
if @after_range
|
|
55
|
+
return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise IncompleteTimeRangeError, INCOMPLETE
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each selected mutation is bounded by the version that reveals it, not by
|
|
62
|
+
# the next mutation that happened to be selected. Bounding by the next
|
|
63
|
+
# selection would fold anything filtered out in between into it, so the same
|
|
64
|
+
# edit would read differently depending on what followed it.
|
|
65
|
+
#: (untyped) -> RootVersionPlan
|
|
66
|
+
def filtered_plan(revealing)
|
|
67
|
+
steps = @selected.filter_map do |version|
|
|
68
|
+
successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
|
|
69
|
+
[version, successor] if successor
|
|
70
|
+
end #: Array[[untyped, untyped]]
|
|
71
|
+
versions = chronological(steps.flatten(1) + closing_versions)
|
|
72
|
+
RootVersionPlan.new(
|
|
73
|
+
versions: versions,
|
|
74
|
+
steps: steps,
|
|
75
|
+
context_version: revealing,
|
|
76
|
+
reconstruction_versions: spanned(versions),
|
|
77
|
+
mutations: @selected
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Nothing can follow a destroy, so it never pairs into a step. It is still a
|
|
82
|
+
# selected mutation, and the activity view closes on the absence it leaves,
|
|
83
|
+
# so it has to stay a boundary or a filtered report loses the deletion
|
|
84
|
+
# entirely — the one event it can least afford to drop.
|
|
85
|
+
#: () -> Array[untyped]
|
|
86
|
+
def closing_versions
|
|
87
|
+
terminal_destroy? ? [@selected.last] : []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Everything the span passes through, filtered out or not. A replay that
|
|
91
|
+
# skipped the excluded versions would carry a stale state into the next
|
|
92
|
+
# reported step, so the two selected mutations either side of a gap would
|
|
93
|
+
# disagree about what the record looked like between them.
|
|
94
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
95
|
+
def spanned(versions)
|
|
96
|
+
first = versions.first
|
|
97
|
+
last = versions.last
|
|
98
|
+
return versions unless first && last
|
|
99
|
+
|
|
100
|
+
@in_range.select do |candidate|
|
|
101
|
+
!Support.compare_versions(first, candidate).positive? &&
|
|
102
|
+
!Support.compare_versions(candidate, last).positive?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
107
|
+
def chronological(versions)
|
|
108
|
+
versions.uniq { |version| [version.class.name, version.id] }
|
|
109
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: (untyped) -> untyped
|
|
113
|
+
def immediate_successor(version)
|
|
114
|
+
@in_range.find { |candidate| Support.compare_versions(version, candidate).negative? }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A version left out by a filter is still the state the last selected change
|
|
118
|
+
# produced, so it is preferred over anything after the range.
|
|
119
|
+
#: () -> untyped
|
|
120
|
+
def revealing_version
|
|
121
|
+
immediate_successor(@selected.last) || @after_range
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A range closing on the record's own destruction needs no later version:
|
|
125
|
+
# nothing can follow it, so demanding one would reject the range forever.
|
|
126
|
+
#: () -> bool
|
|
127
|
+
def terminal_destroy?
|
|
128
|
+
@selected.last&.event.to_s == 'destroy'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -14,17 +14,19 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
#: () -> Array[ActivityStep]
|
|
16
16
|
def build
|
|
17
|
-
history,
|
|
17
|
+
history, _plan, closing = history_and_versions
|
|
18
18
|
activity_steps(history, closing)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
#: () -> Analysis
|
|
22
22
|
def analyze
|
|
23
|
-
history,
|
|
23
|
+
history, plan, closing = history_and_versions
|
|
24
24
|
Analysis.new(
|
|
25
25
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
26
|
-
timeline: ActivityRootSteps.call(
|
|
27
|
-
activity_timeline: activity_steps(history, closing)
|
|
26
|
+
timeline: ActivityRootSteps.call(plan, history.root_snapshots),
|
|
27
|
+
activity_timeline: activity_steps(history, closing),
|
|
28
|
+
from_snapshot: history.first_snapshot,
|
|
29
|
+
to_snapshot: history.last_snapshot
|
|
28
30
|
)
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -35,18 +37,19 @@ module PaperTrailDiff
|
|
|
35
37
|
# @rbs @tree: AssociationTree
|
|
36
38
|
# @rbs @snapshotter: untyped
|
|
37
39
|
|
|
38
|
-
#: () -> [ActivityHistory,
|
|
40
|
+
#: () -> [ActivityHistory, RootVersionPlan, ActivityStep?]
|
|
39
41
|
def history_and_versions
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
plan = @range.select_plan(context_required: !@tree.empty?)
|
|
43
|
+
root_versions = plan.reconstruction_versions
|
|
44
|
+
return [ActivityHistory.empty, plan, nil] if root_versions.empty?
|
|
42
45
|
|
|
43
46
|
prepare_history(root_versions)
|
|
44
47
|
events = collect_events(root_versions)
|
|
45
|
-
selected = selected_events(events)
|
|
46
|
-
return [ActivityHistory.empty,
|
|
48
|
+
selected = selected_events(events, plan)
|
|
49
|
+
return [ActivityHistory.empty, plan, nil] unless time_events?(selected, events)
|
|
47
50
|
|
|
48
51
|
history = build_history(root_versions, events)
|
|
49
|
-
[history,
|
|
52
|
+
[history, plan, closing_step(history, selected.last)]
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
#: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
|
|
@@ -66,9 +69,18 @@ module PaperTrailDiff
|
|
|
66
69
|
(history.steps + [closing]).freeze
|
|
67
70
|
end
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
# Root versions inside the window that the plan does not report are context
|
|
73
|
+
# rather than mutations: the version appended to reveal the last selected
|
|
74
|
+
# change, and anything a filter excluded but the replay still walks. Neither
|
|
75
|
+
# may be counted as a selected mutation. Descendant events are not filtered,
|
|
76
|
+
# so window membership is the whole test for them.
|
|
77
|
+
#: (Array[ActivityEvent], RootVersionPlan) -> Array[ActivityEvent]
|
|
78
|
+
def selected_events(events, plan)
|
|
79
|
+
events.select do |event|
|
|
80
|
+
next false unless @range.include?(event.version)
|
|
81
|
+
|
|
82
|
+
!event.root? || plan.mutation?(event.version)
|
|
83
|
+
end
|
|
72
84
|
end
|
|
73
85
|
|
|
74
86
|
# The window's last selected mutation is the root's own destruction, so the
|
|
@@ -4,34 +4,38 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Selects in-range root versions plus one later reconstruction boundary.
|
|
6
6
|
class TimeVersionRange
|
|
7
|
-
#: (untyped, time_range: TimeRange) -> void
|
|
8
|
-
def initialize(record, time_range:)
|
|
7
|
+
#: (untyped, time_range: TimeRange, ?version_scope: untyped) -> void
|
|
8
|
+
def initialize(record, time_range:, version_scope: nil)
|
|
9
9
|
@record = record
|
|
10
10
|
@time_range = time_range
|
|
11
|
+
@version_scope = version_scope
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
#: (?context_required: bool) -> Array[untyped]
|
|
14
15
|
def select(context_required: false)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
empty = [] #: Array[untyped]
|
|
18
|
-
return empty.freeze if selected.empty? && !context_required
|
|
19
|
-
|
|
20
|
-
trailing = trailing_version(relation)
|
|
21
|
-
unless trailing
|
|
22
|
-
return selected.freeze if terminal_destroy?(selected)
|
|
23
|
-
|
|
24
|
-
message = 'time range requires a later root version to reconstruct its final change'
|
|
25
|
-
raise IncompleteTimeRangeError, message
|
|
26
|
-
end
|
|
16
|
+
select_plan(context_required: context_required).versions
|
|
17
|
+
end
|
|
27
18
|
|
|
28
|
-
|
|
19
|
+
#: (?context_required: bool) -> RootVersionPlan
|
|
20
|
+
def select_plan(context_required: false)
|
|
21
|
+
relation = versions_relation
|
|
22
|
+
in_range = ordered(@time_range.scope(relation).to_a)
|
|
23
|
+
RootVersionSelection.new(
|
|
24
|
+
in_range: in_range,
|
|
25
|
+
selected: VersionScopeFilter.new(@version_scope).call(@time_range.scope(relation),
|
|
26
|
+
in_range),
|
|
27
|
+
after_range: trailing_version(relation),
|
|
28
|
+
windowed: true,
|
|
29
|
+
context_required: context_required,
|
|
30
|
+
filtered: !@version_scope.nil?
|
|
31
|
+
).call
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
private
|
|
32
35
|
|
|
33
36
|
# @rbs @record: untyped
|
|
34
37
|
# @rbs @time_range: TimeRange
|
|
38
|
+
# @rbs @version_scope: untyped
|
|
35
39
|
|
|
36
40
|
#: () -> untyped
|
|
37
41
|
def versions_relation
|
|
@@ -42,15 +46,6 @@ module PaperTrailDiff
|
|
|
42
46
|
raise InvalidTimelineRangeError, message, cause: e
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
# A window closing on the record's own destruction needs no later version:
|
|
46
|
-
# the destroy reveals the preceding mutation and nothing can follow it, so
|
|
47
|
-
# demanding a checkpoint that can never be written would reject the range
|
|
48
|
-
# permanently.
|
|
49
|
-
#: (Array[untyped]) -> bool
|
|
50
|
-
def terminal_destroy?(versions)
|
|
51
|
-
versions.last&.event.to_s == 'destroy'
|
|
52
|
-
end
|
|
53
|
-
|
|
54
49
|
#: (untyped) -> untyped
|
|
55
50
|
def trailing_version(relation)
|
|
56
51
|
@time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
|
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Selects and compares a chronological slice of a record's version history.
|
|
6
6
|
class TimelineBuilder
|
|
7
|
-
#: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?) -> void
|
|
8
|
-
def initialize(
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil
|
|
10
|
+
)
|
|
9
11
|
@record = record
|
|
10
|
-
@range = TimelineRange.new(
|
|
12
|
+
@range = TimelineRange.new(
|
|
13
|
+
record, from: from, to: to, within: within,
|
|
14
|
+
versions: versions, version_scope: version_scope, plan: plan
|
|
15
|
+
)
|
|
11
16
|
@snapshotter = snapshotter
|
|
12
17
|
end
|
|
13
18
|
|
|
@@ -22,7 +27,9 @@ module PaperTrailDiff
|
|
|
22
27
|
steps, first_snapshot, last_snapshot = compare_history(selected_versions)
|
|
23
28
|
Analysis.new(
|
|
24
29
|
diff: Engine.compare(first_snapshot, last_snapshot),
|
|
25
|
-
timeline: steps
|
|
30
|
+
timeline: steps,
|
|
31
|
+
from_snapshot: first_snapshot,
|
|
32
|
+
to_snapshot: last_snapshot
|
|
26
33
|
)
|
|
27
34
|
end
|
|
28
35
|
|
|
@@ -32,24 +39,20 @@ module PaperTrailDiff
|
|
|
32
39
|
# @rbs @range: TimelineRange
|
|
33
40
|
# @rbs @snapshotter: untyped
|
|
34
41
|
|
|
35
|
-
#: (
|
|
36
|
-
def compare_history(
|
|
37
|
-
return empty_history if
|
|
42
|
+
#: (RootVersionPlan) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
|
|
43
|
+
def compare_history(plan)
|
|
44
|
+
return empty_history if plan.empty?
|
|
38
45
|
|
|
46
|
+
versions = plan.versions
|
|
39
47
|
@snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
steps = versions.each_cons(2).map do |from_version, to_version|
|
|
43
|
-
current_snapshot = @snapshotter.call(to_version)
|
|
44
|
-
step = Step.new(
|
|
48
|
+
steps = plan.steps.map do |from_version, to_version|
|
|
49
|
+
Step.new(
|
|
45
50
|
from_version: from_version,
|
|
46
51
|
to_version: to_version,
|
|
47
|
-
diff: Engine.compare(
|
|
52
|
+
diff: Engine.compare(@snapshotter.call(from_version), @snapshotter.call(to_version))
|
|
48
53
|
)
|
|
49
|
-
previous_snapshot = current_snapshot
|
|
50
|
-
step
|
|
51
54
|
end.freeze
|
|
52
|
-
[steps,
|
|
55
|
+
[steps, @snapshotter.call(versions.first), @snapshotter.call(versions.last)]
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
#: () -> [Array[Step], nil, nil]
|
|
@@ -58,9 +61,9 @@ module PaperTrailDiff
|
|
|
58
61
|
[steps.freeze, nil, nil]
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
#: () ->
|
|
64
|
+
#: () -> RootVersionPlan
|
|
62
65
|
def selected_versions
|
|
63
|
-
@range.
|
|
66
|
+
@range.select_plan
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
end
|
|
@@ -10,10 +10,12 @@ module PaperTrailDiff
|
|
|
10
10
|
attr_reader :to #: untyped
|
|
11
11
|
attr_reader :time_range #: TimeRange?
|
|
12
12
|
|
|
13
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?) -> void
|
|
14
|
-
def initialize(record, from:, to:, within:, versions: nil)
|
|
13
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
|
|
14
|
+
def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil) # rubocop:disable Metrics/ParameterLists
|
|
15
15
|
@record = record
|
|
16
|
-
@
|
|
16
|
+
@plan = plan
|
|
17
|
+
@versions = (plan ? plan.versions : versions)&.freeze
|
|
18
|
+
@version_scope = version_scope
|
|
17
19
|
@requested_from = from
|
|
18
20
|
@requested_to = to
|
|
19
21
|
@time_range = build_time_range(within)
|
|
@@ -32,6 +34,26 @@ module PaperTrailDiff
|
|
|
32
34
|
|
|
33
35
|
# A batch may have selected these versions already, in which case reselecting
|
|
34
36
|
# them per record would undo the batching.
|
|
37
|
+
# The plan says which pairs of versions become steps, which a filter can
|
|
38
|
+
# make different from adjacent pairs of the selected versions.
|
|
39
|
+
#: (?context_required: bool) -> RootVersionPlan
|
|
40
|
+
def select_plan(context_required: false)
|
|
41
|
+
preselected = @plan
|
|
42
|
+
return preselected if preselected
|
|
43
|
+
|
|
44
|
+
range = time_range
|
|
45
|
+
if range
|
|
46
|
+
return TimeVersionRange.new(
|
|
47
|
+
@record, time_range: range, version_scope: @version_scope
|
|
48
|
+
).select_plan(context_required: context_required)
|
|
49
|
+
end
|
|
50
|
+
return RootVersionPlan.empty if unresolved?
|
|
51
|
+
|
|
52
|
+
VersionRange.new(
|
|
53
|
+
@record, from: @from, to: @to, version_scope: @version_scope
|
|
54
|
+
).select_plan_for_range
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
#: (?context_required: bool) -> Array[untyped]
|
|
36
58
|
def select(context_required: false)
|
|
37
59
|
preselected = @versions
|
|
@@ -39,13 +61,13 @@ module PaperTrailDiff
|
|
|
39
61
|
|
|
40
62
|
range = time_range
|
|
41
63
|
if range
|
|
42
|
-
return TimeVersionRange.new(
|
|
43
|
-
|
|
44
|
-
)
|
|
64
|
+
return TimeVersionRange.new(
|
|
65
|
+
@record, time_range: range, version_scope: @version_scope
|
|
66
|
+
).select(context_required: context_required)
|
|
45
67
|
end
|
|
46
68
|
return empty_versions if unresolved?
|
|
47
69
|
|
|
48
|
-
VersionRange.new(@record, from: @from, to: @to).select
|
|
70
|
+
VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
|
|
49
71
|
end
|
|
50
72
|
|
|
51
73
|
#: () -> bool
|
|
@@ -68,6 +90,8 @@ module PaperTrailDiff
|
|
|
68
90
|
|
|
69
91
|
# @rbs @record: untyped
|
|
70
92
|
# @rbs @versions: Array[untyped]?
|
|
93
|
+
# @rbs @plan: RootVersionPlan?
|
|
94
|
+
# @rbs @version_scope: untyped
|
|
71
95
|
# @rbs @requested_from: untyped
|
|
72
96
|
# @rbs @requested_to: untyped
|
|
73
97
|
# @rbs @from: untyped
|
|
@@ -4,11 +4,34 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Selects an inclusive, chronological range from a record's root versions.
|
|
6
6
|
class VersionRange
|
|
7
|
-
#: (untyped, from: untyped, to: untyped) -> void
|
|
8
|
-
def initialize(record, from:, to:)
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, ?version_scope: untyped) -> void
|
|
8
|
+
def initialize(record, from:, to:, version_scope: nil)
|
|
9
9
|
@record = record
|
|
10
10
|
@from = from
|
|
11
11
|
@to = to
|
|
12
|
+
@version_scope = version_scope
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Same selection as `select`, but keeping the step pairs a filter implies.
|
|
16
|
+
#: () -> RootVersionPlan
|
|
17
|
+
def select_plan_for_range
|
|
18
|
+
relation = validated_relation
|
|
19
|
+
select_plan(
|
|
20
|
+
relation.where(created_at: @from.created_at..@to.created_at),
|
|
21
|
+
through: @to
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: () -> untyped
|
|
26
|
+
def validated_relation
|
|
27
|
+
relation = versions_relation
|
|
28
|
+
validate_boundary!(@from, relation, boundary: :from)
|
|
29
|
+
validate_boundary!(@to, relation, boundary: :to)
|
|
30
|
+
if Support.compare_versions(@from, @to).positive?
|
|
31
|
+
raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
relation
|
|
12
35
|
end
|
|
13
36
|
|
|
14
37
|
#: () -> Array[untyped]
|
|
@@ -38,6 +61,7 @@ module PaperTrailDiff
|
|
|
38
61
|
# @rbs @record: untyped
|
|
39
62
|
# @rbs @from: untyped
|
|
40
63
|
# @rbs @to: untyped
|
|
64
|
+
# @rbs @version_scope: untyped
|
|
41
65
|
|
|
42
66
|
#: () -> untyped
|
|
43
67
|
def versions_relation
|
|
@@ -50,13 +74,29 @@ module PaperTrailDiff
|
|
|
50
74
|
|
|
51
75
|
#: (untyped, ?through: untyped) -> Array[untyped]
|
|
52
76
|
def select_relation(relation, through: nil)
|
|
53
|
-
|
|
77
|
+
select_plan(relation, through: through).versions
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (untyped, ?through: untyped) -> RootVersionPlan
|
|
81
|
+
def select_plan(relation, through: nil)
|
|
82
|
+
in_range = ordered(relation.to_a.select do |version|
|
|
54
83
|
next false if Support.compare_versions(@from, version).positive?
|
|
55
84
|
next true unless through
|
|
56
85
|
|
|
57
86
|
Support.compare_versions(version, through) <= 0
|
|
58
|
-
end
|
|
59
|
-
|
|
87
|
+
end)
|
|
88
|
+
RootVersionSelection.new(
|
|
89
|
+
in_range: in_range,
|
|
90
|
+
selected: VersionScopeFilter.new(@version_scope).call(relation, in_range),
|
|
91
|
+
after_range: nil,
|
|
92
|
+
windowed: false,
|
|
93
|
+
filtered: !@version_scope.nil?
|
|
94
|
+
).call
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
98
|
+
def ordered(versions)
|
|
99
|
+
versions.sort_by { |version| Support.chronological_version_key(version) }
|
|
60
100
|
end
|
|
61
101
|
|
|
62
102
|
#: (untyped, untyped, boundary: Symbol) -> void
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Applies a caller's version filter to a range, naming which of its versions
|
|
6
|
+
# count as selected mutations. The unfiltered versions stay available to the
|
|
7
|
+
# caller, because the one that reveals the last selected mutation is drawn
|
|
8
|
+
# from them.
|
|
9
|
+
class VersionScopeFilter
|
|
10
|
+
#: (untyped) -> void
|
|
11
|
+
def initialize(scope)
|
|
12
|
+
@scope = validated(scope)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
16
|
+
def call(relation, in_range)
|
|
17
|
+
scope = @scope
|
|
18
|
+
return in_range unless scope
|
|
19
|
+
|
|
20
|
+
# A narrowed relation is the expected return. Active Support also gives
|
|
21
|
+
# `pluck` to plain enumerables, so an array of versions works too.
|
|
22
|
+
chosen = Set.new(scope.call(relation).pluck(:id))
|
|
23
|
+
in_range.select { |version| chosen.include?(version.id) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# @rbs @scope: untyped
|
|
29
|
+
|
|
30
|
+
#: (untyped) -> untyped
|
|
31
|
+
def validated(scope)
|
|
32
|
+
return scope if scope.nil? || scope.respond_to?(:call)
|
|
33
|
+
|
|
34
|
+
raise ConfigurationError, 'version_scope: must respond to call'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|