paper_trail_diff 0.14.0 → 0.15.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
@@ -64,65 +64,92 @@ module PaperTrailDiff
64
64
  end
65
65
 
66
66
  # Performs read-only checks for known PaperTrail/PT-AT reconstruction hazards.
67
+ #
68
+ # The endpoints are a comparison's: two versions, or a version and the
69
+ # current record, in either order. Two current records read no history, so
70
+ # nothing is checked.
67
71
  class HistoryDiagnostics
68
- #: (untyped, untyped, associations: Array[String | Symbol]) -> void
69
- def initialize(from_version, to_version, associations:)
70
- @from_version = from_version
71
- @to_version = to_version
72
+ STOPPING_CODES = %i[
73
+ unknown_association unsupported_association association_tracking_unavailable
74
+ ].freeze
75
+
76
+ #: (untyped, untyped, associations: association_option, ?allow_approximate: bool) -> void
77
+ def initialize(from, to, associations:, allow_approximate: false)
78
+ @versions = validated_versions(from, to)
72
79
  @tree = AssociationTree.build(associations)
80
+ @traversal = AssociationTraversal.new(@tree)
81
+ @preparer = TraversalPreparer.new(tree: @tree, traversal: @traversal,
82
+ allow_approximate: allow_approximate)
83
+ @allow_approximate = allow_approximate
73
84
  @issues = [] #: Array[DiagnosticIssue]
74
85
  end
75
86
 
76
87
  #: () -> DiagnosticReport
77
88
  def call
78
- model_class = validated_model_class
79
- # Runs whether or not associations are selected: unorderable versions
80
- # corrupt a scalar timeline just as surely, and a report that inspected
81
- # nothing has no business answering `ok?`.
82
- inspect_version_sequence
83
- return DiagnosticReport.new(issues: @issues) if @tree.empty?
84
-
85
- unless association_tracking_available?
86
- add_error(:association_tracking_unavailable, tracking_unavailable_message)
87
- return DiagnosticReport.new(issues: @issues)
89
+ unless @versions.empty?
90
+ model_class = Endpoint.model_class(@versions.fetch(0))
91
+ # Runs whether or not associations are selected: unorderable versions
92
+ # corrupt a scalar timeline just as surely, and a report that inspected
93
+ # nothing has no business answering `ok?`.
94
+ inspect_version_sequence(model_class)
95
+ inspect_associations(model_class) unless @tree.empty?
88
96
  end
89
-
90
- traversal = AssociationTraversal.new(@tree)
91
- selected = traversal.selected_reflections(model_class)
92
- inspect_targets(selected)
93
- inspect_checkpoint_timestamp(model_class)
94
- inspect_habtm(model_class, selected)
95
97
  DiagnosticReport.new(issues: @issues)
96
98
  end
97
99
 
98
100
  private
99
101
 
100
- # @rbs @from_version: untyped
101
- # @rbs @to_version: untyped
102
+ # Each refusal a comparison would raise is an error, so `ok?` is false
103
+ # exactly when one would. The checks after them read reflections that must
104
+ # resolve and PT-AT records that must be there.
105
+ #: (untyped) -> void
106
+ def inspect_associations(model_class)
107
+ refusals = @preparer.refusals(model_class, historical: true)
108
+ refusals.each { |refusal| add_error(refusal.code, refusal.message, refusal.path) }
109
+ return if refusals.any? { |refusal| STOPPING_CODES.include?(refusal.code) }
110
+
111
+ selected = @traversal.selected_reflections(model_class)
112
+ inspect_polymorphic_targets(selected)
113
+ inspect_approximate_paths(model_class) if @allow_approximate
114
+ inspect_checkpoint_timestamp(model_class)
115
+ inspect_habtm(model_class, selected)
116
+ end
117
+
118
+ # @rbs @versions: Array[untyped]
102
119
  # @rbs @tree: AssociationTree
120
+ # @rbs @traversal: AssociationTraversal
121
+ # @rbs @preparer: TraversalPreparer
122
+ # @rbs @allow_approximate: bool
103
123
  # @rbs @issues: Array[DiagnosticIssue]
