paper_trail_diff 0.3.0 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +269 -30
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  6. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  7. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  8. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  9. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  10. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  11. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  12. data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
  13. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  14. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  15. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  16. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  17. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  18. data/lib/paper_trail_diff/analysis.rb +9 -0
  19. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  20. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  21. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  22. data/lib/paper_trail_diff/endpoint.rb +27 -2
  23. data/lib/paper_trail_diff/errors.rb +3 -0
  24. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  25. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  28. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  29. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  30. data/lib/paper_trail_diff/snapshot.rb +26 -0
  31. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  32. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  33. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  34. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  35. data/lib/paper_trail_diff/version.rb +1 -1
  36. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  37. data/lib/paper_trail_diff.rb +10 -0
  38. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  39. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  40. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  41. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  42. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  43. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  44. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  45. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  46. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
  47. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  48. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  49. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  50. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  51. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  52. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  53. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  54. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  57. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  58. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  59. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  60. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  61. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  62. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  63. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  64. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  65. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  66. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  67. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  68. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  69. metadata +24 -4
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reads event-side foreign keys and matches them to an immutable snapshot owner.
6
+ class ActivityRelationship
7
+ #: (untyped, untyped, RecordSnapshot, ?state: Symbol) -> bool
8
+ def member_of_owner?(record, reflection, owner, state: :after)
9
+ actual = relationship_owner_values(record, reflection, state: state)
10
+ expected_ids = Array(owner.id)
11
+ # @type var expected_ids: Array[untyped]
12
+ expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
13
+ return false unless actual == expected
14
+ return true unless reflection.options[:as]
15
+
16
+ type = event_attribute(record, reflection.type, state: state).to_s
17
+ owner_types(reflection, owner).include?(type)
18
+ end
19
+
20
+ #: (untyped, untyped, state: Symbol) -> untyped
21
+ def owner_id(record, reflection, state:)
22
+ values = Array(reflection.foreign_key).map do |foreign_key|
23
+ event_attribute(record, foreign_key, state: state)
24
+ end
25
+ values.one? ? values.first : values
26
+ end
27
+
28
+ private
29
+
30
+ #: (untyped, untyped, state: Symbol) -> Array[String]
31
+ def relationship_owner_values(record, reflection, state:)
32
+ Array(reflection.foreign_key).map do |key|
33
+ event_attribute(record, key, state: state).to_s
34
+ end
35
+ end
36
+
37
+ #: (untyped, untyped, state: Symbol) -> untyped
38
+ def event_attribute(record, name, state:)
39
+ if record.is_a?(ActivitySnapshotDelta)
40
+ return state == :before ? record.before_value(name) : record.after_value(name)
41
+ end
42
+
43
+ record.public_send(name)
44
+ end
45
+
46
+ #: (untyped, RecordSnapshot) -> Array[String]
47
+ def owner_types(reflection, owner)
48
+ [
49
+ owner.type.to_s,
50
+ reflection.active_record.name.to_s,
51
+ reflection.active_record.base_class.name.to_s
52
+ ]
53
+ end
54
+ end
55
+ end
@@ -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
@@ -159,6 +159,8 @@ module PaperTrailDiff
159
159
 
160
160
  #: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
161
161
  def components(branches)
162
+ return @components[branches] if @components.key?(branches)
163
+
162
164
  key = branches.sort.freeze
163
165
  @components[key] ||= build_components(key)
164
166
  end
@@ -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]]