paper_trail_diff 0.3.1 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/README.md +260 -30
  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/collection_comparator.rb +14 -1
  11. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  12. data/lib/paper_trail_diff/endpoint.rb +27 -2
  13. data/lib/paper_trail_diff/errors.rb +3 -0
  14. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  15. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  16. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  17. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  18. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  19. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  20. data/lib/paper_trail_diff/snapshot.rb +26 -0
  21. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  22. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  23. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  24. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  25. data/lib/paper_trail_diff/version.rb +1 -1
  26. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  27. data/lib/paper_trail_diff.rb +1 -0
  28. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  29. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  30. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  31. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  32. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  33. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  34. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  35. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  36. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  37. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  38. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  39. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  41. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  42. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  43. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  44. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  45. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  46. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  47. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  48. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  49. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  50. metadata +6 -4
@@ -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
@@ -142,9 +142,22 @@ module PaperTrailDiff
142
142
  identities.sort_by { |identity| sortable_identity(identity) }
143
143
  end
144
144
 
145
+ # Identities sort by type first so that mixed id types stay comparable, then
146
+ # naturally within one type. Ordering by the printed form instead would put
147
+ # id 10 before id 2, which is deterministic but reads as unsorted wherever a
148
+ # result is rendered.
145
149
  #: (identity) -> Array[untyped]
146
150
  def sortable_identity(identity)
147
- [identity.fetch(0), identity.fetch(1).inspect]
151
+ [identity.fetch(0), *sortable_id(identity.fetch(1))]
152
+ end
153
+
154
+ #: (untyped) -> Array[untyped]
155
+ def sortable_id(id)
156
+ case id
157
+ when Numeric then [0, id, '']
158
+ when String, Symbol then [1, 0, id.to_s]
159
+ else [2, 0, id.inspect]
160
+ end
148
161
  end
149
162
 
150
163
  #: (identity) -> void
@@ -7,9 +7,12 @@ module PaperTrailDiff
7
7
  attr_reader :before #: RecordSnapshot?
8
8
  attr_reader :after #: RecordSnapshot?
9
9
 
10
+ # The origin is recorded by serial rather than by reference, because
11
+ # holding it would retain every earlier snapshot at the same path for the
12
+ # whole timeline.
10
13
  #: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
11
14
  def initialize(from:, before:, after:)
12
- @from_object_id = from.object_id
15
+ @from_serial = from.serial
13
16
  @before = before
14
17
  @after = after
15
18
  freeze
@@ -17,9 +20,9 @@ module PaperTrailDiff
17
20
 
18
21
  #: (AssociationSnapshot) -> bool
19
22
  def from?(association)
20
- @from_object_id == association.object_id
23
+ @from_serial == association.serial
21
24
  end
22
25
 
23
- # @rbs @from_object_id: Integer
26
+ # @rbs @from_serial: Integer
24
27
  end
25
28
  end
@@ -10,10 +10,35 @@ module PaperTrailDiff
10
10
  def validate_pair!(from_endpoint, to_endpoint)
11
11
  validate!(from_endpoint)
12
12
  validate!(to_endpoint)
13
- return if identity(from_endpoint) == identity(to_endpoint)
13
+ unless identity(from_endpoint) == identity(to_endpoint)
14
+ raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
15
+ end
16
+
17
+ validate_order!(from_endpoint, to_endpoint)
18
+ end
19
+
20
+ # Two versions carry no visible cue about which is earlier, so transposing
21
+ # them is easy to do by accident and impossible to detect afterwards: the
22
+ # result is a valid inverse diff and carries no direction of its own. It is
23
+ # also redundant, because the two orders differ only in which side of each
24
+ # change is `from`. A current-record endpoint is exempt: it is self-evidently
25
+ # the live state, so putting it first is a deliberate reverse comparison.
26
+ #: (untyped, untyped) -> void
27
+ def validate_order!(from_endpoint, to_endpoint)
28
+ return unless reversed_versions?(from_endpoint, to_endpoint)
29
+
30
+ raise ReversedEndpointsError,
31
+ 'version endpoints must be given in chronological order; ' \
32
+ 'swap them to read the same difference in the other direction'
33
+ end
34
+
35
+ #: (untyped, untyped) -> bool
36
+ def reversed_versions?(from_endpoint, to_endpoint)
37
+ return false unless version?(from_endpoint) && version?(to_endpoint)
14
38
 
15
- raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
39
+ Support.compare_versions(from_endpoint, to_endpoint).positive?
16
40
  end
41
+ private_class_method :reversed_versions?
17
42
 
