paper_trail_diff 0.3.1 → 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +316 -32
  4. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  5. data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
  6. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  7. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  8. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  9. data/lib/paper_trail_diff/analysis.rb +9 -0
  10. data/lib/paper_trail_diff/analysis_batch.rb +74 -0
  11. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  12. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  13. data/lib/paper_trail_diff/batched_root_versions.rb +131 -0
  14. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  15. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  16. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  17. data/lib/paper_trail_diff/endpoint.rb +27 -2
  18. data/lib/paper_trail_diff/errors.rb +3 -0
  19. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  20. data/lib/paper_trail_diff/historical_snapshot_store.rb +12 -9
  21. data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
  22. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  23. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  24. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  25. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  26. data/lib/paper_trail_diff/snapshot.rb +26 -0
  27. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  28. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  29. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  30. data/lib/paper_trail_diff/timeline_builder.rb +3 -3
  31. data/lib/paper_trail_diff/timeline_range.rb +66 -6
  32. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  33. data/lib/paper_trail_diff/version.rb +1 -1
  34. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  35. data/lib/paper_trail_diff.rb +37 -5
  36. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  37. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  38. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  39. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  41. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  42. data/sig/generated/paper_trail_diff/analysis_batch.rbs +42 -0
  43. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  44. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  45. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -0
  46. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  47. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  48. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  49. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  50. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  51. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  52. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -3
  53. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
  54. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  55. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  56. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  57. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  58. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  59. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  60. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  61. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  62. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  63. data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
  64. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  65. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  66. data/sig/generated/paper_trail_diff.rbs +13 -3
  67. metadata +16 -4
@@ -7,63 +7,43 @@ module PaperTrailDiff
7
7
  #: (AssociationTraversal) -> void
8
8
  def initialize(traversal)
9
9
  @traversal = traversal
10
- @collection_model = nil
11
- @collection_tree = nil #: AssociationTree?
12
- @collection_type = nil #: String?
13
- @collection_path = nil #: String?
14
- @collection_routes = nil #: Array[Array[untyped]]?
15
- @belongs_to_model = nil
16
- @belongs_to_tree = nil #: AssociationTree?
17
- @belongs_to_type = nil #: String?
18
- @belongs_to_path = nil #: String?
19
- @belongs_to_routes = nil #: Array[Array[untyped]]?
10
+ @collection_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
11
+ @belongs_to_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
20
12
  end
21
13
 
22
14
  #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
23
15
  def collection_routes(model_class, tree, target_type, path: '')
24
- cached = @collection_routes
25
- if cached && @collection_model.equal?(model_class) && @collection_tree.equal?(tree) &&
26
- @collection_type == target_type && @collection_path == path
27
- return cached
28
- end
29
-
30
- computed = freeze_routes(routes(model_class, tree, target_type, :has_many, path: path))
31
- @collection_model = model_class
32
- @collection_tree = tree
33
- @collection_type = Support.immutable_copy(target_type)
34
- @collection_path = Support.immutable_copy(path)
35
- @collection_routes = computed
16
+ cached(@collection_routes, model_class, tree, target_type, path, :has_many)
36
17
  end
37
18
 
38
19
  #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
39
20
  def belongs_to_routes(model_class, tree, target_type, path: '')
40
- cached = @belongs_to_routes
41
- if cached && @belongs_to_model.equal?(model_class) && @belongs_to_tree.equal?(tree) &&
42
- @belongs_to_type == target_type && @belongs_to_path == path
43
- return cached
44
- end
45
-
46
- computed = freeze_routes(routes(model_class, tree, target_type, :belongs_to, path: path))
47
- @belongs_to_model = model_class
48
- @belongs_to_tree = tree
49
- @belongs_to_type = Support.immutable_copy(target_type)
50
- @belongs_to_path = Support.immutable_copy(path)
51
- @belongs_to_routes = computed
21
+ cached(@belongs_to_routes, model_class, tree, target_type, path, :belongs_to)
52
22
  end
