paper_trail_diff 0.4.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +120 -3
  4. data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +92 -0
  8. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  9. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  10. data/lib/paper_trail_diff/batched_root_versions.rb +149 -0
  11. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  12. data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
  13. data/lib/paper_trail_diff/paper_trail_adapter.rb +70 -47
  14. data/lib/paper_trail_diff/root_version_plan.rb +86 -0
  15. data/lib/paper_trail_diff/root_version_selection.rb +131 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
  17. data/lib/paper_trail_diff/time_version_range.rb +19 -24
  18. data/lib/paper_trail_diff/timeline_builder.rb +21 -18
  19. data/lib/paper_trail_diff/timeline_range.rb +37 -6
  20. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  21. data/lib/paper_trail_diff/version.rb +1 -1
  22. data/lib/paper_trail_diff/version_range.rb +45 -5
  23. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  24. data/lib/paper_trail_diff.rb +54 -12
  25. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
  26. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  27. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  28. data/sig/generated/paper_trail_diff/analysis_batch.rbs +49 -0
  29. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  30. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  31. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +69 -0
  32. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  33. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
  34. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +24 -15
  35. data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
  36. data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
  37. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
  38. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
  39. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
  40. data/sig/generated/paper_trail_diff/timeline_range.rbs +15 -2
  41. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  42. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  43. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  44. data/sig/generated/paper_trail_diff.rbs +17 -7
  45. metadata +20 -4
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects each root's versions for a batched range in a fixed number of
6
+ # queries. Only the range forms that mean the same thing for every root are
7
+ # supported: a shared wall-clock window, or each root's own whole history.
8
+ class BatchedRootVersions
9
+ #: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped) -> void
10
+ def initialize(records, time_range:, version_scope: nil)
11
+ @records = records
12
+ @time_range = time_range
13
+ @version_scope = version_scope
14
+ end
15
+
16
+ # Returns a plan per record identity.
17
+ #: () -> Hash[Array[String], RootVersionPlan]
18
+ def call
19
+ return {} if @records.empty?
20
+
21
+ selected = {} #: Hash[Array[String], RootVersionPlan]
22
+ @records.group_by(&:class).each do |model_class, records|
23
+ select_model(model_class, records, selected)
24
+ end
25
+ selected
26
+ end
27
+
28
+ private
29
+
30
+ # @rbs @records: Array[untyped]
31
+ # @rbs @time_range: TimeRange?
32
+ # @rbs @version_scope: untyped
33
+
34
+ #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
35
+ def select_model(model_class, records, selected)
36
+ ids = records.map(&:id)
37
+ in_range, chosen, trailing = model_versions(model_class, ids)
38
+ records.each do |record|
39
+ key = identity(model_class, record.id)
40
+ selected[key] = versions_for(
41
+ in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s]
42
+ )
43
+ end
44
+ end
45
+
46
+ #: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
47
+ def model_versions(model_class, ids)
48
+ range = @time_range
49
+ empty = {} #: Hash[String, untyped]
50
+ [
51
+ grouped_versions(model_class, ids, range),
52
+ chosen_version_ids(model_class, ids, range),
53
+ range ? trailing_versions(model_class, ids, range) : empty
54
+ ]
55
+ end
56
+
57
+ #: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
58
+ def versions_for(in_range, chosen, after_range)
59
+ RootVersionSelection.new(
60
+ in_range: in_range,
61
+ selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
62
+ after_range: after_range,
63
+ windowed: !@time_range.nil?,
64
+ filtered: !@version_scope.nil?
65
+ ).call
66
+ end
67
+
68
+ # One extra query names the selected mutations without discarding the
69
+ # unfiltered versions the successor lookup still needs.
70
+ #: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
71
+ def chosen_version_ids(model_class, ids, range)
72
+ scope = @version_scope
73
+ return unless scope
74
+
75
+ # A narrowed relation is the expected return. Active Support also gives
76
+ # `pluck` to plain enumerables, so an array of versions works too.
77
+ filtered = scope.call(range_scope(model_class, ids, range))
78
+ Set.new(filtered.pluck(:id))
79
+ end
80
+
81
+ #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
82
+ def grouped_versions(model_class, ids, range)
83
+ ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
84
+ end
85
+
86
+ #: (untyped, Array[untyped], TimeRange?) -> untyped
87
+ def range_scope(model_class, ids, range)
88
+ scope = base_scope(model_class, ids)
89
+ range ? range.scope(scope) : scope
90
+ end
91
+
92
+ # One row per root: the earliest version after the window, found without a
93
+ # window function so the query stays portable.
94
+ #: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
95
+ def trailing_versions(model_class, ids, range)
96
+ table = model_class.paper_trail.version_class.arel_table
97
+ relation = range.trailing_scope(base_scope(model_class, ids))
98
+ ordered(relation.where(no_earlier_trailing(table, range)))
99
+ .to_h { |version| [version.item_id.to_s, version] }
100
+ end
101
+
102
+ #: (untyped, TimeRange) -> untyped
103
+ def no_earlier_trailing(table, range)
104
+ arel = Object.const_get(:Arel) #: untyped
105
+ later = table.alias('paper_trail_diff_later_roots')
106
+ arel.const_get(:SelectManager).new
107
+ .from(later)
108
+ .project(arel.sql('1'))
109
+ .where(preceding(table, later, range))
110
+ .exists
111
+ .not
112
+ end
113
+
114
+ # Mirrors the window's own end handling, so a version sitting exactly on an
115
+ # inclusive boundary stays inside the window instead of masking the trailing
116
+ # version that follows it.
117
+ #: (untyped, untyped, TimeRange) -> untyped
118
+ def preceding(table, later, range)
119
+ same_item = later[:item_type].eq(table[:item_type])
120
+ .and(later[:item_id].eq(table[:item_id]))
121
+ same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
122
+ end
123
+
124
+ #: (untyped, TimeRange) -> untyped
125
+ def after_window(later, range)
126
+ return later[:created_at].gteq(range.end_time) if range.exclude_end?
127
+
128
+ later[:created_at].gt(range.end_time)
129
+ end
130
+
131
+ #: (untyped, Array[untyped]) -> untyped
132
+ def base_scope(model_class, ids)
133
+ model_class.paper_trail.version_class.where(
134
+ item_type: model_class.base_class.name.to_s,
135
+ item_id: ids
136
+ )
137
+ end
138
+
139
+ #: (untyped) -> Array[untyped]
140
+ def ordered(relation)
141
+ relation.reorder(created_at: :asc, id: :asc).to_a
142
+ end
143
+
144
+ #: (untyped, untyped) -> Array[String]
145
+ def identity(model_class, id)
146
+ [model_class.base_class.name.to_s, id.to_s]
147
+ end
148
+ end
149
+ end
@@ -23,18 +23,10 @@ module PaperTrailDiff
23
23
 
