paper_trail_diff 0.2.0 → 0.3.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +188 -14
  5. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  8. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  9. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  10. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  11. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
  15. data/lib/paper_trail_diff/activity_history.rb +136 -0
  16. data/lib/paper_trail_diff/activity_range.rb +28 -12
  17. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  18. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  19. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  20. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  21. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  22. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -3
  23. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  24. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  25. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  26. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  27. data/lib/paper_trail_diff/engine.rb +5 -45
  28. data/lib/paper_trail_diff/errors.rb +9 -0
  29. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  30. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  31. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  32. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  33. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  34. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  35. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  36. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  37. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  38. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  39. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  40. data/lib/paper_trail_diff/snapshot.rb +66 -3
  41. data/lib/paper_trail_diff/support.rb +18 -0
  42. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  43. data/lib/paper_trail_diff/time_range.rb +85 -0
  44. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  45. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  46. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  47. data/lib/paper_trail_diff/version.rb +1 -1
  48. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  49. data/lib/paper_trail_diff.rb +83 -12
  50. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  51. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  52. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  53. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  54. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  55. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  56. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  57. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  58. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  59. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
  60. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  61. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  62. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  63. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  64. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  65. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  66. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  67. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  68. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  69. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  70. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  71. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  72. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  73. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  74. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  75. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  76. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  77. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  78. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  79. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  80. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  81. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  82. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  83. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  84. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  85. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  86. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  87. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  88. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  89. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  90. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  91. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  92. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  93. data/sig/generated/paper_trail_diff.rbs +16 -8
  94. metadata +56 -4
@@ -4,11 +4,18 @@
4
4
  module PaperTrailDiff
5
5
  # Tests activity versions against a historical-version or wall-clock range end.
6
6
  class ActivityRange
7
+ attr_reader :start_time #: untyped
8
+ attr_reader :end_time #: untyped
9
+
7
10
  #: (untyped, untyped) -> void
8
- def initialize(start_version, end_boundary)
9
- @start_version = start_version
11
+ def initialize(start_boundary, end_boundary)
12
+ @start_boundary = start_boundary
10
13
  @end_boundary = end_boundary
11
- @start_key = Support.chronological_version_key(start_version)
14
+ @start_key = if Endpoint.version?(start_boundary)
15
+ Support.chronological_version_key(start_boundary)
16
+ end
17
+ @start_time = Endpoint.version?(start_boundary) ? start_boundary.created_at : start_boundary
18
+ @end_time = Endpoint.version?(end_boundary) ? end_boundary.created_at : end_boundary
12
19
  @end_key = if Endpoint.version?(end_boundary)
13
20
  Support.chronological_version_key(end_boundary)
14
21
  end
@@ -17,23 +24,37 @@ module PaperTrailDiff
17
24
  #: (untyped) -> bool
18
25
  def include?(version)
19
26
  key = Support.chronological_version_key(version)
20
- key_before_or_equal?(@start_key, key) && before_end?(version, key)
27
+ after_start?(version, key) && before_end?(version, key)
21
28
  end
22
29
 
23
30
  # Narrows an Active Record version relation by timestamp. Exact tie-breaking
24
31
  # remains in #include? so this works for integer, UUID, and custom version IDs.
25
32
  #: (untyped) -> untyped
26
33
  def scope(relation)
27
- relation.where(created_at: @start_version.created_at..end_time)
34
+ relation.where(created_at: @start_time..end_time)
35
+ end
36
+
37
+ #: () -> bool
38
+ def current_end?
39
+ !Endpoint.version?(@end_boundary)
28
40
  end
29
41
 
30
42
  private
31
43
 
32
- # @rbs @start_version: untyped
44
+ # @rbs @start_boundary: untyped
33
45
  # @rbs @end_boundary: untyped
34
- # @rbs @start_key: Array[untyped]
46
+ # @rbs @start_key: Array[untyped]?
47
+ # @rbs @start_time: untyped
35
48
  # @rbs @end_key: Array[untyped]?
36
49
 