104
124
 
105
- #: () -> untyped
106
- def validated_model_class
107
- unless version_identity(@from_version) == version_identity(@to_version)
108
- raise VersionMismatchError, 'versions must belong to the same PaperTrail item'
125
+ # The endpoints that are versions, in the order given.
126
+ #: (untyped, untyped) -> Array[untyped]
127
+ def validated_versions(from, to)
128
+ Endpoint.validate!(from)
129
+ Endpoint.validate!(to)
130
+ unless Endpoint.identity(from) == Endpoint.identity(to)
131
+ raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
109
132
  end
110
133
 
111
- Object.const_get(version_type(@from_version))
112
- rescue NoMethodError => e
113
- raise VersionMismatchError, 'expected PaperTrail version endpoints', cause: e
134
+ [from, to].select { |endpoint| Endpoint.version?(endpoint) }
114
135
  end
115
136
 
116
137
  #: (Array[Array[untyped]]) -> void
117
- def inspect_targets(selected)
138
+ def inspect_polymorphic_targets(selected)
118
139
  selected.each do |path, reflection|
119
- if reflection.polymorphic?
120
- add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
121
- next
122
- end
140
+ next unless reflection.polymorphic?
123
141
 
124
- inspect_versioned_model(reflection.klass, path)
125
- inspect_through_model(reflection, path) if reflection.options[:through]
142
+ add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
143
+ end
144
+ end
145
+
146
+ # Accepted, but read the way `allow_approximate:` describes.
147
+ #: (untyped) -> void
148
+ def inspect_approximate_paths(model_class)
149
+ ApproximatePaths.call(@traversal, model_class).each do |path, reason|
150
+ add_warning(:approximate_association,
151
+ "association #{path} is read approximately with PT-AT (#{reason}): a " \
152
+ 'record that entered or left it since is reported wrongly', path)
126
153
  end
127
154
  end
128
155
 
@@ -139,9 +166,7 @@ module PaperTrailDiff
139
166
  # transaction that more than one owner recorded members in cannot be read.
140
167
  #: (untyped, Array[Array[untyped]]) -> void
141
168
  def inspect_shared_transactions(model_class, habtm)
142
- versions = [@from_version, @to_version].select do |version|
143
- version.respond_to?(:transaction_id) && version.transaction_id
144
- end
169
+ versions = @versions.select { |version| VersionBoundary.transaction_id(version) }
145
170
  habtm.each do |path, reflection|
146
171
  owners = HabtmTransactionOwners.new(model_class, reflection)
147
172
  versions.each do |version|
@@ -154,15 +179,29 @@ module PaperTrailDiff
154
179
  end
155
180
  end
156
181
 
157
- #: () -> void
158
- def inspect_version_sequence
182
+ # With the current record as one endpoint there is one version, and the
183
+ # sequence runs on from it.
184
+ #: (untyped) -> void
185
+ def inspect_version_sequence(model_class)
186
+ first, last = @versions
159
187
  @issues.concat(
160
- VersionSequenceDiagnostics.new(
161
- @from_version, @to_version, associations_selected: !@tree.empty?
162
- ).call
188
+ VersionSequenceDiagnostics.new(first, last,
189
+ fallback_selected: fallback_selected?(model_class)).call
163
190
  )
164
191
  end
165
192
 
193
+ # Only a path PT-AT reads can lose a change between tied versions, and a
194
+ # traversal that does not resolve reads nothing at all.
195
+ #: (untyped) -> bool
196
+ def fallback_selected?(model_class)
197
+ return false if @tree.empty?
198
+
199
+ refusals = @preparer.refusals(model_class, historical: true)
200
+ return false if refusals.any? { |refusal| STOPPING_CODES.include?(refusal.code) }
201
+
202
+ FallbackPaths.any?(@traversal, model_class, approximate: @allow_approximate)
203
+ end
204
+
166
205
  #: (untyped) -> void
167
206
  def inspect_checkpoint_timestamp(model_class)