24
24
  #: () -> comparison_results
25
25
  def call
26
- pairs = comparison_pairs
27
- pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
28
- ensure_unique_identities!(pairs)
29
- prepare_endpoint_classes!(pairs)
30
- live_records = load_live_records(pairs.flatten)
31
- prepare_historical_batches!(pairs, live_records)
32
- live_snapshots = normalize_live_records(live_records)
33
-
34
- pairs.to_h do |from, to|
35
- identity = Support.immutable_copy(Endpoint.identity(from))
36
- [identity, compare(from, to, live_snapshots)]
37
- end.freeze
26
+ resolved = BatchBoundaryResolver.new(comparison_pairs).call
27
+ pairs = resolved.reject { |pair| pair.any? { |endpoint| symbolic?(endpoint) } }
28
+ ensure_unique_identities!(resolved)
29
+ results(resolved, prepared_live_snapshots(pairs)).freeze
38
30
  end
39
31
 
40
32
  private
@@ -46,6 +38,38 @@ module PaperTrailDiff
46
38
  # @rbs @historical_snapshotter: untyped
47
39
  # @rbs @live_normalizer: untyped
48
40
 
41
+ #: (Array[[untyped, untyped]]) -> Hash[identity, RecordSnapshot]
42
+ def prepared_live_snapshots(pairs)
43
+ pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
44
+ prepare_endpoint_classes!(pairs)
45
+ live_records = load_live_records(pairs.flatten)
46
+ prepare_historical_batches!(pairs, live_records)
47
+ normalize_live_records(live_records)
48
+ end
49
+
50
+ #: (untyped) -> bool
51
+ def symbolic?(endpoint)
52
+ BatchBoundaryResolver.symbolic?(endpoint)
53
+ end
54
+
55
+ # A boundary symbol that stayed unresolved means the root has no recorded
56
+ # history. An absent history is an empty result rather than a failed
57
+ # request, matching how the timeline APIs answer the same question.
58
+ #: (Array[[untyped, untyped]], Hash[identity, RecordSnapshot]) -> comparison_results
59
+ def results(resolved, live_snapshots)
60
+ resolved.to_h do |from, to|
61
+ identity = Support.immutable_copy(entry_identity(from, to))
62
+ next [identity, Diff.new] if symbolic?(from) || symbolic?(to)
63
+
64
+ [identity, compare(from, to, live_snapshots)]
65
+ end
66
+ end
67
+
68
+ #: (untyped, untyped) -> identity
69
+ def entry_identity(from, to)
70
+ Endpoint.identity(symbolic?(from) ? to : from)
71
+ end
72
+
49
73
  #: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