18
43
  #: (untyped) -> void
19
44
  def validate!(endpoint)
@@ -11,6 +11,9 @@ module PaperTrailDiff
11
11
  # Raised when compare endpoints do not belong to the same PaperTrail item.
12
12
  class VersionMismatchError < Error; end
13
13
 
14
+ # Raised when comparison endpoints are given in reverse chronological order.
15
+ class ReversedEndpointsError < Error; end
16
+
14
17
  # Raised when a live endpoint is not a clean, persisted ActiveRecord record.
15
18
  class InvalidEndpointError < Error; end
16
19
 
@@ -11,7 +11,10 @@ module PaperTrailDiff
11
11
  @habtm_transaction_id = habtm_version.transaction_id
12
12
  end
13
13
  @version_at = version.created_at
14
- @reified_associations = {} #: Hash[Array[untyped], bool]
14
+ # Keyed on record identity rather than `object_id`, which Ruby may reuse
15
+ # once an earlier record in the same pass has been collected.
16
+ reified = {} #: Hash[untyped, Hash[untyped, bool]]
17
+ @reified_associations = reified.compare_by_identity
15
18
  end
16
19
 
17
20
  #: (untyped, Array[untyped]) -> void
@@ -24,14 +27,14 @@ module PaperTrailDiff
24
27
  # @rbs @transaction_id: untyped
25
28
  # @rbs @habtm_transaction_id: untyped
26
29
  # @rbs @version_at: untyped
27
- # @rbs @reified_associations: Hash[Array[untyped], bool]
30
+ # @rbs @reified_associations: Hash[untyped, Hash[untyped, bool]]
28
31
 
29
32
  #: (untyped, untyped) -> void
30
33
  def reify_association(record, reflection)
31
- key = [record.object_id, reflection.name]
32
- return if @reified_associations[key]
34
+ reified = @reified_associations[record] ||= {} #: Hash[untyped, bool]
35
+ return if reified[reflection.name]
33
36
 
34
- @reified_associations[key] = true
37
+ reified[reflection.name] = true
35
38
  case reflection.macro
36
39
  when :belongs_to
37
40
  reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
@@ -16,14 +16,20 @@ module PaperTrailDiff
16
16
  @prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
17
17
  end
18
18
 
19
- #: (untyped, Array[untyped], ?start_at: untyped) -> void
20
- def prepare(record, root_versions, start_at: root_versions.first.created_at)
19
+ #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
20
+ def prepare(
21
+ record,
22
+ root_versions,
23
+ start_at: root_versions.first.created_at,
24
+ end_at: root_versions.last.created_at
25
+ )
21
26
  return if @tree.empty?
22
27
 
23
28
  @prepared_history = PreparedHistoryLoader.new(
24
29
  record,
25
30
  root_versions: root_versions,
26
31
  start_at: start_at,
32
+ end_at: end_at,
27
33
  tree: @tree,
28
34
  traversal: @traversal
29
35
  ).call
@@ -139,6 +145,7 @@ module PaperTrailDiff
139
145
  root_ids: records.map(&:id),
140
146
  root_versions: root_versions,
141
147
  start_at: root_versions.map(&:created_at).min,
148
+ end_at: root_versions.map(&:created_at).max,
142
149
  tree: @tree,
143
150
  traversal: @traversal,
144
151
  live_records: @live_graph_collector.call(records)
@@ -10,7 +10,10 @@ module PaperTrailDiff
10
10
  @boundary = boundary
11
11
  @habtm_boundary = habtm_boundary
12
12
  @fallback = fallback
13
- @reified = {} #: Hash[Array[untyped], bool]
13
+ # Keyed on record identity rather than `object_id`, which Ruby may reuse
14
+ # once an earlier record in the same pass has been collected.
15
+ reified = {} #: Hash[untyped, Hash[untyped, bool]]
16
+ @reified = reified.compare_by_identity
14
17
  end
15
18
 
16
19
  #: (untyped, Array[untyped]) -> void
@@ -24,14 +27,14 @@ module PaperTrailDiff
24
27
  # @rbs @boundary: untyped
25
28
  # @rbs @habtm_boundary: untyped
26
29
  # @rbs @fallback: HistoricalAssociationReifier
27
- # @rbs @reified: Hash[Array[untyped], bool]
30
+ # @rbs @reified: Hash[untyped, Hash[untyped, bool]]
28
31
 
29
32
  #: (untyped, untyped) -> void
30
33
  def reify_association(record, reflection)
31
- key = [record.object_id, reflection.name]
32
- return if @reified[key]
34
+ reified = @reified[record] ||= {} #: Hash[untyped, bool]
35
+ return if reified[reflection.name]
33
36
 
34
- @reified[key] = true
37
+ reified[reflection.name] = true
35
38
  handled, records = @history.resolve(
36
39
  record,
37
40
  reflection,
@@ -164,13 +164,15 @@ module PaperTrailDiff
164
164
  { model_class.name.to_s => group }
165
165
  end
166
166
 
167
+ # Keyed rather than scanned so that a wide edge stays linear in its width.
167
168
  #: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
168
169
  def group_ids_by_owner(pairs)
169
- grouped = {} #: Hash[String, Array[untyped]]
170
- pairs.each_with_object(grouped) do |(owner_id, child_id), result|
171
- ids = result[owner_id.to_s] ||= []
172
- ids << child_id unless ids.include?(child_id)
170
+ grouped = {} #: Hash[String, Hash[untyped, true]]
171
+ pairs.each do |owner_id, child_id|
172
+ ids = grouped[owner_id.to_s] ||= {} #: Hash[untyped, true]
173
+ ids[child_id] = true
173
174
  end
175
+ grouped.transform_values(&:keys)
174
176
  end
175
177
 
176
178
  #: (Hash[String, Array[untyped]], Hash[String, Array[untyped]]) -> Hash[String, Array[untyped]]
@@ -4,7 +4,7 @@
4
4
  module PaperTrailDiff
5
5
  # Expands the explicit association tree into a request-scoped PreparedHistory.
6
6
  class PreparedHistoryLoader
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
9
  record,
10
10
  root_versions:,
@@ -12,13 +12,14 @@ module PaperTrailDiff
12
12
  traversal:,
13
13
  root_ids: [record.id],
14
14
  start_at: root_versions.first.created_at,
15
+ end_at: nil,
15
16
  live_records: []
16
17
  )
17
18
  @record = record
18
19
  @root_ids = root_ids
19
20
  @tree = tree
20
21
  @traversal = traversal
21
- @records = PreparedRecordIndex.new(start_at, live_records: live_records)
22
+ @records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
22
23
  @history = PreparedHistory.new(@records)
23
24
  @edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
24
25
  @prepared = {} #: Hash[Array[String], Array[String]]
@@ -21,9 +21,14 @@ module PaperTrailDiff
21
21
  state
22
22
  end
23
23
 
24
+ # Mirrors PaperTrail's reifier, which writes attributes directly. Mass
25
+ # assignment would route reconstructed state through application-defined
26
+ # attribute writers and reify a different record than the version stored.
24
27
  #: () -> untyped
25
28
  def instantiate
26
- @model_class.new(@attributes)
29
+ record = @model_class.new
30
+ @attributes.each { |name, value| record[name] = value }
31
+ record
27
32
  end
28
33
 
29
34
  private
@@ -108,6 +113,7 @@ module PaperTrailDiff
108
113
  @version_positions = @versions.each_with_index.to_h do |version, index|
109
114
  [version.id.to_s, index]
110
115
  end.freeze
116
+ @transaction_positions = build_transaction_positions
111
117
  @live = live
112
118
  @state_loader = state_loader
113
119
  @states = {} #: Hash[untyped, PreparedRecordState?]
@@ -115,8 +121,8 @@ module PaperTrailDiff
115
121
 
116
122
  #: (untyped) -> untyped
117
123
  def record_before(boundary)
118
- version = versions.find { |candidate| eligible?(candidate, boundary) }
119
- return state_for(version)&.instantiate if version
124
+ index = boundary_index(boundary)
125
+ return state_for(versions.fetch(index))&.instantiate if index
120
126
 
121
127
  @live&.instantiate
122
128
  end
@@ -146,6 +152,7 @@ module PaperTrailDiff
146
152
  # @rbs @state_loader: PreparedVersionStateLoader
147
153
  # @rbs @states: Hash[untyped, PreparedRecordState?]
148
154
  # @rbs @version_positions: Hash[String, Integer]
155
+ # @rbs @transaction_positions: Hash[untyped, Integer]
149
156
 
150
157
  #: (Integer) -> PreparedRecordState?
151
158
  def state_after(index)
@@ -153,16 +160,41 @@ module PaperTrailDiff
153
160
  successor ? state_for(successor) : @live
154
161
  end
155
162
 
156
- #: (untyped, untyped) -> bool
157
- def eligible?(version, boundary)
158
- version.created_at >= boundary.created_at || same_transaction?(version, boundary)
163
+ # A version is eligible when it is at or after the boundary, or shares the
164
+ # boundary's transaction. The first is monotonic over the chronological
165
+ # list and the second is indexed, so neither rescans a long history once
166
+ # per boundary.
167
+ #: (untyped) -> Integer?
168
+ def boundary_index(boundary)
169
+ created_at = boundary.created_at
170
+ chronological = versions.bsearch_index do |candidate|
171
+ candidate.created_at >= created_at
172
+ end
173
+ transactional = transaction_index(boundary)
174
+ return chronological unless transactional
175
+ return transactional unless chronological
176
+
177
+ [chronological, transactional].min
159
178
  end
160
179
 
161
- #: (untyped, untyped) -> bool
162
- def same_transaction?(version, boundary)
180
+ #: (untyped) -> Integer?
181
+ def transaction_index(boundary)
163
182
  transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
164
- transaction_id && version.respond_to?(:transaction_id) &&
165
- version.transaction_id == transaction_id
183
+ return unless transaction_id
184
+
185
+ @transaction_positions[transaction_id]
186
+ end
187
+
188
+ #: () -> Hash[untyped, Integer]
189
+ def build_transaction_positions
190
+ positions = {} #: Hash[untyped, Integer]
191
+ @versions.each_with_index do |version, index|
192
+ next unless version.respond_to?(:transaction_id)
193
+
194
+ transaction_id = version.transaction_id
195
+ positions[transaction_id] ||= index if transaction_id
196
+ end
197
+ positions.freeze
166
198
  end
167
199
 
168
200
  #: (untyped) -> PreparedRecordState?
@@ -185,9 +217,10 @@ module PaperTrailDiff
185
217
 
186
218
  # Request-scoped scalar history loaded once per model identity.
187
219
  class PreparedRecordIndex
188
- #: (untyped, ?live_records: Array[untyped]) -> void
189
- def initialize(start_at, live_records: [])
190
- @start_time = start_at
220
+ #: (untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
221
+ def initialize(start_at, end_at: nil, live_records: [])
222
+ @start_time = boundary_time(start_at)
223
+ @end_time = boundary_time(end_at)
191
224
  @seeded_live_records = live_records.to_h do |record|
192
225
  [identity(record.class, record.id), record]
193
226
  end
@@ -230,10 +263,17 @@ module PaperTrailDiff
230
263
  private
231
264
 
232
265
  # @rbs @start_time: untyped
266
+ # @rbs @end_time: untyped
233
267
  # @rbs @seeded_live_records: Hash[Array[String], untyped]
234
268
  # @rbs @state_loader: PreparedVersionStateLoader
235
269
  # @rbs @series: Hash[Array[String], PreparedRecordSeries]
236
270
 
271
+ # Callers bound the range with either a version or a bare timestamp.
272
+ #: (untyped) -> untyped
273
+ def boundary_time(value)
274
+ value.respond_to?(:created_at) ? value.created_at : value
275
+ end
276
+
237
277
  #: (untyped, untyped) -> Array[String]
238
278
  def identity(model_class, id)
239
279
  [model_class.base_class.name.to_s, id.to_s]
@@ -254,12 +294,47 @@ module PaperTrailDiff
254
294
  )
255
295
  end
256
296
 
297
+ # A PaperTrail version is a pre-change snapshot, so the state at the last
298
+ # selected boundary can only live in the next version after it. One
299
+ # trailing version per identity is retained, rather than every version
300
+ # recorded between the requested range and the present.
257
301
  #: (untyped, Array[untyped]) -> Array[untyped]
258
302
  def versions_for(model_class, ids)
259
- model_class.paper_trail.version_class.where(
303
+ version_class = model_class.paper_trail.version_class
304
+ scope = version_class.where(
260
305
  item_type: model_class.base_class.name,
261
306
  item_id: ids
262
- ).where('created_at >= ?', @start_time).order(:id).to_a
307
+ ).where(created_at: @start_time..)
308
+ scope = scope.where(window_condition(version_class)) if @end_time
309
+ scope.order(:id).to_a
310
+ end
311
+
312
+ # In range, or the earliest version after it, resolved in the same query
313
+ # rather than with a window function or one query per identity.
314
+ #: (untyped) -> untyped
315
+ def window_condition(version_class)
316
+ table = version_class.arel_table
317
+ table[:created_at].lteq(@end_time).or(first_after_range(table))
318
+ end
319
+
320
+ #: (untyped) -> untyped
321
+ def first_after_range(table)
322
+ arel = Object.const_get(:Arel) #: untyped
323
+ later = table.alias('paper_trail_diff_later_versions')
324
+ arel.const_get(:SelectManager).new
325
+ .from(later)
326
+ .project(arel.sql('1'))
327
+ .where(preceding_trailing_version(table, later))
328
+ .exists
329
+ .not
330
+ end
331
+
332
+ #: (untyped, untyped) -> untyped
333
+ def preceding_trailing_version(table, later)
334
+ later[:item_type].eq(table[:item_type])
335
+ .and(later[:item_id].eq(table[:item_id]))
336
+ .and(later[:created_at].gt(@end_time))
337
+ .and(later[:created_at].lt(table[:created_at]))
263
338
  end