168
207
  return if synchronized_timestamp_disabled?(model_class)
@@ -173,7 +212,7 @@ module PaperTrailDiff
173
212
 
174
213
  #: (Array[String]) -> void
175
214
  def inspect_transaction_metadata(paths)
176
- [@from_version, @to_version].each do |version|
215
+ @versions.each do |version|
177
216
  if !version.respond_to?(:transaction_id)
178
217
  add_error(:transaction_id_column_missing, 'version has no transaction_id column')
179
218
  elsif version.transaction_id.nil?
@@ -189,36 +228,6 @@ module PaperTrailDiff
189
228
  end
190
229
  end
191
230
 
192
- #: (untyped, String) -> void
193
- def inspect_versioned_model(model_class, path)
194
- return if model_versioned?(model_class)
195
-
196
- # An error rather than a warning: `ok?` must not stay true for a
197
- # comparison that cannot work, and this one raises at runtime.
198
- add_error(
199
- :unversioned_association_target,
200
- "#{model_class.name} does not appear to have PaperTrail enabled",
201
- path
202
- )
203
- end
204
-
205
- #: (untyped, String) -> void
206
- def inspect_through_model(reflection, path)
207
- model_class = reflection.through_reflection.klass
208
- return if model_versioned?(model_class)
209
-
210
- add_warning(
211
- :unversioned_through_model,
212
- "#{model_class.name} does not appear to have PaperTrail enabled",
213
- path
214
- )
215
- end
216
-
217
- #: (untyped) -> bool
218
- def model_versioned?(model_class)
219
- Support.versioned?(model_class)
220
- end
221
-
222
231
  #: (untyped) -> bool
223
232
  def synchronized_timestamp_disabled?(model_class)
224
233
  options = model_class.paper_trail_options
@@ -227,30 +236,6 @@ module PaperTrailDiff
227
236
  false
228
237
  end
229
238
 
230
- #: () -> bool
231
- def association_tracking_available?
232
- paper_trail = Object.const_get(:PaperTrail) #: untyped
233
- config = paper_trail.config #: untyped
234
- !!(defined?(::PaperTrailAssociationTracking) &&
235
- config.respond_to?(:track_associations?) && config.track_associations?)
236
- end
237
-
238
- #: (untyped) -> Array[String]
239
- def version_identity(version)
240
- [version.item_type.to_s, version.item_id.to_s]
241
- end
242
-
243
- #: (untyped) -> String
244
- def version_type(version)
245
- subtype = version.item_subtype if version.respond_to?(:item_subtype)
246
- subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
247
- end
248
-
249
- #: () -> String
250
- def tracking_unavailable_message
251
- 'association tracking must be loaded and enabled to diagnose associations'
252
- end
253
-
254
239
  #: (Symbol, String, ?String?, ?untyped) -> void
255
240
  def add_error(code, message, path = nil, version_id = nil)
256
241
  add_issue(:error, code, message, path, version_id)
@@ -47,6 +47,29 @@ module PaperTrailDiff
47
47
  validate_record!(endpoint)
48
48
  end
49
49
 
50
+ # The record a method reads the history of: a record, saved or destroyed,
51
+ # of a model PaperTrail versions.
52
+ #: (untyped) -> void
53
+ def validate_root!(record)
54
+ unless record?(record)
55
+ raise InvalidEndpointError, "expected a record, got #{described(record)}"
56
+ end
57
+ return if Support.versioned?(record.class)
58
+
59
+ raise InvalidEndpointError,
60
+ "#{record.class.name} is not versioned, so it has no history: add has_paper_trail to it"
61
+ end
62
+
63
+ # A relation is named rather than inspected, which would load it.
64
+ #: (untyped) -> String
65
+ def described(value)
66
+ return 'nil' if value.nil?
67
+ return "the class #{value.name}" if value.is_a?(Module)
68
+
69
+ value.class.name.to_s
70
+ end
71
+ private_class_method :described
72
+
50
73
  #: (untyped) -> bool
51
74
  def version?(endpoint)
52
75
  endpoint.respond_to?(:reify) &&