50
74
  def compare(from, to, live_snapshots)
51
75
  Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
@@ -125,7 +149,7 @@ module PaperTrailDiff
125
149
 
126
150
  #: (Array[[untyped, untyped]]) -> void
127
151
  def ensure_unique_identities!(pairs)
128
- identities = pairs.map { |from, _to| Endpoint.identity(from) }
152
+ identities = pairs.map { |from, to| entry_identity(from, to) }
129
153
  return if identities.uniq.length == identities.length
130
154
 
131
155
  raise ConfigurationError, 'comparisons: root identities must be unique'
@@ -24,6 +24,8 @@ module PaperTrailDiff
24
24
  end_at: root_versions.last.created_at
25
25
  )
26
26
  return if @tree.empty?
27
+ # A batched preparation already covers these roots.
28
+ return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
27
29
 
28
30
  @prepared_history = PreparedHistoryLoader.new(
29
31
  record,
@@ -35,7 +37,6 @@ module PaperTrailDiff
35
37
  ).call
36
38
  end
37
39
 
38
- # Prepares selected history for several roots of the same model class.
39
40
  #: (Array[untyped], Array[untyped]) -> void
40
41
  def prepare_batch(records, root_versions)
41
42
  return if @tree.empty? || records.empty? || root_versions.empty?
@@ -50,12 +51,7 @@ module PaperTrailDiff
50
51
  key = snapshot_key(root_endpoint, context_endpoint)
51
52
  return @snapshots[key] if @snapshots.key?(key)
52
53
 
53
- @snapshots[key] = custom(
54
- root_endpoint,
55
- context_endpoint,
56
- tree: @tree,
57
- normalizer: @normalizer
58
- )
54
+ @snapshots[key] = uncached(root_endpoint, context_endpoint)
59
55
  end
60
56
 
61
57
  #: (untyped, untyped) -> RecordSnapshot?
@@ -2,13 +2,17 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # PaperTrail/ActiveRecord boundary that produces plain record snapshots.
6
- class PaperTrailAdapter
5
+ # PaperTrail/ActiveRecord boundary that produces plain record snapshots. It is
6
+ # deliberately the widest class here: it fronts every public operation and is
7
+ # the only place allowed to know about both PaperTrail and the pure engine.
8
+ # Reconstruction logic lives in the collaborators it wires together.
9
+ class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
7
10
  #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
8
11
  def initialize(associations:, ignore:, reload_live_endpoints: true)
9
12
  @association_tree = AssociationTree.build(associations)
