paper_trail_diff 0.10.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 +72 -0
- data/README.md +132 -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 +1 -1
- data/lib/paper_trail_diff/errors.rb +4 -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 +1 -1
- 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 +5 -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.rbs +58 -4
- metadata +14 -4
|
@@ -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
|
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative 'paper_trail_diff/snapshot_traversal'
|
|
|
24
24
|
require_relative 'paper_trail_diff/association_diff_traversal'
|
|
25
25
|
require_relative 'paper_trail_diff/traversal'
|
|
26
26
|
require_relative 'paper_trail_diff/collection_comparator'
|
|
27
|
+
require_relative 'paper_trail_diff/nested_comparator'
|
|
27
28
|
require_relative 'paper_trail_diff/engine'
|
|
28
29
|
require_relative 'paper_trail_diff/historical_association_reifier'
|
|
29
30
|
require_relative 'paper_trail_diff/prepared_record_index'
|
|
@@ -59,10 +60,14 @@ require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
|
|
|
59
60
|
require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
|
|
60
61
|
require_relative 'paper_trail_diff/branch_snapshot_refresher'
|
|
61
62
|
require_relative 'paper_trail_diff/activity_boundary'
|
|
63
|
+
require_relative 'paper_trail_diff/activity_grouping'
|
|
64
|
+
require_relative 'paper_trail_diff/activity_transaction_grouper'
|
|
62
65
|
require_relative 'paper_trail_diff/step'
|
|
63
66
|
require_relative 'paper_trail_diff/analysis'
|
|
64
67
|
require_relative 'paper_trail_diff/activity_root_steps'
|
|
65
68
|
require_relative 'paper_trail_diff/analysis_batch'
|
|
69
|
+
require_relative 'paper_trail_diff/scoped_analysis'
|
|
70
|
+
require_relative 'paper_trail_diff/scoped_root_selection'
|
|
66
71
|
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
67
72
|
require_relative 'paper_trail_diff/version_range'
|
|
68
73
|
require_relative 'paper_trail_diff/version_sequence_diagnostics'
|
|
@@ -181,7 +186,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
181
186
|
reload_live_endpoints: true,
|
|
182
187
|
version_scope: nil,
|
|
183
188
|
close_on: nil,
|
|
184
|
-
snapshots: false
|
|
189
|
+
snapshots: false,
|
|
190
|
+
group: nil
|
|
185
191
|
)
|
|
186
192
|
PaperTrailAdapter.new(
|
|
187
193
|
associations: associations,
|
|
@@ -194,12 +200,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
194
200
|
within: within,
|
|
195
201
|
version_scope: version_scope,
|
|
196
202
|
close_on: close_on,
|
|
203
|
+
group: group,
|
|
197
204
|
snapshots: snapshots
|
|
198
205
|
)
|
|
199
206
|
end
|
|
200
207
|
|
|
201
208
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
202
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
209
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
203
210
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
204
211
|
record,
|
|
205
212
|
from: nil,
|
|
@@ -210,7 +217,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
210
217
|
activity: false,
|
|
211
218
|
version_scope: nil,
|
|
212
219
|
close_on: nil,
|
|
213
|
-
snapshots: false
|
|
220
|
+
snapshots: false,
|
|
221
|
+
group: nil
|
|
214
222
|
)
|
|
215
223
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
216
224
|
record,
|
|
@@ -220,16 +228,25 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
220
228
|
activity: activity,
|
|
221
229
|
version_scope: version_scope,
|
|
222
230
|
close_on: close_on,
|
|
223
|
-
snapshots: snapshots
|
|
231
|
+
snapshots: snapshots,
|
|
232
|
+
group: group
|
|
224
233
|
)
|
|
225
234
|
end
|
|
226
235
|
|
|
227
236
|
# Analyzes many roots over one shared time window, preparing their selected
|
|
228
237
|
# history once for the batch instead of once per record. Roots with no
|
|
229
238
|
# versions in the window return an empty `Analysis`.
|
|
230
|
-
|
|
239
|
+
#
|
|
240
|
+
# Pass `records` to analyze a list you assembled, which returns a Hash keyed
|
|
241
|
+
# by identity. Pass `scope:` with a `limit:` to have the roots selected for
|
|
242
|
+
# you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
|
|
243
|
+
# plus the roots the relation could not reach. See `analyze_scope` for why
|
|
244
|
+
# that second collection exists.
|
|
245
|
+
#: (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
|
|
231
246
|
def analyze_many( # rubocop:disable Metrics/ParameterLists
|
|
232
|
-
records,
|
|
247
|
+
records = nil,
|
|
248
|
+
scope: nil,
|
|
249
|
+
limit: nil,
|
|
233
250
|
within: nil,
|
|
234
251
|
associations: [],
|
|
235
252
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
@@ -237,12 +254,57 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
237
254
|
version_scope: nil,
|
|
238
255
|
close_on: nil
|
|
239
256
|
)
|
|
240
|
-
PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
257
|
+
adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
258
|
+
if scope
|
|
259
|
+
raise ConfigurationError, 'pass either records or scope:, not both' unless records.nil?
|
|
260
|
+
|
|
261
|
+
return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
|
|
262
|
+
version_scope: version_scope, close_on: close_on)
|
|
263
|
+
end
|
|
264
|
+
raise ConfigurationError, 'pass records or scope:' if records.nil?
|
|
265
|
+
|
|
266
|
+
adapter.analyze_many(records, within: within, activity: activity,
|
|
267
|
+
version_scope: version_scope, close_on: close_on)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Analyzes every root the relation reaches whose history moved inside the
|
|
271
|
+
# window, selecting them in a fixed number of queries rather than making the
|
|
272
|
+
# caller rediscover them.
|
|
273
|
+
#
|
|
274
|
+
# `limit:` is required and exceeding it raises. Selection moves into the gem
|
|
275
|
+
# here, so the bound on how much work a page can ask for has to move with
|
|
276
|
+
# it, and a truncated audit report is worse than a refused one.
|
|
277
|
+
#
|
|
278
|
+
# Returns a `ScopedAnalysis`, which destructures:
|
|
279
|
+
#
|
|
280
|
+
# analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
281
|
+
# Article.where(status: 'published'), within: july, limit: 500
|
|
282
|
+
# )
|
|
283
|
+
#
|
|
284
|
+
# `unreachable` names roots that changed in the window but have no live row
|
|
285
|
+
# left. A relation's conditions are evaluated against the live table, so a
|
|
286
|
+
# destroyed root cannot be tested against them at all -- its history is
|
|
287
|
+
# intact and the state it held at destruction may well have matched. Those
|
|
288
|
+
# roots are reported rather than dropped so that a page auditing deletions
|
|
289
|
+
# is told where to look instead of quietly coming up short.
|
|
290
|
+
#
|
|
291
|
+
# Note also that a relation selects on current state, not on state during
|
|
292
|
+
# the window: `where(status: 'published')` means published *now*, which is a
|
|
293
|
+
# different set from what was published while the window was open.
|
|
294
|
+
#: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
295
|
+
def analyze_scope( # rubocop:disable Metrics/ParameterLists
|
|
296
|
+
scope,
|
|
297
|
+
limit:,
|
|
298
|
+
within: nil,
|
|
299
|
+
associations: [],
|
|
300
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
301
|
+
activity: false,
|
|
302
|
+
version_scope: nil,
|
|
303
|
+
close_on: nil
|
|
304
|
+
)
|
|
305
|
+
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_scope(
|
|
306
|
+
scope, limit: limit, within: within, activity: activity,
|
|
307
|
+
version_scope: version_scope, close_on: close_on
|
|
246
308
|
)
|
|
247
309
|
end
|
|
248
310
|
|
|
@@ -263,5 +325,32 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
263
325
|
def diagnose(from_version, to_version, associations: [])
|
|
264
326
|
HistoryDiagnostics.new(from_version, to_version, associations: associations).call
|
|
265
327
|
end
|
|
328
|
+
|
|
329
|
+
# Looks inside an attribute the database stores whole, such as a JSON or
|
|
330
|
+
# jsonb column, and reports which keys changed.
|
|
331
|
+
#
|
|
332
|
+
# change = diff.attributes.fetch('config')
|
|
333
|
+
# PaperTrailDiff.nested_changes(change)
|
|
334
|
+
# # => { ['theme'] => <from "dark" to "light">,
|
|
335
|
+
# # ['limits', 'max'] => <from 10 to 20> }
|
|
336
|
+
#
|
|
337
|
+
# Accepts the `ValueChange` an attribute diff already produced, or a bare
|
|
338
|
+
# pair. Returns an empty hash when the pair is not two readable structures,
|
|
339
|
+
# which is the honest answer: a column that held text on one side and JSON
|
|
340
|
+
# on the other changed wholesale, and the caller still has that change.
|
|
341
|
+
#
|
|
342
|
+
# Paths are arrays because a JSON key may contain a dot. Arrays are reported
|
|
343
|
+
# whole rather than by index, since their elements carry no identity and a
|
|
344
|
+
# list that merely shifted would otherwise look changed throughout. A key
|
|
345
|
+
# that was absent reads as `NestedComparator::ABSENT` rather than nil, which
|
|
346
|
+
# JSON uses for a present null.
|
|
347
|
+
#: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
|
|
348
|
+
def nested_changes(change, to_value = nil)
|
|
349
|
+
# Tested by type rather than by responding to `from`: ActiveSupport gives
|
|
350
|
+
# String#from, so duck-typing here quietly reads a plain string as a pair.
|
|
351
|
+
return NestedComparator.call(change.from, change.to) if change.is_a?(ValueChange)
|
|
352
|
+
|
|
353
|
+
NestedComparator.call(change, to_value)
|
|
354
|
+
end
|
|
266
355
|
end
|
|
267
356
|
end
|
|
@@ -19,6 +19,11 @@ module PaperTrailDiff
|
|
|
19
19
|
|
|
20
20
|
attr_reader record: RecordReference
|
|
21
21
|
|
|
22
|
+
# The transaction the version was written in, when PaperTrail recorded one.
|
|
23
|
+
# Several versions saved together share it, which is what lets a timeline
|
|
24
|
+
# report one save as one step rather than as its parts.
|
|
25
|
+
attr_reader transaction_id: untyped
|
|
26
|
+
|
|
22
27
|
# : (untyped) -> ActivityBoundary
|
|
23
28
|
def self.from_version: (untyped) -> ActivityBoundary
|
|
24
29
|
|
|
@@ -31,8 +36,14 @@ module PaperTrailDiff
|
|
|
31
36
|
# : (untyped) -> ActivityBoundary
|
|
32
37
|
def self.destroyed: (untyped) -> ActivityBoundary
|
|
33
38
|
|
|
34
|
-
#
|
|
35
|
-
|
|
39
|
+
# A custom version class need not carry the column, and PaperTrail leaves
|
|
40
|
+
# it nil outside a transaction. Both read as "no transaction here", which
|
|
41
|
+
# groups with nothing.
|
|
42
|
+
# : (untyped) -> untyped
|
|
43
|
+
def self.transaction_id: (untyped) -> untyped
|
|
44
|
+
|
|
45
|
+
# : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
|
|
46
|
+
def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
|
|
36
47
|
|
|
37
48
|
# : () -> bool
|
|
38
49
|
def version?: () -> bool
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_grouping.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Shared by the two activity builders. They differ in how they choose
|
|
5
|
+
# boundaries -- one from an explicit version range, one from a wall-clock
|
|
6
|
+
# window -- but a transaction has to collapse the same way in both, or the
|
|
7
|
+
# same history would read differently depending on how it was asked for.
|
|
8
|
+
module ActivityGrouping
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# : () -> bool
|
|
12
|
+
def grouping?: () -> bool
|
|
13
|
+
|
|
14
|
+
# Applied last, to finished steps, so grouping sees the same timeline the
|
|
15
|
+
# caller would otherwise have received.
|
|
16
|
+
# : (Array[ActivityStep]) -> Array[ActivityStep]
|
|
17
|
+
def group_steps: (Array[ActivityStep]) -> Array[ActivityStep]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
5
5
|
class ActivityTimelineBuilder
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include ActivityGrouping
|
|
7
|
+
|
|
8
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
8
10
|
|
|
9
11
|
# : () -> Array[ActivityStep]
|
|
10
12
|
def build: () -> Array[ActivityStep]
|