@@ -82,11 +105,11 @@ module PaperTrailDiff
82
105
  raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
83
106
  end
84
107
 
85
- # The record's own PaperTrail versions, under whatever name the model gave
86
- # the association.
108
+ # Every PaperTrail version of the record, whatever scope its versions
109
+ # association has.
87
110
  #: (untyped) -> untyped
88
111
  def versions(record)
89
- record.public_send(record.class.versions_association_name)
112
+ VersionQuery.for_items(record.class, record.id)
90
113
  rescue NoMethodError => e
91
114
  message = 'record does not expose a PaperTrail version history'
92
115
  raise InvalidTimelineRangeError, message, cause: e
@@ -42,7 +42,9 @@ module PaperTrailDiff
42
42
  # Historical candidate loading exceeded its explicitly bounded work budget.
43
43
  class FilterLimitExceededError < Error; end
44
44
 
45
- # A batch selected a known expensive association without explicitly opting in.
45
+ # No longer raised: `allow_approximate: true` alone accepts the paths it
46
+ # refused. Kept until `allow_expensive:` is removed, so code that rescues it
47
+ # still loads.
46
48
  class UnsupportedBatchPathError < ConfigurationError; end
47
49
 
48
50
  # Raised when a requested ActiveRecord association does not exist.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Whether a request may read a path with PT-AT's reifier rather than from
6
+ # loaded history. PT-AT places association changes by timestamp alone, so
7
+ # only such a path can miss a change between two versions that share one;
8
+ # loaded history orders those versions by id.
9
+ module FallbackPaths
10
+ # A path history reads only approximately counts when it is allowed: a
11
+ # request that does not allow it is refused before anything is read.
12
+ #: (AssociationTraversal, untyped, approximate: bool) -> bool
13
+ def self.any?(traversal, model, approximate:)
14
+ return true if approximate && !ApproximatePaths.call(traversal, model).empty?
15
+
16
+ traversal.selected_reflections(model).any? { |_path, reflection| per_record?(reflection) }
17
+ end
18
+
19
+ # Loaded history cannot index a composite foreign key, and a polymorphic
20
+ # belongs_to's target, with its default scope, is known only per record.
21
+ #: (untyped) -> bool
22
+ def self.per_record?(reflection)
23
+ composite_key?(reflection) || (reflection.belongs_to? && reflection.polymorphic?)
24
+ end
25
+ private_class_method :per_record?
26
+
27
+ #: (untyped) -> bool
28
+ def self.composite_key?(reflection)
29
+ return true if reflection.foreign_key.is_a?(Array)
30
+ return false unless reflection.through_reflection?
31
+
32
+ [reflection.through_reflection, reflection.source_reflection].any? do |part|
33
+ composite_key?(part)
34
+ end
35
+ end
36
+ private_class_method :composite_key?
37
+ end
38
+ end
@@ -6,10 +6,8 @@ module PaperTrailDiff
6
6
  class HistoricalAssociationReifier
7
7
  #: (untyped, ?habtm_version: untyped) -> void
8
8
  def initialize(version, habtm_version: version)
9
- @transaction_id = version.transaction_id if version.respond_to?(:transaction_id)
10
- if habtm_version.respond_to?(:transaction_id)
11
- @habtm_transaction_id = habtm_version.transaction_id
12
- end
9
+ @transaction_id = VersionBoundary.transaction_id(version)
10
+ @habtm_transaction_id = VersionBoundary.transaction_id(habtm_version)
13
11
  @version_at = version.created_at
14
12
  # Keyed on record identity rather than `object_id`, which Ruby may reuse
15
13
  # once an earlier record in the same pass has been collected.
@@ -24,8 +24,10 @@ module PaperTrailDiff
24
24
  @state_loader = PreparedVersionStateLoader.new
25
25
  end
26
26
 
27
- #: (Array[String]) -> Array[String]
28
- def call(candidates)
27
+ # More matches than the limit raise, unless the caller does not count
28
+ # them: candidates it only lists, such as deleted roots.
29
+ #: (Array[String], ?counted: bool) -> Array[String]
30
+ def call(candidates, counted: true)
29
31
  matched = [] #: Array[String]
