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
@@ -46,9 +46,7 @@ module PaperTrailDiff
46
46
  def preload_model_tree(model_class, records, tree)
47
47
  reflections = selected_reflections(model_class, tree)
48
48
  resolve_polymorphic_targets(records, reflections)
49
- batch, individual = reflections.partition do |reflection, _|
50
- batch_safe?(records.fetch(0), reflection)
51
- end
49
+ batch, individual = reflections.partition { |reflection, _| BatchPreload.safe?(reflection) }
52
50
  preload_batch(records, batch.map { |reflection, _| reflection.name })
53
51
  preload_subtrees(records, batch, individually: false)
54
52
  preload_subtrees(records, individual, individually: true)
@@ -101,38 +99,6 @@ module PaperTrailDiff
101
99
  active_record.const_get(:Associations).const_get(:Preloader)
102
100
  end
103
101
 
104
- # The preloader applies a limit or offset to all owners' records at once,
105
- # not to each owner's, so such an association is loaded per record. A
106
- # has_one is no exception. Through a part with a limit, an offset or a
107
- # has_one cut, the preloader keeps what the part alone would load, while
108
- # the through's own query keeps the part's conditions only.
109
- #: (untyped, untyped) -> bool
110
- def batch_safe?(record, reflection)
111
- scope = reflection.scope
112
- return false if scope && !scope.arity.zero?
113
- return true unless reflection.collection? || reflection.has_one?
114
- return false if cut_part?(reflection)
115
-
116
- relation = record.association(reflection.name).scope
117
- relation.limit_value.nil? && relation.offset_value.nil?
118
- end
119
-
120
- #: (untyped) -> bool
121
- def cut_part?(reflection)
122
- reflection.collection? &&
123
- (ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
124
- end
125
-
126
- # Every part a through passes, at any depth.
127
- #: (untyped) -> Array[untyped]
128
- def through_parts(reflection)
129
- return [] unless reflection.through_reflection?
130
-
131
- [reflection.through_reflection, reflection.source_reflection].flat_map do |part|
132
- [part, *through_parts(part)]
133
- end
134
- end
135
-
136
102
  #: (Array[untyped], untyped) -> Array[untyped]
137
103
  def load_individually(records, reflection)
138
104
  unique_records(records.flat_map do |record|
@@ -52,13 +52,17 @@ module PaperTrailDiff
52
52
  # Returns the changed paths, or an empty hash when the pair is not two
53
53
  # structures this can look inside. An empty result therefore means "nothing
54
54
  # to report at this depth", and the caller still has the whole-value change.
55
+ # Two arrays at the top are one membership change, at the empty path.
55
56
  #: () -> nested_changes
56
57
  def call
57
- from_structure, to_structure = structures
58
+ from, to = structures
58
59
  changes = {} #: nested_changes
59
- return changes.freeze unless from_structure && to_structure
60
-
61
- walk(from_structure, to_structure, [], changes)
60
+ if from.is_a?(Hash) && to.is_a?(Hash)
61
+ walk(from, to, [], changes)
62
+ elsif from.is_a?(Array) && to.is_a?(Array)
63
+ whole = [] #: Array[String]
64
+ changes[whole.freeze] = change_for(from, to) if from != to
65
+ end
62
66
  changes.freeze
63
67
  end
64
68
 
@@ -67,26 +71,23 @@ module PaperTrailDiff
67
71
  # @rbs @from_value: untyped
68
72
  # @rbs @to_value: untyped
69
73
 
70
- # Both sides have to be readable as a Hash for a nested answer to mean
71
- # anything. A column that held text on one side and JSON on the other
72
- # changed wholesale, and saying so is the accurate report.
73
- #: () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
74
+ # Both sides have to be readable as the same kind of structure for a
75
+ # nested answer to mean anything. A column that held text on one side and
76
+ # JSON on the other changed wholesale, and saying so is the accurate report.
77
+ #: () -> [untyped, untyped]
74
78
  def structures
75
79
  [structure(@from_value), structure(@to_value)]
76
80
  end
77
81
 
78
- #: (untyped) -> Hash[untyped, untyped]?
82
+ # A string is read as JSON; anything else, or JSON that is neither an
83
+ # object nor an array, is left for #call to decline.
84
+ #: (untyped) -> untyped
79
85
  def structure(value)
80
- return value if value.is_a?(Hash)
81
- return unless value.is_a?(String)
86
+ return value unless value.is_a?(String)
82
87
 
83
- parsed = begin
84
- JSON.parse(value)
85
- rescue JSON::ParserError
86
- nil
87
- end
88
- # JSON parses objects into plain Hashes, never a subclass.
89
- parsed if parsed.instance_of?(Hash)
88
+ JSON.parse(value)
89
+ rescue JSON::ParserError
90
+ nil
90
91
  end
91
92
 
92
93
  #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
@@ -7,16 +7,16 @@ module PaperTrailDiff
7
7
  # the only place allowed to know about both PaperTrail and the pure engine.
8
8
  # Reconstruction logic lives in the collaborators it wires together.
9
9
  class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
10
- # The only thing a wall-clock window can close on besides a later version.
11
- CLOSE_ON_CURRENT = :current
12
-
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)
10
+ # `recorded_changes:` reads what each activity boundary's version recorded.
11
+ # `allow_approximate:` accepts association paths read with PT-AT.
12
+ #: (associations: association_option, ignore: ignore_option, ?reload_live_endpoints: bool, ?recorded_changes: bool, ?allow_approximate: bool) -> void
13
+ def initialize(associations:, ignore:, reload_live_endpoints: true, recorded_changes: false, allow_approximate: false) # rubocop:disable Metrics/MethodLength, Layout/LineLength
15
14
  @association_tree = AssociationTree.build(associations)
16
15
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
16
+ @recorded = (@ignore_policy if recorded_changes)
17
17
  @traversal = AssociationTraversal.new(@association_tree)
18
- @batch_safety = BatchSafety.new(@traversal, allow_expensive: allow_expensive)
19
- @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
18
+ @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal,
19
+ allow_approximate: allow_approximate)
20
20
  @live_endpoints = LiveEndpointProvider.new(
21
21
  tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
22
22
  )