10
13
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
11
14
  @traversal = AssociationTraversal.new(@association_tree)
15
+ @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
12
16
  @live_endpoints = LiveEndpointProvider.new(
13
17
  tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
14
18
  )
@@ -18,9 +22,7 @@ module PaperTrailDiff
18
22
  tree: @association_tree, ignore_policy: @ignore_policy,
19
23
  traversal: @traversal, pool: @snapshot_pool
20
24
  )
21
- @historical_store = build_historical_store
22
- @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
23
- @activity_snapshotter = build_activity_snapshotter
25
+ build_snapshotters
24
26
  end
25
27
 
26
28
  #: (untyped, untyped) -> Diff
@@ -40,7 +42,7 @@ module PaperTrailDiff
40
42
  Instrumentation.instrument('compare_many', payload) do
41
43
  ComparisonBatch.new(
42
44
  comparisons,
43
- live_loader: @live_endpoints.method(:call), preparer: method(:prepare_traversal!),
45
+ live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
44
46
  history_preparer: @historical_store.method(:prepare_batch),
45
47
  historical_snapshotter: method(:historical_snapshot),
46
48
  live_normalizer: method(:normalize_live_snapshot)
@@ -48,48 +50,69 @@ module PaperTrailDiff
48
50
  end
49
51
  end
50
52
 
51
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
52
- def timeline(record, from:, to:, within:)
53
- prepare_traversal!(record.class, historical: true)
54
- builder = TimelineBuilder.new(
53
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
54
+ def timeline(record, from:, to:, within:, version_scope: nil)
55
+ @traversal_preparer.call(record.class, historical: true)
56
+ TimelineBuilder.new(
55
57
  record,
56
58
  from: from,
57
59
  to: to,
58
60
  within: within,
61
+ version_scope: version_scope,
59
62
  snapshotter: @timeline_snapshotter
60
- )
61
- builder.build
63
+ ).build
62
64
  end
63
65
 
64
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
65
- def activity_timeline(record, from:, to:, within:)
66
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
67
+ def activity_timeline(record, from:, to:, within:, version_scope: nil)
66
68
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
67
69
  Instrumentation.instrument('activity_timeline', payload) do
68
- prepare_traversal!(record.class, historical: true)
70
+ @traversal_preparer.call(record.class, historical: true)
69
71
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
70
- steps = activity_builder(record, from: from, to: to, within: within).build
72
+ steps = activity_builder(
73
+ record, from: from, to: to, within: within, version_scope: version_scope
74
+ ).build
71
75
  payload[:step_count] = steps.length
72
76
  steps
73
77
  end
74
78
  end
75
79
 
76
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
77
- def analyze(record, from:, to:, within:, activity: false)
80
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
81
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil) # rubocop:disable Metrics/ParameterLists
82
+ @traversal_preparer.call(record.class, historical: true)
78
83
  if activity
79
- prepare_traversal!(record.class, historical: true)
80
- return activity_builder(record, from: from, to: to, within: within).analyze
84
+ return activity_builder(
85
+ record, from: from, to: to, within: within, version_scope: version_scope
86
+ ).analyze
81
87
  end
82
88
 
83
- prepare_traversal!(record.class, historical: true)
84
89
  TimelineBuilder.new(
85
90
  record,
86
91
  from: from,
87
92
  to: to,
88
93
  within: within,
94
+ version_scope: version_scope,
89
95
  snapshotter: @timeline_snapshotter
90
96
  ).analyze
91
97
  end
92
98
 
99
+ # Analyzes many roots over one shared range, preparing their history once.
100
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
101
+ def analyze_many(records, within:, activity: false, version_scope: nil)
102
+ count = records.is_a?(Array) ? records.length : 0
103
+ payload = @instrumentation_payload.merge(comparison_count: count)
104
+ Instrumentation.instrument('analyze_many', payload) do
105
+ AnalysisBatch.new(
106
+ records,
107
+ time_range: within.nil? ? nil : TimeRange.new(within),
108
+ version_scope: version_scope,
109
+ live_loader: @live_endpoints.method(:call),
110
+ history_preparer: @historical_store.method(:prepare_batch),
111
+ analyzer: batched_root_analyzer(activity)
112
+ ).call
113
+ end
114
+ end
115
+
93
116
  private
94
117
 
95
118
  # @rbs @association_tree: AssociationTree
@@ -97,19 +120,38 @@ module PaperTrailDiff
97
120
  # @rbs @instrumentation_payload: Hash[Symbol, untyped]
98
121
  # @rbs @ignore_policy: IgnorePolicy
99
122
  # @rbs @traversal: AssociationTraversal
123
+ # @rbs @traversal_preparer: TraversalPreparer
100
124
  # @rbs @snapshot_pool: SnapshotPool
101
125
  # @rbs @normalizer: SnapshotNormalizer
102
126
  # @rbs @historical_store: HistoricalSnapshotStore
103
127
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
104
128
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
105
129
 
130
+ #: () -> void
131
+ def build_snapshotters
132
+ @historical_store = build_historical_store
133
+ @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
134
+ @activity_snapshotter = build_activity_snapshotter
135
+ end
136
+
137
+ #: (bool) -> BatchedRootAnalyzer
138
+ def batched_root_analyzer(activity)
139
+ BatchedRootAnalyzer.new(
140
+ tree: @association_tree,
141
+ timeline_snapshotter: @timeline_snapshotter,
142
+ activity_snapshotter: @activity_snapshotter,
143
+ preparer: @traversal_preparer.method(:call),
144
+ activity: activity
145
+ )
146
+ end
147
+
106
148
  #: () -> HistoricalSnapshotStore
107
149
  def build_historical_store
108
150
  HistoricalSnapshotStore.new(
109
151
  tree: @association_tree,
110
152
  traversal: @traversal,
111
153
  normalizer: @normalizer,
112
- preparer: method(:prepare_traversal!)
154
+ preparer: @traversal_preparer.method(:call)
113
155
  )
114
156
  end
115
157
 
@@ -131,11 +173,13 @@ module PaperTrailDiff
131
173
  )
132
174
  end
133
175
 
134
- #: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
135
- def activity_builder(record, from:, to:, within:)
176
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
177
+ def activity_builder(record, from:, to:, within:, version_scope: nil)
136
178
  ActivityTimelineBuilder.new(
137
179
  record,
138
- range: TimelineRange.new(record, from: from, to: to, within: within),
180
+ range: TimelineRange.new(
181
+ record, from: from, to: to, within: within, version_scope: version_scope
182
+ ),
139
183
  tree: @association_tree,
140
184
  snapshotter: @activity_snapshotter
141
185
  )
@@ -180,30 +224,9 @@ module PaperTrailDiff
180
224
 
181
225
  #: (untyped) -> RecordSnapshot
182
226
  def normalize_live_snapshot(current)
183
- prepare_traversal!(current.class, historical: false)
227
+ @traversal_preparer.call(current.class, historical: false)
184
228
  @normalizer.call(current, reifier: LiveAssociationReader.new) ||
185
229
  raise(InvalidEndpointError, 'current record endpoint could not be normalized')
186
230
  end
187
-
188
- #: (untyped, historical: bool) -> void
189
- def prepare_traversal!(model_class, historical:)
190
- return if @association_tree.empty?
191
-
192
- ensure_association_tracking! if historical
193
- @traversal.validate!(model_class)
194
- end
195
-
196
- #: () -> void
197
- def ensure_association_tracking!
198
- paper_trail = Object.const_get(:PaperTrail) #: untyped
199
- config = paper_trail.config #: untyped
200
- available = defined?(::PaperTrailAssociationTracking) &&
201
- config.respond_to?(:track_associations?) &&
202
- config.track_associations?
203
- return if available
204
-
205
- message = 'association tracking must be loaded and enabled to compare historical associations'
206
- raise AssociationTrackingUnavailableError, message
207
- end
208
231
  end
209
232
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Which versions a range reconstructs, and which pairs of them become steps.
6
+ #
7
+ # Without a filter those are the same thing: every selected version is a
8
+ # boundary and adjacent ones bound each other. A filter separates them, because
9
+ # each selected mutation then has to be bounded by the version that actually
10
+ # reveals it rather than by the next mutation that happened to be selected.
11
+ #
12
+ # A filter also separates what is *reported* from what has to be *replayed*.
13
+ # The activity view rebuilds state by carrying one snapshot forward across
14
+ # every event in order, so skipping a filtered-out version would leave that
15
+ # snapshot holding a state the record had already moved past. `versions` are
16
+ # the boundaries the steps refer to; `reconstruction_versions` are every root
17
+ # version the span passes through, which is what any forward replay must walk.
18
+ class RootVersionPlan
19
+ attr_reader :versions #: Array[untyped]
20
+ attr_reader :reconstruction_versions #: Array[untyped]
21
+ attr_reader :steps #: Array[[untyped, untyped]]
22
+ attr_reader :context_version #: untyped
23
+ # The root versions this plan reports as mutations, which excludes any
24
+ # version present only to reveal what the last of them produced.
25
+ attr_reader :mutations #: Array[untyped]
26
+
27
+ class << self
28
+ # Adjacent boundaries, which is what an unfiltered range reports.
29
+ #: (Array[untyped], ?context_version: untyped) -> RootVersionPlan
30
+ def contiguous(versions, context_version: nil)
31
+ new(
32
+ versions: versions,
33
+ steps: versions.each_cons(2).map { |from, to| [from, to] },
34
+ context_version: context_version
35
+ )
36
+ end
37
+
38
+ #: () -> RootVersionPlan
39
+ def empty
40
+ versions = [] #: Array[untyped]
41
+ steps = [] #: Array[[untyped, untyped]]
42
+ new(versions: versions, steps: steps)
43
+ end
44
+ end
45
+
46
+ #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
47
+ def initialize(
48
+ versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil
49
+ )
50
+ @versions = versions.freeze
51
+ @reconstruction_versions = (reconstruction_versions || versions).freeze
52
+ @steps = steps.freeze
53
+ @context_version = context_version
54
+ @mutations = (mutations || default_mutations).freeze
55
+ @mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
56
+ freeze
57
+ end
58
+
59
+ #: () -> bool
60
+ def empty?
61
+ versions.empty?
62
+ end
63
+
64
+ #: (untyped) -> bool
65
+ def mutation?(version)
66
+ @mutation_keys.include?(key(version))
67
+ end
68
+
69
+ private
70
+
71
+ # @rbs @mutation_keys: Set[Array[untyped]]
72
+
73
+ #: () -> Array[untyped]
74
+ def default_mutations
75
+ return @versions unless @context_version
76
+
77
+ context = key(@context_version)
78
+ @versions.reject { |version| key(version) == context }
79
+ end
80
+
81
+ #: (untyped) -> Array[untyped]
82
+ def key(version)
83
+ [version.class.name, version.id]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Decides which versions a range reports, and which one reveals the last of
6
+ # them. A version records the state before its own event, so whatever follows
7
+ # the final selected mutation is the only thing that can show what it produced.
8
+ # That successor is reconstruction context rather than a reported mutation, so
9
+ # a filter must never remove it.
10
+ #
11
+ # Both the single-record and the batched selectors resolve this here, because
12
+ # the rule is subtle enough that two copies of it would drift apart.
13
+ class RootVersionSelection
14
+ INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
15
+
16
+ #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool) -> void
17
+ def initialize( # rubocop:disable Metrics/ParameterLists
18
+ in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false
19
+ )
20
+ @in_range = in_range
21
+ @selected = selected
22
+ @after_range = after_range
23
+ @windowed = windowed
24
+ @context_required = context_required
25
+ @filtered = filtered
26
+ end
27
+
28
+ #: () -> RootVersionPlan
29
+ def call
30
+ return without_selection if @selected.empty?
31
+
32
+ revealing = revealing_version
33
+ raise IncompleteTimeRangeError, INCOMPLETE if !revealing && @windowed && !terminal_destroy?
34
+ return filtered_plan(revealing) if @filtered
35
+
36
+ versions = revealing ? (@selected + [revealing]) : @selected
37
+ RootVersionPlan.contiguous(versions, context_version: revealing)
38
+ end
39
+
40
+ private
41
+
42
+ # @rbs @in_range: Array[untyped]
43
+ # @rbs @selected: Array[untyped]
44
+ # @rbs @after_range: untyped
45
+ # @rbs @windowed: bool
46
+ # @rbs @context_required: bool
47
+ # @rbs @filtered: bool
48
+
49
+ # An activity view still needs a root to reconstruct from even when no root
50
+ # version falls inside the window, because descendants may have moved.
51
+ #: () -> RootVersionPlan
52
+ def without_selection
53
+ return RootVersionPlan.empty unless @context_required
54
+ if @after_range
55
+ return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
56
+ end
57
+
58
+ raise IncompleteTimeRangeError, INCOMPLETE
59
+ end
60
+
61
+ # Each selected mutation is bounded by the version that reveals it, not by
62
+ # the next mutation that happened to be selected. Bounding by the next
63
+ # selection would fold anything filtered out in between into it, so the same
64
+ # edit would read differently depending on what followed it.
65
+ #: (untyped) -> RootVersionPlan
66
+ def filtered_plan(revealing)
67
+ steps = @selected.filter_map do |version|
68
+ successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
69
+ [version, successor] if successor
70
+ end #: Array[[untyped, untyped]]
71
+ versions = chronological(steps.flatten(1) + closing_versions)
72
+ RootVersionPlan.new(
73
+ versions: versions,
74
+ steps: steps,
75
+ context_version: revealing,
76
+ reconstruction_versions: spanned(versions),
77
+ mutations: @selected
78
+ )
79
+ end
80
+
81
+ # Nothing can follow a destroy, so it never pairs into a step. It is still a
82
+ # selected mutation, and the activity view closes on the absence it leaves,
83
+ # so it has to stay a boundary or a filtered report loses the deletion
84
+ # entirely — the one event it can least afford to drop.
85
+ #: () -> Array[untyped]
86
+ def closing_versions
87
+ terminal_destroy? ? [@selected.last] : []
88
+ end
89
+
90
+ # Everything the span passes through, filtered out or not. A replay that
91
+ # skipped the excluded versions would carry a stale state into the next
92
+ # reported step, so the two selected mutations either side of a gap would
93
+ # disagree about what the record looked like between them.
94
+ #: (Array[untyped]) -> Array[untyped]
95
+ def spanned(versions)
96
+ first = versions.first
97
+ last = versions.last
98
+ return versions unless first && last
99
+
100
+ @in_range.select do |candidate|
101
+ !Support.compare_versions(first, candidate).positive? &&
102
+ !Support.compare_versions(candidate, last).positive?
103
+ end
104
+ end
105
+
106
+ #: (Array[untyped]) -> Array[untyped]
107
+ def chronological(versions)
108
+ versions.uniq { |version| [version.class.name, version.id] }
109
+ .sort_by { |version| Support.chronological_version_key(version) }
110
+ end
111
+
112
+ #: (untyped) -> untyped
113
+ def immediate_successor(version)
114
+ @in_range.find { |candidate| Support.compare_versions(version, candidate).negative? }
115
+ end
116
+
117
+ # A version left out by a filter is still the state the last selected change
118
+ # produced, so it is preferred over anything after the range.
119
+ #: () -> untyped
120
+ def revealing_version
121
+ immediate_successor(@selected.last) || @after_range
122
+ end
123
+
124
+ # A range closing on the record's own destruction needs no later version:
125
+ # nothing can follow it, so demanding one would reject the range forever.
126
+ #: () -> bool
127
+ def terminal_destroy?
128
+ @selected.last&.event.to_s == 'destroy'
129
+ end
130
+ end
131
+ end