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
@@ -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
@@ -23,18 +23,10 @@ module PaperTrailDiff
23
23
 
24
24
  #: () -> comparison_results
25
25
  def call
26
- pairs = comparison_pairs
27
- pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
28
- ensure_unique_identities!(pairs)
29
- prepare_endpoint_classes!(pairs)
30
- live_records = load_live_records(pairs.flatten)
31
- prepare_historical_batches!(pairs, live_records)
32
- live_snapshots = normalize_live_records(live_records)
33
-
34
- pairs.to_h do |from, to|
35
- identity = Support.immutable_copy(Endpoint.identity(from))
36
- [identity, compare(from, to, live_snapshots)]
37
- end.freeze
26
+ resolved = BatchBoundaryResolver.new(comparison_pairs).call
27
+ pairs = resolved.reject { |pair| pair.any? { |endpoint| symbolic?(endpoint) } }
28
+ ensure_unique_identities!(resolved)
29
+ results(resolved, prepared_live_snapshots(pairs)).freeze
38
30
  end
39
31
 
40
32
  private
@@ -46,6 +38,38 @@ module PaperTrailDiff
46
38
  # @rbs @historical_snapshotter: untyped
47
39
  # @rbs @live_normalizer: untyped
48
40
 
41
+ #: (Array[[untyped, untyped]]) -> Hash[identity, RecordSnapshot]
42
+ def prepared_live_snapshots(pairs)
43
+ pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
44
+ prepare_endpoint_classes!(pairs)
45
+ live_records = load_live_records(pairs.flatten)
46
+ prepare_historical_batches!(pairs, live_records)
47
+ normalize_live_records(live_records)
48
+ end
49
+
50
+ #: (untyped) -> bool
51
+ def symbolic?(endpoint)
52
+ BatchBoundaryResolver.symbolic?(endpoint)
53
+ end
54
+
55
+ # A boundary symbol that stayed unresolved means the root has no recorded
56
+ # history. An absent history is an empty result rather than a failed
57
+ # request, matching how the timeline APIs answer the same question.
58
+ #: (Array[[untyped, untyped]], Hash[identity, RecordSnapshot]) -> comparison_results
59
+ def results(resolved, live_snapshots)
60
+ resolved.to_h do |from, to|
61
+ identity = Support.immutable_copy(entry_identity(from, to))
62
+ next [identity, Diff.new] if symbolic?(from) || symbolic?(to)
63
+
64
+ [identity, compare(from, to, live_snapshots)]
65
+ end
66
+ end
67
+
68
+ #: (untyped, untyped) -> identity
69
+ def entry_identity(from, to)
70
+ Endpoint.identity(symbolic?(from) ? to : from)
71
+ end
72
+
49
73
  #: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
50
74
  def compare(from, to, live_snapshots)
51
75
  Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
@@ -125,7 +149,7 @@ module PaperTrailDiff
125
149
 
126
150
  #: (Array[[untyped, untyped]]) -> void
127
151
  def ensure_unique_identities!(pairs)
128
- identities = pairs.map { |from, _to| Endpoint.identity(from) }
152
+ identities = pairs.map { |from, to| entry_identity(from, to) }
129
153
  return if identities.uniq.length == identities.length
130
154
 
131
155
  raise ConfigurationError, 'comparisons: root identities must be unique'
@@ -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,20 +16,27 @@ 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?
27
+ # A batched preparation already covers these roots.
28
+ return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
22
29
 
23
30
  @prepared_history = PreparedHistoryLoader.new(
24
31
  record,
25
32
  root_versions: root_versions,
26
33
  start_at: start_at,
34
+ end_at: end_at,
27
35
  tree: @tree,
28
36
  traversal: @traversal
29
37
  ).call
30
38
  end
31
39
 
32
- # Prepares selected history for several roots of the same model class.
33
40
  #: (Array[untyped], Array[untyped]) -> void
34
41
  def prepare_batch(records, root_versions)
35
42
  return if @tree.empty? || records.empty? || root_versions.empty?
@@ -44,12 +51,7 @@ module PaperTrailDiff
44
51
  key = snapshot_key(root_endpoint, context_endpoint)