@@ -53,12 +53,13 @@ module PaperTrailDiff
53
53
  count = comparisons.is_a?(Array) ? comparisons.length : 0
54
54
  payload = @instrumentation_payload.merge(comparison_count: count)
55
55
  Instrumentation.instrument('compare_many', payload) do
56
- comparison_batch(comparisons, preparer: method(:prepare_batch_model)).call
56
+ comparison_batch(comparisons, preparer: @traversal_preparer.method(:call)).call
57
57
  end
58
58
  end
59
59
 
60
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
61
- def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
60
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
61
+ def timeline(record, from:, to:, within:, version_scope: nil)
62
+ Endpoint.validate_root!(record)
62
63
  @traversal_preparer.call(record.class, historical: true)
63
64
  from, to = default_range(record, from, to, within)
64
65
  TimelineBuilder.new(
@@ -67,38 +68,40 @@ module PaperTrailDiff
67
68
  to: to,
68
69
  within: within,
69
70
  version_scope: version_scope,
70
- live_endpoint: live_endpoint_for(record, close_on, within),
71
+ live_endpoint: live_endpoint_for(record, within),
71
72
  snapshotter: @timeline_snapshotter
72
73
  ).build
73
74
  end
74
75
 
75
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
76
- def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
77
- payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
76
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
77
+ def activity_timeline(record, from:, to:, within:, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
78
+ Endpoint.validate_root!(record)
79
+ payload = model_payload(record)
78
80
  Instrumentation.instrument('activity_timeline', payload) do
79
81
  @traversal_preparer.call(record.class, historical: true)
80
82
  from, to = default_range(record, from, to, within)
81
- live = live_endpoint_for(record, close_on, within)
83
+ live = live_endpoint_for(record, within)
82
84
  steps = activity_builder(
83
85
  record, from: from, to: to, within: within, version_scope: version_scope,
84
- live_endpoint: live, snapshots: snapshots, group: group
86
+ live_endpoint: live, view: activity_view(true, snapshots, group)
85
87
  ).build
86
88
  payload[:step_count] = steps.length
87
89
  steps
88
90
  end
89
91
  end
90
92
 
91
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
92
- def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
93
- ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
93
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
94
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
95
+ view = activity_view(activity, snapshots, group)
96
+ Endpoint.validate_root!(record)
94
97
  @traversal_preparer.call(record.class, historical: true)
95
98
  from, to = default_range(record, from, to, within)
96
- live = live_endpoint_for(record, close_on, within)
99
+ live = live_endpoint_for(record, within)
97
100
  if activity
98
- return analyze_activity(
101
+ return activity_builder(
99
102
  record, from: from, to: to, within: within, version_scope: version_scope,
100
- live_endpoint: live, snapshots: snapshots, group: group
101
- )
103
+ live_endpoint: live, view: view
104
+ ).analyze
102
105
  end
103
106
 
104
107
  TimelineBuilder.new(
@@ -108,40 +111,29 @@ module PaperTrailDiff
108
111
  end
109
112
 
110
113
  # Analyzes many roots over one shared range, preparing their history once.
111
- #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
112
- def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
113
- ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
114
- count = records.is_a?(Array) ? records.length : 0
115
- payload = @instrumentation_payload.merge(comparison_count: count)
116
- Instrumentation.instrument('analyze_many', payload) do
117
- AnalysisBatch.new(
118
- records,
119
- time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
120
- close_on_current: close_on_current?(close_on, within) || within.nil?,
121
- context_required: activity && !@association_tree.empty?,
122
- live_loader: method(:load_batch_endpoints),
123
- history_preparer: @historical_store.method(:prepare_batch),
124
- analyzer: batched_root_analyzer(activity, within: within, snapshots: snapshots,
125
- group: group)
126
- ).call
127
- end
114
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
115
+ def analyze_many(records, within:, activity: false, version_scope: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
116
+ analyze_batch(records, within: within, activity: activity, version_scope: version_scope,
117
+ view: activity_view(activity, snapshots, group))
128
118
  end
129
119
 
130
120
  # Selects the roots from a relation before running the ordinary batch, so a
131
121
  # caller reporting on a population does not have to rediscover which of its
132
122
  # members changed. The roots the relation could not reach come back named
133
123
  # rather than dropped -- see `PaperTrailDiff.analyze_scope`.
134
- #: (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
135
- 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
136
- ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
137
- check_scope(scope, activity)
124
+ #: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
125
+ def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
126
+ view = activity_view(activity, snapshots, group)
127
+ check_scope(scope)
138
128
  # @type var options: report_options
139
- options = { within: within, activity: activity, version_scope: version_scope,
140
- close_on: close_on, snapshots: snapshots, group: group }
129
+ options = { within: within, activity: activity, version_scope: version_scope, view: view }
141
130
  ScopedAnalysisBuilder.new(
142
131
  scope, limit: limit, within: within, historical_filter: historical_filter,
143
132
  max_filter_versions: max_filter_versions, include_deleted: include_deleted,
144
- analyzer: ->(records) { analyze_many(records, **options, within: within) },
133
+ analyzer: lambda { |records|
134
+ analyze_batch(records, within: within, activity: activity,
135
+ version_scope: version_scope, view: view)
136
+ },
145
137
  deleted_analyzer: lambda { |identities|
146
138
  analyze_deleted(scope.all.model, identities, options)
147
139
  },
@@ -153,14 +145,40 @@ module PaperTrailDiff
153
145
 
154
146
  private
155
147
 
156
- # Selecting by activity reads association history, so it must be available
157
- # before the first selection query rather than once analysis starts.
158
- #: (untyped, bool) -> void
159
- def check_scope(scope, activity)
148
+ #: (bool, bool, Symbol?) -> ActivityView
149
+ def activity_view(activity, snapshots, group)
150
+ ActivityView.build(activity: activity, snapshots: snapshots, group: group,
151
+ recorded: @recorded)
152
+ end
153
+
154
+ #: (untyped) -> Hash[Symbol, untyped]
155
+ def model_payload(record)
156
+ @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
157
+ end
158
+
159
+ #: (Array[untyped], within: untyped, activity: bool, version_scope: untyped, view: ActivityView) -> Hash[identity, Analysis]
160
+ def analyze_batch(records, within:, activity:, version_scope:, view:)
161
+ count = records.is_a?(Array) ? records.length : 0
162
+ payload = @instrumentation_payload.merge(comparison_count: count)
163
+ Instrumentation.instrument('analyze_many', payload) do
164
+ AnalysisBatch.new(
165
+ records,
166
+ time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
167
+ context_required: activity && !@association_tree.empty?,
168
+ live_loader: method(:load_batch_endpoints),
169
+ history_preparer: @historical_store.method(:prepare_batch),
170
+ analyzer: batched_root_analyzer(activity, within: within, view: view)
171
+ ).call
172
+ end
173
+ end
174
+
175
+ # Analysis reads association history, so a request it would refuse is
176
+ # refused before the first selection query rather than once analysis starts.
177
+ #: (untyped) -> void
178
+ def check_scope(scope)
160
179
  return unless scope.respond_to?(:all) && scope.respond_to?(:where)
161
180
 
162
- @batch_safety.call(scope.all.model)
163
- @traversal_preparer.call(scope.all.model, historical: true) if activity
181
+ @traversal_preparer.call(scope.all.model, historical: true)
164
182
  end
165
183
 
166
184
  #: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
@@ -175,27 +193,18 @@ module PaperTrailDiff
175
193
  )
176
194
  end
177
195
 
196
+ # Refused before the current records load, rather than once analysis starts.
178
197
  #: (Array[untyped]) -> Hash[identity, untyped]
179
198
  def load_batch_endpoints(records)
180
- records.map(&:class).uniq.each { |model| @batch_safety.call(model) }
199
+ records.map(&:class).uniq.each { |model| @traversal_preparer.call(model, historical: true) }
181
200
  @live_endpoints.call(records)
182
201
  end
183
202
 
184
- #: (untyped, historical: bool) -> void
185
- def prepare_batch_model(model, historical:)
186
- @batch_safety.call(model)
187
- @traversal_preparer.call(model, historical: historical)
188
- end
189
-
190
- # @rbs @batch_safety: BatchSafety
191
-
192
203
  #: (untyped, Array[Array[String]], Hash[Symbol, untyped]) -> Hash[Array[String], Analysis?]
193
204
  def analyze_deleted(model, identities, options)
194
205
  within = options.fetch(:within)
195
- analyzer = batched_root_analyzer(
196
- options.fetch(:activity), within: within, snapshots: options.fetch(:snapshots),
197
- group: options.fetch(:group)
198
- )
206
+ analyzer = batched_root_analyzer(options.fetch(:activity), within: within,
207
+ view: options.fetch(:view))
199
208
  DeletedAnalysisBatch.new(
200
209
  model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
201
210
  version_scope: options.fetch(:version_scope),
@@ -209,6 +218,7 @@ module PaperTrailDiff
209
218
  # @rbs @live_endpoints: LiveEndpointProvider
210
219
  # @rbs @instrumentation_payload: Hash[Symbol, untyped]
211
220
  # @rbs @ignore_policy: IgnorePolicy
221
+ # @rbs @recorded: IgnorePolicy?
212
222
  # @rbs @traversal: AssociationTraversal
213
223
  # @rbs @traversal_preparer: TraversalPreparer
214
224
  # @rbs @snapshot_pool: SnapshotPool
@@ -217,24 +227,13 @@ module PaperTrailDiff
217
227
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
218
228
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
219
229
 
220
- #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
221
- def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
222
- activity_builder(
223
- record, from: from, to: to, within: within, version_scope: version_scope,
224
- live_endpoint: live_endpoint, snapshots: snapshots, group: group
225
- ).analyze
226
- end
227
-
228
- # Association membership is resolved by timestamp, so endpoints sharing one
229
- # cannot be told apart and any association change between them is invisible.
230
- # The result may still be correct -- nothing associated may have changed --
231
- # and the gem cannot tell which, since not seeing the change is the symptom.
232
- # So it reports the condition and leaves the judgement to the application.
230
+ # The result may still be correct -- nothing the path reaches may have
231
+ # changed -- and the gem cannot tell which, since not seeing the change is
232
+ # the symptom. So it reports the condition and leaves the judgement to the
233
+ # application.
233
234
  #: (untyped, untyped) -> void
234
235
  def notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
235
- return if @association_tree.empty?
236
- return unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
237
- return unless from_endpoint.created_at == to_endpoint.created_at
236
+ return unless ambiguous_association_boundary?(from_endpoint, to_endpoint)
238
237
 
239
238
  Instrumentation.notify(
240
239
  'ambiguous_association_boundary',
@@ -246,19 +245,19 @@ module PaperTrailDiff
246
245
  )
247
246
  end
248
247
 
249
- # A window with nothing recorded after its last change ends at the live
250
- # record, which holds the state that change produced -- whether the window
251
- # ended a moment ago or long before. `close_on: :current` asked for this
252
- # before it was the default and is still accepted. It names what ends a
253
- # wall-clock window, so it is meaningless for explicit endpoints.
254
- #: (Symbol?, untyped) -> bool
255
- def close_on_current?(close_on, within)
256
- unless close_on.nil? || close_on == CLOSE_ON_CURRENT
257
- raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
258
- end
259
- raise ConfigurationError, 'close_on: requires `within`' if close_on && within.nil?
260
-
261
- !within.nil?
248
+ # Loaded history orders two versions that share a timestamp by id, but
249
+ # PT-AT places association changes by timestamp alone, so only a path it
250
+ # reads can lose a change between them. A request the preparer refuses
251
+ # raises here, before anything is reported.
252
+ #: (untyped, untyped) -> bool
253
+ def ambiguous_association_boundary?(from_endpoint, to_endpoint)
254
+ return false if @association_tree.empty?
255
+ return false unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
256
+ return false unless from_endpoint.created_at == to_endpoint.created_at
257
+
258
+ model_class = RecordedClass.for(from_endpoint)
259
+ @traversal_preparer.call(model_class, historical: true)
260
+ FallbackPaths.any?(@traversal, model_class, approximate: true)
262
261
  end
263
262
 
264
263
  # With neither a window nor endpoints, a range is the record's whole
@@ -271,12 +270,15 @@ module PaperTrailDiff
271
270
  [from.nil? ? :first : from, to.nil? ? record : to]
272
271
  end
273
272
 
273
+ # A window with nothing recorded after its last change ends at the live
274
+ # record, which holds the state that change produced -- whether the window
275
+ # ended a moment ago or long before. Explicit endpoints name their own end.
274
276
  # Only a saved row has current state to close on. A destroyed root's own
275
277
  # destroy version already terminates its history, and a reified or unsaved
276
278
  # instance stands for a state that is not in the table.
277
- #: (untyped, Symbol?, untyped) -> untyped
278
- def live_endpoint_for(record, close_on, within)
279
- return unless close_on_current?(close_on, within)
279
+ #: (untyped, untyped) -> untyped
280
+ def live_endpoint_for(record, within)
281
+ return if within.nil?
280
282
  return unless Endpoint.record?(record) && record.persisted?
281
283
 
282
284
  record
@@ -288,17 +290,20 @@ module PaperTrailDiff
288
290
  @timeline_snapshotter = TimelineSnapshotProvider.new(
289
291
  @historical_store, live_snapshotter: method(:live_snapshot)
290
292
  )
291
- @activity_snapshotter = build_activity_snapshotter
293
+ @activity_snapshotter = ActivitySnapshotProvider.new(
294
+ store: @historical_store, snapshotter: method(:snapshot_at),
295
+ refresher: method(:branch_refresher)
296
+ )
292
297
  end
293
298
 
294
- #: (bool, within: untyped, snapshots: bool, group: Symbol?) -> BatchedRootAnalyzer
295
- def batched_root_analyzer(activity, within:, snapshots:, group:)
299
+ #: (bool, within: untyped, view: ActivityView) -> BatchedRootAnalyzer
300
+ def batched_root_analyzer(activity, within:, view:)
296
301
  BatchedRootAnalyzer.new(
297
302
  tree: @association_tree,
298
303
  timeline_snapshotter: @timeline_snapshotter,
299
304
  activity_snapshotter: @activity_snapshotter,
300
305
  preparer: @traversal_preparer.method(:call),
301
- activity: activity, within: within, snapshots: snapshots, group: group
306
+ activity: activity, within: within, view: view
302
307
  )
303
308
  end
304
309
 
@@ -312,27 +317,23 @@ module PaperTrailDiff
312
317
  )
313
318
  end
314
319
 
315
- #: () -> ActivitySnapshotProvider
316
- def build_activity_snapshotter
317
- refresher = BranchSnapshotRefresher.new(
320
+ # Refreshes one root's snapshots, reading with the history prepared for it.
321
+ #: (PreparedActivitySnapshots) -> BranchSnapshotRefresher
322
+ def branch_refresher(snapshots)
323
+ BranchSnapshotRefresher.new(
318
324
  tree: @association_tree, ignore_policy: @ignore_policy,
319
325
  traversal: @traversal, pool: @snapshot_pool,
320
326
  normalizer: @normalizer,
321
- full_snapshotter: method(:snapshot_at),
322
- partial_snapshotter: @historical_store.method(:custom),
323
- association_reader: @historical_store.method(:association_reader),
324
- record_transition: @historical_store.method(:record_transition),
325
- prepared_record_after: @historical_store.method(:record_after)
326
- )
327
- ActivitySnapshotProvider.new(
328
- snapshotter: method(:snapshot_at), refresher: refresher,
329
- preparer: @historical_store.method(:prepare),
330
- activity_index: @historical_store.method(:activity_index)
327
+ full_snapshotter: snapshots.method(:call),
328
+ partial_snapshotter: snapshots.method(:custom),
329
+ association_reader: snapshots.method(:association_reader),
330
+ record_transition: snapshots.method(:record_transition),
331
+ prepared_record_after: snapshots.method(:record_after)
331
332
  )
332
333
  end
333
334
 
334
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
335
- def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
335
+ #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, view: ActivityView) -> ActivityTimelineBuilder
336
+ def activity_builder(record, from:, to:, within:, version_scope:, live_endpoint:, view:) # rubocop:disable Metrics/ParameterLists
336
337
  ActivityTimelineBuilder.new(
337
338
  record,
338
339
  range: TimelineRange.new(
@@ -341,8 +342,7 @@ module PaperTrailDiff
341
342
  ),
342
343
  tree: @association_tree,
343
344
  snapshotter: @activity_snapshotter,
344
- snapshots: snapshots,
345
- group: group
345
+ view: view
346
346
  )
347
347
  end
348
348
 
@@ -356,16 +356,11 @@ module PaperTrailDiff
356
356
  snapshot_at(version, version)
357
357
  end
358
358
 
359
- #: (untyped) -> RecordSnapshot?
360
- def uncached_historical_snapshot(version)
361
- @historical_store.uncached(version, version)
362
- end
363
-
364
- #: (untyped, untyped) -> RecordSnapshot?
365
- def snapshot_at(root_endpoint, context_endpoint)
359
+ #: (untyped, untyped, ?history: PreparedHistory?) -> RecordSnapshot?
360
+ def snapshot_at(root_endpoint, context_endpoint, history: nil)
366
361
  return live_snapshot(root_endpoint) if Endpoint.record?(context_endpoint)
367
362
 
368
- @historical_store.call(root_endpoint, context_endpoint)
363
+ @historical_store.call(root_endpoint, context_endpoint, history: history)
369
364
  end
370
365
 
371
366
  #: (untyped) -> RecordSnapshot
@@ -2,7 +2,8 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Validates and reuses caller-owned live records without issuing association queries.
5
+ # Validates and reuses caller-owned live records, reading again only the
6
+ # associations a preload of many records cannot have loaded correctly.
6
7
  class PreloadedEndpointBatchLoader
7
8
  #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
8
9
  def initialize(tree:, traversal:)
@@ -36,6 +37,7 @@ module PaperTrailDiff
36
37
  message = "current endpoint association is not preloaded: #{association_path}"
37
38
  raise UnloadedAssociationError, message
38
39
  end
40
+ read_alone(association) unless BatchPreload.safe?(reflection)
39
41
 
40
42
  subtree = tree.child(reflection.name)
41
43
  return unless subtree
@@ -44,5 +46,15 @@ module PaperTrailDiff
44
46
  validate_record(child, subtree, path: association_path)
45
47
  end
46
48
  end
49
+
50
+ # Preloading many records applies a limit or offset to all of them
51
+ # together, so what a caller preloaded cannot be trusted: one record keeps
52
+ # another's share, or none. Such an association is read again for each
53
+ # record, as reloading reads it.
54
+ #: (untyped) -> void
55
+ def read_alone(association)
56
+ association.reset
57
+ association.load_target
58
+ end
47
59
  end
48
60
  end
@@ -189,7 +189,7 @@ module PaperTrailDiff
189
189
  # A version without a transaction id names no rows, so it can stay in.
190
190
  #: (Array[untyped]) -> Array[untyped]
191
191
  def transaction_ids(versions)
192
- versions.map { |version| version.transaction_id if version.respond_to?(:transaction_id) }.uniq
192
+ versions.map { |version| VersionBoundary.transaction_id(version) }.uniq
193
193
  end
194
194
  end
195
195
  end
@@ -111,7 +111,8 @@ module PaperTrailDiff
111
111
  #: (untyped, Array[untyped], untyped, String) -> untyped
112
112
  def historical_child_relation(owner_class, owner_ids, reflection, foreign_key)
113
113
  version_class = owner_class.paper_trail.version_class
114
- owner_class.paper_trail.version_association_class.joins(:version).where(
114
+ associations = owner_class.paper_trail.version_association_class
115
+ VersionTable.joined(associations, version_class).where(
115
116
  foreign_key_name: foreign_key,
116
117
  foreign_key_id: owner_ids,
117
118
  foreign_type: parent_types(owner_class)
@@ -146,6 +146,11 @@ module PaperTrailDiff
146
146
  owners ? owners.fetch(owner.id.to_s, empty) : group.fetch(:ids)
147
147
  end
148
148
 
149
+ # PT-AT records a checkpoint's membership as it is when the checkpoint is
150
+ # saved, so a tag attached earlier in the checkpoint's own transaction is a
151
+ # member there, unlike other associations, which a version reads as they
152
+ # were before its transaction. A member created in that transaction has no
153
+ # earlier state, so it is read as it was created.
149
154
  #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
150
155
  def habtm_records(record, reflection, boundary, habtm_boundary, ranked)
151
156
  transaction_id = transaction_id_for(habtm_boundary)
@@ -155,7 +160,10 @@ module PaperTrailDiff
155
160
  ids = @habtm.fetch(edge_key(record.class, reflection), empty)[transaction_id.to_s]
156
161
  return unless ids
157
162
 
158
- members = ids.filter_map { |id| @records.record_before(reflection.klass, id, boundary) }
163
+ members = ids.filter_map do |id|
164
+ @records.record_before(reflection.klass, id, boundary) ||
165
+ @records.record_created_in(reflection.klass, id, habtm_boundary)
166
+ end
159
167
  @conditions.select(reflection, members, ranked: ranked)
160
168
  end
161
169
 
@@ -21,6 +21,7 @@ module PaperTrailDiff
21
21
  @root_versions = root_versions
22
22
  @tree = tree
23
23
  @traversal = traversal
24
+ start_at = VersionBoundary.transaction_start(root_versions, start_at)
24
25
  @records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
25
26
  @activity = PreparedActivityIndex.new(
26
27
  @records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at