paper_trail_diff 0.11.0 → 0.13.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1367
  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 +146 -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 +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +59 -24
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/value_objects.rb +66 -0
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  70. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  71. data/lib/paper_trail_diff/version_query.rb +33 -0
  72. data/lib/paper_trail_diff/version_range.rb +10 -38
  73. data/lib/paper_trail_diff.rb +81 -43
  74. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  76. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  77. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  78. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  79. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  80. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  81. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  82. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  83. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  84. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  85. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  86. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  87. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  88. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  89. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  90. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  93. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  94. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  95. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  96. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  97. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  98. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  99. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  100. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  101. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  102. data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
  103. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  104. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  105. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  106. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  107. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  108. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  109. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  110. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  111. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  112. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  113. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  114. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  115. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  116. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  117. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  118. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  119. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  120. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  121. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  122. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  123. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  124. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  125. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  126. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  127. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  128. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  129. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  130. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  131. data/sig/generated/paper_trail_diff.rbs +37 -27
  132. metadata +31 -5
@@ -16,23 +16,29 @@ module PaperTrailDiff
16
16
  # names and locales routinely do -- and joining would make `a.b` ambiguous
17
17
  # between one key and two.
18
18
  #
19
- # Arrays are leaves. Their elements carry no identity, so an insertion at the
20
- # front makes every later index look changed; reporting "element 2 changed"
21
- # would be confidently wrong about a list that merely shifted. The whole array
22
- # is reported as one change, which is the same rule the collection comparator
23
- # follows for records it cannot identify.
19
+ # Arrays are reported by membership, not by position -- see ArrayChange. Their
20
+ # elements carry no identity, so an insertion at the front makes every later
21
+ # index look changed, and one insertion reads as several edits. What is added
22
+ # and removed can be answered without claiming any pairing; what cannot is the
23
+ # same elements in a new order, so that is named rather than passed over.
24
24
  #
25
25
  # An absent key is not a null one. `{"a": null}` and `{}` mean different
26
26
  # things in JSON and an audit trail that conflated them would be lying about
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>'
34
33
 
35
- #: (untyped, untyped) -> Hash[Array[String], ValueChange]
34
+ #: () -> String
35
+ def to_s = 'absent'
36
+ end
37
+ private_constant :Absent
38
+
39
+ ABSENT = Absent.new.freeze
40
+
41
+ #: (untyped, untyped) -> nested_changes
36
42
  def self.call(from_value, to_value)
37
43
  new(from_value, to_value).call
38
44
  end
@@ -46,12 +52,12 @@ module PaperTrailDiff
46
52
  # Returns the changed paths, or an empty hash when the pair is not two
47
53
  # structures this can look inside. An empty result therefore means "nothing
48
54
  # to report at this depth", and the caller still has the whole-value change.
49
- #: () -> Hash[Array[String], ValueChange]
55
+ #: () -> nested_changes
50
56
  def call
51
57
  from_structure, to_structure = structures
52
- return {} unless from_structure && to_structure
58
+ changes = {} #: nested_changes
59
+ return changes.freeze unless from_structure && to_structure
53
60
 
54
- changes = {} #: Hash[Array[String], ValueChange]
55
61
  walk(from_structure, to_structure, [], changes)
56
62
  changes.freeze
57
63
  end
@@ -76,33 +82,62 @@ 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
- #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
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]
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
96
- changes[here.freeze] = ValueChange.new(from: from_item, to: to_item)
105
+ changes[here] = change_for(from_item, to_item)
97
106
  end
98
107
  end
99
108
  end
100
109
 
101
- # Sorted so a report reads the same twice, and stringified because a hash
102
- # loaded from JSON and one built in Ruby can key the same field differently.
103
- #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
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
+
124
+ # Two arrays are a membership change; anything else is a change to the
125
+ # value as a whole, including an array facing something that is not one.
126
+ #: (untyped, untyped) -> nested_change
127
+ def change_for(from_item, to_item)
128
+ if from_item.is_a?(Array) && to_item.is_a?(Array)
129
+ ArrayChange.new(from: from_item, to: to_item)
130
+ else
131
+ ValueChange.new(from: from_item, to: to_item)
132
+ end
133
+ end
134
+
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]
104
139
  def keys(from_hash, to_hash)
105
- (from_hash.keys | to_hash.keys).sort_by(&:to_s)
140
+ (from_hash.keys | to_hash.keys).sort
106
141
  end
107
142
  end
