paper_trail_diff 0.3.1 → 0.5.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 +81 -0
- data/README.md +316 -32
- 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/analysis_batch.rb +74 -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 +131 -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/comparison_batch.rb +37 -13
- 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 +12 -9
- data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
- 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_builder.rb +3 -3
- data/lib/paper_trail_diff/timeline_range.rb +66 -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_association_candidate_scope.rb +46 -19
- data/lib/paper_trail_diff.rb +37 -5
- 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/analysis_batch.rbs +42 -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 +62 -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/comparison_batch.rbs +15 -0
- 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 -3
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
- 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_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- data/sig/generated/paper_trail_diff.rbs +13 -3
- metadata +16 -4
|
@@ -57,6 +57,30 @@ module PaperTrailDiff
|
|
|
57
57
|
end
|
|
58
58
|
private_constant :IdentityIndexCache
|
|
59
59
|
|
|
60
|
+
# Identifies one snapshot for internal carry-forward. A transition cannot
|
|
61
|
+
# hold its origin without retaining every earlier snapshot at that path,
|
|
62
|
+
# and `object_id` is only guaranteed unique among live objects, so Ruby may
|
|
63
|
+
# hand a collected snapshot's id to a later one.
|
|
64
|
+
class SerialSequence
|
|
65
|
+
#: () -> void
|
|
66
|
+
def initialize
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
@counter = 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: () -> Integer
|
|
72
|
+
def next_serial
|
|
73
|
+
@mutex.synchronize { @counter += 1 }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs @mutex: Thread::Mutex
|
|
77
|
+
# @rbs @counter: Integer
|
|
78
|
+
end
|
|
79
|
+
private_constant :SerialSequence
|
|
80
|
+
|
|
81
|
+
SERIALS = SerialSequence.new
|
|
82
|
+
private_constant :SERIALS
|
|
83
|
+
|
|
60
84
|
class << self
|
|
61
85
|
#: (Symbol) -> bool
|
|
62
86
|
def collection_kind?(kind)
|
|
@@ -66,6 +90,7 @@ module PaperTrailDiff
|
|
|
66
90
|
|
|
67
91
|
attr_reader :kind #: Symbol
|
|
68
92
|
attr_reader :records #: Array[RecordSnapshot]
|
|
93
|
+
attr_reader :serial #: Integer
|
|
69
94
|
|
|
70
95
|
#: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
|
|
71
96
|
def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
|
|
@@ -80,6 +105,7 @@ module PaperTrailDiff
|
|
|
80
105
|
@records = records.frozen? ? records : records.dup.freeze
|
|
81
106
|
@identity_index_cache = identity_index_cache || IdentityIndexCache.new
|
|
82
107
|
@transition = transition
|
|
108
|
+
@serial = SERIALS.next_serial
|
|
83
109
|
freeze
|
|
84
110
|
end
|
|
85
111
|
|
|
@@ -53,6 +53,7 @@ module PaperTrailDiff
|
|
|
53
53
|
@traversal = traversal
|
|
54
54
|
@pool = pool
|
|
55
55
|
@structural_columns = {} #: Hash[String, Array[String]]
|
|
56
|
+
@excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
#: (untyped, reifier: untyped) -> RecordSnapshot?
|
|
@@ -86,6 +87,7 @@ module PaperTrailDiff
|
|
|
86
87
|
# @rbs @traversal: AssociationTraversal
|
|
87
88
|
# @rbs @pool: SnapshotPool
|
|
88
89
|
# @rbs @structural_columns: Hash[String, Array[String]]
|
|
90
|
+
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
|
|
89
91
|
|
|
90
92
|
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
|
|
91
93
|
def normalize_record(record, tree, path, reifier, reflections)
|
|
@@ -107,15 +109,25 @@ module PaperTrailDiff
|
|
|
107
109
|
attributes
|
|
108
110
|
end
|
|
109
111
|
|
|
112
|
+
# Every input is fixed for one model class at one selected path, and the
|
|
113
|
+
# path's structural columns are recorded before its records normalize, so
|
|
114
|
+
# this is resolved once instead of per record per boundary.
|
|
110
115
|
#: (untyped, Array[untyped], String) -> Array[String]
|
|
111
116
|
def excluded_attributes(record, reflections, path)
|
|
112
|
-
|
|
117
|
+
model_class = record.class
|
|
118
|
+
@excluded_attributes[[model_class, path]] ||=
|
|
119
|
+
build_excluded_attributes(model_class, reflections, path)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (untyped, Array[untyped], String) -> Array[String]
|
|
123
|
+
def build_excluded_attributes(model_class, reflections, path)
|
|
124
|
+
primary_key = model_class.primary_key #: untyped
|
|
113
125
|
primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
|
|
114
126
|
# @type var primary_keys: Array[untyped]
|
|
115
127
|
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
116
128
|
ignored = @ignore_policy.attributes_for(path)
|
|
117
129
|
structural = @structural_columns.fetch(path, [])
|
|
118
|
-
(primary_keys + ignored + relationship_columns(reflections) + structural).uniq
|
|
130
|
+
(primary_keys + ignored + relationship_columns(reflections) + structural).uniq.freeze
|
|
119
131
|
end
|
|
120
132
|
|
|
121
133
|
#: (Array[untyped]) -> Array[String]
|
|
@@ -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,10 +4,10 @@
|
|
|
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(record, from:, to:, snapshotter:, within: nil)
|
|
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
|
|
9
9
|
@record = record
|
|
10
|
-
@range = TimelineRange.new(record, from: from, to: to, within: within)
|
|
10
|
+
@range = TimelineRange.new(record, from: from, to: to, within: within, versions: versions)
|
|
11
11
|
@snapshotter = snapshotter
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -4,28 +4,46 @@
|
|
|
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?
|
|
10
12
|
|
|
11
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> void
|
|
12
|
-
def initialize(record, from:, to:, within:)
|
|
13
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?) -> void
|
|
14
|
+
def initialize(record, from:, to:, within:, versions: nil)
|
|
13
15
|
@record = record
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
+
@versions = versions&.freeze
|
|
17
|
+
@requested_from = from
|
|
18
|
+
@requested_to = to
|
|
16
19
|
@time_range = build_time_range(within)
|
|
17
20
|
validate_mode!
|
|
21
|
+
@from = resolve(from)
|
|
22
|
+
@to = resolve(to)
|
|
18
23
|
freeze
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
# A record with no versions has no first or last boundary to resolve, which
|
|
27
|
+
# is an empty history rather than a bad request.
|
|
28
|
+
#: () -> bool
|
|
29
|
+
def unresolved?
|
|
30
|
+
(symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A batch may have selected these versions already, in which case reselecting
|
|
34
|
+
# them per record would undo the batching.
|
|
21
35
|
#: (?context_required: bool) -> Array[untyped]
|
|
22
36
|
def select(context_required: false)
|
|
37
|
+
preselected = @versions
|
|
38
|
+
return preselected if preselected
|
|
39
|
+
|
|
23
40
|
range = time_range
|
|
24
41
|
if range
|
|
25
42
|
return TimeVersionRange.new(@record, time_range: range).select(
|
|
26
43
|
context_required: context_required
|
|
27
44
|
)
|
|
28
45
|
end
|
|
46
|
+
return empty_versions if unresolved?
|
|
29
47
|
|
|
30
48
|
VersionRange.new(@record, from: @from, to: @to).select
|
|
31
49
|
end
|
|
@@ -49,9 +67,51 @@ module PaperTrailDiff
|
|
|
49
67
|
private
|
|
50
68
|
|
|
51
69
|
# @rbs @record: untyped
|
|
70
|
+
# @rbs @versions: Array[untyped]?
|
|
71
|
+
# @rbs @requested_from: untyped
|
|
72
|
+
# @rbs @requested_to: untyped
|
|
52
73
|
# @rbs @from: untyped
|
|
53
74
|
# @rbs @to: untyped
|
|
54
75
|
|
|
76
|
+
#: () -> Array[untyped]
|
|
77
|
+
def empty_versions
|
|
78
|
+
versions = [] #: Array[untyped]
|
|
79
|
+
versions.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (untyped) -> bool
|
|
83
|
+
def symbolic?(boundary)
|
|
84
|
+
boundary.is_a?(Symbol)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolving `:first` and `:last` here keeps callers from depending on the
|
|
88
|
+
# order PaperTrail happens to give its versions association, which a caller
|
|
89
|
+
# is also free to reorder.
|
|
90
|
+
#: (untyped) -> untyped
|
|
91
|
+
def resolve(boundary)
|
|
92
|
+
return boundary unless symbolic?(boundary)
|
|
93
|
+
|
|
94
|
+
unless BOUNDARY_SYMBOLS.include?(boundary)
|
|
95
|
+
raise InvalidTimelineRangeError,
|
|
96
|
+
"unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
boundary == :first ? ordered_versions.first : ordered_versions.last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
#: () -> untyped
|
|
103
|
+
def ordered_versions
|
|
104
|
+
versions_relation.reorder(created_at: :asc, id: :asc)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#: () -> untyped
|
|
108
|
+
def versions_relation
|
|
109
|
+
@record.public_send(@record.class.versions_association_name)
|
|
110
|
+
rescue NoMethodError => e
|
|
111
|
+
message = 'record does not expose a PaperTrail version history'
|
|
112
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
113
|
+
end
|
|
114
|
+
|
|
55
115
|
#: (untyped) -> TimeRange?
|
|
56
116
|
def build_time_range(within)
|
|
57
117
|
TimeRange.new(within) unless within.nil?
|
|
@@ -60,11 +120,11 @@ module PaperTrailDiff
|
|
|
60
120
|
#: () -> void
|
|
61
121
|
def validate_mode!
|
|
62
122
|
if time?
|
|
63
|
-
return if @
|
|
123
|
+
return if @requested_from.nil? && @requested_to.nil?
|
|
64
124
|
|
|
65
125
|
raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
|
|
66
126
|
end
|
|
67
|
-
return unless @
|
|
127
|
+
return unless @requested_from.nil? || @requested_to.nil?
|
|
68
128
|
|
|
69
129
|
raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
|
|
70
130
|
end
|
|
@@ -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
|
|
@@ -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
|
@@ -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,9 @@ 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/batched_root_versions'
|
|
40
43
|
require_relative 'paper_trail_diff/snapshot_normalizer'
|
|
41
44
|
require_relative 'paper_trail_diff/historical_snapshot_store'
|
|
42
45
|
require_relative 'paper_trail_diff/timeline_snapshot_provider'
|
|
@@ -55,6 +58,9 @@ require_relative 'paper_trail_diff/branch_snapshot_refresher'
|
|
|
55
58
|
require_relative 'paper_trail_diff/activity_boundary'
|
|
56
59
|
require_relative 'paper_trail_diff/step'
|
|
57
60
|
require_relative 'paper_trail_diff/analysis'
|
|
61
|
+
require_relative 'paper_trail_diff/activity_root_steps'
|
|
62
|
+
require_relative 'paper_trail_diff/analysis_batch'
|
|
63
|
+
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
58
64
|
require_relative 'paper_trail_diff/version_range'
|
|
59
65
|
require_relative 'paper_trail_diff/time_range'
|
|
60
66
|
require_relative 'paper_trail_diff/time_version_range'
|
|
@@ -89,8 +95,9 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
|
|
|
89
95
|
# type traversal_record_path = Array[RecordReference]
|
|
90
96
|
# end
|
|
91
97
|
|
|
92
|
-
# Structured version comparison for PaperTrail.
|
|
93
|
-
|
|
98
|
+
# Structured version comparison for PaperTrail. Each method is a thin,
|
|
99
|
+
# documented entry point, so this reads as an API listing rather than logic.
|
|
100
|
+
module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
94
101
|
DEFAULT_IGNORED_ATTRIBUTES = ['updated_at'].freeze
|
|
95
102
|
SUPPORTED_ASSOCIATION_MACROS = %i[
|
|
96
103
|
belongs_to
|
|
@@ -153,16 +160,23 @@ module PaperTrailDiff
|
|
|
153
160
|
end
|
|
154
161
|
|
|
155
162
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
156
|
-
|
|
163
|
+
# `reload_live_endpoints:` applies only when `to:` is a current record; the
|
|
164
|
+
# other range forms never read live state.
|
|
165
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Array[ActivityStep]
|
|
157
166
|
def activity_timeline( # rubocop:disable Metrics/ParameterLists
|
|
158
167
|
record,
|
|
159
168
|
from: nil,
|
|
160
169
|
to: nil,
|
|
161
170
|
within: nil,
|
|
162
171
|
associations: [],
|
|
163
|
-
ignore: DEFAULT_IGNORED_ATTRIBUTES
|
|
172
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
173
|
+
reload_live_endpoints: true
|
|
164
174
|
)
|
|
165
|
-
PaperTrailAdapter.new(
|
|
175
|
+
PaperTrailAdapter.new(
|
|
176
|
+
associations: associations,
|
|
177
|
+
ignore: ignore,
|
|
178
|
+
reload_live_endpoints: reload_live_endpoints
|
|
179
|
+
).activity_timeline(
|
|
166
180
|
record,
|
|
167
181
|
from: from,
|
|
168
182
|
to: to,
|
|
@@ -190,6 +204,24 @@ module PaperTrailDiff
|
|
|
190
204
|
)
|
|
191
205
|
end
|
|
192
206
|
|
|
207
|
+
# Analyzes many roots over one shared time window, preparing their selected
|
|
208
|
+
# history once for the batch instead of once per record. Roots with no
|
|
209
|
+
# versions in the window return an empty `Analysis`.
|
|
210
|
+
#: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Hash[identity, Analysis]
|
|
211
|
+
def analyze_many(
|
|
212
|
+
records,
|
|
213
|
+
within: nil,
|
|
214
|
+
associations: [],
|
|
215
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
216
|
+
activity: false
|
|
217
|
+
)
|
|
218
|
+
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
|
|
219
|
+
records,
|
|
220
|
+
within: within,
|
|
221
|
+
activity: activity
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
193
225
|
# Returns the association macros this release can normalize.
|
|
194
226
|
#: () -> Array[Symbol]
|
|
195
227
|
def supported_association_macros
|
|
@@ -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]]
|