paper_trail_diff 0.4.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 +44 -0
- data/README.md +120 -3
- 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 +92 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
- data/lib/paper_trail_diff/batched_root_versions.rb +149 -0
- data/lib/paper_trail_diff/comparison_batch.rb +37 -13
- data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
- data/lib/paper_trail_diff/paper_trail_adapter.rb +70 -47
- 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 +37 -6
- data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
- 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 +54 -12
- 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 +49 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +69 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +24 -15
- 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 +15 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- 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 +17 -7
- metadata +20 -4
|
@@ -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) -> 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,9 +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) -> void
|
|
14
|
-
def initialize(record, from:, to:, within:)
|
|
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
|
+
@plan = plan
|
|
17
|
+
@versions = (plan ? plan.versions : versions)&.freeze
|
|
18
|
+
@version_scope = version_scope
|
|
16
19
|
@requested_from = from
|
|
17
20
|
@requested_to = to
|
|
18
21
|
@time_range = build_time_range(within)
|
|
@@ -29,17 +32,42 @@ module PaperTrailDiff
|
|
|
29
32
|
(symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
|
|
30
33
|
end
|
|
31
34
|
|
|
35
|
+
# A batch may have selected these versions already, in which case reselecting
|
|
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
|
+
|
|
32
57
|
#: (?context_required: bool) -> Array[untyped]
|
|
33
58
|
def select(context_required: false)
|
|
59
|
+
preselected = @versions
|
|
60
|
+
return preselected if preselected
|
|
61
|
+
|
|
34
62
|
range = time_range
|
|
35
63
|
if range
|
|
36
|
-
return TimeVersionRange.new(
|
|
37
|
-
|
|
38
|
-
)
|
|
64
|
+
return TimeVersionRange.new(
|
|
65
|
+
@record, time_range: range, version_scope: @version_scope
|
|
66
|
+
).select(context_required: context_required)
|
|
39
67
|
end
|
|
40
68
|
return empty_versions if unresolved?
|
|
41
69
|
|
|
42
|
-
VersionRange.new(@record, from: @from, to: @to).select
|
|
70
|
+
VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
|
|
43
71
|
end
|
|
44
72
|
|
|
45
73
|
#: () -> bool
|
|
@@ -61,6 +89,9 @@ module PaperTrailDiff
|
|
|
61
89
|
private
|
|
62
90
|
|
|
63
91
|
# @rbs @record: untyped
|
|
92
|
+
# @rbs @versions: Array[untyped]?
|
|
93
|
+
# @rbs @plan: RootVersionPlan?
|
|
94
|
+
# @rbs @version_scope: untyped
|
|
64
95
|
# @rbs @requested_from: untyped
|
|
65
96
|
# @rbs @requested_to: untyped
|
|
66
97
|
# @rbs @from: untyped
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Checks that a model can be traversed before any reconstruction starts, and
|
|
6
|
+
# that association tracking is actually available when history is involved.
|
|
7
|
+
class TraversalPreparer
|
|
8
|
+
#: (tree: AssociationTree, traversal: AssociationTraversal) -> void
|
|
9
|
+
def initialize(tree:, traversal:)
|
|
10
|
+
@tree = tree
|
|
11
|
+
@traversal = traversal
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (untyped, historical: bool) -> void
|
|
15
|
+
def call(model_class, historical:)
|
|
16
|
+
return if @tree.empty?
|
|
17
|
+
|
|
18
|
+
ensure_association_tracking! if historical
|
|
19
|
+
@traversal.validate!(model_class)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs @tree: AssociationTree
|
|
25
|
+
# @rbs @traversal: AssociationTraversal
|
|
26
|
+
|
|
27
|
+
#: () -> void
|
|
28
|
+
def ensure_association_tracking!
|
|
29
|
+
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
30
|
+
config = paper_trail.config #: untyped
|
|
31
|
+
available = defined?(::PaperTrailAssociationTracking) &&
|
|
32
|
+
config.respond_to?(:track_associations?) &&
|
|
33
|
+
config.track_associations?
|
|
34
|
+
return if available
|
|
35
|
+
|
|
36
|
+
message = 'association tracking must be loaded and enabled to compare historical associations'
|
|
37
|
+
raise AssociationTrackingUnavailableError, message
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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
|
-
|
|
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
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative 'paper_trail_diff/errors'
|
|
|
10
10
|
require_relative 'paper_trail_diff/configuration'
|
|
11
11
|
require_relative 'paper_trail_diff/endpoint'
|
|
12
12
|
require_relative 'paper_trail_diff/association_traversal'
|
|
13
|
+
require_relative 'paper_trail_diff/traversal_preparer'
|
|
13
14
|
require_relative 'paper_trail_diff/association_discovery'
|
|
14
15
|
require_relative 'paper_trail_diff/diagnostics'
|
|
15
16
|
require_relative 'paper_trail_diff/collection_identity_index'
|
|
@@ -36,7 +37,12 @@ require_relative 'paper_trail_diff/live_endpoint_batch_loader'
|
|
|
36
37
|
require_relative 'paper_trail_diff/preloaded_endpoint_batch_loader'
|
|
37
38
|
require_relative 'paper_trail_diff/live_endpoint_provider'
|
|
38
39
|
require_relative 'paper_trail_diff/live_graph_collector'
|
|
40
|
+
require_relative 'paper_trail_diff/batch_boundary_resolver'
|
|
39
41
|
require_relative 'paper_trail_diff/comparison_batch'
|
|
42
|
+
require_relative 'paper_trail_diff/root_version_plan'
|
|
43
|
+
require_relative 'paper_trail_diff/version_scope_filter'
|
|
44
|
+
require_relative 'paper_trail_diff/root_version_selection'
|
|
45
|
+
require_relative 'paper_trail_diff/batched_root_versions'
|
|
40
46
|
require_relative 'paper_trail_diff/snapshot_normalizer'
|
|
41
47
|
require_relative 'paper_trail_diff/historical_snapshot_store'
|
|
42
48
|
require_relative 'paper_trail_diff/timeline_snapshot_provider'
|
|
@@ -56,6 +62,8 @@ require_relative 'paper_trail_diff/activity_boundary'
|
|
|
56
62
|
require_relative 'paper_trail_diff/step'
|
|
57
63
|
require_relative 'paper_trail_diff/analysis'
|
|
58
64
|
require_relative 'paper_trail_diff/activity_root_steps'
|
|
65
|
+
require_relative 'paper_trail_diff/analysis_batch'
|
|
66
|
+
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
59
67
|
require_relative 'paper_trail_diff/version_range'
|
|
60
68
|
require_relative 'paper_trail_diff/time_range'
|
|
61
69
|
require_relative 'paper_trail_diff/time_version_range'
|
|
@@ -90,8 +98,9 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
|
|
|
90
98
|
# type traversal_record_path = Array[RecordReference]
|
|
91
99
|
# end
|
|
92
100
|
|
|
93
|
-
# Structured version comparison for PaperTrail.
|
|
94
|
-
|
|
101
|
+
# Structured version comparison for PaperTrail. Each method is a thin,
|
|
102
|
+
# documented entry point, so this reads as an API listing rather than logic.
|
|
103
|
+
module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
95
104
|
DEFAULT_IGNORED_ATTRIBUTES = ['updated_at'].freeze
|
|
96
105
|
SUPPORTED_ASSOCIATION_MACROS = %i[
|
|
97
106
|
belongs_to
|
|
@@ -136,43 +145,54 @@ module PaperTrailDiff
|
|
|
136
145
|
end
|
|
137
146
|
|
|
138
147
|
# Compares every adjacent reconstructed state in an inclusive version range.
|
|
139
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option) -> Array[Step]
|
|
148
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped) -> Array[Step]
|
|
140
149
|
def timeline( # rubocop:disable Metrics/ParameterLists
|
|
141
150
|
record,
|
|
142
151
|
from: nil,
|
|
143
152
|
to: nil,
|
|
144
153
|
within: nil,
|
|
145
154
|
associations: [],
|
|
146
|
-
ignore: DEFAULT_IGNORED_ATTRIBUTES
|
|
155
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
156
|
+
version_scope: nil
|
|
147
157
|
)
|
|
148
158
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).timeline(
|
|
149
159
|
record,
|
|
150
160
|
from: from,
|
|
151
161
|
to: to,
|
|
152
|
-
within: within
|
|
162
|
+
within: within,
|
|
163
|
+
version_scope: version_scope
|
|
153
164
|
)
|
|
154
165
|
end
|
|
155
166
|
|
|
156
167
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
157
|
-
|
|
168
|
+
# `reload_live_endpoints:` applies only when `to:` is a current record; the
|
|
169
|
+
# other range forms never read live state.
|
|
170
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped) -> Array[ActivityStep]
|
|
158
171
|
def activity_timeline( # rubocop:disable Metrics/ParameterLists
|
|
159
172
|
record,
|
|
160
173
|
from: nil,
|
|
161
174
|
to: nil,
|
|
162
175
|
within: nil,
|
|
163
176
|
associations: [],
|
|
164
|
-
ignore: DEFAULT_IGNORED_ATTRIBUTES
|
|
177
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
178
|
+
reload_live_endpoints: true,
|
|
179
|
+
version_scope: nil
|
|
165
180
|
)
|
|
166
|
-
PaperTrailAdapter.new(
|
|
181
|
+
PaperTrailAdapter.new(
|
|
182
|
+
associations: associations,
|
|
183
|
+
ignore: ignore,
|
|
184
|
+
reload_live_endpoints: reload_live_endpoints
|
|
185
|
+
).activity_timeline(
|
|
167
186
|
record,
|
|
168
187
|
from: from,
|
|
169
188
|
to: to,
|
|
170
|
-
within: within
|
|
189
|
+
within: within,
|
|
190
|
+
version_scope: version_scope
|
|
171
191
|
)
|
|
172
192
|
end
|
|
173
193
|
|
|
174
194
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
175
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Analysis
|
|
195
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Analysis
|
|
176
196
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
177
197
|
record,
|
|
178
198
|
from: nil,
|
|
@@ -180,14 +200,36 @@ module PaperTrailDiff
|
|
|
180
200
|
within: nil,
|
|
181
201
|
associations: [],
|
|
182
202
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
183
|
-
activity: false
|
|
203
|
+
activity: false,
|
|
204
|
+
version_scope: nil
|
|
184
205
|
)
|
|
185
206
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
186
207
|
record,
|
|
187
208
|
from: from,
|
|
188
209
|
to: to,
|
|
189
210
|
within: within,
|
|
190
|
-
activity: activity
|
|
211
|
+
activity: activity,
|
|
212
|
+
version_scope: version_scope
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Analyzes many roots over one shared time window, preparing their selected
|
|
217
|
+
# history once for the batch instead of once per record. Roots with no
|
|
218
|
+
# versions in the window return an empty `Analysis`.
|
|
219
|
+
#: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
|
|
220
|
+
def analyze_many( # rubocop:disable Metrics/ParameterLists
|
|
221
|
+
records,
|
|
222
|
+
within: nil,
|
|
223
|
+
associations: [],
|
|
224
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
225
|
+
activity: false,
|
|
226
|
+
version_scope: nil
|
|
227
|
+
)
|
|
228
|
+
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
|
|
229
|
+
records,
|
|
230
|
+
within: within,
|
|
231
|
+
activity: activity,
|
|
232
|
+
version_scope: version_scope
|
|
191
233
|
)
|
|
192
234
|
end
|
|
193
235
|
|
|
@@ -4,8 +4,8 @@ module PaperTrailDiff
|
|
|
4
4
|
# Root checkpoint steps recovered from the snapshots an activity pass retained,
|
|
5
5
|
# so a combined result does not reconstruct the same boundaries twice.
|
|
6
6
|
module ActivityRootSteps
|
|
7
|
-
# : (
|
|
8
|
-
def self?.call: (
|
|
7
|
+
# : (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
8
|
+
def self?.call: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
9
9
|
|
|
10
10
|
# : (untyped) -> Array[untyped]
|
|
11
11
|
def self?.version_key: (untyped) -> Array[untyped]
|
|
@@ -30,6 +30,10 @@ module PaperTrailDiff
|
|
|
30
30
|
# : () -> Array[ActivityStep]
|
|
31
31
|
def no_steps: () -> Array[ActivityStep]
|
|
32
32
|
|
|
33
|
+
# Every boundary in the span, filtered out or not. A filter narrows where the
|
|
34
|
+
# span starts and ends, but the sequence inside it stays complete: dropping
|
|
35
|
+
# boundaries would fold the changes they carried into a neighbouring step and
|
|
36
|
+
# credit them to whoever made that one.
|
|
33
37
|
# : () -> Array[ActivityStep]
|
|
34
38
|
def build_between_versions: () -> Array[ActivityStep]
|
|
35
39
|
|
|
@@ -67,8 +71,8 @@ module PaperTrailDiff
|
|
|
67
71
|
# Only the activity view gains the closing removal. The endpoint diff and
|
|
68
72
|
# the root timeline keep their `compare` and `timeline` semantics, under
|
|
69
73
|
# which the state at a destroy version is the state before the deletion.
|
|
70
|
-
# : (
|
|
71
|
-
def build_analysis: (
|
|
74
|
+
# : (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
75
|
+
def build_analysis: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
72
76
|
|
|
73
77
|
# : () -> TimeActivityTimelineBuilder
|
|
74
78
|
def time_builder: () -> TimeActivityTimelineBuilder
|
|
@@ -9,15 +9,24 @@ module PaperTrailDiff
|
|
|
9
9
|
|
|
10
10
|
attr_reader activity_timeline: Array[ActivityStep]?
|
|
11
11
|
|
|
12
|
+
attr_reader from_snapshot: RecordSnapshot?
|
|
13
|
+
|
|
14
|
+
attr_reader to_snapshot: RecordSnapshot?
|
|
15
|
+
|
|
12
16
|
# The result for a record whose requested history contains no versions,
|
|
13
17
|
# which is an empty history rather than a failed request.
|
|
14
18
|
# : () -> Analysis
|
|
15
19
|
def self.empty: () -> Analysis
|
|
16
20
|
|
|
17
|
-
#
|
|
18
|
-
|
|
21
|
+
# The reconstructed states the diff was taken between. A report that has to
|
|
22
|
+
# render unchanged columns needs the whole final state, not only what moved.
|
|
23
|
+
# : (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
24
|
+
def initialize: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
19
25
|
|
|
20
|
-
#
|
|
21
|
-
|
|
26
|
+
# Reconstructed endpoint states are opt-in, because they carry the whole
|
|
27
|
+
# selected graph whether or not anything changed, which dwarfs the rest of
|
|
28
|
+
# the payload for a wide graph.
|
|
29
|
+
# : (?snapshots: bool) -> Hash[Symbol, untyped]
|
|
30
|
+
def to_h: (?snapshots: bool) -> Hash[Symbol, untyped]
|
|
22
31
|
end
|
|
23
32
|
end
|