108
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)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Shares association event discovery across every root in a prepared graph.
6
+ class PreparedActivityIndex
7
+ #: (PreparedRecordIndex, Array[untyped], start_at: untyped, end_at: untyped) -> void
8
+ def initialize(records, root_versions, start_at:, end_at:)
9
+ @records = records
10
+ @range = ActivityRange.new(start_at, end_at)
11
+ @roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
12
+ @nodes = {} #: Hash[String, Array[untyped]]
13
+ @children = {} #: Hash[Array[String], untyped]
14
+ @rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> void
18
+ def register(model, ids)
19
+ name = model.base_class.name.to_s
20
+ @nodes[name] = (@nodes.fetch(name, []) | ids)
21
+ end
22
+
23
+ #: (untyped, Array[untyped]) -> Array[untyped]
24
+ def versions(model, ids)
25
+ ids.flat_map do |id|
26
+ key = [model.base_class.name.to_s, id.to_s]
27
+ @roots[key] || @records.versions_for_id(model, id)
28
+ end
29
+ end
30
+
31
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
32
+ def child_ids(model, ids, reflection, range)
33
+ key = [model.base_class.name.to_s, reflection.name.to_s]
34
+ historical, live = @children[key] ||= child_rows(model, reflection)
35
+ empty = [] #: Array[untyped]
36
+ selected = historical_ids(reflection.klass, ids, historical, range)
37
+ selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
38
+ selected.uniq
39
+ end
40
+
41
+ #: (untyped, Array[untyped], untyped, ?transactions: bool) -> Array[untyped]
42
+ def association_rows(model, selected_versions, reflection, transactions: false)
43
+ key = [model.base_class.name.to_s, reflection.name.to_s]
44
+ rows = @rows[key] ||= load_association_rows(model, reflection, transactions)
45
+ version_ids(selected_versions, transactions).flat_map { |id| rows.fetch(id.to_s, []) }
46
+ end
47
+
48
+ private
49
+
50
+ # @rbs @records: PreparedRecordIndex
51
+ # @rbs @range: ActivityRange
52
+ # @rbs @roots: Hash[Array[String], Array[untyped]]
53
+ # @rbs @nodes: Hash[String, Array[untyped]]
54
+ # @rbs @children: Hash[Array[String], untyped]
55
+ # @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
56
+
57
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
58
+ def historical_ids(model, ids, historical, range)
59
+ empty = [] #: Array[untyped]
60
+ ids.flat_map do |id|
61
+ historical.fetch(id.to_s, empty).filter_map do |row|
62
+ row[1] if eligible_child?(model, row, range)
63
+ end
64
+ end
65
+ end
66
+
67
+ #: (untyped) -> Array[untyped]
68
+ def node_ids(model)
69
+ name = model.base_class.name.to_s
70
+ @nodes.fetch(name) do
71
+ raise ConfigurationError,
72
+ "prepared activity graph is missing #{name}; " \
73
+ 'register the model before collecting activity'
74
+ end
75
+ end
76
+
77
+ #: (untyped, untyped) -> untyped
78
+ def child_rows(model, reflection)
79
+ loader = ActivityChildCandidateLoader.new(
80
+ model, node_ids(model), reflection, @range
81
+ )
82
+ [loader.historical_rows.group_by { |row| row.first.to_s },
83
+ loader.current_pairs.group_by { |row| row.first.to_s }
84
+ .transform_values { |rows| rows.map(&:last) }]
85
+ end
86
+
87
+ # Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
88
+ # series includes changes to other owners too, so an old membership cannot
89
+ # survive a later pre-window reassignment just because that owner is absent.
90
+ #: (untyped, Array[untyped], ActivityRange) -> bool
91
+ def eligible_child?(model, row, range)
92
+ _owner, id, timestamp, event = row
93
+ return timestamp <= range.end_time if timestamp >= range.start_time
94
+ return false if event == 'destroy'
95
+
96
+ later = @records.versions_for_id(model, id)
97
+ .bsearch { |version| version.created_at > timestamp }
98
+ !later || later.created_at >= range.start_time
99
+ end
100
+
101
+ #: (untyped, untyped, bool) -> Hash[String, Array[untyped]]
102
+ def load_association_rows(model, reflection, transactions)
103
+ all_versions = versions(model, node_ids(model))
104
+ foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
105
+ model.paper_trail.version_association_class.where(
106
+ version_id: version_ids(all_versions, transactions), foreign_key_name: foreign_key
107
+ ).to_a.group_by { |row| row.version_id.to_s }
108
+ end
109
+
110
+ #: (Array[untyped], bool) -> Array[untyped]
111
+ def version_ids(versions, transactions)
112
+ if transactions
113
+ versions.filter_map do |version|
114
+ version.transaction_id if version.respond_to?(:transaction_id)
115
+ end.uniq
116
+ else
117
+ versions.map(&:id)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -50,11 +50,15 @@ module PaperTrailDiff
50
50
  def belongs_to_groups(owner_class, owner_ids, reflection)
51
51
  key = scalar_foreign_key(reflection)
52
52
  return {} unless key
53
+ unless reflection.polymorphic?
54
+ return group_for(reflection.klass,
55
+ @records.attribute_values_for(owner_class, owner_ids, key))
56
+ end
53
57
 
54
58
  grouped = {} #: Hash[String, Hash[Symbol, untyped]]
55
59
  @records.records_for(owner_class, owner_ids).each do |owner|
56
60
  id = owner.public_send(key)
57
- model_class = target_class(reflection, owner)
61
+ model_class = AssociationTraversal.belongs_to_target(reflection, owner)
58
62
  add_identity(grouped, model_class, id) if model_class && id
59
63
  end
60
64
  grouped
@@ -109,7 +113,12 @@ module PaperTrailDiff
109
113
  association_class.arel_table[:foreign_key_id],
110
114
  version_class.arel_table[:item_id]
111
115
  )
112
- group_ids_by_owner(pairs)
116
+ group_ids_by_owner(typed_child_ids(reflection.klass, pairs))
117
+ end
118
+
119
+ #: (untyped, Array[Array[untyped]]) -> Array[Array[untyped]]
120
+ def typed_child_ids(model_class, pairs)
121
+ pairs.map { |owner_id, child_id| [owner_id, Support.record_id(model_class, child_id)] }
113
122
  end
114
123
 
115
124
  #: (untyped, Array[untyped], untyped, String) -> untyped
@@ -139,15 +148,6 @@ module PaperTrailDiff
139
148
  group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
140
149
  end
141
150
 
142
- #: (untyped, untyped) -> untyped
143
- def target_class(reflection, owner)
144
- return reflection.klass unless reflection.polymorphic?
145
-
146
- Object.const_get(owner.public_send(reflection.foreign_type).to_s)
147
- rescue NameError
148
- nil
149
- end
150
-
151
151
  #: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
152
152
  def add_identity(groups, model_class, id)
153
153
  group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }