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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +132 -0
  4. data/lib/paper_trail_diff/activity_boundary.rb +20 -4
  5. data/lib/paper_trail_diff/activity_grouping.rb +26 -0
  6. data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
  7. data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
  8. data/lib/paper_trail_diff/diagnostics.rb +1 -1
  9. data/lib/paper_trail_diff/errors.rb +4 -0
  10. data/lib/paper_trail_diff/nested_comparator.rb +108 -0
  11. data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
  12. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
  13. data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
  14. data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
  15. data/lib/paper_trail_diff/support.rb +13 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
  17. data/lib/paper_trail_diff/traversal_preparer.rb +1 -1
  18. data/lib/paper_trail_diff/version.rb +1 -1
  19. data/lib/paper_trail_diff.rb +101 -12
  20. data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
  21. data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  23. data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
  24. data/sig/generated/paper_trail_diff/errors.rbs +5 -0
  25. data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
  26. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
  27. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
  28. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
  29. data/sig/generated/paper_trail_diff/support.rbs +11 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  31. data/sig/generated/paper_trail_diff.rbs +58 -4
  32. 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
 
@@ -129,7 +129,7 @@ module PaperTrailDiff
129
129
 
130
130
  #: (untyped) -> bool
131
131
  def versioned?(model_class)
132
- model_class.respond_to?(:paper_trail)
132
+ Support.versioned?(model_class)
133
133
  end
134
134
 
135
135
  #: (untyped, untyped) -> Array[String]
@@ -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
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
- def initialize(record, range:, tree:, snapshotter:, snapshots: false)
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: @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
- return history.steps unless closing
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: @snapshots
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: @snapshots
159
+ from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @retain
155
160
  )
156
161
  end
157
162
 
@@ -46,7 +46,7 @@ module PaperTrailDiff
46
46
 
47
47
  #: (untyped) -> bool
48
48
  def versioned?(model_class)
49
- model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
49
+ Support.versioned?(model_class)
50
50
  end
51
51
 
52
52
  #: () -> void
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.10.0'
5
+ VERSION = '0.11.0'
6
6
  end
@@ -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
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
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).analyze_many(
241
- records,
242
- within: within,
243
- activity: activity,
244
- version_scope: version_scope,
245
- close_on: close_on
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
- # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
35
- def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
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
- # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
7
- def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
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]