45
52
  return @snapshots[key] if @snapshots.key?(key)
46
53
 
47
- @snapshots[key] = custom(
48
- root_endpoint,
49
- context_endpoint,
50
- tree: @tree,
51
- normalizer: @normalizer
52
- )
54
+ @snapshots[key] = uncached(root_endpoint, context_endpoint)
53
55
  end
54
56
 
55
57
  #: (untyped, untyped) -> RecordSnapshot?
@@ -139,6 +141,7 @@ module PaperTrailDiff
139
141
  root_ids: records.map(&:id),
140
142
  root_versions: root_versions,
141
143
  start_at: root_versions.map(&:created_at).min,
144
+ end_at: root_versions.map(&:created_at).max,
142
145
  tree: @tree,
143
146
  traversal: @traversal,
144
147
  live_records: @live_graph_collector.call(records)
@@ -2,13 +2,17 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # PaperTrail/ActiveRecord boundary that produces plain record snapshots.
6
- class PaperTrailAdapter
5
+ # PaperTrail/ActiveRecord boundary that produces plain record snapshots. It is
6
+ # deliberately the widest class here: it fronts every public operation and is
7
+ # the only place allowed to know about both PaperTrail and the pure engine.
8
+ # Reconstruction logic lives in the collaborators it wires together.
9
+ class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
7
10
  #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
8
11
  def initialize(associations:, ignore:, reload_live_endpoints: true)
9
12
  @association_tree = AssociationTree.build(associations)
10
13
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
11
14
  @traversal = AssociationTraversal.new(@association_tree)
15
+ @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
12
16
  @live_endpoints = LiveEndpointProvider.new(
13
17
  tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
14
18
  )
@@ -18,9 +22,7 @@ module PaperTrailDiff
18
22
  tree: @association_tree, ignore_policy: @ignore_policy,
19
23
  traversal: @traversal, pool: @snapshot_pool
20
24
  )
21
- @historical_store = build_historical_store
22
- @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
23
- @activity_snapshotter = build_activity_snapshotter
25
+ build_snapshotters
24
26
  end
25
27
 
26
28
  #: (untyped, untyped) -> Diff
@@ -40,7 +42,7 @@ module PaperTrailDiff
40
42
  Instrumentation.instrument('compare_many', payload) do
