paper_trail_diff 0.3.1 → 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 +61 -0
- data/README.md +260 -30
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
- 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/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 +1 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
- 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 +6 -4
|
@@ -14,17 +14,17 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
#: () -> Array[ActivityStep]
|
|
16
16
|
def build
|
|
17
|
-
history, = history_and_versions
|
|
18
|
-
history
|
|
17
|
+
history, _root_versions, closing = history_and_versions
|
|
18
|
+
activity_steps(history, closing)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
#: () -> Analysis
|
|
22
22
|
def analyze
|
|
23
|
-
history, root_versions = history_and_versions
|
|
23
|
+
history, root_versions, closing = history_and_versions
|
|
24
24
|
Analysis.new(
|
|
25
25
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
26
|
-
timeline:
|
|
27
|
-
activity_timeline: history
|
|
26
|
+
timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
|
|
27
|
+
activity_timeline: activity_steps(history, closing)
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -35,22 +35,61 @@ module PaperTrailDiff
|
|
|
35
35
|
# @rbs @tree: AssociationTree
|
|
36
36
|
# @rbs @snapshotter: untyped
|
|
37
37
|
|
|
38
|
-
#: () -> [ActivityHistory, Array[untyped]]
|
|
38
|
+
#: () -> [ActivityHistory, Array[untyped], ActivityStep?]
|
|
39
39
|
def history_and_versions
|
|
40
40
|
root_versions = @range.select(context_required: !@tree.empty?)
|
|
41
|
-
return [ActivityHistory.empty, root_versions] if root_versions.empty?
|
|
41
|
+
return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
|
|
42
42
|
|
|
43
43
|
prepare_history(root_versions)
|
|
44
44
|
events = collect_events(root_versions)
|
|
45
|
-
|
|
45
|
+
selected = selected_events(events)
|
|
46
|
+
return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
|
|
46
47
|
|
|
47
|
-
history =
|
|
48
|
+
history = build_history(root_versions, events)
|
|
49
|
+
[history, root_versions, closing_step(history, selected.last)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
|
|
53
|
+
def build_history(root_versions, events)
|
|
54
|
+
ActivityHistoryBuilder.new(
|
|
48
55
|
root_versions,
|
|
49
56
|
events,
|
|
50
57
|
@snapshotter,
|
|
51
58
|
include_step: ->(event) { @range.include?(event.version) }
|
|
52
59
|
).call
|
|
53
|
-
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
|
|
63
|
+
def activity_steps(history, closing)
|
|
64
|
+
return history.steps unless closing
|
|
65
|
+
|
|
66
|
+
(history.steps + [closing]).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (Array[ActivityEvent]) -> Array[ActivityEvent]
|
|
70
|
+
def selected_events(events)
|
|
71
|
+
events.select { |event| @range.include?(event.version) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The window's last selected mutation is the root's own destruction, so the
|
|
75
|
+
# timeline closes on the absence it leaves rather than on a later boundary.
|
|
76
|
+
#: (ActivityHistory, ActivityEvent?) -> ActivityStep?
|
|
77
|
+
def closing_step(history, event)
|
|
78
|
+
return unless event && terminal_destroy?(event)
|
|
79
|
+
|
|
80
|
+
version = event.version
|
|
81
|
+
ActivityStep.new(
|
|
82
|
+
from_boundary: ActivityBoundary.from_version(version),
|
|
83
|
+
to_boundary: ActivityBoundary.destroyed(version),
|
|
84
|
+
diff: Engine.compare(history.root_snapshots[ActivityRootSteps.version_key(version)], nil)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (ActivityEvent?) -> bool
|
|
89
|
+
def terminal_destroy?(event)
|
|
90
|
+
return false unless event
|
|
91
|
+
|
|
92
|
+
event.root? && event.version.event.to_s == 'destroy'
|
|
54
93
|
end
|
|
55
94
|
|
|
56
95
|
#: (Array[untyped]) -> void
|
|
@@ -72,11 +111,11 @@ module PaperTrailDiff
|
|
|
72
111
|
).call
|
|
73
112
|
end
|
|
74
113
|
|
|
75
|
-
#: (Array[ActivityEvent]) -> bool
|
|
76
|
-
def time_events?(events)
|
|
77
|
-
selected = events.select { |event| @range.include?(event.version) }
|
|
114
|
+
#: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
|
|
115
|
+
def time_events?(selected, events)
|
|
78
116
|
return false if selected.empty?
|
|
79
117
|
return true if later_event?(selected.last, events)
|
|
118
|
+
return true if terminal_destroy?(selected.last)
|
|
80
119
|
|
|
81
120
|
message = 'time range requires a later activity boundary to reconstruct its final change'
|
|
82
121
|
raise IncompleteTimeRangeError, message
|
|
@@ -88,24 +127,5 @@ module PaperTrailDiff
|
|
|
88
127
|
Support.compare_versions(selected.version, event.version).negative?
|
|
89
128
|
end
|
|
90
129
|
end
|
|
91
|
-
|
|
92
|
-
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
93
|
-
def root_steps(root_versions, root_snapshots)
|
|
94
|
-
snapshots = root_versions.map do |version|
|
|
95
|
-
root_snapshots.fetch(version_key(version))
|
|
96
|
-
end
|
|
97
|
-
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
98
|
-
Step.new(
|
|
99
|
-
from_version: versions.fetch(0),
|
|
100
|
-
to_version: versions.fetch(1),
|
|
101
|
-
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
102
|
-
)
|
|
103
|
-
end.freeze
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
#: (untyped) -> Array[untyped]
|
|
107
|
-
def version_key(version)
|
|
108
|
-
[version.class.name, version.id]
|
|
109
|
-
end
|
|
110
130
|
end
|
|
111
131
|
end
|
|
@@ -19,6 +19,8 @@ module PaperTrailDiff
|
|
|
19
19
|
|
|
20
20
|
trailing = trailing_version(relation)
|
|
21
21
|
unless trailing
|
|
22
|
+
return selected.freeze if terminal_destroy?(selected)
|
|
23
|
+
|
|
22
24
|
message = 'time range requires a later root version to reconstruct its final change'
|
|
23
25
|
raise IncompleteTimeRangeError, message
|
|
24
26
|
end
|
|
@@ -40,6 +42,15 @@ module PaperTrailDiff
|
|
|
40
42
|
raise InvalidTimelineRangeError, message, cause: e
|
|
41
43
|
end
|
|
42
44
|
|
|
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
|
+
|
|
43
54
|
#: (untyped) -> untyped
|
|
44
55
|
def trailing_version(relation)
|
|
45
56
|
@time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Chooses explicit-version or wall-clock selection for one timeline request.
|
|
6
6
|
class TimelineRange
|
|
7
|
+
BOUNDARY_SYMBOLS = %i[first last].freeze
|
|
8
|
+
|
|
7
9
|
attr_reader :from #: untyped
|
|
8
10
|
attr_reader :to #: untyped
|
|
9
11
|
attr_reader :time_range #: TimeRange?
|
|
@@ -11,13 +13,22 @@ module PaperTrailDiff
|
|
|
11
13
|
#: (untyped, from: untyped, to: untyped, within: untyped) -> void
|
|
12
14
|
def initialize(record, from:, to:, within:)
|
|
13
15
|
@record = record
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
+
@requested_from = from
|
|
17
|
+
@requested_to = to
|
|
16
18
|
@time_range = build_time_range(within)
|
|
17
19
|
validate_mode!
|
|
20
|
+
@from = resolve(from)
|
|
21
|
+
@to = resolve(to)
|
|
18
22
|
freeze
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# A record with no versions has no first or last boundary to resolve, which
|
|
26
|
+
# is an empty history rather than a bad request.
|
|
27
|
+
#: () -> bool
|
|
28
|
+
def unresolved?
|
|
29
|
+
(symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
#: (?context_required: bool) -> Array[untyped]
|
|
22
33
|
def select(context_required: false)
|
|
23
34
|
range = time_range
|
|
@@ -26,6 +37,7 @@ module PaperTrailDiff
|
|
|
26
37
|
context_required: context_required
|
|
27
38
|
)
|
|
28
39
|
end
|
|
40
|
+
return empty_versions if unresolved?
|
|
29
41
|
|
|
30
42
|
VersionRange.new(@record, from: @from, to: @to).select
|
|
31
43
|
end
|
|
@@ -49,9 +61,50 @@ module PaperTrailDiff
|
|
|
49
61
|
private
|
|
50
62
|
|
|
51
63
|
# @rbs @record: untyped
|
|
64
|
+
# @rbs @requested_from: untyped
|
|
65
|
+
# @rbs @requested_to: untyped
|
|
52
66
|
# @rbs @from: untyped
|
|
53
67
|
# @rbs @to: untyped
|
|
54
68
|
|
|
69
|
+
#: () -> Array[untyped]
|
|
70
|
+
def empty_versions
|
|
71
|
+
versions = [] #: Array[untyped]
|
|
72
|
+
versions.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (untyped) -> bool
|
|
76
|
+
def symbolic?(boundary)
|
|
77
|
+
boundary.is_a?(Symbol)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resolving `:first` and `:last` here keeps callers from depending on the
|
|
81
|
+
# order PaperTrail happens to give its versions association, which a caller
|
|
82
|
+
# is also free to reorder.
|
|
83
|
+
#: (untyped) -> untyped
|
|
84
|
+
def resolve(boundary)
|
|
85
|
+
return boundary unless symbolic?(boundary)
|
|
86
|
+
|
|
87
|
+
unless BOUNDARY_SYMBOLS.include?(boundary)
|
|
88
|
+
raise InvalidTimelineRangeError,
|
|
89
|
+
"unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
boundary == :first ? ordered_versions.first : ordered_versions.last
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: () -> untyped
|
|
96
|
+
def ordered_versions
|
|
97
|
+
versions_relation.reorder(created_at: :asc, id: :asc)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: () -> untyped
|
|
101
|
+
def versions_relation
|
|
102
|
+
@record.public_send(@record.class.versions_association_name)
|
|
103
|
+
rescue NoMethodError => e
|
|
104
|
+
message = 'record does not expose a PaperTrail version history'
|
|
105
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
106
|
+
end
|
|
107
|
+
|
|
55
108
|
#: (untyped) -> TimeRange?
|
|
56
109
|
def build_time_range(within)
|
|
57
110
|
TimeRange.new(within) unless within.nil?
|
|
@@ -60,11 +113,11 @@ module PaperTrailDiff
|
|
|
60
113
|
#: () -> void
|
|
61
114
|
def validate_mode!
|
|
62
115
|
if time?
|
|
63
|
-
return if @
|
|
116
|
+
return if @requested_from.nil? && @requested_to.nil?
|
|
64
117
|
|
|
65
118
|
raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
|
|
66
119
|
end
|
|
67
|
-
return unless @
|
|
120
|
+
return unless @requested_from.nil? || @requested_to.nil?
|
|
68
121
|
|
|
69
122
|
raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
|
|
70
123
|
end
|
|
@@ -24,45 +24,72 @@ module PaperTrailDiff
|
|
|
24
24
|
# @rbs @start_at: untyped
|
|
25
25
|
# @rbs @end_at: untyped
|
|
26
26
|
|
|
27
|
+
# Only quoting helpers are needed, so the connection is borrowed for the
|
|
28
|
+
# duration of building the condition rather than checked out permanently,
|
|
29
|
+
# which Active Record 7.2 and newer can deprecate. Active Record 7.1 has no
|
|
30
|
+
# `with_connection` and reaches the same connection through the accessor.
|
|
31
|
+
#: () { (untyped) -> String } -> String
|
|
32
|
+
def with_connection(&block)
|
|
33
|
+
return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
|
|
34
|
+
|
|
35
|
+
block.call(@version_class.connection)
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
#: () -> String
|
|
28
|
-
def candidate_condition
|
|
29
|
-
connection
|
|
39
|
+
def candidate_condition
|
|
40
|
+
with_connection { |connection| condition_sql(connection) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> String
|
|
44
|
+
def condition_sql(connection) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
30
45
|
outer = connection.quote_table_name(@version_class.table_name)
|
|
31
46
|
later = connection.quote_table_name('paper_trail_diff_later_versions')
|
|
32
47
|
start_at = connection.quote(@start_at)
|
|
33
|
-
|
|
34
|
-
activity = activity_condition(outer, start_at)
|
|
48
|
+
columns = quoted_columns(connection, outer, later)
|
|
35
49
|
compact_sql(<<~SQL)
|
|
36
50
|
(
|
|
37
|
-
#{
|
|
51
|
+
#{activity_condition(columns, start_at, connection)}
|
|
38
52
|
OR (
|
|
39
|
-
#{
|
|
40
|
-
AND #{
|
|
53
|
+
#{columns.fetch(:outer_created_at)} < #{start_at}
|
|
54
|
+
AND #{columns.fetch(:outer_event)} != #{connection.quote('destroy')}
|
|
41
55
|
AND NOT EXISTS (
|
|
42
56
|
SELECT 1
|
|
43
57
|
FROM #{outer} #{later}
|
|
44
|
-
WHERE #{
|
|
45
|
-
AND #{
|
|
46
|
-
AND #{
|
|
47
|
-
AND #{
|
|
58
|
+
WHERE #{columns.fetch(:later_item_type)} = #{columns.fetch(:outer_item_type)}
|
|
59
|
+
AND #{columns.fetch(:later_item_id)} = #{columns.fetch(:outer_item_id)}
|
|
60
|
+
AND #{columns.fetch(:later_created_at)} < #{start_at}
|
|
61
|
+
AND #{columns.fetch(:later_created_at)} > #{columns.fetch(:outer_created_at)}
|
|
48
62
|
)
|
|
49
63
|
)
|
|
50
64
|
)
|
|
51
65
|
SQL
|
|
52
66
|
end
|
|
53
67
|
|
|
54
|
-
#: (String, String) -> String
|
|
55
|
-
def
|
|
56
|
-
|
|
68
|
+
#: (untyped, String, String) -> Hash[Symbol, String]
|
|
69
|
+
def quoted_columns(connection, outer, later)
|
|
70
|
+
{
|
|
71
|
+
outer_created_at: column(connection, outer, 'created_at'),
|
|
72
|
+
outer_event: column(connection, outer, 'event'),
|
|
73
|
+
outer_item_type: column(connection, outer, 'item_type'),
|
|
74
|
+
outer_item_id: column(connection, outer, 'item_id'),
|
|
75
|
+
later_created_at: column(connection, later, 'created_at'),
|
|
76
|
+
later_item_type: column(connection, later, 'item_type'),
|
|
77
|
+
later_item_id: column(connection, later, 'item_id')
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (Hash[Symbol, String], String, untyped) -> String
|
|
82
|
+
def activity_condition(columns, start_at, connection)
|
|
83
|
+
created_at = columns.fetch(:outer_created_at)
|
|
84
|
+
condition = "#{created_at} >= #{start_at}"
|
|
57
85
|
return condition unless @end_at
|
|
58
86
|
|
|
59
|
-
|
|
60
|
-
"(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
|
|
87
|
+
"(#{condition} AND #{created_at} <= #{connection.quote(@end_at)})"
|
|
61
88
|
end
|
|
62
89
|
|
|
63
|
-
#: (String, String) -> String
|
|
64
|
-
def column(table, name)
|
|
65
|
-
"#{table}.#{
|
|
90
|
+
#: (untyped, String, String) -> String
|
|
91
|
+
def column(connection, table, name)
|
|
92
|
+
"#{table}.#{connection.quote_column_name(name)}"
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
#: (String) -> String
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative 'paper_trail_diff/branch_snapshot_refresher'
|
|
|
55
55
|
require_relative 'paper_trail_diff/activity_boundary'
|
|
56
56
|
require_relative 'paper_trail_diff/step'
|
|
57
57
|
require_relative 'paper_trail_diff/analysis'
|
|
58
|
+
require_relative 'paper_trail_diff/activity_root_steps'
|
|
58
59
|
require_relative 'paper_trail_diff/version_range'
|
|
59
60
|
require_relative 'paper_trail_diff/time_range'
|
|
60
61
|
require_relative 'paper_trail_diff/time_version_range'
|
|
@@ -25,6 +25,12 @@ module PaperTrailDiff
|
|
|
25
25
|
# : (untyped, captured_at: untyped) -> ActivityBoundary
|
|
26
26
|
def self.current: (untyped, captured_at: untyped) -> ActivityBoundary
|
|
27
27
|
|
|
28
|
+
# The state a `destroy` version leaves behind. A version records the state
|
|
29
|
+
# before its own event, so the boundary built from a destroy version still
|
|
30
|
+
# holds the record; this one is the absence that follows it.
|
|
31
|
+
# : (untyped) -> ActivityBoundary
|
|
32
|
+
def self.destroyed: (untyped) -> ActivityBoundary
|
|
33
|
+
|
|
28
34
|
# : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
|
|
29
35
|
def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
|
|
30
36
|
|
|
@@ -34,6 +40,9 @@ module PaperTrailDiff
|
|
|
34
40
|
# : () -> bool
|
|
35
41
|
def current?: () -> bool
|
|
36
42
|
|
|
43
|
+
# : () -> bool
|
|
44
|
+
def destroyed?: () -> bool
|
|
45
|
+
|
|
37
46
|
# : () -> Hash[Symbol, untyped]
|
|
38
47
|
def to_h: () -> Hash[Symbol, untyped]
|
|
39
48
|
end
|
|
@@ -14,27 +14,20 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
@
|
|
17
|
+
@belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
|
|
18
18
|
|
|
19
|
-
@
|
|
19
|
+
@collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
|
|
20
20
|
|
|
21
|
-
@
|
|
22
|
-
|
|
23
|
-
@belongs_to_tree: AssociationTree?
|
|
24
|
-
|
|
25
|
-
@belongs_to_type: String?
|
|
26
|
-
|
|
27
|
-
@collection_model: untyped
|
|
28
|
-
|
|
29
|
-
@collection_path: String?
|
|
30
|
-
|
|
31
|
-
@collection_routes: Array[Array[untyped]]?
|
|
32
|
-
|
|
33
|
-
@collection_tree: AssociationTree?
|
|
21
|
+
@traversal: AssociationTraversal
|
|
34
22
|
|
|
35
|
-
|
|
23
|
+
# Timelines interleave event types, so every requested combination is
|
|
24
|
+
# retained. Model, tree, and path are fixed per adapter, leaving one entry
|
|
25
|
+
# per selected item type.
|
|
26
|
+
# : (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
|
|
27
|
+
def cached: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
|
|
36
28
|
|
|
37
|
-
|
|
29
|
+
# : (Array[untyped]) -> Array[untyped]
|
|
30
|
+
def cache_key: (Array[untyped]) -> Array[untyped]
|
|
38
31
|
|
|
39
32
|
# : (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
40
33
|
def freeze_routes: (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_root_steps.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Root checkpoint steps recovered from the snapshots an activity pass retained,
|
|
5
|
+
# so a combined result does not reconstruct the same boundaries twice.
|
|
6
|
+
module ActivityRootSteps
|
|
7
|
+
# : (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
8
|
+
def self?.call: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
9
|
+
|
|
10
|
+
# : (untyped) -> Array[untyped]
|
|
11
|
+
def self?.version_key: (untyped) -> Array[untyped]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -6,8 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
# : (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
|
|
7
7
|
def initialize: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
|
|
8
8
|
|
|
9
|
-
# : (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
10
|
-
def prepare: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
9
|
+
# : (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
10
|
+
def prepare: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
11
11
|
|
|
12
12
|
# : (untyped, untyped) -> RecordSnapshot?
|
|
13
13
|
def call: (untyped, untyped) -> RecordSnapshot?
|
|
@@ -27,6 +27,9 @@ module PaperTrailDiff
|
|
|
27
27
|
|
|
28
28
|
@tree: AssociationTree
|
|
29
29
|
|
|
30
|
+
# : () -> Array[ActivityStep]
|
|
31
|
+
def no_steps: () -> Array[ActivityStep]
|
|
32
|
+
|
|
30
33
|
# : () -> Array[ActivityStep]
|
|
31
34
|
def build_between_versions: () -> Array[ActivityStep]
|
|
32
35
|
|
|
@@ -42,23 +45,30 @@ module PaperTrailDiff
|
|
|
42
45
|
# : (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
|
|
43
46
|
def collect_events: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
|
|
44
47
|
|
|
45
|
-
# : (Array[untyped], ?
|
|
46
|
-
def prepare_history: (Array[untyped], ?
|
|
48
|
+
# : (Array[untyped], ?end_at: untyped) -> void
|
|
49
|
+
def prepare_history: (Array[untyped], ?end_at: untyped) -> void
|
|
47
50
|
|
|
48
51
|
# : (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
49
52
|
def build_event_steps: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
50
53
|
|
|
51
|
-
#
|
|
52
|
-
|
|
54
|
+
# Appends the transition into an explicit closing boundary, which is either
|
|
55
|
+
# a requested current record or the absence a final root destroy leaves.
|
|
56
|
+
# : (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
57
|
+
def activity_steps: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
53
58
|
|
|
54
|
-
#
|
|
55
|
-
|
|
59
|
+
# A destroyed root has no later version, but its own event states that
|
|
60
|
+
# nothing follows it, so the removal can still close the timeline.
|
|
61
|
+
# : (Array[untyped]) -> ActivityBoundary?
|
|
62
|
+
def destroyed_boundary: (Array[untyped]) -> ActivityBoundary?
|
|
56
63
|
|
|
57
|
-
# : (Array[untyped], Array[
|
|
58
|
-
def
|
|
64
|
+
# : (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
65
|
+
def event_history: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
59
66
|
|
|
60
|
-
#
|
|
61
|
-
|
|
67
|
+
# Only the activity view gains the closing removal. The endpoint diff and
|
|
68
|
+
# the root timeline keep their `compare` and `timeline` semantics, under
|
|
69
|
+
# which the state at a destroy version is the state before the deletion.
|
|
70
|
+
# : (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
71
|
+
def build_analysis: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
62
72
|
|
|
63
73
|
# : () -> TimeActivityTimelineBuilder
|
|
64
74
|
def time_builder: () -> TimeActivityTimelineBuilder
|
|
@@ -9,6 +9,11 @@ module PaperTrailDiff
|
|
|
9
9
|
|
|
10
10
|
attr_reader activity_timeline: Array[ActivityStep]?
|
|
11
11
|
|
|
12
|
+
# The result for a record whose requested history contains no versions,
|
|
13
|
+
# which is an empty history rather than a failed request.
|
|
14
|
+
# : () -> Analysis
|
|
15
|
+
def self.empty: () -> Analysis
|
|
16
|
+
|
|
12
17
|
# : (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
|
|
13
18
|
def initialize: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
|
|
14
19
|
|
|
@@ -50,9 +50,16 @@ module PaperTrailDiff
|
|
|
50
50
|
# : (Array[identity]) -> Array[identity]
|
|
51
51
|
def sorted_identities: (Array[identity]) -> Array[identity]
|
|
52
52
|
|
|
53
|
+
# Identities sort by type first so that mixed id types stay comparable, then
|
|
54
|
+
# naturally within one type. Ordering by the printed form instead would put
|
|
55
|
+
# id 10 before id 2, which is deterministic but reads as unsorted wherever a
|
|
56
|
+
# result is rendered.
|
|
53
57
|
# : (identity) -> Array[untyped]
|
|
54
58
|
def sortable_identity: (identity) -> Array[untyped]
|
|
55
59
|
|
|
60
|
+
# : (untyped) -> Array[untyped]
|
|
61
|
+
def sortable_id: (untyped) -> Array[untyped]
|
|
62
|
+
|
|
56
63
|
# : (identity) -> void
|
|
57
64
|
def raise_duplicate_identity!: (identity) -> void
|
|
58
65
|
end
|
|
@@ -7,12 +7,15 @@ module PaperTrailDiff
|
|
|
7
7
|
|
|
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: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
|
|
12
15
|
|
|
13
16
|
# : (AssociationSnapshot) -> bool
|
|
14
17
|
def from?: (AssociationSnapshot) -> bool
|
|
15
18
|
|
|
16
|
-
@
|
|
19
|
+
@from_serial: Integer
|
|
17
20
|
end
|
|
18
21
|
end
|
|
@@ -6,6 +6,18 @@ module PaperTrailDiff
|
|
|
6
6
|
# : (untyped, untyped) -> void
|
|
7
7
|
def self?.validate_pair!: (untyped, untyped) -> void
|
|
8
8
|
|
|
9
|
+
# Two versions carry no visible cue about which is earlier, so transposing
|
|
10
|
+
# them is easy to do by accident and impossible to detect afterwards: the
|
|
11
|
+
# result is a valid inverse diff and carries no direction of its own. It is
|
|
12
|
+
# also redundant, because the two orders differ only in which side of each
|
|
13
|
+
# change is `from`. A current-record endpoint is exempt: it is self-evidently
|
|
14
|
+
# the live state, so putting it first is a deliberate reverse comparison.
|
|
15
|
+
# : (untyped, untyped) -> void
|
|
16
|
+
def self?.validate_order!: (untyped, untyped) -> void
|
|
17
|
+
|
|
18
|
+
# : (untyped, untyped) -> bool
|
|
19
|
+
def self?.reversed_versions?: (untyped, untyped) -> bool
|
|
20
|
+
|
|
9
21
|
# : (untyped) -> void
|
|
10
22
|
def self?.validate!: (untyped) -> void
|
|
11
23
|
|
|
@@ -13,6 +13,10 @@ module PaperTrailDiff
|
|
|
13
13
|
class VersionMismatchError < Error
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Raised when comparison endpoints are given in reverse chronological order.
|
|
17
|
+
class ReversedEndpointsError < Error
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
# Raised when a live endpoint is not a clean, persisted ActiveRecord record.
|
|
17
21
|
class InvalidEndpointError < Error
|
|
18
22
|
end
|
|
@@ -6,8 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
# : (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
|
|
7
7
|
def initialize: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
|
|
8
8
|
|
|
9
|
-
# : (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
10
|
-
def prepare: (untyped, Array[untyped], ?start_at: untyped) -> void
|
|
9
|
+
# : (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
10
|
+
def prepare: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
11
11
|
|
|
12
12
|
# Prepares selected history for several roots of the same model class.
|
|
13
13
|
# : (Array[untyped], Array[untyped]) -> void
|
|
@@ -64,6 +64,7 @@ module PaperTrailDiff
|
|
|
64
64
|
# : (untyped, Array[untyped], ?owners: Hash[String, Array[untyped]]?) -> Hash[String, Hash[Symbol, untyped]]
|
|
65
65
|
def group_for: (untyped, Array[untyped], ?owners: Hash[String, Array[untyped]]?) -> Hash[String, Hash[Symbol, untyped]]
|
|
66
66
|
|
|
67
|
+
# Keyed rather than scanned so that a wide edge stays linear in its width.
|
|
67
68
|
# : (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
68
69
|
def group_ids_by_owner: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
|
|
69
70
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Expands the explicit association tree into a request-scoped PreparedHistory.
|
|
5
5
|
class PreparedHistoryLoader
|
|
6
|
-
# : (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
|
|
7
|
-
def initialize: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
|
|
6
|
+
# : (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
|
|
7
|
+
def initialize: (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
|
|
|
9
9
|
# : () -> PreparedHistory
|
|
10
10
|
def call: () -> PreparedHistory
|