30
32
  candidates.each_slice(CANDIDATE_BATCH) do |slice|
31
33
  versions = candidate_versions(slice)
@@ -34,7 +36,7 @@ module PaperTrailDiff
34
36
  next unless held?(versions.fetch(id, []), closing[id])
35
37
 
36
38
  matched << id
37
- reject_root_limit! if matched.length > @limit
39
+ reject_root_limit! if counted && matched.length > @limit
38
40
  end
39
41
  end
40
42
  matched
@@ -64,7 +66,17 @@ module PaperTrailDiff
64
66
  "unknown historical attributes: #{unknown.join(', ')}"
65
67
  end
66
68
 
67
- Support.immutable_copy(attributes)
69
+ attributes.to_h { |name, value| [name, cast(name, value)] }
70
+ end
71
+
72
+ # Expected and recorded values compare as the attribute casts them, as
73
+ # `where` reads its values: an enum's symbol names its stored value, and a
74
+ # `json` or `jsonb` version column, which PaperTrail does not cast, holds
75
+ # dates and times as strings. Casting also copies a mutable value, so the
76
+ # filter keeps its own.
77
+ #: (String, untyped) -> untyped
78
+ def cast(name, value)
79
+ @model.type_for_attribute(name).cast(value)
68
80
  end
69
81
 
70
82
  #: (untyped) -> Integer
@@ -76,8 +88,7 @@ module PaperTrailDiff
76
88
 
77
89
  #: (Array[String]) -> Hash[String, Array[untyped]]
78
90
  def candidate_versions(ids)
79
- relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
80
- item_id: ids)
91
+ relation = VersionQuery.for_items(@model, ids)
81
92
  range = @time_range
82
93
  relation = range.scope(relation) if range
83
94
  # One past the remaining budget is enough to know it was exceeded.
@@ -107,9 +118,7 @@ module PaperTrailDiff
107
118
  range = @time_range
108
119
  return {} unless range
109
120
 
110
- relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
111
- item_id: ids)
112
- trailing = range.first_trailing_versions(relation)
121
+ trailing = range.first_trailing_versions(VersionQuery.for_items(@model, ids))
113
122
  account_versions!(trailing.length)
114
123
  trailing
115
124
  end
@@ -162,7 +171,7 @@ module PaperTrailDiff
162
171
  "historical_filter: #{name} was not recorded in version #{version.id}"
163
172
  end
164
173
 
165
- state.attributes.fetch(name) == expected
174
+ cast(name, state.attributes.fetch(name)) == expected
166
175
  end
167
176
  end
168
177
 
@@ -2,7 +2,10 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Reconstructs and caches immutable historical snapshots for one adapter configuration.
5
+ # Reconstructs and caches immutable historical snapshots for one adapter
6
+ # configuration. It keeps the histories batches prepare, by root version;
7
+ # one prepared for a single root goes back to the caller, who passes it to
8
+ # every read for that root.
6
9
  class HistoricalSnapshotStore
7
10
  #: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
8
11
  def initialize(tree:, traversal:, normalizer:, preparer:)
@@ -13,11 +16,11 @@ module PaperTrailDiff
13
16
  @prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
14
17
  @live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
15
18
  @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
16
- @prepared_history = nil #: PreparedHistory?
17
19
  @prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
20
+ @live_roots = {} #: Hash[identity, untyped]
18
21
  end
19
22
 