41
43
  ComparisonBatch.new(
42
44
  comparisons,
43
- live_loader: @live_endpoints.method(:call), preparer: method(:prepare_traversal!),
45
+ live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
44
46
  history_preparer: @historical_store.method(:prepare_batch),
45
47
  historical_snapshotter: method(:historical_snapshot),
46
48
  live_normalizer: method(:normalize_live_snapshot)
@@ -50,7 +52,7 @@ module PaperTrailDiff
50
52
 
51
53
  #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
52
54
  def timeline(record, from:, to:, within:)
53
- prepare_traversal!(record.class, historical: true)
55
+ @traversal_preparer.call(record.class, historical: true)
54
56
  builder = TimelineBuilder.new(
55
57
  record,
56
58
  from: from,
@@ -65,7 +67,7 @@ module PaperTrailDiff
65
67
  def activity_timeline(record, from:, to:, within:)
66
68
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
67
69
  Instrumentation.instrument('activity_timeline', payload) do
68
- prepare_traversal!(record.class, historical: true)
70
+ @traversal_preparer.call(record.class, historical: true)
69
71
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
70
72
  steps = activity_builder(record, from: from, to: to, within: within).build
71
73
  payload[:step_count] = steps.length
@@ -76,11 +78,11 @@ module PaperTrailDiff
76
78
  #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
77
79
  def analyze(record, from:, to:, within:, activity: false)
78
80
  if activity
79
- prepare_traversal!(record.class, historical: true)
81
+ @traversal_preparer.call(record.class, historical: true)
80
82
  return activity_builder(record, from: from, to: to, within: within).analyze
81
83
  end
82
84
 
83
- prepare_traversal!(record.class, historical: true)
85
+ @traversal_preparer.call(record.class, historical: true)
84
86
  TimelineBuilder.new(
85
87
  record,
86
88
  from: from,
@@ -90,6 +92,22 @@ module PaperTrailDiff
90
92
  ).analyze
91
93
  end
92
94
 
95
+ # Analyzes many roots over one shared range, preparing their history once.
96
+ #: (Array[untyped], within: untyped, ?activity: bool) -> Hash[identity, Analysis]
97
+ def analyze_many(records, within:, activity: false)
98
+ count = records.is_a?(Array) ? records.length : 0
99
+ payload = @instrumentation_payload.merge(comparison_count: count)
100
+ Instrumentation.instrument('analyze_many', payload) do
101
+ AnalysisBatch.new(
102
+ records,
103
+ time_range: within.nil? ? nil : TimeRange.new(within),
104
+ live_loader: @live_endpoints.method(:call),
105
+ history_preparer: @historical_store.method(:prepare_batch),
106
+ analyzer: batched_root_analyzer(activity)
107
+ ).call
108
+ end
109
+ end
110
+
93
111
  private
94
112
 
95
113
  # @rbs @association_tree: AssociationTree
@@ -97,19 +115,38 @@ module PaperTrailDiff
97
115
  # @rbs @instrumentation_payload: Hash[Symbol, untyped]
98
116
  # @rbs @ignore_policy: IgnorePolicy
99
117
  # @rbs @traversal: AssociationTraversal
118
+ # @rbs @traversal_preparer: TraversalPreparer
100
119
  # @rbs @snapshot_pool: SnapshotPool
101
120
  # @rbs @normalizer: SnapshotNormalizer
102
121
  # @rbs @historical_store: HistoricalSnapshotStore
103
122
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
104
123
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
105
124
 
125
+ #: () -> void
126
+ def build_snapshotters
127
+ @historical_store = build_historical_store
128
+ @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
129
+ @activity_snapshotter = build_activity_snapshotter
130
+ end
131
+
132
+ #: (bool) -> BatchedRootAnalyzer
133
+ def batched_root_analyzer(activity)
134
+ BatchedRootAnalyzer.new(
135
+ tree: @association_tree,
136
+ timeline_snapshotter: @timeline_snapshotter,
137
+ activity_snapshotter: @activity_snapshotter,
138
+ preparer: @traversal_preparer.method(:call),
139
+ activity: activity
140
+ )
141
+ end
142
+
106
143
  #: () -> HistoricalSnapshotStore
107
144
  def build_historical_store
108
145
  HistoricalSnapshotStore.new(
109
146
  tree: @association_tree,
110
147
  traversal: @traversal,
111
148
  normalizer: @normalizer,
112
- preparer: method(:prepare_traversal!)
149
+ preparer: @traversal_preparer.method(:call)
113
150
  )
114
151
  end
115
152
 
@@ -180,30 +217,9 @@ module PaperTrailDiff
180
217
 
181
218
  #: (untyped) -> RecordSnapshot
182
219
  def normalize_live_snapshot(current)
183
- prepare_traversal!(current.class, historical: false)
220
+ @traversal_preparer.call(current.class, historical: false)
184
221
  @normalizer.call(current, reifier: LiveAssociationReader.new) ||
185
222
  raise(InvalidEndpointError, 'current record endpoint could not be normalized')
186
223
  end
187
-
188
- #: (untyped, historical: bool) -> void
189
- def prepare_traversal!(model_class, historical:)
190
- return if @association_tree.empty?
191
-
192
- ensure_association_tracking! if historical
193
- @traversal.validate!(model_class)
194
- end
195
-
196
- #: () -> void
197
- def ensure_association_tracking!
198
- paper_trail = Object.const_get(:PaperTrail) #: untyped
199
- config = paper_trail.config #: untyped
200
- available = defined?(::PaperTrailAssociationTracking) &&
201
- config.respond_to?(:track_associations?) &&
202
- config.track_associations?
203
- return if available
204
-
205
- message = 'association tracking must be loaded and enabled to compare historical associations'
206
- raise AssociationTrackingUnavailableError, message
207
- end
208
224
  end
209
225
  end
@@ -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]