paper_trail_diff 0.10.0 → 0.12.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 +89 -0
- data/README.md +153 -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 +119 -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/value_objects.rb +66 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +103 -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 +73 -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/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff.rbs +62 -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
|
|
|
@@ -20,6 +20,72 @@ module PaperTrailDiff
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# A change to an array inside a value the database stores whole, reported by
|
|
24
|
+
# membership rather than by position.
|
|
25
|
+
#
|
|
26
|
+
# Position would be the obvious thing to report and the wrong one: elements
|
|
27
|
+
# here carry no identity, so inserting at the front makes every later index
|
|
28
|
+
# look changed, and one insertion is described as several edits. Membership is
|
|
29
|
+
# answerable without claiming any pairing -- "gained \"saturn v\"" is true
|
|
30
|
+
# whether the array is a set, a queue, or a ranked list.
|
|
31
|
+
#
|
|
32
|
+
# That leaves one case membership cannot see, so it is named rather than
|
|
33
|
+
# hidden: the same elements in a different order. `reordered?` says so.
|
|
34
|
+
#
|
|
35
|
+
# An element that is itself a Hash or Array is reported whole. Saying which
|
|
36
|
+
# field of which object changed would require pairing before-elements with
|
|
37
|
+
# after-elements, and nothing in the value licenses that pairing.
|
|
38
|
+
class ArrayChange
|
|
39
|
+
attr_reader :from #: Array[untyped]
|
|
40
|
+
attr_reader :to #: Array[untyped]
|
|
41
|
+
attr_reader :added #: Array[untyped]
|
|
42
|
+
attr_reader :removed #: Array[untyped]
|
|
43
|
+
|
|
44
|
+
#: (from: Array[untyped], to: Array[untyped]) -> void
|
|
45
|
+
def initialize(from:, to:)
|
|
46
|
+
@from = Support.immutable_copy(from)
|
|
47
|
+
@to = Support.immutable_copy(to)
|
|
48
|
+
@added = Support.immutable_copy(ArrayChange.surplus(to, from))
|
|
49
|
+
@removed = Support.immutable_copy(ArrayChange.surplus(from, to))
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Elements of `left` with no counterpart left in `right`, matching by value
|
|
54
|
+
# and respecting duplicates, so ["a", "a"] -> ["a"] reports one removal
|
|
55
|
+
# rather than none.
|
|
56
|
+
#: (Array[untyped], Array[untyped]) -> Array[untyped]
|
|
57
|
+
def self.surplus(left, right)
|
|
58
|
+
pool = right.dup
|
|
59
|
+
left.reject do |item|
|
|
60
|
+
index = pool.index(item)
|
|
61
|
+
index ? pool.delete_at(index) || true : false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Same elements, different sequence. Only meaningful for a change that was
|
|
66
|
+
# recorded at all, which this only is when the two sides differ.
|
|
67
|
+
#: () -> bool
|
|
68
|
+
def reordered?
|
|
69
|
+
added.empty? && removed.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: () -> bool
|
|
73
|
+
def empty?
|
|
74
|
+
from == to
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: () -> Hash[Symbol, untyped]
|
|
78
|
+
def to_h
|
|
79
|
+
{
|
|
80
|
+
from: Support.serialize(from),
|
|
81
|
+
to: Support.serialize(to),
|
|
82
|
+
added: Support.serialize(added),
|
|
83
|
+
removed: Support.serialize(removed),
|
|
84
|
+
reordered: reordered?
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
23
89
|
# Attribute and nested-association changes for a record whose identity did not change.
|
|
24
90
|
class RecordChange
|
|
25
91
|
attr_reader :record #: RecordReference
|
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'
|
|
@@ -87,6 +92,8 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
|
|
|
87
92
|
# type attribute_changes = Hash[String, ValueChange]
|
|
88
93
|
# type association_diff = ToOneAssociationDiff | CollectionAssociationDiff
|
|
89
94
|
# type association_diffs = Hash[String, association_diff]
|
|
95
|
+
# type nested_change = ValueChange | ArrayChange
|
|
96
|
+
# type nested_changes = Hash[Array[String], nested_change]
|
|
90
97
|
# type association_snapshots = Hash[String, AssociationSnapshot]
|
|
91
98
|
# type identity = Array[untyped]
|
|
92
99
|
# type comparison_input = Hash[String | Symbol, untyped]
|
|
@@ -181,7 +188,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
181
188
|
reload_live_endpoints: true,
|
|
182
189
|
version_scope: nil,
|
|
183
190
|
close_on: nil,
|
|
184
|
-
snapshots: false
|
|
191
|
+
snapshots: false,
|
|
192
|
+
group: nil
|
|
185
193
|
)
|
|
186
194
|
PaperTrailAdapter.new(
|
|
187
195
|
associations: associations,
|
|
@@ -194,12 +202,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
194
202
|
within: within,
|
|
195
203
|
version_scope: version_scope,
|
|
196
204
|
close_on: close_on,
|
|
205
|
+
group: group,
|
|
197
206
|
snapshots: snapshots
|
|
198
207
|
)
|
|
199
208
|
end
|
|
200
209
|
|
|
201
210
|
# 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
|
|
211
|
+
#: (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
212
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
204
213
|
record,
|
|
205
214
|
from: nil,
|
|
@@ -210,7 +219,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
210
219
|
activity: false,
|
|
211
220
|
version_scope: nil,
|
|
212
221
|
close_on: nil,
|
|
213
|
-
snapshots: false
|
|
222
|
+
snapshots: false,
|
|
223
|
+
group: nil
|
|
214
224
|
)
|
|
215
225
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
216
226
|
record,
|
|
@@ -220,16 +230,25 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
220
230
|
activity: activity,
|
|
221
231
|
version_scope: version_scope,
|
|
222
232
|
close_on: close_on,
|
|
223
|
-
snapshots: snapshots
|
|
233
|
+
snapshots: snapshots,
|
|
234
|
+
group: group
|
|
224
235
|
)
|
|
225
236
|
end
|
|
226
237
|
|
|
227
238
|
# Analyzes many roots over one shared time window, preparing their selected
|
|
228
239
|
# history once for the batch instead of once per record. Roots with no
|
|
229
240
|
# versions in the window return an empty `Analysis`.
|
|
230
|
-
|
|
241
|
+
#
|
|
242
|
+
# Pass `records` to analyze a list you assembled, which returns a Hash keyed
|
|
243
|
+
# by identity. Pass `scope:` with a `limit:` to have the roots selected for
|
|
244
|
+
# you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
|
|
245
|
+
# plus the roots the relation could not reach. See `analyze_scope` for why
|
|
246
|
+
# that second collection exists.
|
|
247
|
+
#: (?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
248
|
def analyze_many( # rubocop:disable Metrics/ParameterLists
|
|
232
|
-
records,
|
|
249
|
+
records = nil,
|
|
250
|
+
scope: nil,
|
|
251
|
+
limit: nil,
|
|
233
252
|
within: nil,
|
|
234
253
|
associations: [],
|
|
235
254
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
@@ -237,12 +256,57 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
237
256
|
version_scope: nil,
|
|
238
257
|
close_on: nil
|
|
239
258
|
)
|
|
240
|
-
PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
259
|
+
adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
260
|
+
if scope
|
|
261
|
+
raise ConfigurationError, 'pass either records or scope:, not both' unless records.nil?
|
|
262
|
+
|
|
263
|
+
return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
|
|
264
|
+
version_scope: version_scope, close_on: close_on)
|
|
265
|
+
end
|
|
266
|
+
raise ConfigurationError, 'pass records or scope:' if records.nil?
|
|
267
|
+
|
|
268
|
+
adapter.analyze_many(records, within: within, activity: activity,
|
|
269
|
+
version_scope: version_scope, close_on: close_on)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Analyzes every root the relation reaches whose history moved inside the
|
|
273
|
+
# window, selecting them in a fixed number of queries rather than making the
|
|
274
|
+
# caller rediscover them.
|
|
275
|
+
#
|
|
276
|
+
# `limit:` is required and exceeding it raises. Selection moves into the gem
|
|
277
|
+
# here, so the bound on how much work a page can ask for has to move with
|
|
278
|
+
# it, and a truncated audit report is worse than a refused one.
|
|
279
|
+
#
|
|
280
|
+
# Returns a `ScopedAnalysis`, which destructures:
|
|
281
|
+
#
|
|
282
|
+
# analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
283
|
+
# Article.where(status: 'published'), within: july, limit: 500
|
|
284
|
+
# )
|
|
285
|
+
#
|
|
286
|
+
# `unreachable` names roots that changed in the window but have no live row
|
|
287
|
+
# left. A relation's conditions are evaluated against the live table, so a
|
|
288
|
+
# destroyed root cannot be tested against them at all -- its history is
|
|
289
|
+
# intact and the state it held at destruction may well have matched. Those
|
|
290
|
+
# roots are reported rather than dropped so that a page auditing deletions
|
|
291
|
+
# is told where to look instead of quietly coming up short.
|
|
292
|
+
#
|
|
293
|
+
# Note also that a relation selects on current state, not on state during
|
|
294
|
+
# the window: `where(status: 'published')` means published *now*, which is a
|
|
295
|
+
# different set from what was published while the window was open.
|
|
296
|
+
#: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
297
|
+
def analyze_scope( # rubocop:disable Metrics/ParameterLists
|
|
298
|
+
scope,
|
|
299
|
+
limit:,
|
|
300
|
+
within: nil,
|
|
301
|
+
associations: [],
|
|
302
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
303
|
+
activity: false,
|
|
304
|
+
version_scope: nil,
|
|
305
|
+
close_on: nil
|
|
306
|
+
)
|
|
307
|
+
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_scope(
|
|
308
|
+
scope, limit: limit, within: within, activity: activity,
|
|
309
|
+
version_scope: version_scope, close_on: close_on
|
|
246
310
|
)
|
|
247
311
|
end
|
|
248
312
|
|
|
@@ -263,5 +327,32 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
263
327
|
def diagnose(from_version, to_version, associations: [])
|
|
264
328
|
HistoryDiagnostics.new(from_version, to_version, associations: associations).call
|
|
265
329
|
end
|
|
330
|
+
|
|
331
|
+
# Looks inside an attribute the database stores whole, such as a JSON or
|
|
332
|
+
# jsonb column, and reports which keys changed.
|
|
333
|
+
#
|
|
334
|
+
# change = diff.attributes.fetch('config')
|
|
335
|
+
# PaperTrailDiff.nested_changes(change)
|
|
336
|
+
# # => { ['theme'] => <from "dark" to "light">,
|
|
337
|
+
# # ['limits', 'max'] => <from 10 to 20> }
|
|
338
|
+
#
|
|
339
|
+
# Accepts the `ValueChange` an attribute diff already produced, or a bare
|
|
340
|
+
# pair. Returns an empty hash when the pair is not two readable structures,
|
|
341
|
+
# which is the honest answer: a column that held text on one side and JSON
|
|
342
|
+
# on the other changed wholesale, and the caller still has that change.
|
|
343
|
+
#
|
|
344
|
+
# Paths are arrays because a JSON key may contain a dot. Arrays are reported
|
|
345
|
+
# whole rather than by index, since their elements carry no identity and a
|
|
346
|
+
# list that merely shifted would otherwise look changed throughout. A key
|
|
347
|
+
# that was absent reads as `NestedComparator::ABSENT` rather than nil, which
|
|
348
|
+
# JSON uses for a present null.
|
|
349
|
+
#: (untyped, ?untyped) -> nested_changes
|
|
350
|
+
def nested_changes(change, to_value = nil)
|
|
351
|
+
# Tested by type rather than by responding to `from`: ActiveSupport gives
|
|
352
|
+
# String#from, so duck-typing here quietly reads a plain string as a pair.
|
|
353
|
+
return NestedComparator.call(change.from, change.to) if change.is_a?(ValueChange)
|
|
354
|
+
|
|
355
|
+
NestedComparator.call(change, to_value)
|
|
356
|
+
end
|
|
266
357
|
end
|
|
267
358
|
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
|