264
339
 
265
340
  #: (untyped, Array[untyped]) -> Array[untyped]
@@ -57,6 +57,30 @@ module PaperTrailDiff
57
57
  end
58
58
  private_constant :IdentityIndexCache
59
59
 
60
+ # Identifies one snapshot for internal carry-forward. A transition cannot
61
+ # hold its origin without retaining every earlier snapshot at that path,
62
+ # and `object_id` is only guaranteed unique among live objects, so Ruby may
63
+ # hand a collected snapshot's id to a later one.
64
+ class SerialSequence
65
+ #: () -> void
66
+ def initialize
67
+ @mutex = Mutex.new
68
+ @counter = 0
69
+ end
70
+
71
+ #: () -> Integer
72
+ def next_serial
73
+ @mutex.synchronize { @counter += 1 }
74
+ end
75
+
76
+ # @rbs @mutex: Thread::Mutex
77
+ # @rbs @counter: Integer
78
+ end
79
+ private_constant :SerialSequence
80
+
81
+ SERIALS = SerialSequence.new
82
+ private_constant :SERIALS
83
+
60
84
  class << self
61
85
  #: (Symbol) -> bool
62
86
  def collection_kind?(kind)
@@ -66,6 +90,7 @@ module PaperTrailDiff
66
90
 
67
91
  attr_reader :kind #: Symbol
68
92
  attr_reader :records #: Array[RecordSnapshot]
93
+ attr_reader :serial #: Integer
69
94
 
70
95
  #: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
71
96
  def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
@@ -80,6 +105,7 @@ module PaperTrailDiff
80
105
  @records = records.frozen? ? records : records.dup.freeze
81
106
  @identity_index_cache = identity_index_cache || IdentityIndexCache.new
82
107
  @transition = transition
108
+ @serial = SERIALS.next_serial
83
109
  freeze
84
110
  end
85
111
 
@@ -53,6 +53,7 @@ module PaperTrailDiff
53
53
  @traversal = traversal
54
54
  @pool = pool
55
55
  @structural_columns = {} #: Hash[String, Array[String]]
56
+ @excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
56
57
  end
57
58
 
58
59
  #: (untyped, reifier: untyped) -> RecordSnapshot?
@@ -86,6 +87,7 @@ module PaperTrailDiff
86
87
  # @rbs @traversal: AssociationTraversal
87
88
  # @rbs @pool: SnapshotPool
88
89
  # @rbs @structural_columns: Hash[String, Array[String]]
90
+ # @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
89
91
 
90
92
  #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
91
93
  def normalize_record(record, tree, path, reifier, reflections)
@@ -107,15 +109,25 @@ module PaperTrailDiff
107
109
  attributes
108
110
  end
109
111
 
112
+ # Every input is fixed for one model class at one selected path, and the
113
+ # path's structural columns are recorded before its records normalize, so
114
+ # this is resolved once instead of per record per boundary.
110
115
  #: (untyped, Array[untyped], String) -> Array[String]
111
116
  def excluded_attributes(record, reflections, path)
112
- primary_key = record.class.primary_key #: untyped
117
+ model_class = record.class
118
+ @excluded_attributes[[model_class, path]] ||=
119
+ build_excluded_attributes(model_class, reflections, path)
120
+ end
121
+
122
+ #: (untyped, Array[untyped], String) -> Array[String]
123
+ def build_excluded_attributes(model_class, reflections, path)
124
+ primary_key = model_class.primary_key #: untyped
113
125
  primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
114
126
  # @type var primary_keys: Array[untyped]
115
127
  primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
116
128
  ignored = @ignore_policy.attributes_for(path)
117
129
  structural = @structural_columns.fetch(path, [])
118
- (primary_keys + ignored + relationship_columns(reflections) + structural).uniq
130
+ (primary_keys + ignored + relationship_columns(reflections) + structural).uniq.freeze
119
131
  end
120
132
 
121
133
  #: (Array[untyped]) -> Array[String]