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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +153 -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 +119 -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/value_objects.rb +66 -0
  19. data/lib/paper_trail_diff/version.rb +1 -1
  20. data/lib/paper_trail_diff.rb +103 -12
  21. data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
  22. data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
  23. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  24. data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
  25. data/sig/generated/paper_trail_diff/errors.rbs +5 -0
  26. data/sig/generated/paper_trail_diff/nested_comparator.rbs +73 -0
  27. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
  28. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
  29. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
  30. data/sig/generated/paper_trail_diff/support.rbs +11 -0
  31. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  32. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  33. data/sig/generated/paper_trail_diff.rbs +62 -4
  34. 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
@@ -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
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.10.0'
5
+ VERSION = '0.12.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'
@@ -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
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
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).analyze_many(
241
- records,
242
- within: within,
243
- activity: activity,
244
- version_scope: version_scope,
245
- close_on: close_on
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
- # : (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