paper_trail_diff 0.12.0 → 0.13.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +169 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
@@ -10,6 +10,7 @@ module PaperTrailDiff
10
10
  @traversal = traversal
11
11
  @normalizer = normalizer
12
12
  @preparer = preparer
13
+ @prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
13
14
  @live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
14
15
  @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
15
16
  @prepared_history = nil #: PreparedHistory?
@@ -24,8 +25,13 @@ module PaperTrailDiff
24
25
  end_at: root_versions.last.created_at
25
26
  )
26
27
  return if @tree.empty?
28
+
27
29
  # A batched preparation already covers these roots.
28
- return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
30
+ batched = root_versions.find { |version| @prepared_histories.key?(context_key(version)) }
31
+ if batched
32
+ @prepared_history = @prepared_histories.fetch(context_key(batched))
33
+ return
34
+ end
29
35
 
30
36
  @prepared_history = PreparedHistoryLoader.new(
31
37
  record,
@@ -37,15 +43,24 @@ module PaperTrailDiff
37
43
  ).call
38
44
  end
39
45
 
40
- #: (Array[untyped], Array[untyped]) -> void
41
- def prepare_batch(records, root_versions)
46
+ #: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
47
+ def prepare_batch(records, root_versions, start_at: nil, end_at: nil)
42
48
  return if @tree.empty? || records.empty? || root_versions.empty?
43
49
 
44
50
  Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
45
- register_batch(records, root_versions)
51
+ register_batch(records, root_versions, start_at: start_at, end_at: end_at)
46
52
  end
47
53
  end
48
54
 
55
+ # Missing roots have no live graph to collect. Reading their associations
56
+ # here would issue a query per root and seed nonexistent rows as live state.
57
+ #: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
58
+ def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
59
+ return if @tree.empty? || records.empty? || root_versions.empty?
60
+
61
+ register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
62
+ end
63
+
49
64
  #: (untyped, untyped) -> RecordSnapshot?
50
65
  def call(root_endpoint, context_endpoint)
51
66
  key = snapshot_key(root_endpoint, context_endpoint)
@@ -100,6 +115,11 @@ module PaperTrailDiff
100
115
  )
101
116
  end
102
117
 
118
+ #: () -> PreparedActivityIndex?
119
+ def activity_index
120
+ @prepared_history&.activity_index
121
+ end
122
+
103
123
  #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
104
124
  def record_transition(version)
105
125
  history = @prepared_history
@@ -108,12 +128,19 @@ module PaperTrailDiff
108
128
  history.record_transition(Endpoint.model_class(version), version.item_id, version)
109
129
  end
110
130
 
131
+ #: (untyped, ?direct: bool) -> [bool, untyped]
132
+ def record_after(version, direct: false)
133
+ @prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
134
+ direct: direct) || [false, nil]
135
+ end
136
+
111
137
  private
112
138
 
113
139
  # @rbs @tree: AssociationTree
114
140
  # @rbs @traversal: AssociationTraversal
115
141
  # @rbs @normalizer: SnapshotNormalizer
116
142
  # @rbs @preparer: untyped
143
+ # @rbs @prepared_snapshotter: PreparedSnapshotBuilder
117
144
  # @rbs @live_graph_collector: LiveGraphCollector
118
145
  # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
119
146
  # @rbs @prepared_history: PreparedHistory?
@@ -126,25 +153,22 @@ module PaperTrailDiff
126
153
 
127
154
  #: (Array[untyped], Array[untyped]) -> Hash[Symbol, untyped]
128
155
  def batch_payload(records, root_versions)
129
- {
130
- root_count: records.length,
131
- version_count: root_versions.length,
132
- model_type: records.first.class.base_class.name.to_s,
133
- batched: true
134
- }
156
+ { root_count: records.length, version_count: root_versions.length,
157
+ model_type: records.first.class.base_class.name.to_s, batched: true }
135
158
  end
136
159
 
137
- #: (Array[untyped], Array[untyped]) -> void
138
- def register_batch(records, root_versions)
160
+ #: (Array[untyped], Array[untyped], ?live_records: Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
161
+ def register_batch(records, root_versions, live_records: @live_graph_collector.call(records),
162
+ start_at: nil, end_at: nil)
139
163
  history = PreparedHistoryLoader.new(
140
164
  records.first,
141
165
  root_ids: records.map(&:id),
142
166
  root_versions: root_versions,
143
- start_at: root_versions.map(&:created_at).min,
144
- end_at: root_versions.map(&:created_at).max,
167
+ start_at: start_at || root_versions.map(&:created_at).min,
168
+ end_at: end_at || root_versions.map(&:created_at).max,
145
169
  tree: @tree,
146
170
  traversal: @traversal,
147
- live_records: @live_graph_collector.call(records)
171
+ live_records: live_records
148
172
  ).call
149
173
  root_versions.each { |version| @prepared_histories[context_key(version)] = history }
150
174
  end
@@ -154,27 +178,24 @@ module PaperTrailDiff
154
178
  model_class = Endpoint.model_class(root_version)
155
179
  @preparer.call(model_class, historical: true)
156
180
  @traversal.ensure_habtm_history!(model_class, root_version)
157
- record = root_version.reify(dup: true)
158
- normalize(
159
- record,
160
- context_version,
161
- tree: tree,
162
- normalizer: normalizer,
163
- habtm_version: root_version
181
+ handled, snapshot = @prepared_snapshotter.call(
182
+ root_version, context_version, tree: tree, normalizer: normalizer,
183
+ history: @prepared_histories[context_key(context_version)] ||
184
+ @prepared_history
164
185
  )
186
+ return snapshot if handled
187
+
188
+ record = root_version.reify(dup: true)
189
+ normalize(record, context_version, tree: tree, normalizer: normalizer,
190
+ habtm_version: root_version)
165
191
  end
166
192
 
167
193
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
168
194
  def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
169
195
  record = Endpoint.reload_record(root_record)
170
196
  @preparer.call(record.class, historical: true)
171
- normalize(
172
- record,
173
- context_version,
174
- tree: tree,
175
- normalizer: normalizer,
176
- habtm_version: context_version
177
- )
197
+ normalize(record, context_version, tree: tree, normalizer: normalizer,
198
+ habtm_version: context_version)
178
199
  end
179
200
 
180
201
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
@@ -188,12 +209,7 @@ module PaperTrailDiff
188
209
 
189
210
  #: (untyped, untyped) -> Array[untyped]
190
211
  def snapshot_key(root_endpoint, context_endpoint)
191
- [
192
- root_endpoint.class.name,
193
- root_endpoint.id,
194
- context_endpoint.class.name,
195
- context_endpoint.id
196
- ]
212
+ context_key(root_endpoint) + context_key(context_endpoint)
197
213
  end
198
214
  end
199
215
  end
@@ -9,13 +9,13 @@ module PaperTrailDiff
9
9
  module_function
10
10
 
11
11
  #: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
12
- def instrument(event, payload, &)
12
+ def instrument(event, payload, &block)
13
13
  active_support = Object.const_get(:ActiveSupport) #: untyped
14
14
  notifications = active_support.const_get(:Notifications) #: untyped
15
15
  notifications.instrument(
16
16
  "#{event}.paper_trail_diff",
17
17
  payload,
18
- &
18
+ &block
19
19
  )
20
20
  end
21
21
 
@@ -27,10 +27,16 @@ module PaperTrailDiff
27
27
  # one of them, so absence is its own value rather than nil.
28
28
  class NestedComparator
29
29
  # Stands in for a key that was not there at all.
30
- ABSENT = Object.new
31
- def ABSENT.inspect = '#<PaperTrailDiff absent>'
32
- def ABSENT.to_s = 'absent'
33
- ABSENT.freeze
30
+ class Absent
31
+ #: () -> String
32
+ def inspect = '#<PaperTrailDiff absent>'
33
+
34
+ #: () -> String
35
+ def to_s = 'absent'
36
+ end
37
+ private_constant :Absent
38
+
39
+ ABSENT = Absent.new.freeze
34
40
 
35
41
  #: (untyped, untyped) -> nested_changes
36
42
  def self.call(from_value, to_value)
@@ -49,9 +55,9 @@ module PaperTrailDiff
49
55
  #: () -> nested_changes
50
56
  def call
51
57
  from_structure, to_structure = structures
52
- return {} unless from_structure && to_structure
53
-
54
58
  changes = {} #: nested_changes
59
+ return changes.freeze unless from_structure && to_structure
60
+
55
61
  walk(from_structure, to_structure, [], changes)
56
62
  changes.freeze
57
63
  end
@@ -76,20 +82,23 @@ module PaperTrailDiff
76
82
 
77
83
  parsed = begin
78
84
  JSON.parse(value)
79
- rescue JSON::ParserError, TypeError
85
+ rescue JSON::ParserError
80
86
  nil
81
87
  end
82
- parsed if parsed.is_a?(Hash)
88
+ # JSON parses objects into plain Hashes, never a subclass.
89
+ parsed if parsed.instance_of?(Hash)
83
90
  end
84
91
 
85
92
  #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
86
93
  def walk(from_hash, to_hash, path, changes)
94
+ from_hash = normalize_keys(from_hash)
95
+ to_hash = normalize_keys(to_hash)
87
96
  keys(from_hash, to_hash).each do |key|
88
- from_item = from_hash.key?(key) ? from_hash[key] : ABSENT
89
- to_item = to_hash.key?(key) ? to_hash[key] : ABSENT
97
+ from_item = from_hash.fetch(key, ABSENT)
98
+ to_item = to_hash.fetch(key, ABSENT)
90
99
  next if from_item == to_item
91
100
 
92
- here = [*path, key.to_s].freeze
101
+ here = (path + [key]).freeze
93
102
  if from_item.is_a?(Hash) && to_item.is_a?(Hash)
94
103
  walk(from_item, to_item, here, changes)
95
104
  else
@@ -98,6 +107,20 @@ module PaperTrailDiff
98
107
  end
99
108
  end
100
109
 
110
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
111
+ def normalize_keys(hash)
112
+ normalized = {} #: Hash[String, untyped]
113
+ hash.each do |key, value|
114
+ name = key.to_s
115
+ if normalized.key?(name)
116
+ raise AmbiguousNestedKeyError, "duplicate nested key after normalization: #{name.inspect}"
117
+ end
118
+
119
+ normalized[name] = value
120
+ end
121
+ normalized
122
+ end
123
+
101
124
  # Two arrays are a membership change; anything else is a change to the
102
125
  # value as a whole, including an array facing something that is not one.
103
126
  #: (untyped, untyped) -> nested_change
@@ -109,11 +132,12 @@ module PaperTrailDiff
109
132
  end
110
133
  end
111
134
 
112
- # Sorted so a report reads the same twice, and stringified because a hash
113
- # loaded from JSON and one built in Ruby can key the same field differently.
114
- #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
135
+ # Sorted so a report reads the same twice. Both hashes are already keyed by
136
+ # strings, because a hash loaded from JSON and one built in Ruby can key the
137
+ # same field differently.
138
+ #: (Hash[String, untyped], Hash[String, untyped]) -> Array[String]
115
139
  def keys(from_hash, to_hash)
116
- (from_hash.keys | to_hash.keys).sort_by(&:to_s)
140
+ (from_hash.keys | to_hash.keys).sort
117
141
  end
118
142
  end
119
143
  end
@@ -10,11 +10,12 @@ module PaperTrailDiff
10
10
  # The only thing a wall-clock window can close on besides a later version.
11
11
  CLOSE_ON_CURRENT = :current
12
12
 
13
- #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
14
- def initialize(associations:, ignore:, reload_live_endpoints: true)
13
+ #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> void
14
+ def initialize(associations:, ignore:, reload_live_endpoints: true, allow_expensive: false)
15
15
  @association_tree = AssociationTree.build(associations)
16
16
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
17
17
  @traversal = AssociationTraversal.new(@association_tree)
18
+ @batch_safety = BatchSafety.new(@traversal, allow_expensive: allow_expensive)
18
19
  @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
19
20
  @live_endpoints = LiveEndpointProvider.new(
20
21
  tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
@@ -34,7 +35,14 @@ module PaperTrailDiff
34
35
  Instrumentation.instrument('compare', payload) do
35
36
  Endpoint.validate_pair!(from_endpoint, to_endpoint)
36
37
  notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
37
- Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
38
+ if @association_tree.empty?
39
+ Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
40
+ else
41
+ # Prepared history orders versions that share a timestamp; reading
42
+ # associations straight from PT-AT would resolve them by time alone.
43
+ comparison_batch([{ from: from_endpoint, to: to_endpoint }],
44
+ preparer: @traversal_preparer.method(:call)).call.values.fetch(0)
45
+ end
38
46
  end
39
47
  end
40
48
 
@@ -44,19 +52,14 @@ module PaperTrailDiff
44
52
  count = comparisons.is_a?(Array) ? comparisons.length : 0
45
53
  payload = @instrumentation_payload.merge(comparison_count: count)
46
54
  Instrumentation.instrument('compare_many', payload) do
47
- ComparisonBatch.new(
48
- comparisons,
49
- live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
50
- history_preparer: @historical_store.method(:prepare_batch),
51
- historical_snapshotter: method(:historical_snapshot),
52
- live_normalizer: method(:normalize_live_snapshot)
53
- ).call
55
+ comparison_batch(comparisons, preparer: method(:prepare_batch_model)).call
54
56
  end
55
57
  end
56
58
 
57
59
  #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
58
60
  def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
59
61
  @traversal_preparer.call(record.class, historical: true)
62
+ from, to = default_range(record, from, to, within)
60
63
  TimelineBuilder.new(
61
64
  record,
62
65
  from: from,
@@ -73,8 +76,8 @@ module PaperTrailDiff
73
76
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
74
77
  Instrumentation.instrument('activity_timeline', payload) do
75
78
  @traversal_preparer.call(record.class, historical: true)
79
+ from, to = default_range(record, from, to, within)
76
80
  live = live_endpoint_for(record, close_on, within)
77
- reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
78
81
  steps = activity_builder(
79
82
  record, from: from, to: to, within: within, version_scope: version_scope,
80
83
  live_endpoint: live, snapshots: snapshots, group: group
@@ -86,7 +89,9 @@ module PaperTrailDiff
86
89
 
87
90
  #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
88
91
  def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
92
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
89
93
  @traversal_preparer.call(record.class, historical: true)
94
+ from, to = default_range(record, from, to, within)
90
95
  live = live_endpoint_for(record, close_on, within)
91
96
  if activity
92
97
  return analyze_activity(
@@ -102,19 +107,21 @@ module PaperTrailDiff
102
107
  end
103
108
 
104
109
  # Analyzes many roots over one shared range, preparing their history once.
105
- #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
106
- def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
110
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
111
+ def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
112
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
107
113
  count = records.is_a?(Array) ? records.length : 0
108
114
  payload = @instrumentation_payload.merge(comparison_count: count)
109
115
  Instrumentation.instrument('analyze_many', payload) do
110
116
  AnalysisBatch.new(
111
117
  records,
112
- time_range: within.nil? ? nil : TimeRange.new(within),
113
- version_scope: version_scope,
114
- close_on_current: close_on_current?(close_on, within),
115
- live_loader: @live_endpoints.method(:call),
118
+ time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
119
+ close_on_current: close_on_current?(close_on, within) || within.nil?,
120
+ context_required: activity && !@association_tree.empty?,
121
+ live_loader: method(:load_batch_endpoints),
116
122
  history_preparer: @historical_store.method(:prepare_batch),
117
- analyzer: batched_root_analyzer(activity)
123
+ analyzer: batched_root_analyzer(activity, within: within, snapshots: snapshots,
124
+ group: group)
118
125
  ).call
119
126
  end
120
127
  end
@@ -123,22 +130,65 @@ module PaperTrailDiff
123
130
  # caller reporting on a population does not have to rediscover which of its
124
131
  # members changed. The roots the relation could not reach come back named
125
132
  # rather than dropped -- see `PaperTrailDiff.analyze_scope`.
126
- #: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
127
- def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
128
- raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
129
-
130
- selection = ScopedRootSelection.new(
131
- scope, time_range: within.nil? ? nil : TimeRange.new(within), limit: limit
133
+ #: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
134
+ def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
135
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
136
+ @batch_safety.call(scope.all.model) if scope.respond_to?(:all) && scope.respond_to?(:where)
137
+ # @type var options: report_options
138
+ options = { within: within, activity: activity, version_scope: version_scope,
139
+ close_on: close_on, snapshots: snapshots, group: group }
140
+ ScopedAnalysisBuilder.new(
141
+ scope, limit: limit, within: within, historical_filter: historical_filter,
142
+ max_filter_versions: max_filter_versions, include_deleted: include_deleted,
143
+ analyzer: ->(records) { analyze_many(records, **options, within: within) },
144
+ deleted_analyzer: lambda { |identities|
145
+ analyze_deleted(scope.all.model, identities, options)
146
+ }
132
147
  ).call
133
- ScopedAnalysis.new(
134
- analyses: analyze_many(selection.records, within: within, activity: activity,
135
- version_scope: version_scope, close_on: close_on),
136
- unreachable: selection.unreachable
137
- )
138
148
  end
139
149
 
140
150
  private
141
151
 
152
+ #: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
153
+ def comparison_batch(comparisons, preparer:)
154
+ ComparisonBatch.new(
155
+ comparisons,
156
+ live_loader: @live_endpoints, preparer: preparer,
157
+ history_preparer: @historical_store.method(:prepare_batch),
158
+ historical_snapshotter: method(:historical_snapshot),
159
+ live_normalizer: method(:normalize_live_snapshot)
160
+ )
161
+ end
162
+
163
+ #: (Array[untyped]) -> Hash[identity, untyped]
164
+ def load_batch_endpoints(records)
165
+ records.map(&:class).uniq.each { |model| @batch_safety.call(model) }
166
+ @live_endpoints.call(records)
167
+ end
168
+
169
+ #: (untyped, historical: bool) -> void
170
+ def prepare_batch_model(model, historical:)
171
+ @batch_safety.call(model, historical: historical)
172
+ @traversal_preparer.call(model, historical: historical)
173
+ end
174
+
175
+ # @rbs @batch_safety: BatchSafety
176
+
177
+ #: (untyped, Array[Array[String]], Hash[Symbol, untyped]) -> Hash[Array[String], Analysis?]
178
+ def analyze_deleted(model, identities, options)
179
+ within = options.fetch(:within)
180
+ analyzer = batched_root_analyzer(
181
+ options.fetch(:activity), within: within, snapshots: options.fetch(:snapshots),
182
+ group: options.fetch(:group)
183
+ )
184
+ DeletedAnalysisBatch.new(
185
+ model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
186
+ version_scope: options.fetch(:version_scope),
187
+ history_preparer: @historical_store.method(:prepare_deleted_batch),
188
+ analyzer: analyzer
189
+ ).call
190
+ end
191
+
142
192
  # @rbs @association_tree: AssociationTree
143
193
  # @rbs @live_endpoints: LiveEndpointProvider
144
194
  # @rbs @instrumentation_payload: Hash[Symbol, untyped]
@@ -153,7 +203,6 @@ module PaperTrailDiff
153
203
 
154
204
  #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
155
205
  def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
156
- reject_live_habtm_activity!(record.class) if live_endpoint
157
206
  activity_builder(
158
207
  record, from: from, to: to, within: within, version_scope: version_scope,
159
208
  live_endpoint: live_endpoint, snapshots: snapshots, group: group
@@ -181,25 +230,38 @@ module PaperTrailDiff
181
230
  )
182
231
  end
183
232
 
184
- # `close_on:` names what ends a wall-clock window, so it is meaningless for a
185
- # range whose endpoints the caller already gave explicitly.
233
+ # A window with nothing recorded after its last change ends at the live
234
+ # record, which holds the state that change produced -- whether the window
235
+ # ended a moment ago or long before. `close_on: :current` asked for this
236
+ # before it was the default and is still accepted. It names what ends a
237
+ # wall-clock window, so it is meaningless for explicit endpoints.
186
238
  #: (Symbol?, untyped) -> bool
187
239
  def close_on_current?(close_on, within)
188
- return false if close_on.nil?
189
- unless close_on == CLOSE_ON_CURRENT
240
+ unless close_on.nil? || close_on == CLOSE_ON_CURRENT
190
241
  raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
191
242
  end
192
- raise ConfigurationError, 'close_on: requires `within`' if within.nil?
243
+ raise ConfigurationError, 'close_on: requires `within`' if close_on && within.nil?
193
244
 
194
- true
245
+ !within.nil?
195
246
  end
196
247
 
197
- # A destroyed root has no current state to close on, and its own destroy
198
- # version already terminates the history.
248
+ # With neither a window nor endpoints, a range is the record's whole
249
+ # history through its current saved state; a missing endpoint takes that
250
+ # side of the same default.
251
+ #: (untyped, untyped, untyped, untyped) -> [untyped, untyped]
252
+ def default_range(record, from, to, within)
253
+ return [from, to] unless within.nil?
254
+
255
+ [from.nil? ? :first : from, to.nil? ? record : to]
256
+ end
257
+
258
+ # Only a saved row has current state to close on. A destroyed root's own
259
+ # destroy version already terminates its history, and a reified or unsaved
260
+ # instance stands for a state that is not in the table.
199
261
  #: (untyped, Symbol?, untyped) -> untyped
200
262
  def live_endpoint_for(record, close_on, within)
201
263
  return unless close_on_current?(close_on, within)
202
- return unless Endpoint.record?(record) && !record.destroyed?
264
+ return unless Endpoint.record?(record) && record.persisted?
203
265
 
204
266
  record
205
267
  end
@@ -213,14 +275,14 @@ module PaperTrailDiff
213
275
  @activity_snapshotter = build_activity_snapshotter
214
276
  end
215
277
 
216
- #: (bool) -> BatchedRootAnalyzer
217
- def batched_root_analyzer(activity)
278
+ #: (bool, within: untyped, snapshots: bool, group: Symbol?) -> BatchedRootAnalyzer
279
+ def batched_root_analyzer(activity, within:, snapshots:, group:)
218
280
  BatchedRootAnalyzer.new(
219
281
  tree: @association_tree,
220
282
  timeline_snapshotter: @timeline_snapshotter,
221
283
  activity_snapshotter: @activity_snapshotter,
222
284
  preparer: @traversal_preparer.method(:call),
223
- activity: activity
285
+ activity: activity, within: within, snapshots: snapshots, group: group
224
286
  )
225
287
  end
226
288
 
@@ -243,12 +305,13 @@ module PaperTrailDiff
243
305
  full_snapshotter: method(:snapshot_at),
244
306
  partial_snapshotter: @historical_store.method(:custom),
245
307
  association_reader: @historical_store.method(:association_reader),
246
- record_transition: @historical_store.method(:record_transition)
308
+ record_transition: @historical_store.method(:record_transition),
309
+ prepared_record_after: @historical_store.method(:record_after)
247
310
  )
248
311
  ActivitySnapshotProvider.new(
249
- snapshotter: method(:snapshot_at),
250
- refresher: refresher,
251
- preparer: @historical_store.method(:prepare)
312
+ snapshotter: method(:snapshot_at), refresher: refresher,
313
+ preparer: @historical_store.method(:prepare),
314
+ activity_index: @historical_store.method(:activity_index)
252
315
  )
253
316
  end
254
317
 
@@ -267,15 +330,6 @@ module PaperTrailDiff
267
330
  )
268
331
  end
269
332
 
270
- #: (untyped) -> void
271
- def reject_live_habtm_activity!(model_class)
272
- paths = @traversal.habtm_paths(model_class)
273
- return if paths.empty?
274
-
275
- message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
276
- raise UnsupportedLiveActivityError, message
277
- end
278
-
279
333
  #: (untyped) -> RecordSnapshot?
280
334
  def snapshot_for_endpoint(endpoint)
281
335
  Endpoint.version?(endpoint) ? historical_snapshot(endpoint) : live_snapshot(endpoint)