53
23
 
54
24
  private
55
25
 
56
26
  # @rbs @traversal: AssociationTraversal
57
- # @rbs @collection_model: untyped
58
- # @rbs @collection_tree: AssociationTree?
59
- # @rbs @collection_type: String?
60
- # @rbs @collection_path: String?
61
- # @rbs @collection_routes: Array[Array[untyped]]?
62
- # @rbs @belongs_to_model: untyped
63
- # @rbs @belongs_to_tree: AssociationTree?
64
- # @rbs @belongs_to_type: String?
65
- # @rbs @belongs_to_path: String?
66
- # @rbs @belongs_to_routes: Array[Array[untyped]]?
27
+ # @rbs @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
28
+ # @rbs @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
29
+
30
+ # Timelines interleave event types, so every requested combination is
31
+ # retained. Model, tree, and path are fixed per adapter, leaving one entry
32
+ # per selected item type.
33
+ #: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
34
+ def cached(store, model_class, tree, target_type, path, macro) # rubocop:disable Metrics/ParameterLists
35
+ key = [model_class, tree, target_type, path]
36
+ existing = store[key]
37
+ return existing if existing
38
+
39
+ store[cache_key(key)] =
40
+ freeze_routes(routes(model_class, tree, target_type, macro, path: path))
41
+ end
42
+
43
+ #: (Array[untyped]) -> Array[untyped]
44
+ def cache_key(key)
45
+ key.map { |part| part.is_a?(String) ? -part : part }.freeze
46
+ end
67
47
 
68
48
  #: (Array[Array[untyped]]) -> Array[Array[untyped]]
69
49
  def freeze_routes(routes)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Root checkpoint steps recovered from the snapshots an activity pass retained,
6
+ # so a combined result does not reconstruct the same boundaries twice.
7
+ module ActivityRootSteps
8
+ module_function
9
+
10
+ #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
+ def call(root_versions, root_snapshots)
12
+ snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
13
+ root_versions.each_cons(2).with_index.map do |versions, index|
14
+ Step.new(
15
+ from_version: versions.fetch(0),
16
+ to_version: versions.fetch(1),
17
+ diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
18
+ )
19
+ end.freeze
20
+ end
21
+
22
+ #: (untyped) -> Array[untyped]
23
+ def version_key(version)
24
+ [version.class.name, version.id]
25
+ end
26
+ end
27
+ end
@@ -11,9 +11,14 @@ module PaperTrailDiff
11
11
  @preparer = preparer
12
12
  end
13
13
 
14
- #: (untyped, Array[untyped], ?start_at: untyped) -> void
15
- def prepare(record, root_versions, start_at: root_versions.first.created_at)
16
- @preparer.call(record, root_versions, start_at: start_at)
14
+ #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
15
+ def prepare(
16
+ record,
17
+ root_versions,
18
+ start_at: root_versions.first.created_at,
19
+ end_at: root_versions.last.created_at
20
+ )
21
+ @preparer.call(record, root_versions, start_at: start_at, end_at: end_at)
17
22
  end
18
23
 
19
24
  #: (untyped, untyped) -> RecordSnapshot?
@@ -17,6 +17,7 @@ module PaperTrailDiff
17
17
  #: () -> Array[ActivityStep]
18
18
  def build
19
19
  return time_builder.build if @range.time?
20
+ return no_steps if @range.unresolved?
20
21
  return build_to_current if Endpoint.record?(@to)
21
22
 
22
23
  Endpoint.validate!(@to)
@@ -27,6 +28,7 @@ module PaperTrailDiff
27
28
  #: () -> Analysis
28
29
  def analyze
29
30
  return time_builder.analyze if @range.time?
31
+ return Analysis.empty if @range.unresolved?
30
32
 
31
33
  unless Endpoint.version?(@to)
32
34
  raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
@@ -35,7 +37,7 @@ module PaperTrailDiff
35
37
  root_versions = @range.select
36
38
  prepare_history(root_versions)
37
39
  events = collect_events(root_versions)
38
- build_analysis(root_versions, event_history(root_versions, events))
40
+ build_analysis(root_versions, events, event_history(root_versions, events))
39
41
  end
40
42
 
41
43
  private
@@ -47,6 +49,12 @@ module PaperTrailDiff
47
49
  # @rbs @tree: AssociationTree
48
50
  # @rbs @snapshotter: untyped
49
51
 
52
+ #: () -> Array[ActivityStep]
53
+ def no_steps
54
+ steps = [] #: Array[ActivityStep]
55
+ steps.freeze
56
+ end
57
+
50
58
  #: () -> Array[ActivityStep]
51
59
  def build_between_versions
52
60
  root_versions = @range.select
@@ -60,7 +68,9 @@ module PaperTrailDiff
60
68
  validate_current_range!
61
69
  current_snapshot, captured_at = capture_current
62
70
  root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
63
- prepare_history(root_versions)
71
+ # Descendants can move after the last root version, so the prepared range
72
+ # ends at the captured instant rather than at that version.
73
+ prepare_history(root_versions, end_at: captured_at)
64
74
  events = collect_events(root_versions, range_end: captured_at)
65
75
  build_event_steps(
66
76
  root_versions,
@@ -102,15 +112,12 @@ module PaperTrailDiff
102
112
  ).call
103
113
  end
104
114
 
105
- #: (Array[untyped], ?start_at: untyped) -> void
106
- def prepare_history(root_versions, start_at: nil)
115
+ #: (Array[untyped], ?end_at: untyped) -> void
116
+ def prepare_history(root_versions, end_at: nil)
107
117
  return unless @snapshotter.respond_to?(:prepare)
118
+ return @snapshotter.prepare(@record, root_versions) unless end_at
108
119
 
109
- if start_at
110
- @snapshotter.prepare(@record, root_versions, start_at: start_at)
111
- else
112
- @snapshotter.prepare(@record, root_versions)
113
- end
120
+ @snapshotter.prepare(@record, root_versions, end_at: end_at)
114
121
  end
115
122
 
116
123
  #: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
@@ -122,6 +129,18 @@ module PaperTrailDiff
122
129
  final_snapshot: nil
123
130
  )
124
131
  history = event_history(root_versions, events, current: current)
132
+ activity_steps(
133
+ history,
134
+ events,
135
+ final_boundary || destroyed_boundary(root_versions),
136
+ final_snapshot
137
+ )
138
+ end
139
+
140
+ # Appends the transition into an explicit closing boundary, which is either
141
+ # a requested current record or the absence a final root destroy leaves.
142
+ #: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
143
+ def activity_steps(history, events, final_boundary, final_snapshot)
125
144
  steps = history.steps.dup
126
145
  previous_event = events.last
127
146
  previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
@@ -135,6 +154,16 @@ module PaperTrailDiff
135
154
  steps.freeze
136
155
  end
137
156
 
157
+ # A destroyed root has no later version, but its own event states that
158
+ # nothing follows it, so the removal can still close the timeline.
159
+ #: (Array[untyped]) -> ActivityBoundary?
160
+ def destroyed_boundary(root_versions)
161
+ version = root_versions.last
162
+ return unless version && version.event.to_s == 'destroy'
163
+
164
+ ActivityBoundary.destroyed(version)
165
+ end
166
+
138
167
  #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
139
168
  def event_history(root_versions, events, current: nil)
140
169
  ActivityHistoryBuilder.new(
@@ -145,32 +174,18 @@ module PaperTrailDiff
145
174
  ).call
146
175
  end
147
176
 
148
- #: (Array[untyped], ActivityHistory) -> Analysis
149
- def build_analysis(root_versions, history)
150
- snapshots = root_versions.map do |version|
151
- history.root_snapshots.fetch(version_key(version))
152
- end
177
+ # Only the activity view gains the closing removal. The endpoint diff and
178
+ # the root timeline keep their `compare` and `timeline` semantics, under
179
+ # which the state at a destroy version is the state before the deletion.
180
+ #: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
181
+ def build_analysis(root_versions, events, history)
153
182
  Analysis.new(
154
183
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
155
- timeline: build_root_steps(root_versions, snapshots),
156
- activity_timeline: history.steps
157
- )
158
- end
159
-
160
- #: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
161
- def build_root_steps(root_versions, snapshots)
162
- root_versions.each_cons(2).with_index.map do |versions, index|
163
- Step.new(
164
- from_version: versions.fetch(0),
165
- to_version: versions.fetch(1),
166
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
184
+ timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
185
+ activity_timeline: activity_steps(
186
+ history, events, destroyed_boundary(root_versions), nil
167
187
  )
168
- end.freeze
169
- end
170
-
171
- #: (untyped) -> Array[untyped]
172
- def version_key(version)
173
- [version.class.name, version.id]
188
+ )
174
189
  end
175
190
 
176
191
  #: () -> TimeActivityTimelineBuilder
@@ -8,6 +8,15 @@ module PaperTrailDiff
8
8
  attr_reader :timeline #: Array[Step]
9
9
  attr_reader :activity_timeline #: Array[ActivityStep]?
10
10
 
11
+ # The result for a record whose requested history contains no versions,
12
+ # which is an empty history rather than a failed request.
13
+ #: () -> Analysis
14
+ def self.empty
15
+ timeline = [] #: Array[Step]
16
+ activity_timeline = [] #: Array[ActivityStep]
17
+ new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
18
+ end
19
+
11
20
  #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
12
21
  def initialize(diff:, timeline:, activity_timeline: nil)
13
22
  @diff = diff
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Builds one Analysis per root over a shared range, selecting every root's
6
+ # versions and preparing their association history once for the whole batch
7
+ # rather than once per root.
8
+ class AnalysisBatch
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
10
+ def initialize(records, time_range:, live_loader:, history_preparer:, analyzer:)
11
+ @records = records
12
+ @time_range = time_range
13
+ @live_loader = live_loader
14
+ @history_preparer = history_preparer
15
+ @analyzer = analyzer
16
+ end
17
+
18
+ #: () -> Hash[identity, Analysis]
19
+ def call
20
+ records = validated_records
21
+ selected = BatchedRootVersions.new(records, time_range: @time_range).call
22
+ prepare(records, selected)
23
+ records.to_h do |record|
24
+ key = Endpoint.identity(record)
25
+ [Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
26
+ end.freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs @records: Array[untyped]
32
+ # @rbs @time_range: TimeRange?
33
+ # @rbs @live_loader: untyped
34
+ # @rbs @history_preparer: untyped
35
+ # @rbs @analyzer: untyped
36
+
37
+ #: () -> Array[untyped]
38
+ def validated_records
39
+ raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
40
+
41
+ @records.each { |record| Endpoint.validate!(record) }
42
+ identities = @records.map { |record| Endpoint.identity(record) }
43
+ return @records if identities.uniq.length == identities.length
44
+
45
+ raise ConfigurationError, 'records: identities must be unique'
46
+ end
47
+
48
+ # Association history is prepared per model class across every selected root
49
+ # version, which is the work that would otherwise repeat for each record.
50
+ # The roots are preloaded first, because preparation reads their current
51
+ # association state as a fallback and would otherwise walk it one root at a
52
+ # time.
53
+ #: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
54
+ def prepare(records, selected)
55
+ loaded = @live_loader.call(records)
56
+ records.group_by(&:class).each_value do |grouped|
57
+ versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
58
+ next if versions.empty?
59
+
60
+ roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
61
+ @history_preparer.call(roots, versions)
62
+ end
63
+ end
64
+
65
+ # A root with no versions in range has nothing to report, which is an empty
66
+ # result rather than a failed request.
67
+ #: (untyped, Array[untyped]) -> Analysis
68
+ def analysis_for(record, versions)
69
+ return Analysis.empty if versions.empty?
70
+
71
+ @analyzer.call(record, versions)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Resolves `:first` and `:last` endpoints for a whole batch in two queries per
6
+ # model class. Left to the caller this is a per-root lookup, which reintroduces
7
+ # exactly the queries a batched comparison exists to remove.
8
+ class BatchBoundaryResolver
9
+ BOUNDARIES = %i[first last].freeze
10
+
11
+ class << self
12
+ #: (untyped) -> bool
13
+ def symbolic?(endpoint)
14
+ endpoint.is_a?(Symbol)
15
+ end
16
+ end
17
+
18
+ #: (Array[[untyped, untyped]]) -> void
19
+ def initialize(pairs)
20
+ @pairs = pairs
21
+ end
22
+
23
+ # Returns the pairs with every resolvable symbol replaced. A symbol that
24
+ # names history the record does not have is left in place, so the caller can
25
+ # decide what an absent history means rather than being handed nil.
26
+ #: () -> Array[[untyped, untyped]]
27
+ def call
28
+ return @pairs unless @pairs.flatten(1).any? { |endpoint| symbolic?(endpoint) }
29
+
30
+ index = boundary_index
31
+ @pairs.map do |from, to|
32
+ [resolve(from, to, index), resolve(to, from, index)]
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # @rbs @pairs: Array[[untyped, untyped]]
39
+
40
+ #: (untyped) -> bool
41
+ def symbolic?(endpoint)
42
+ self.class.symbolic?(endpoint)
43
+ end
44
+
45
+ #: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
46
+ def resolve(endpoint, other, index)
47
+ return endpoint unless symbolic?(endpoint)
48
+
49
+ unless BOUNDARIES.include?(endpoint)
50
+ raise ConfigurationError,
51
+ "unsupported boundary: #{endpoint.inspect}; use :first, :last, a version, or a record"
52
+ end
53
+
54
+ identity = anchor_identity(other)
55
+ unless identity
56
+ raise ConfigurationError,
57
+ 'a :first or :last endpoint needs the other endpoint to name a record'
58
+ end
59
+
60
+ index.dig(identity, endpoint) || endpoint
61
+ end
62
+
63
+ # A symbol carries no identity of its own, so the pair's other endpoint has
64
+ # to say which record is meant.
65
+ #: (untyped) -> Array[String]?
66
+ def anchor_identity(endpoint)
67
+ return if endpoint.nil? || symbolic?(endpoint)
68
+
69
+ Endpoint.identity(endpoint)
70
+ rescue InvalidEndpointError
71
+ nil
72
+ end
73
+
74
+ #: () -> Hash[Array[String], Hash[Symbol, untyped]]
75
+ def boundary_index
76
+ index = {} #: Hash[Array[String], Hash[Symbol, untyped]]
77
+ anchors.group_by { |model_class, _id| model_class }.each do |model_class, entries|
78
+ add_model_boundaries(index, model_class, entries.map { |_klass, id| id }.uniq)
79
+ end
80
+ index
81
+ end
82
+
83
+ #: () -> Array[[untyped, untyped]]
84
+ def anchors
85
+ @pairs.flatten(1).filter_map { |endpoint| anchor(endpoint) }.uniq
86
+ end
87
+
88
+ #: (untyped) -> [untyped, untyped]?
89
+ def anchor(endpoint)
90
+ return if endpoint.nil? || symbolic?(endpoint)
91
+
92
+ if Endpoint.version?(endpoint)
93
+ [Endpoint.model_class(endpoint), endpoint.item_id]
94
+ elsif Endpoint.record?(endpoint)
95
+ [endpoint.class, endpoint.id]
96
+ end
97
+ rescue InvalidEndpointError
98
+ nil
99
+ end
100
+
101
+ #: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
102
+ def add_model_boundaries(index, model_class, ids)
103
+ first_ids, last_ids, versions = boundary_versions(model_class, ids)
104
+ ids.each do |id|
105
+ index[identity_key(model_class, id)] = {
106
+ first: versions[boundary_key(first_ids, id)],
107
+ last: versions[boundary_key(last_ids, id)]
108
+ }
109
+ end
110
+ end
111
+
112
+ # Two grouped queries name each root's outermost versions, and one more
113
+ # loads them, whatever the size of the batch.
114
+ #: (untyped, Array[untyped]) -> [Hash[untyped, untyped], Hash[untyped, untyped], Hash[untyped, untyped]]
115
+ def boundary_versions(model_class, ids)
116
+ version_class = model_class.paper_trail.version_class
117
+ scope = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
118
+ first_ids = scope.group(:item_id).minimum(:id)
119
+ last_ids = scope.group(:item_id).maximum(:id)
120
+ loaded = version_class.where(id: (first_ids.values + last_ids.values).uniq).index_by(&:id)
121
+ [first_ids, last_ids, loaded]
122
+ end
123
+
124
+ # Grouped keys come back with whatever type the column uses, so match on the
125
+ # string form rather than assuming integers.
126
+ #: (Hash[untyped, untyped], untyped) -> untyped
127
+ def boundary_key(grouped, id)
128
+ key = grouped.keys.find { |candidate| candidate.to_s == id.to_s }
129
+ grouped[key]
130
+ end
131
+
132
+ #: (untyped, untyped) -> Array[String]
133
+ def identity_key(model_class, id)
134
+ [model_class.base_class.name.to_s, id.to_s]
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Builds one root's Analysis inside a batch, from versions the batch already
6
+ # selected and history it already prepared.
7
+ class BatchedRootAnalyzer
8
+ #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
9
+ def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:)
10
+ @tree = tree
11
+ @timeline_snapshotter = timeline_snapshotter
12
+ @activity_snapshotter = activity_snapshotter
13
+ @preparer = preparer
14
+ @activity = activity
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> Analysis
18
+ def call(record, versions)
19
+ @preparer.call(record.class, historical: true)
20
+ return activity_analysis(record, versions) if @activity
21
+
22
+ TimelineBuilder.new(
23
+ record,
24
+ from: versions.first,
25
+ to: versions.last,
26
+ within: nil,
27
+ versions: versions,
28
+ snapshotter: @timeline_snapshotter
29
+ ).analyze
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs @tree: AssociationTree
35
+ # @rbs @timeline_snapshotter: untyped
36
+ # @rbs @activity_snapshotter: untyped
37
+ # @rbs @preparer: untyped
38
+ # @rbs @activity: bool
39
+
40
+ #: (untyped, Array[untyped]) -> Analysis
41
+ def activity_analysis(record, versions)
42
+ range = TimelineRange.new(
43
+ record, from: versions.first, to: versions.last, within: nil, versions: versions
44
+ )
45
+ ActivityTimelineBuilder.new(
46
+ record, range: range, tree: @tree, snapshotter: @activity_snapshotter
47
+ ).analyze
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,131 @@
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?) -> void
10
+ def initialize(records, time_range:)
11
+ @records = records
12
+ @time_range = time_range
13
+ end
14
+
15
+ # Returns root versions per record identity, in chronological order.
16
+ #: () -> Hash[Array[String], Array[untyped]]
17
+ def call
18
+ return {} if @records.empty?
19
+
20
+ selected = {} #: Hash[Array[String], Array[untyped]]
21
+ @records.group_by(&:class).each do |model_class, records|
22
+ select_model(model_class, records, selected)
23
+ end
24
+ selected
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @records: Array[untyped]
30
+ # @rbs @time_range: TimeRange?
31
+
32
+ #: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
33
+ def select_model(model_class, records, selected)
34
+ ids = records.map(&:id)
35
+ range = @time_range
36
+ grouped = grouped_versions(model_class, ids, range)
37
+ empty = {} #: Hash[String, untyped]
38
+ trailing = range ? trailing_versions(model_class, ids, range) : empty
39
+ records.each do |record|
40
+ key = identity(model_class, record.id)
41
+ selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
42
+ end
43
+ end
44
+
45
+ # The window's own versions plus, when it has one, the later version needed
46
+ # to reveal the last mutation inside it. A window closing on the root's own
47
+ # destruction needs no later version, because none can exist.
48
+ #: (Array[untyped], untyped) -> Array[untyped]
49
+ def versions_for(in_range, trailing)
50
+ return in_range if @time_range.nil?
51
+ return in_range.freeze if in_range.empty?
52
+ return in_range.freeze if trailing.nil? && terminal_destroy?(in_range)
53
+
54
+ unless trailing
55
+ raise IncompleteTimeRangeError,
56
+ 'time range requires a later root version to reconstruct its final change'
57
+ end
58
+
59
+ (in_range + [trailing]).freeze
60
+ end
61
+
62
+ #: (Array[untyped]) -> bool
63
+ def terminal_destroy?(versions)
64
+ versions.last&.event.to_s == 'destroy'
65
+ end
66
+
67
+ #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
68
+ def grouped_versions(model_class, ids, range)
69
+ scope = base_scope(model_class, ids)
70
+ scope = range.scope(scope) if range
71
+ ordered(scope).group_by { |version| version.item_id.to_s }
72
+ end
73
+
74
+ # One row per root: the earliest version after the window, found without a
75
+ # window function so the query stays portable.
76
+ #: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
77
+ def trailing_versions(model_class, ids, range)
78
+ table = model_class.paper_trail.version_class.arel_table
79
+ relation = range.trailing_scope(base_scope(model_class, ids))
80
+ ordered(relation.where(no_earlier_trailing(table, range)))
81
+ .to_h { |version| [version.item_id.to_s, version] }
82
+ end
83
+
84
+ #: (untyped, TimeRange) -> untyped
85
+ def no_earlier_trailing(table, range)
86
+ arel = Object.const_get(:Arel) #: untyped
87
+ later = table.alias('paper_trail_diff_later_roots')
88
+ arel.const_get(:SelectManager).new
89
+ .from(later)
90
+ .project(arel.sql('1'))
91
+ .where(preceding(table, later, range))
92
+ .exists
93
+ .not
94
+ end
95
+
96
+ # Mirrors the window's own end handling, so a version sitting exactly on an
97
+ # inclusive boundary stays inside the window instead of masking the trailing
98
+ # version that follows it.
99
+ #: (untyped, untyped, TimeRange) -> untyped
100
+ def preceding(table, later, range)
101
+ same_item = later[:item_type].eq(table[:item_type])
102
+ .and(later[:item_id].eq(table[:item_id]))
103
+ same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
104
+ end
105
+
106
+ #: (untyped, TimeRange) -> untyped
107
+ def after_window(later, range)
108
+ return later[:created_at].gteq(range.end_time) if range.exclude_end?
109
+
110
+ later[:created_at].gt(range.end_time)
111
+ end
112
+
113
+ #: (untyped, Array[untyped]) -> untyped
114
+ def base_scope(model_class, ids)
115
+ model_class.paper_trail.version_class.where(
116
+ item_type: model_class.base_class.name.to_s,
117
+ item_id: ids
118
+ )
119
+ end
120
+
121
+ #: (untyped) -> Array[untyped]
122
+ def ordered(relation)
123
+ relation.reorder(created_at: :asc, id: :asc).to_a
124
+ end
125
+
126
+ #: (untyped, untyped) -> Array[String]
127
+ def identity(model_class, id)
128
+ [model_class.base_class.name.to_s, id.to_s]
129
+ end
130
+ end
131
+ end