paper_trail_diff 0.9.0 → 0.11.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 +94 -0
- data/README.md +181 -0
- data/lib/paper_trail_diff/activity_boundary.rb +20 -4
- data/lib/paper_trail_diff/activity_grouping.rb +26 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
- data/lib/paper_trail_diff/diagnostics.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +8 -0
- data/lib/paper_trail_diff/nested_comparator.rb +108 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
- data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
- data/lib/paper_trail_diff/support.rb +13 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
- data/lib/paper_trail_diff/traversal_preparer.rb +25 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +101 -12
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +10 -0
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
- data/sig/generated/paper_trail_diff/support.rbs +11 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +10 -0
- data/sig/generated/paper_trail_diff.rbs +58 -4
- metadata +14 -4
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module PaperTrailDiff
|
|
7
|
+
# Compares the inside of a value a database column holds whole.
|
|
8
|
+
#
|
|
9
|
+
# A JSON or jsonb column reifies to one Hash, so an ordinary attribute diff
|
|
10
|
+
# can only say that the blob changed. This says which keys changed, leaving
|
|
11
|
+
# the surrounding diff untouched.
|
|
12
|
+
#
|
|
13
|
+
# Three decisions worth knowing about.
|
|
14
|
+
#
|
|
15
|
+
# Paths are arrays, not dotted strings. A JSON key may contain a dot -- host
|
|
16
|
+
# names and locales routinely do -- and joining would make `a.b` ambiguous
|
|
17
|
+
# between one key and two.
|
|
18
|
+
#
|
|
19
|
+
# Arrays are leaves. Their elements carry no identity, so an insertion at the
|
|
20
|
+
# front makes every later index look changed; reporting "element 2 changed"
|
|
21
|
+
# would be confidently wrong about a list that merely shifted. The whole array
|
|
22
|
+
# is reported as one change, which is the same rule the collection comparator
|
|
23
|
+
# follows for records it cannot identify.
|
|
24
|
+
#
|
|
25
|
+
# An absent key is not a null one. `{"a": null}` and `{}` mean different
|
|
26
|
+
# things in JSON and an audit trail that conflated them would be lying about
|
|
27
|
+
# one of them, so absence is its own value rather than nil.
|
|
28
|
+
class NestedComparator
|
|
29
|
+
# Stands in for a key that was not there at all.
|
|
30
|
+
ABSENT = Object.new
|
|
31
|
+
def ABSENT.inspect = '#<PaperTrailDiff absent>'
|
|
32
|
+
def ABSENT.to_s = 'absent'
|
|
33
|
+
ABSENT.freeze
|
|
34
|
+
|
|
35
|
+
#: (untyped, untyped) -> Hash[Array[String], ValueChange]
|
|
36
|
+
def self.call(from_value, to_value)
|
|
37
|
+
new(from_value, to_value).call
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped, untyped) -> void
|
|
41
|
+
def initialize(from_value, to_value)
|
|
42
|
+
@from_value = from_value
|
|
43
|
+
@to_value = to_value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the changed paths, or an empty hash when the pair is not two
|
|
47
|
+
# structures this can look inside. An empty result therefore means "nothing
|
|
48
|
+
# to report at this depth", and the caller still has the whole-value change.
|
|
49
|
+
#: () -> Hash[Array[String], ValueChange]
|
|
50
|
+
def call
|
|
51
|
+
from_structure, to_structure = structures
|
|
52
|
+
return {} unless from_structure && to_structure
|
|
53
|
+
|
|
54
|
+
changes = {} #: Hash[Array[String], ValueChange]
|
|
55
|
+
walk(from_structure, to_structure, [], changes)
|
|
56
|
+
changes.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @rbs @from_value: untyped
|
|
62
|
+
# @rbs @to_value: untyped
|
|
63
|
+
|
|
64
|
+
# Both sides have to be readable as a Hash for a nested answer to mean
|
|
65
|
+
# anything. A column that held text on one side and JSON on the other
|
|
66
|
+
# changed wholesale, and saying so is the accurate report.
|
|
67
|
+
#: () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
|
|
68
|
+
def structures
|
|
69
|
+
[structure(@from_value), structure(@to_value)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (untyped) -> Hash[untyped, untyped]?
|
|
73
|
+
def structure(value)
|
|
74
|
+
return value if value.is_a?(Hash)
|
|
75
|
+
return unless value.is_a?(String)
|
|
76
|
+
|
|
77
|
+
parsed = begin
|
|
78
|
+
JSON.parse(value)
|
|
79
|
+
rescue JSON::ParserError, TypeError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
parsed if parsed.is_a?(Hash)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
|
|
86
|
+
def walk(from_hash, to_hash, path, changes)
|
|
87
|
+
keys(from_hash, to_hash).each do |key|
|
|
88
|
+
from_item = from_hash.key?(key) ? from_hash[key] : ABSENT
|
|
89
|
+
to_item = to_hash.key?(key) ? to_hash[key] : ABSENT
|
|
90
|
+
next if from_item == to_item
|
|
91
|
+
|
|
92
|
+
here = [*path, key.to_s]
|
|
93
|
+
if from_item.is_a?(Hash) && to_item.is_a?(Hash)
|
|
94
|
+
walk(from_item, to_item, here, changes)
|
|
95
|
+
else
|
|
96
|
+
changes[here.freeze] = ValueChange.new(from: from_item, to: to_item)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Sorted so a report reads the same twice, and stringified because a hash
|
|
102
|
+
# loaded from JSON and one built in Ruby can key the same field differently.
|
|
103
|
+
#: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
104
|
+
def keys(from_hash, to_hash)
|
|
105
|
+
(from_hash.keys | to_hash.keys).sort_by(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -68,8 +68,8 @@ module PaperTrailDiff
|
|
|
68
68
|
).build
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
72
|
-
def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
71
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
72
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
73
73
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
74
74
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
75
75
|
@traversal_preparer.call(record.class, historical: true)
|
|
@@ -77,21 +77,21 @@ module PaperTrailDiff
|
|
|
77
77
|
reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
|
|
78
78
|
steps = activity_builder(
|
|
79
79
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
80
|
-
live_endpoint: live, snapshots: snapshots
|
|
80
|
+
live_endpoint: live, snapshots: snapshots, group: group
|
|
81
81
|
).build
|
|
82
82
|
payload[:step_count] = steps.length
|
|
83
83
|
steps
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
88
|
-
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
87
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
88
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
89
89
|
@traversal_preparer.call(record.class, historical: true)
|
|
90
90
|
live = live_endpoint_for(record, close_on, within)
|
|
91
91
|
if activity
|
|
92
92
|
return analyze_activity(
|
|
93
93
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
94
|
-
live_endpoint: live, snapshots: snapshots
|
|
94
|
+
live_endpoint: live, snapshots: snapshots, group: group
|
|
95
95
|
)
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -119,6 +119,24 @@ module PaperTrailDiff
|
|
|
119
119
|
end
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
# Selects the roots from a relation before running the ordinary batch, so a
|
|
123
|
+
# caller reporting on a population does not have to rediscover which of its
|
|
124
|
+
# members changed. The roots the relation could not reach come back named
|
|
125
|
+
# rather than dropped -- see `PaperTrailDiff.analyze_scope`.
|
|
126
|
+
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
127
|
+
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
128
|
+
raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
|
|
129
|
+
|
|
130
|
+
selection = ScopedRootSelection.new(
|
|
131
|
+
scope, time_range: within.nil? ? nil : TimeRange.new(within), limit: limit
|
|
132
|
+
).call
|
|
133
|
+
ScopedAnalysis.new(
|
|
134
|
+
analyses: analyze_many(selection.records, within: within, activity: activity,
|
|
135
|
+
version_scope: version_scope, close_on: close_on),
|
|
136
|
+
unreachable: selection.unreachable
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
122
140
|
private
|
|
123
141
|
|
|
124
142
|
# @rbs @association_tree: AssociationTree
|
|
@@ -133,12 +151,12 @@ module PaperTrailDiff
|
|
|
133
151
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
134
152
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
135
153
|
|
|
136
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
137
|
-
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
154
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
155
|
+
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
138
156
|
reject_live_habtm_activity!(record.class) if live_endpoint
|
|
139
157
|
activity_builder(
|
|
140
158
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
141
|
-
live_endpoint: live_endpoint, snapshots: snapshots
|
|
159
|
+
live_endpoint: live_endpoint, snapshots: snapshots, group: group
|
|
142
160
|
).analyze
|
|
143
161
|
end
|
|
144
162
|
|
|
@@ -234,8 +252,8 @@ module PaperTrailDiff
|
|
|
234
252
|
)
|
|
235
253
|
end
|
|
236
254
|
|
|
237
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
238
|
-
def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
255
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
|
|
256
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
239
257
|
ActivityTimelineBuilder.new(
|
|
240
258
|
record,
|
|
241
259
|
range: TimelineRange.new(
|
|
@@ -244,7 +262,8 @@ module PaperTrailDiff
|
|
|
244
262
|
),
|
|
245
263
|
tree: @association_tree,
|
|
246
264
|
snapshotter: @activity_snapshotter,
|
|
247
|
-
snapshots: snapshots
|
|
265
|
+
snapshots: snapshots,
|
|
266
|
+
group: group
|
|
248
267
|
)
|
|
249
268
|
end
|
|
250
269
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# What the relation form of `analyze_many` returns: the analyses, plus the
|
|
6
|
+
# roots the relation could not reach.
|
|
7
|
+
#
|
|
8
|
+
# `to_ary` is defined so the pair destructures, which keeps the common case
|
|
9
|
+
# reading like the plain Hash the record form returns:
|
|
10
|
+
#
|
|
11
|
+
# analyses, unreachable = PaperTrailDiff.analyze_many(scope: ..., limit: 500)
|
|
12
|
+
#
|
|
13
|
+
# It deliberately does not pretend to be a Hash beyond that. Somebody
|
|
14
|
+
# iterating this object should have to decide which half they meant.
|
|
15
|
+
class ScopedAnalysis
|
|
16
|
+
attr_reader :analyses #: Hash[Array[String], Analysis]
|
|
17
|
+
attr_reader :unreachable #: Array[Array[String]]
|
|
18
|
+
|
|
19
|
+
#: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
20
|
+
def initialize(analyses:, unreachable:)
|
|
21
|
+
@analyses = analyses
|
|
22
|
+
@unreachable = Support.immutable_copy(unreachable)
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
|
|
27
|
+
def to_ary
|
|
28
|
+
[analyses, unreachable]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Chooses the roots a batch will analyze from a relation instead of from an
|
|
6
|
+
# array the caller assembled, so a reporting page does not reimplement the
|
|
7
|
+
# root selection this gem already performs.
|
|
8
|
+
#
|
|
9
|
+
# Two populations are deliberately kept apart, because conflating them would
|
|
10
|
+
# make the report wrong in a way nothing announces:
|
|
11
|
+
#
|
|
12
|
+
# - A root whose live row does not satisfy the relation is filtered out. That
|
|
13
|
+
# is what the caller asked for, and it needs no reporting.
|
|
14
|
+
# - A root whose live row is gone cannot be filtered at all. The relation's
|
|
15
|
+
# conditions read the live table, and a destroyed root has nothing there to
|
|
16
|
+
# read -- even though its history is intact, and the state it held when it
|
|
17
|
+
# was destroyed may well have satisfied those conditions. Reifying every
|
|
18
|
+
# candidate to find out would cost the batched query plan this class exists
|
|
19
|
+
# to provide.
|
|
20
|
+
#
|
|
21
|
+
# So the second population is returned by name rather than dropped. A caller
|
|
22
|
+
# that does not care can ignore it; one auditing deletions is told where to
|
|
23
|
+
# look instead of silently coming up short.
|
|
24
|
+
#
|
|
25
|
+
# Note that a relation filters on current state, not on state during the
|
|
26
|
+
# window. `where(status: 'published')` selects what is published now, which is
|
|
27
|
+
# not the same set as what was published while the window was open.
|
|
28
|
+
class ScopedRootSelection
|
|
29
|
+
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
30
|
+
# gem supports 3.1.
|
|
31
|
+
class Result
|
|
32
|
+
attr_reader :records #: Array[untyped]
|
|
33
|
+
attr_reader :unreachable #: Array[Array[String]]
|
|
34
|
+
|
|
35
|
+
#: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
|
|
36
|
+
def initialize(records:, unreachable:)
|
|
37
|
+
@records = records
|
|
38
|
+
@unreachable = unreachable
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
44
|
+
def initialize(scope, time_range:, limit:)
|
|
45
|
+
@scope = normalize_scope(scope)
|
|
46
|
+
@time_range = time_range
|
|
47
|
+
@limit = validate_limit(limit)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> Result
|
|
51
|
+
def call
|
|
52
|
+
candidates = versioned_ids
|
|
53
|
+
return Result.new(records: [], unreachable: []) if candidates.empty?
|
|
54
|
+
|
|
55
|
+
live = live_ids(candidates)
|
|
56
|
+
Result.new(
|
|
57
|
+
records: selected_records(live),
|
|
58
|
+
unreachable: (candidates - live).map { |id| identity(id) }.freeze
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# @rbs @scope: untyped
|
|
65
|
+
# @rbs @time_range: TimeRange?
|
|
66
|
+
# @rbs @limit: Integer
|
|
67
|
+
|
|
68
|
+
# Accepts a model class as readily as a relation: `Article` and
|
|
69
|
+
# `Article.where(...)` both name a population, and `all` is what makes them
|
|
70
|
+
# the same kind of thing.
|
|
71
|
+
#: (untyped) -> untyped
|
|
72
|
+
def normalize_scope(scope)
|
|
73
|
+
unless scope.respond_to?(:all) && scope.respond_to?(:where)
|
|
74
|
+
raise ConfigurationError, 'scope: must be an ActiveRecord relation or model class'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
relation = scope.all
|
|
78
|
+
return relation if Support.versioned?(relation.model)
|
|
79
|
+
|
|
80
|
+
raise UnversionedAssociationError,
|
|
81
|
+
"scope: #{relation.model.name} is not versioned, so it has no history to select from"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: (Integer) -> Integer
|
|
85
|
+
def validate_limit(limit)
|
|
86
|
+
return limit if limit.is_a?(Integer) && limit.positive?
|
|
87
|
+
|
|
88
|
+
raise ConfigurationError, 'limit: must be a positive Integer'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Every root whose history moved inside the window, destroyed ones included.
|
|
92
|
+
# This is the gem's own notion of "which roots changed", answered from the
|
|
93
|
+
# version table alone so that it does not depend on rows still existing.
|
|
94
|
+
#: () -> Array[String]
|
|
95
|
+
def versioned_ids
|
|
96
|
+
relation = version_class.where(item_type: item_type)
|
|
97
|
+
range = @time_range
|
|
98
|
+
relation = range.scope(relation) if range
|
|
99
|
+
relation.distinct.pluck(:item_id).map(&:to_s).uniq
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Which of those candidates still have a row, asked without the relation's
|
|
103
|
+
# conditions so that "filtered out" and "no longer exists" stay separable.
|
|
104
|
+
#: (Array[String]) -> Array[String]
|
|
105
|
+
def live_ids(candidates)
|
|
106
|
+
base_class.unscoped.where(primary_key => candidates).pluck(primary_key).map(&:to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Loading one past the limit is what turns an oversized page into an error
|
|
110
|
+
# rather than a silently truncated report.
|
|
111
|
+
#: (Array[String]) -> Array[untyped]
|
|
112
|
+
def selected_records(live)
|
|
113
|
+
return [] if live.empty?
|
|
114
|
+
|
|
115
|
+
records = @scope.where(primary_key => live).limit(@limit + 1).to_a
|
|
116
|
+
return records.freeze unless records.length > @limit
|
|
117
|
+
|
|
118
|
+
raise BatchLimitExceededError,
|
|
119
|
+
"scope: selected more than #{@limit} roots; narrow the window or the " \
|
|
120
|
+
'relation, or raise limit: to the page size you intend to analyze'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: (String) -> Array[String]
|
|
124
|
+
def identity(id)
|
|
125
|
+
[item_type, id].freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: () -> String
|
|
129
|
+
def item_type
|
|
130
|
+
base_class.name.to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: () -> untyped
|
|
134
|
+
def base_class
|
|
135
|
+
@scope.model.base_class
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
#: () -> untyped
|
|
139
|
+
def primary_key
|
|
140
|
+
@scope.model.primary_key
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
#: () -> untyped
|
|
144
|
+
def version_class
|
|
145
|
+
@scope.model.paper_trail.version_class
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -81,6 +81,19 @@ module PaperTrailDiff
|
|
|
81
81
|
versions.each_cons(2).find { |left, right| left.created_at == right.created_at }
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Whether a model records history at all.
|
|
85
|
+
#
|
|
86
|
+
# PaperTrail defines `paper_trail` on every ActiveRecord model, so asking
|
|
87
|
+
# whether a class responds to it says nothing -- it is true for models that
|
|
88
|
+
# never called `has_paper_trail`, and reading history from one of those fails
|
|
89
|
+
# at the version class rather than at the question. Only configured options
|
|
90
|
+
# distinguish the two, which is why this lives in one place: the predicate is
|
|
91
|
+
# easy to write in a form that looks right and always answers true.
|
|
92
|
+
#: (untyped) -> bool
|
|
93
|
+
def versioned?(model_class)
|
|
94
|
+
model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
84
97
|
#: (untyped) -> bool
|
|
85
98
|
def sequential_id?(id)
|
|
86
99
|
id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Builds activity views for mutations selected by a wall-clock range.
|
|
6
6
|
class TimeActivityTimelineBuilder
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
include ActivityGrouping
|
|
8
|
+
|
|
9
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
10
|
+
def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
|
|
9
11
|
@snapshots = snapshots
|
|
12
|
+
@group = group
|
|
13
|
+
# Merging a group compares its outer states, so the snapshots must survive
|
|
14
|
+
# the build even when the caller did not ask to keep them.
|
|
15
|
+
@retain = snapshots || grouping?
|
|
10
16
|
@record = record
|
|
11
17
|
@range = range
|
|
12
18
|
@tree = tree
|
|
@@ -92,15 +98,14 @@ module PaperTrailDiff
|
|
|
92
98
|
events,
|
|
93
99
|
@snapshotter,
|
|
94
100
|
include_step: ->(event) { @range.include?(event.version) },
|
|
95
|
-
snapshots: @
|
|
101
|
+
snapshots: @retain
|
|
96
102
|
).call
|
|
97
103
|
end
|
|
98
104
|
|
|
99
105
|
#: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
|
|
100
106
|
def activity_steps(history, closing)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
(history.steps + [closing]).freeze
|
|
107
|
+
steps = closing ? history.steps + [closing] : history.steps
|
|
108
|
+
group_steps(steps).freeze
|
|
104
109
|
end
|
|
105
110
|
|
|
106
111
|
# Root versions inside the window that the plan does not report are context
|
|
@@ -136,7 +141,7 @@ module PaperTrailDiff
|
|
|
136
141
|
from_boundary: ActivityBoundary.from_version(version),
|
|
137
142
|
to_boundary: ActivityBoundary.destroyed(version),
|
|
138
143
|
from_snapshot: history.root_snapshots[ActivityRootSteps.version_key(version)],
|
|
139
|
-
to_snapshot: nil, retain: @
|
|
144
|
+
to_snapshot: nil, retain: @retain
|
|
140
145
|
)
|
|
141
146
|
end
|
|
142
147
|
|
|
@@ -151,7 +156,7 @@ module PaperTrailDiff
|
|
|
151
156
|
ActivityStep.between(
|
|
152
157
|
from_boundary: previous,
|
|
153
158
|
to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
|
|
154
|
-
from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @
|
|
159
|
+
from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @retain
|
|
155
160
|
)
|
|
156
161
|
end
|
|
157
162
|
|
|
@@ -17,6 +17,7 @@ module PaperTrailDiff
|
|
|
17
17
|
|
|
18
18
|
ensure_association_tracking! if historical
|
|
19
19
|
@traversal.validate!(model_class)
|
|
20
|
+
ensure_versioned_targets!(model_class) if historical
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
private
|
|
@@ -24,6 +25,30 @@ module PaperTrailDiff
|
|
|
24
25
|
# @rbs @tree: AssociationTree
|
|
25
26
|
# @rbs @traversal: AssociationTraversal
|
|
26
27
|
|
|
28
|
+
# A model PaperTrail never versioned has no history to reconstruct, so a
|
|
29
|
+
# comparison over it can only ever answer "nothing changed" -- which is a
|
|
30
|
+
# wrong answer rather than an empty one. Live-to-live comparison reads
|
|
31
|
+
# current state and is unaffected, so this applies to historical work only.
|
|
32
|
+
#: (untyped) -> void
|
|
33
|
+
def ensure_versioned_targets!(model_class)
|
|
34
|
+
@traversal.selected_reflections(model_class).each do |path, reflection|
|
|
35
|
+
# A polymorphic target is not known until a row names it; `diagnose`
|
|
36
|
+
# reports that separately rather than guessing here.
|
|
37
|
+
next if reflection.polymorphic?
|
|
38
|
+
next if versioned?(reflection.klass)
|
|
39
|
+
|
|
40
|
+
raise UnversionedAssociationError,
|
|
41
|
+
"association #{path} cannot be compared historically: " \
|
|
42
|
+
"#{reflection.klass.name} is not versioned. Add `has_paper_trail` to it, " \
|
|
43
|
+
'or mirror the fields you need onto a model that has it.'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: (untyped) -> bool
|
|
48
|
+
def versioned?(model_class)
|
|
49
|
+
Support.versioned?(model_class)
|
|
50
|
+
end
|
|
51
|
+
|
|
27
52
|
#: () -> void
|
|
28
53
|
def ensure_association_tracking!
|
|
29
54
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|