50
+ #: (untyped, Array[untyped]) -> bool
51
+ def after_start?(version, key)
52
+ start_key = @start_key
53
+ return key_before_or_equal?(start_key, key) if start_key
54
+
55
+ version.created_at >= @start_time
56
+ end
57
+
37
58
  #: (untyped, Array[untyped]) -> bool
38
59
  def before_end?(version, key)
39
60
  end_key = @end_key
@@ -42,11 +63,6 @@ module PaperTrailDiff
42
63
  version.created_at <= @end_boundary
43
64
  end
44
65
 
45
- #: () -> untyped
46
- def end_time
47
- Endpoint.version?(@end_boundary) ? @end_boundary.created_at : @end_boundary
48
- end
49
-
50
66
  #: (Array[untyped], Array[untyped]) -> bool
51
67
  def key_before_or_equal?(left, right)
52
68
  comparison = left <=> right
@@ -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,64 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Internal scalar transition for one isolated PaperTrail update event.
6
+ class ActivitySnapshotDelta
7
+ #: (before_attributes: Hash[untyped, untyped], after_attributes: Hash[untyped, untyped]) -> void
8
+ def initialize(before_attributes:, after_attributes:)
9
+ @before_attributes = normalize_hash(before_attributes)
10
+ @after_attributes = normalize_hash(after_attributes)
11
+ freeze
12
+ end
13
+
14
+ #: (RecordSnapshot) -> RecordSnapshot
15
+ def apply(snapshot)
16
+ attributes = snapshot.attributes.dup
17
+ attributes.each_key do |name|
18
+ attributes[name] = @after_attributes[name] if @after_attributes.key?(name)
19
+ end
20
+ return snapshot if attributes == snapshot.attributes
21
+
22
+ RecordSnapshot.new(
23
+ type: snapshot.type,
24
+ id: snapshot.id,
25
+ attributes: attributes,
26
+ associations: snapshot.associations
27
+ )
28
+ end
29
+
30
+ #: (untyped) -> untyped
31
+ def before_value(name)
32
+ @before_attributes[name.to_s]
33
+ end
34
+
35
+ #: (untyped) -> untyped
36
+ def after_value(name)
37
+ @after_attributes[name.to_s]
38
+ end
39
+
40
+ #: (untyped) -> bool
41
+ def relationship_changed?(reflection)
42
+ relationship_keys(reflection).any? do |name|
43
+ before_value(name).to_s != after_value(name).to_s
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # @rbs @before_attributes: Hash[String, untyped]
50
+ # @rbs @after_attributes: Hash[String, untyped]
51
+
52
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
53
+ def normalize_hash(hash)
54
+ hash.to_h { |name, value| [name.to_s, value] }.freeze
55
+ end
56
+
57
+ #: (untyped) -> Array[untyped]
58
+ def relationship_keys(reflection)
59
+ keys = Array(reflection.foreign_key)
60
+ keys << reflection.type if reflection.options[:as]
61
+ keys
62
+ end
63
+ end
64
+ end
@@ -11,9 +11,9 @@ module PaperTrailDiff
11
11
  @preparer = preparer
12
12
  end
13
13
 
14
- #: (untyped, Array[untyped]) -> void
15
- def prepare(record, root_versions)
16
- @preparer.call(record, root_versions)
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)
17
17
  end
18
18
 
19
19
  #: (untyped, untyped) -> RecordSnapshot?
@@ -4,17 +4,19 @@
4
4
  module PaperTrailDiff
5
5
  # Compares adjacent root and selected-descendant activity boundaries.
6
6
  class ActivityTimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, tree: AssociationTree, snapshotter: untyped) -> void
8
- def initialize(record, from:, to:, tree:, snapshotter:)
7
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
8
+ def initialize(record, range:, tree:, snapshotter:)
9
9
  @record = record
10
- @from = from
11
- @to = to
10
+ @from = range.from
11
+ @to = range.to
12
+ @range = range
12
13
  @tree = tree
13
14
  @snapshotter = snapshotter
14
15
  end
15
16
 
16
17
  #: () -> Array[ActivityStep]
17
18
  def build
19
+ return time_builder.build if @range.time?
18
20
  return build_to_current if Endpoint.record?(@to)
19
21
 
20
22
  Endpoint.validate!(@to)
@@ -24,15 +26,16 @@ module PaperTrailDiff
24
26
  # Builds all three version-bounded views from one activity snapshot pass.
25
27
  #: () -> Analysis
26
28
  def analyze
29
+ return time_builder.analyze if @range.time?
30
+
27
31
  unless Endpoint.version?(@to)
28
32
  raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
29
33
  end
30
34
 
31
- root_versions = VersionRange.new(@record, from: @from, to: @to).select
35
+ root_versions = @range.select
32
36
  prepare_history(root_versions)
33
37
  events = collect_events(root_versions)
34
- activity_steps, root_snapshots, = event_history(root_versions, events)
35
- build_analysis(root_versions, root_snapshots, activity_steps)
38
+ build_analysis(root_versions, event_history(root_versions, events))
36
39
  end
37
40
 
38
41
  private
@@ -40,12 +43,13 @@ module PaperTrailDiff
40
43
  # @rbs @record: untyped
41
44
  # @rbs @from: untyped
42
45
  # @rbs @to: untyped
46
+ # @rbs @range: TimelineRange
43
47
  # @rbs @tree: AssociationTree
44
48
  # @rbs @snapshotter: untyped
45
49
 
46
50
  #: () -> Array[ActivityStep]
47
51
  def build_between_versions
48
- root_versions = VersionRange.new(@record, from: @from, to: @to).select
52
+ root_versions = @range.select
49
53
  prepare_history(root_versions)
50
54
  events = collect_events(root_versions)
51
55
  build_event_steps(root_versions, events)
@@ -82,20 +86,31 @@ module PaperTrailDiff
82
86
  Endpoint.validate_pair!(@record, @to)
83
87
  end
84
88
 
85
- #: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
86
- def collect_events(root_versions, range_end: root_versions.last)
89
+ #: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
90
+ def collect_events(
91
+ root_versions,
92
+ range_start: root_versions.first,
93
+ range_end: root_versions.last
94
+ )
87
95
  ActivityVersionCollector.new(
88
96
  @record,
89
97
  root_versions: root_versions,
90
98
  tree: @tree,
91
99
  traversal: AssociationTraversal.new(@tree),
100
+ range_start: range_start,
92
101
  range_end: range_end
93
102
  ).call
94
103
  end
95
104
 
96
- #: (Array[untyped]) -> void
97
- def prepare_history(root_versions)
98
- @snapshotter.prepare(@record, root_versions) if @snapshotter.respond_to?(:prepare)
105
+ #: (Array[untyped], ?start_at: untyped) -> void
106
+ def prepare_history(root_versions, start_at: nil)
107
+ return unless @snapshotter.respond_to?(:prepare)
108
+
109
+ if start_at
110
+ @snapshotter.prepare(@record, root_versions, start_at: start_at)
111
+ else
112
+ @snapshotter.prepare(@record, root_versions)
113
+ end
99
114
  end
100
115
 
101
116
  #: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
@@ -106,55 +121,39 @@ module PaperTrailDiff
106
121
  final_boundary: nil,
107
122
  final_snapshot: nil
108
123
  )
109
- steps, _root_snapshots, previous_snapshot = event_history(
110
- root_versions,
111
- events,
112
- current: current
113
- )
124
+ history = event_history(root_versions, events, current: current)
125
+ steps = history.steps.dup
114
126
  previous_event = events.last
115
127
  previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
116
128
  if final_boundary && previous_boundary
117
129
  steps << ActivityStep.new(
118
130
  from_boundary: previous_boundary,
119
131
  to_boundary: final_boundary,
120
- diff: Engine.compare(previous_snapshot, final_snapshot)
132
+ diff: Engine.compare(history.last_snapshot, final_snapshot)
121
133
  )
122
134
  end
123
135
  steps.freeze
124
136
  end
125
137
 
126
- #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> [Array[ActivityStep], Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?]
127
- def event_history(root_versions, events, current: nil) # rubocop:disable Metrics/MethodLength
128
- sequence = ActivitySnapshotSequence.new(root_versions, events, @snapshotter, current: current)
129
- steps = [] #: Array[ActivityStep]
130
- root_snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
131
- previous_boundary = nil #: ActivityBoundary?
132
- previous_snapshot = nil #: RecordSnapshot?
133
- sequence.each do |event, snapshot|
134
- boundary = ActivityBoundary.from_version(event.version)
135
- root_snapshots[version_key(event.version)] = snapshot if event.root?
136
- if previous_boundary
137
- steps << ActivityStep.new(
138
- from_boundary: previous_boundary,
139
- to_boundary: boundary,
140
- diff: Engine.compare(previous_snapshot, snapshot)
141
- )
142
- end
143
- previous_boundary = boundary
144
- previous_snapshot = snapshot
145
- end
146
- [steps, root_snapshots, previous_snapshot]
138
+ #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
139
+ def event_history(root_versions, events, current: nil)
140
+ ActivityHistoryBuilder.new(
141
+ root_versions,
142
+ events,
143
+ @snapshotter,
144
+ current: current
145
+ ).call
147
146
  end
148
147
 
149
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?], Array[ActivityStep]) -> Analysis
150
- def build_analysis(root_versions, root_snapshots, activity_steps)
148
+ #: (Array[untyped], ActivityHistory) -> Analysis
149
+ def build_analysis(root_versions, history)
151
150
  snapshots = root_versions.map do |version|
152
- root_snapshots.fetch(version_key(version))
151
+ history.root_snapshots.fetch(version_key(version))
153
152
  end
154
153
  Analysis.new(
155
- diff: Engine.compare(snapshots.first, snapshots.last),
154
+ diff: Engine.compare(history.first_snapshot, history.last_snapshot),
156
155
  timeline: build_root_steps(root_versions, snapshots),
157
- activity_timeline: activity_steps.freeze
156
+ activity_timeline: history.steps
158
157
  )
159
158
  end
160
159
 
@@ -173,5 +172,15 @@ module PaperTrailDiff
173
172
  def version_key(version)
174
173
  [version.class.name, version.id]
175
174
  end
175
+
176
+ #: () -> TimeActivityTimelineBuilder
177
+ def time_builder
178
+ TimeActivityTimelineBuilder.new(
179
+ @record,
180
+ range: @range,
181
+ tree: @tree,
182
+ snapshotter: @snapshotter
183
+ )
184
+ end
176
185
  end
177
186
  end
@@ -4,13 +4,20 @@
4
4
  module PaperTrailDiff
5
5
  # Collects root and selected-descendant versions relevant to one root history range.
6
6
  class ActivityVersionCollector
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_end: untyped) -> void
8
- def initialize(record, root_versions:, tree:, traversal:, range_end: root_versions.last)
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ record,
10
+ root_versions:,
11
+ tree:,
12
+ traversal:,
13
+ range_start: root_versions.first,
14
+ range_end: root_versions.last
15
+ )
9
16
  @record = record
10
17
  @root_versions = root_versions
11
18
  @tree = tree
12
19
  @traversal = traversal
13
- @range = ActivityRange.new(root_versions.first, range_end)
20
+ @range = ActivityRange.new(range_start, range_end)
14
21
  @registry = ActivityEventRegistry.new
15
22
  end
16
23
 
@@ -98,15 +105,10 @@ module PaperTrailDiff
98
105
  end
99
106
 
100
107
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
101
- def direct_child_groups(parent_class, parent_ids, reflection) # rubocop:disable Metrics/AbcSize
102
- version_class = parent_class.paper_trail.version_class
103
- relation = parent_class.paper_trail.version_association_class.joins(:version).where(
104
- foreign_key_name: reflection.foreign_key.to_s,
105
- foreign_key_id: parent_ids
106
- ).where(foreign_type: related_parent_types(parent_class))
107
- item_ids = relation.where(
108
- version_class.table_name => { item_type: reflection.klass.base_class.name }
109
- ).distinct.pluck(version_class.arel_table[:item_id])
108
+ def direct_child_groups(parent_class, parent_ids, reflection)
109
+ item_ids = ActivityChildCandidateLoader.new(
110
+ parent_class, parent_ids, reflection, @range
111
+ ).call
110
112
  return {} if item_ids.empty?
111
113
 
112
114
  { reflection.klass.name => group(reflection.klass, item_ids) }
@@ -178,11 +180,6 @@ module PaperTrailDiff
178
180
  nil
179
181
  end
180
182
 
181
- #: (untyped) -> Array[String?]
182
- def related_parent_types(parent_class)
183
- [nil, '', parent_class.name.to_s, parent_class.base_class.name.to_s].uniq
184
- end
185
-
186
183
  #: (untyped) -> bool
187
184
  def in_range?(version)
188
185
  @range.include?(version)
@@ -4,7 +4,7 @@
4
4
  module PaperTrailDiff
5
5
  # Reconstructs selected root branches and merges them into a prior immutable snapshot.
6
6
  class BranchSnapshotRefresher
7
- #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped) -> void
7
+ #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped) -> void
8
8
  def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
9
9
  tree:,
10
10
  ignore_policy:,
@@ -13,7 +13,8 @@ module PaperTrailDiff
13
13
  normalizer:,
14
14
  full_snapshotter:,
15
15
  partial_snapshotter:,
16
- association_reader:
16
+ association_reader:,
17
+ record_transition:
17
18
  )
18
19
  @tree = tree
19
20
  @ignore_policy = ignore_policy
@@ -26,7 +27,8 @@ module PaperTrailDiff
26
27
  traversal: traversal,
27
28
  pool: pool,
28
29
  components: method(:components),
29
- association_reader: association_reader
30
+ association_reader: association_reader,
31
+ record_transition: record_transition
30
32
  )
31
33
  @root_refresher = ActivityRootSnapshotRefresher.new(
32
34
  tree: tree,
@@ -157,6 +159,8 @@ module PaperTrailDiff
157
159
 
158
160
  #: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
159
161
  def components(branches)
162
+ return @components[branches] if @components.key?(branches)
163
+
160
164
  key = branches.sort.freeze
161
165
  @components[key] ||= build_components(key)
162
166
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Compares collection snapshots with a low-allocation path for stable membership.
6
+ class CollectionComparator
7
+ #: (AssociationSnapshot, AssociationSnapshot, record_comparer: untyped) -> void
8
+ def initialize(from_association, to_association, record_comparer:)
9
+ @from = from_association
10
+ @to = to_association
11
+ @record_comparer = record_comparer
12
+ end
13
+
14
+ #: () -> CollectionAssociationDiff
15
+ def call
16
+ transition = @to.transition_from(@from)
17
+ return transition_difference(transition) if transition
18
+
19
+ aligned = aligned_changes
20
+ return difference(added: [], removed: [], changed: aligned) if aligned
21
+
22
+ indexed_difference
23
+ end
24
+
25
+ private
26
+
27
+ # @rbs @from: AssociationSnapshot
28
+ # @rbs @to: AssociationSnapshot
29
+ # @rbs @record_comparer: untyped
30
+
31
+ #: (CollectionTransition) -> CollectionAssociationDiff
32
+ def transition_difference(transition)
33
+ @from.validate_unique_identities!
34
+ @to.validate_unique_identities!
35
+ before = transition.before
36
+ after = transition.after
37
+ changed = compare_transition_records(before, after)
38
+ difference(
39
+ added: before.nil? && after ? [after] : [],
40
+ removed: after.nil? && before ? [before] : [],
41
+ changed: changed ? [changed] : []
42
+ )
43
+ end
44
+
45
+ #: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
46
+ def compare_transition_records(before, after)
47
+ return unless before && after && before.identity == after.identity
48
+
49
+ @record_comparer.call(before, after)
50
+ end
51
+
52
+ #: () -> Array[RecordChange]?
53
+ def aligned_changes
54
+ pairs = aligned_changed_pairs
55
+ return unless pairs
56
+
57
+ pairs.sort_by { |_from, to| sortable_identity(to.identity) }.filter_map do |pair|
58
+ @record_comparer.call(pair.fetch(0), pair.fetch(1))
59
+ end
60
+ end
61
+
62
+ #: () -> Array[[RecordSnapshot, RecordSnapshot]]?
63
+ def aligned_changed_pairs
64
+ from_records = @from.records
65
+ to_records = @to.records
66
+ return unless from_records.length == to_records.length
67
+
68
+ collect_aligned_pairs(from_records, to_records)
69
+ end
70
+
71
+ #: (Array[RecordSnapshot], Array[RecordSnapshot]) -> Array[[RecordSnapshot, RecordSnapshot]]?
72
+ def collect_aligned_pairs(from_records, to_records)
73
+ seen = {} #: Hash[identity, bool]
74
+ changed = [] #: Array[[RecordSnapshot, RecordSnapshot]]
75
+ index = 0
76
+ while index < from_records.length
77
+ from_record = from_records.fetch(index)
78
+ to_record = to_records.fetch(index)
79
+ identity = from_record.identity
80
+ raise_duplicate_identity!(identity) if seen.key?(identity)
81
+ return unless identity == to_record.identity
82
+
83
+ seen[identity] = true
84
+ changed << [from_record, to_record] unless from_record.equal?(to_record)
85
+ index += 1
86
+ end
87
+ changed
88
+ end
89
+
90
+ #: () -> CollectionAssociationDiff
91
+ def indexed_difference
92
+ from_records = index_records(@from.records)
93
+ to_records = index_records(@to.records)
94
+ difference(
95
+ added: records_missing_from(to_records, from_records),
96
+ removed: records_missing_from(from_records, to_records),
97
+ changed: changed_records(from_records, to_records)
98
+ )
99
+ end
100
+
101
+ #: (added: Array[RecordSnapshot], removed: Array[RecordSnapshot], changed: Array[RecordChange]) -> CollectionAssociationDiff
102
+ def difference(added:, removed:, changed:)
103
+ CollectionAssociationDiff.new(
104
+ kind: @from.kind,
105
+ added: added,
106
+ removed: removed,
107
+ changed: changed
108
+ )
109
+ end
110
+
111
+ #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
112
+ def records_missing_from(records, other_records)
113
+ identities = sorted_identities(records.keys - other_records.keys)
114
+ identities.map { |identity| records.fetch(identity) }
115
+ end
116
+
117
+ #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
118
+ def changed_records(from_records, to_records)
119
+ sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
120
+ from_record = from_records.fetch(identity)
121
+ to_record = to_records.fetch(identity)
122
+ next if from_record.equal?(to_record)
123
+
124
+ @record_comparer.call(from_record, to_record)
125
+ end
126
+ end
127
+
128
+ #: (Array[RecordSnapshot]) -> Hash[identity, RecordSnapshot]
129
+ def index_records(records)
130
+ index = {} #: Hash[identity, RecordSnapshot]
131
+ records.each do |record|
132
+ identity = record.identity
133
+ raise_duplicate_identity!(identity) if index.key?(identity)
134
+
135
+ index[identity] = record
136
+ end
137
+ index
138
+ end
139
+
140
+ #: (Array[identity]) -> Array[identity]
141
+ def sorted_identities(identities)
142
+ identities.sort_by { |identity| sortable_identity(identity) }
143
+ end
144
+
145
+ #: (identity) -> Array[untyped]
146
+ def sortable_identity(identity)
147
+ [identity.fetch(0), identity.fetch(1).inspect]
148
+ end
149
+
150
+ #: (identity) -> void
151
+ def raise_duplicate_identity!(identity)
152
+ raise ArgumentError, "duplicate record identity: #{identity.inspect}"
153
+ end
154
+ end
155
+ end