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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +64 -1
  4. data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +28 -10
  8. data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
  9. data/lib/paper_trail_diff/batched_root_versions.rb +48 -30
  10. data/lib/paper_trail_diff/paper_trail_adapter.rb +25 -18
  11. data/lib/paper_trail_diff/root_version_plan.rb +86 -0
  12. data/lib/paper_trail_diff/root_version_selection.rb +131 -0
  13. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
  14. data/lib/paper_trail_diff/time_version_range.rb +19 -24
  15. data/lib/paper_trail_diff/timeline_builder.rb +21 -18
  16. data/lib/paper_trail_diff/timeline_range.rb +31 -7
  17. data/lib/paper_trail_diff/version.rb +1 -1
  18. data/lib/paper_trail_diff/version_range.rb +45 -5
  19. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  20. data/lib/paper_trail_diff.rb +24 -13
  21. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  23. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  24. data/sig/generated/paper_trail_diff/analysis_batch.rbs +13 -6
  25. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
  26. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +21 -14
  27. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +10 -10
  28. data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
  29. data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
  31. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
  32. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
  33. data/sig/generated/paper_trail_diff/timeline_range.rbs +11 -2
  34. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  35. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  36. data/sig/generated/paper_trail_diff.rbs +8 -8
  37. 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, _root_versions, closing = history_and_versions
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, root_versions, closing = history_and_versions
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(root_versions, history.root_snapshots),
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, Array[untyped], ActivityStep?]
40
+ #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?]
39
41
  def history_and_versions
40
- root_versions = @range.select(context_required: !@tree.empty?)
41
- return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
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, root_versions, nil] unless time_events?(selected, events)
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, root_versions, closing_step(history, selected.last)]
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
- #: (Array[ActivityEvent]) -> Array[ActivityEvent]
70
- def selected_events(events)
71
- events.select { |event| @range.include?(event.version) }
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
- relation = versions_relation
16
- selected = ordered(@time_range.scope(relation).to_a)
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
- (selected + [trailing]).freeze
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(record, from:, to:, snapshotter:, within: nil, versions: nil) # rubocop:disable Metrics/ParameterLists
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(record, from: from, to: to, within: within, versions: versions)
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
- #: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
36
- def compare_history(versions)
37
- return empty_history if versions.empty?
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
- first_snapshot = @snapshotter.call(versions.first)
41
- previous_snapshot = first_snapshot
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(previous_snapshot, current_snapshot)
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, first_snapshot, previous_snapshot]
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
- #: () -> Array[untyped]
64
+ #: () -> RootVersionPlan
62
65
  def selected_versions
63
- @range.select
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
- @versions = versions&.freeze
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(@record, time_range: range).select(
43
- context_required: context_required
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
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
@@ -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
- selected = relation.to_a.select do |version|
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
- selected.sort_by { |version| Support.chronological_version_key(version) }
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