20
- #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
23
+ #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> PreparedHistory?
21
24
  def prepare(
22
25
  record,
23
26
  root_versions,
@@ -28,12 +31,9 @@ module PaperTrailDiff
28
31
 
29
32
  # A batched preparation already covers these roots.
30
33
  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
34
+ return @prepared_histories.fetch(context_key(batched)) if batched
35
35
 
36
- @prepared_history = PreparedHistoryLoader.new(
36
+ PreparedHistoryLoader.new(
37
37
  record, root_ids: [record.id], root_versions: root_versions, start_at: start_at,
38
38
  end_at: end_at, tree: @tree, traversal: @traversal, live_records: []
39
39
  ).call
@@ -46,6 +46,9 @@ module PaperTrailDiff
46
46
  Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
47
47
  register_batch(records, root_versions, start_at: start_at, end_at: end_at)
48
48
  end
49
+ # A batch passes its roots as it just loaded them, so the current state
50
+ # they close on is read from them rather than once more per root.
51
+ records.each { |record| @live_roots[Endpoint.identity(record)] = record }
49
52
  end
50
53
 
51
54
  # Missing roots, and roots compared only between versions, have no live
@@ -58,50 +61,41 @@ module PaperTrailDiff
58
61
  register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
59
62
  end
60
63
 
61
- #: (untyped, untyped) -> RecordSnapshot?
62
- def call(root_endpoint, context_endpoint)
64
+ # `history` is the one `prepare` returned for this root, if any.
65
+ #: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
66
+ def call(root_endpoint, context_endpoint, history: nil)
63
67
  key = snapshot_key(root_endpoint, context_endpoint)
64
68
  return @snapshots[key] if @snapshots.key?(key)
65
69
 
66
- @snapshots[key] = uncached(root_endpoint, context_endpoint)
70
+ @snapshots[key] = uncached(root_endpoint, context_endpoint, history: history)
67
71
  end
68
72
 
69
- #: (untyped, untyped) -> RecordSnapshot?
70
- def uncached(root_endpoint, context_endpoint)
71
- custom(
72
- root_endpoint,
73
- context_endpoint,
74
- tree: @tree,
75
- normalizer: @normalizer
76
- )
73
+ #: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
74
+ def uncached(root_endpoint, context_endpoint, history: nil)
75
+ custom(root_endpoint, context_endpoint, tree: @tree, normalizer: @normalizer,
76
+ history: history)
77
77
  end
78
78
 
79
- #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
80
- def custom(root_endpoint, context_endpoint, tree:, normalizer:)
79
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, ?history: PreparedHistory?) -> RecordSnapshot?
80
+ def custom(root_endpoint, context_endpoint, tree:, normalizer:, history: nil)
81
+ history = history_for(context_endpoint, history)
81
82
  if Endpoint.version?(root_endpoint)
82
83
  return snapshot_from_version(
83
- root_endpoint,
84
- context_endpoint,
85
- tree: tree,
86
- normalizer: normalizer
84
+ root_endpoint, context_endpoint, tree: tree, normalizer: normalizer, history: history
87
85
  )
88
86
  end
89
87
 
90
- snapshot_from_live_root(
91
- root_endpoint,
92
- context_endpoint,
93
- tree: tree,
94
- normalizer: normalizer
95
- )
88
+ snapshot_from_live_root(root_endpoint, context_endpoint, tree: tree, normalizer: normalizer,
89
+ history: history)
96
90
  end
97
91
 
98
- #: (untyped, habtm_version: untyped) -> untyped
99
- def association_reader(context_version, habtm_version:)
92
+ #: (untyped, habtm_version: untyped, ?history: PreparedHistory?) -> untyped
93
+ def association_reader(context_version, habtm_version:, history: nil)
100
94
  fallback = HistoricalAssociationReifier.new(
101
95
  context_version,
102
96
  habtm_version: habtm_version
103
97
  )
104
- history = @prepared_histories[context_key(context_version)] || @prepared_history
98
+ history = history_for(context_version, history)
105
99
  return fallback unless history
106
100
 
107
101
  PreparedAssociationReifier.new(
@@ -112,25 +106,6 @@ module PaperTrailDiff
112
106
  )
113
107
  end
114
108
 
115
- #: () -> PreparedActivityIndex?
116
- def activity_index
117
- @prepared_history&.activity_index
118
- end
119
-
120
- #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
121
- def record_transition(version)
122
- history = @prepared_history
123
- return unless history
124
-
125
- history.record_transition(Endpoint.model_class(version), version.item_id, version)
126
- end
127
-
128
- #: (untyped, ?direct: bool) -> [bool, untyped]
129
- def record_after(version, direct: false)
130
- @prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
131
- direct: direct) || [false, nil]
132
- end
133
-
134
109
  private
135
110
 
136
111
  # @rbs @tree: AssociationTree
@@ -140,8 +115,14 @@ module PaperTrailDiff
140
115
  # @rbs @prepared_snapshotter: PreparedSnapshotBuilder
141
116
  # @rbs @live_graph_collector: LiveGraphCollector
142
117
  # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
143
- # @rbs @prepared_history: PreparedHistory?
144
118
  # @rbs @prepared_histories: Hash[Array[untyped], PreparedHistory]
119
+ # @rbs @live_roots: Hash[identity, untyped]
120
+
121
+ # A batch's history for the version wins over the one a caller passes.
122
+ #: (untyped, PreparedHistory?) -> PreparedHistory?
123
+ def history_for(context_endpoint, history)
124
+ @prepared_histories[context_key(context_endpoint)] || history
125
+ end
145
126
 
146
127
  #: (untyped) -> Array[untyped]
147
128
  def context_key(endpoint)
@@ -170,34 +151,43 @@ module PaperTrailDiff
170
151
  root_versions.each { |version| @prepared_histories[context_key(version)] = history }
171
152
  end
172
153
 
173
- #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
174
- def snapshot_from_version(root_version, context_version, tree:, normalizer:)
154
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> RecordSnapshot?
155
+ def snapshot_from_version(root_version, context_version, tree:, normalizer:, history:)
175
156
  model_class = RecordedClass.for(root_version)
176
157
  @preparer.call(model_class, historical: true)
177
158
  @traversal.ensure_habtm_history!(model_class, root_version)
178
159
  handled, snapshot = @prepared_snapshotter.call(
179
- root_version, context_version, tree: tree, normalizer: normalizer,
180
- history: @prepared_histories[context_key(context_version)] ||
181
- @prepared_history
160
+ root_version, context_version, tree: tree, normalizer: normalizer, history: history
182
161
  )
183
162
  return snapshot if handled
184
163
 
185
164
  record = root_version.reify(dup: true)
186
165
  normalize(record, context_version, tree: tree, normalizer: normalizer,
187
- habtm_version: root_version)
166
+ habtm_version: root_version, history: history)
188
167
  end
189
168
 
190
- #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
191
- def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
192
- record = Endpoint.reload_record(root_record)
169
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> RecordSnapshot?
170
+ def snapshot_from_live_root(root_record, context_version, tree:, normalizer:, history:)
171
+ record = live_root(root_record)
193
172
  @preparer.call(record.class, historical: true)
194
173
  normalize(record, context_version, tree: tree, normalizer: normalizer,
195
- habtm_version: context_version)
174
+ habtm_version: context_version, history: history)
175
+ end
176
+
177
+ # Rebuilding a version's associations writes them onto the record, so each
178
+ # snapshot gets its own copy of a batch's loaded row, built from the row's
179
+ # stored values without a query.
180
+ #: (untyped) -> untyped
181
+ def live_root(root_record)
182
+ loaded = @live_roots[Endpoint.identity(root_record)]
183
+ return Endpoint.reload_record(root_record) unless loaded
184
+
185
+ loaded.class.instantiate(loaded.attributes_before_type_cast)
196
186
  end
197
187
 
198
- #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
199
- def normalize(record, context_version, tree:, normalizer:, habtm_version:)
200
- reifier = association_reader(context_version, habtm_version: habtm_version)
188
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped, history: PreparedHistory?) -> RecordSnapshot?
189
+ def normalize(record, context_version, tree:, normalizer:, habtm_version:, history:) # rubocop:disable Metrics/ParameterLists
190
+ reifier = association_reader(context_version, habtm_version: habtm_version, history: history)
201
191
  reflections = [] #: Array[untyped]
202
192
  reflections = @traversal.reflections_for(record.class, tree, path: '') if record
203
193
  reifier.reify(record, reflections) if record && !reflections.empty?